From 0d0f6fbadf07e4c253e8b0904aebdc32339345ae Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Thu, 10 Sep 2026 12:40:37 +0530 Subject: [PATCH 1/6] Apply saved-search filters to alert sample queries --- .changeset/silent-pandas-search.md | 6 ++++ .../__tests__/renderAlertTemplate.int.test.ts | 30 +++++++++++++++++++ .../api/src/tasks/checkAlerts/template.ts | 3 ++ 3 files changed, 39 insertions(+) create mode 100644 .changeset/silent-pandas-search.md diff --git a/.changeset/silent-pandas-search.md b/.changeset/silent-pandas-search.md new file mode 100644 index 0000000000..9d9ef3e761 --- /dev/null +++ b/.changeset/silent-pandas-search.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/api': patch +--- + +Apply saved-search pinned filters to alert notification sample queries so sample +log lines come from the same row set the alert counted. diff --git a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts index d5e05842ad..81bb74476c 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts @@ -713,6 +713,36 @@ describe('enriched message fields', () => { }); }); + it('applies the saved search pinned filters to the sample log query', async () => { + const seenQueries: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const capturingClickhouseClient = { + query: jest.fn().mockImplementation((args: { query: string }) => { + seenQueries.push(args.query); + return Promise.resolve({ + json: jest.fn().mockResolvedValue({ data: [] }), + text: jest.fn().mockResolvedValue(sampleLogsCsv), + }); + }), + } as any; + + await renderAlertTemplate({ + alertProvider, + clickhouseClient: capturingClickhouseClient, + metadata: mockMetadata, + state: AlertState.ALERT, + template: null, + title: 'Test Alert Title', + view: makeSearchView({ + filters: [{ type: 'sql', condition: "ServiceName = 'checkout'" }], + }), + teamId: TEST_TEAM_ID, + teamWebhooksById: new Map(), + }); + + expect(seenQueries.join('\n')).toContain("ServiceName = 'checkout'"); + }); + it('reports an empty sourceQuery when the chart carries no condition', async () => { const { dispatched } = await renderWithWebhook( AlertState.ALERT, diff --git a/packages/api/src/tasks/checkAlerts/template.ts b/packages/api/src/tasks/checkAlerts/template.ts index 9c9bf7dad6..b3dc2229ee 100644 --- a/packages/api/src/tasks/checkAlerts/template.ts +++ b/packages/api/src/tasks/checkAlerts/template.ts @@ -807,6 +807,9 @@ ${targetTemplate}`; select: resolvedSelect, where: savedSearch.where, whereLanguage: savedSearch.whereLanguage, + // The alert evaluation counts the filtered row set, so the sample + // query must apply the same pinned filters or it shows unrelated lines. + filters: savedSearch.filters?.map(f => ({ ...f })), implicitColumnExpression: source.implicitColumnExpression, useTextIndexForImplicitColumn: source.useTextIndexForImplicitColumn, ...pickSampleWeightExpressionProps(source), From 3b15bea89e5450da302c984e11ddd318e9e26799 Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Thu, 10 Sep 2026 18:06:10 +0530 Subject: [PATCH 2/6] fix(alerts): reuse search config for sample queries --- .../__tests__/renderAlertTemplate.int.test.ts | 3 +- .../api/src/tasks/checkAlerts/template.ts | 29 ++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts index 81bb74476c..19f1472415 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts @@ -5,6 +5,7 @@ import { SourceKind, Tile, } from '@hyperdx/common-utils/dist/types'; +import type { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; import mongoose from 'mongoose'; import { @@ -716,7 +717,7 @@ describe('enriched message fields', () => { it('applies the saved search pinned filters to the sample log query', async () => { const seenQueries: string[] = []; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const capturingClickhouseClient = { + const capturingClickhouseClient: Pick = { query: jest.fn().mockImplementation((args: { query: string }) => { seenQueries.push(args.query); return Promise.resolve({ diff --git a/packages/api/src/tasks/checkAlerts/template.ts b/packages/api/src/tasks/checkAlerts/template.ts index b3dc2229ee..1027f2ac5b 100644 --- a/packages/api/src/tasks/checkAlerts/template.ts +++ b/packages/api/src/tasks/checkAlerts/template.ts @@ -1,6 +1,7 @@ import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; import { Metadata } from '@hyperdx/common-utils/dist/core/metadata'; import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig'; +import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { formatDate, objectHash } from '@hyperdx/common-utils/dist/core/utils'; import { isPromqlSavedChartConfig, @@ -797,24 +798,18 @@ ${targetTemplate}`; } // TODO: show group + total count for group-by alerts // fetch sample logs - const resolvedSelect = - savedSearch.select || source.defaultTableSelectExpression || ''; const chartConfig: ChartConfigWithOptDateRange = { - connection: '', // no need for the connection id since clickhouse client is already initialized - displayType: DisplayType.Search, - dateRange: [startTime, endTime], - from: source.from, - select: resolvedSelect, - where: savedSearch.where, - whereLanguage: savedSearch.whereLanguage, - // The alert evaluation counts the filtered row set, so the sample - // query must apply the same pinned filters or it shows unrelated lines. - filters: savedSearch.filters?.map(f => ({ ...f })), - implicitColumnExpression: source.implicitColumnExpression, - useTextIndexForImplicitColumn: source.useTextIndexForImplicitColumn, - ...pickSampleWeightExpressionProps(source), - timestampValueExpression: source.timestampValueExpression, - orderBy: savedSearch.orderBy, + ...buildSearchChartConfig(source, { + connection: '', // no need for the connection id since clickhouse client is already initialized + dateRange: [startTime, endTime], + select: savedSearch.select, + where: savedSearch.where, + whereLanguage: savedSearch.whereLanguage, + filters: savedSearch.filters, + orderBy: savedSearch.orderBy, + dateRangeStartInclusive: true, + dateRangeEndInclusive: false, + }), limit: { limit: 5, offset: 0, From b11786bafbc04fa109e7e3469f8278fda8e089f7 Mon Sep 17 00:00:00 2001 From: Tyagiquamar Date: Mon, 14 Sep 2026 16:00:08 +0530 Subject: [PATCH 3/6] fix(alerts): fix lint errors and typing in alert template test --- .../__tests__/renderAlertTemplate.int.test.ts | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts index 19f1472415..cb2859d1fc 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/renderAlertTemplate.int.test.ts @@ -5,7 +5,6 @@ import { SourceKind, Tile, } from '@hyperdx/common-utils/dist/types'; -import type { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; import mongoose from 'mongoose'; import { @@ -716,32 +715,39 @@ describe('enriched message fields', () => { it('applies the saved search pinned filters to the sample log query', async () => { const seenQueries: string[] = []; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const capturingClickhouseClient: Pick = { - query: jest.fn().mockImplementation((args: { query: string }) => { + const querySpy = jest + .spyOn(mockClickhouseClient, 'query') + .mockImplementation((args: { query: string }) => { seenQueries.push(args.query); return Promise.resolve({ json: jest.fn().mockResolvedValue({ data: [] }), text: jest.fn().mockResolvedValue(sampleLogsCsv), }); - }), - } as any; + }); - await renderAlertTemplate({ - alertProvider, - clickhouseClient: capturingClickhouseClient, - metadata: mockMetadata, - state: AlertState.ALERT, - template: null, - title: 'Test Alert Title', - view: makeSearchView({ - filters: [{ type: 'sql', condition: "ServiceName = 'checkout'" }], - }), - teamId: TEST_TEAM_ID, - teamWebhooksById: new Map(), - }); + try { + await renderAlertTemplate({ + alertProvider, + clickhouseClient: mockClickhouseClient, + metadata: mockMetadata, + state: AlertState.ALERT, + template: null, + title: 'Test Alert Title', + view: makeSearchView({ + filters: [{ type: 'sql', condition: "ServiceName = 'checkout'" }], + }), + teamId: TEST_TEAM_ID, + teamWebhooksById: new Map(), + }); - expect(seenQueries.join('\n')).toContain("ServiceName = 'checkout'"); + const fullQuery = seenQueries.join('\n'); + expect(fullQuery).toContain("ServiceName = 'checkout'"); + expect(fullQuery).toContain('default.otel_logs'); + expect(fullQuery).toContain('Timestamp >='); + expect(fullQuery).toContain('Timestamp <'); + } finally { + querySpy.mockRestore(); + } }); it('reports an empty sourceQuery when the chart carries no condition', async () => { From 402cd5fa487b653e4355096edf655096dd0f170b Mon Sep 17 00:00:00 2001 From: Warren Lee <5959690+wrn14897@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:03:43 -0700 Subject: [PATCH 4/6] feat: add an LLM dashboard user filter and make presence filters index-eligible (#3104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two changes to the LLM dashboard, both touching how its `WHERE` clauses are built. **Performance.** Every tile carries a `WHERE` testing whether attribute keys are present, expressed as `SpanAttributes['key'] != ''` — the one shape that can't be indexed. `!= ''` normalizes to `notEmpty()`, which no skip index supports, so every granule is scanned. Map subscripts are also subcolumn references, so on ClickHouse 26.3+ each distinct key adds a per-part size lookup during PREWHERE planning (HDX-5348). These filters now lead with `mapContains`, which the `mapKeys()` index serves and which costs no per-part lookups. **Feature.** Adds a user filter beside the existing session filter, so the dashboard can be scoped to one end user.
Index and function references ClickHouse's text index condition ([`MergeTreeIndexConditionText::isSupportedFunction`](https://github.com/ClickHouse/ClickHouse/blob/master/src/Storages/MergeTree/MergeTreeIndexConditionText.cpp)) serves `mapContainsKey` and `has()` against a `mapKeys()` index; a `bloom_filter` over `mapKeys()` answers the same shape. #3096 moved `idx_span_attr_key` to `text(tokenizer='array')`, which makes this pointed: the index is there and these queries couldn't use it. `mapContains` is a case-sensitive alias of `mapContainsKey` ([FunctionsMapMiscellaneous.cpp:766](https://github.com/ClickHouse/ClickHouse/blob/master/src/Functions/array/FunctionsMapMiscellaneous.cpp)).
## Measured on staging SharedMergeTree, 26.6.1, 162 active parts. `EXPLAIN` only — no rows read. **Planning cost**, 10-key detection predicate (`isLLMSpan`), subcolumn sizes forced on: | | duration | `S3GetObject` | outcome | | :--- | ---: | ---: | :--- | | `!= ''` | 36,111 ms | 1,480 | `TIMEOUT_EXCEEDED` before start | | `mapContains` | **7 ms** | **0** | ok | **Granule pruning**, 2-key predicate over 6h (`EXPLAIN indexes = 1`): | | parts | granules | skip index | | :--- | ---: | ---: | :--- | | `!= ''` | 19 | 1,306 | *no `Skip` section* | | `mapContains` | 3 | **3** | `idx_span_attr_key` | **Equivalence**: both forms return `66` over 7 days (248M rows scanned). ## How the filters are shaped Review moved this on from the first version, which was bare `mapContains` everywhere. Presence alone admits a key explicitly set to `''`, and most of these gates are paired with a value expression the dashboard *groups by* — so an empty `user.email` became a blank "Top Users" row whose drill-in link went nowhere. Gates that feed a group-by therefore keep their value check, and the presence term is pruning only: ```sql indexHint(mapContains(a,'k1') OR mapContains(a,'k2')) AND coalesce(nullif(a['k1'],''), …) != '' ``` The value term defines the result; the presence term reaches skip-index analysis without being re-evaluated per surviving row. `indexHint` is the existing repo idiom for this (`mapKeyIndexExpression`, queryParser.ts:1728). It costs no extra per-part lookups, because the value term reads the same keys the group-by already reads. Three positional rules fell out of this, each with a test pinning it: - **`isLLMSpan` is bare presence, not hinted.** `indexHint` returns true at execution, so hinting a filter that *is* the presence check would match every span. This gate is where the planning win comes from. - **Select-list gates are unhinted.** Skip-index analysis never reaches the select list, so a hint there folds to a constant while lengthening every query. `hasReportedTokens` is unhinted for this reason; `isToolSpan` is used in both positions and is exposed in both forms. - **Hints sit above an `OR`, never inside one.** A skip index can only drop a granule when every arm of an `OR` is decidable by it, so `isToolSpan`'s hint is hoisted to cover the span-kind key too — sound because both arms imply one of the hinted keys. ## User filter Follows the session filter exactly: a dropdown of the distinct values seen in the searched range, applied centrally in `baseLLMChartConfig` so every tab picks it up, and held in the URL rather than the search form so it can be set programmatically without two-way sync. Users resolve through the same cross-dialect expression "Top Users" groups by (`user.email`, `enduser.id`, `user.id`, `ai.telemetry.metadata.userId`), so a dropdown value always matches the rows that produced it. The Errors tab's correlated **log** query is scoped too, matched against the log source's own attribute column. `SessionSelect` and `UserSelect` are now thin wrappers over a shared `DistinctValueSelect`. That extraction also fixed a bug pre-existing in `SessionSelect`: an applied value absent from the fetched options fell back to the placeholder, so the control read "All users" while every chart stayed filtered. Absence isn't only staleness — it also happens before the query resolves (deep-linking a filtered URL) and when the value falls off the row limit — so the applied value is kept selectable rather than cleared. `buildSessionCondition` is now `buildScopeCondition`: it was already generic, and there are two scopes. ## Behavior changes **LLM span detection is presence-based**: a span carrying `gen_ai.system` at all is an LLM span whatever the value. Nothing groups by that predicate, so it can't produce a blank row. On staging this made no difference (66 = 66 over 248M rows) — OTel SDKs omit attributes rather than emitting empty strings — but it is a real semantic change and is in the changeset. Every other gate preserves its exact previous semantics. ## Deliberately unchanged - **Value expressions** (`model`, token counts, `costUsd`, `ttftMs`, …) still read subscripts — they need the values. They remain subject to the planning fan-out, which the client-level setting in #3102 covers. - **`isToolSpan`'s `openinference.span.kind = 'TOOL'`** stays an equality: it discriminates one span kind from others, and the query builder rewrites equality onto the attribute-items index. - **`hasTtft`** keeps `> 0` — it gates a p50/p95 and zero-valued rows would drag the percentiles down. - **JSON attribute columns** are untouched. Their paths are real subcolumns, there is no key index to prune with, and a presence term would only duplicate reads — so the JSON branch emits the original expression, pinned by `toBe` assertions. - **`buildScopeCondition`'s `coalesce(...) = 'value'`.** The indexable form is `has(SpanAttributeItems, 'key=value')`, but the items column name lives in `renderChartConfig`'s `textIndexInfoLookup` and isn't reachable from the app package. Follow-up. ## Caveat: materialized columns `fastifySQL` rewrites a `Col['key']` subscript onto a materialized column when an operator has created one, and it matches on the subscript AST node — so it cannot see `mapContains`. Such tables never had the planning problem either, since a rewritten subscript is no longer a subcolumn reference. This trades that rewrite for skip-index pruning, which is the better deal only where those columns don't exist (the default, and what the numbers above were measured on). Teaching `fastifySQL` to match the `mapContains` shape would fix this and benefit queryParser's existing `indexHint(mapContains(...))` emissions too, but it changes shared query planning for every source. Not in this PR. ## How to test on Vercel preview **Preview route:** `/llm` 1. Open `/llm`; confirm the Overview tab renders tiles with data, and that values match what the page showed before this change. 2. Latency tab: "Time to First Token" still plots median and p95. 3. Sessions tab: the session dropdown populates and selecting one filters every tile. 4. **User filter**: pick a user from the new dropdown and confirm every tab rescopes, including the Errors tab's log rows. Copy the URL, open it in a new tab, and confirm the dropdown still shows the selected user rather than "All users". 5. Move the time picker to a window with no data for the selected user; the dropdown must keep showing that user (with a clear "×") rather than reading "All users" over empty charts. ## Verification - `yarn ci:unit` (app): 233 suites, 3,827 tests, pass - `yarn ci:unit` (common-utils): 36 suites, 2,526 tests, pass - `make ci-lint`: 0 errors · `tsc --noEmit` clean · `knip` clean - Staging `EXPLAIN` A/B above; result equivalence confirmed over 7 days of data ## Notes for reviewers - **The staging numbers were taken on the bare `mapContains` shape**, before review moved most gates to `indexHint(presence) AND value`. `indexHint` feeds the same expression to index analysis, and the `isLLMSpan` headline (36s → 7ms) is directly valid since that gate is still bare presence — but the post-review shape was not re-measured on staging. It is covered by a `renderChartConfig` test asserting the kv-items rewrite still fires through an `indexHint`-bearing condition. - Staging still runs the **bloom-filter** `idx_span_attr_key`, since #3096 isn't deployed there. `mapContains` is served by both bloom-filter and text indexes on `mapKeys`, so the pruning result holds either way — but the measurements are from the bloom-filter index. - The two `EXPLAIN indexes = 1` runs report different part counts (16 vs 19) because ingest continued between them; it doesn't affect the granule comparison. - Two changesets: `patch` for the perf fix, `minor` for the user filter. ## References - Related: [HDX-5348](https://linear.app/clickhouse/issue/HDX-5348/ch-263-allow-calculating-subcolumns-sizes-for-merge-tree-reading-makes) — the 26.3 planning regression; #3102 handles it at the client level - Builds on: #3096 (trace schema text indexes) --- .changeset/great-hoops-shave.md | 41 ++++++ .changeset/olive-cameras-repeat.md | 15 ++ .../__tests__/DistinctValueSelect.test.tsx | 95 +++++++++++++ .../app/src/llm/__tests__/ErrorsTab.test.tsx | 10 ++ .../app/src/llm/__tests__/LatencyTab.test.tsx | 10 +- .../src/llm/__tests__/SessionsTab.test.tsx | 10 ++ .../app/src/llm/__tests__/chartConfig.test.ts | 58 +++++++- packages/app/src/llm/__tests__/detect.test.ts | 14 +- .../app/src/llm/__tests__/expressions.test.ts | 129 +++++++++++++++-- .../src/llm/dashboard/DistinctValueSelect.tsx | 101 ++++++++++++++ packages/app/src/llm/dashboard/ErrorsTab.tsx | 13 +- .../src/llm/dashboard/LLMDashboardPage.tsx | 12 ++ packages/app/src/llm/dashboard/LatencyTab.tsx | 4 +- .../app/src/llm/dashboard/SessionSelect.tsx | 75 ++-------- .../app/src/llm/dashboard/SessionsTab.tsx | 3 +- packages/app/src/llm/dashboard/UserSelect.tsx | 40 ++++++ packages/app/src/llm/dashboard/chartConfig.ts | 31 +++-- packages/app/src/llm/dashboard/types.ts | 6 + packages/app/src/llm/lib/detect.ts | 78 ++++++++++- packages/app/src/llm/lib/expressions.ts | 130 +++++++++++++++--- .../rewriteSqlFilterWithKvItems.test.ts | 26 ++++ 21 files changed, 781 insertions(+), 120 deletions(-) create mode 100644 .changeset/great-hoops-shave.md create mode 100644 .changeset/olive-cameras-repeat.md create mode 100644 packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx create mode 100644 packages/app/src/llm/dashboard/DistinctValueSelect.tsx create mode 100644 packages/app/src/llm/dashboard/UserSelect.tsx diff --git a/.changeset/great-hoops-shave.md b/.changeset/great-hoops-shave.md new file mode 100644 index 0000000000..52b23549a4 --- /dev/null +++ b/.changeset/great-hoops-shave.md @@ -0,0 +1,41 @@ +--- +'@hyperdx/app': patch +--- + +fix: use mapContains for LLM dashboard attribute-presence filters + +The LLM dashboard tested attribute presence with `SpanAttributes['key'] != ''`, +which no skip index can serve — the trace schema's `mapKeys(SpanAttributes)` +index only answers `mapContains`, and `!= ''` normalizes to `notEmpty()`. Every +tile therefore scanned all granules. Map subscripts are also subcolumn +references, so on ClickHouse 26.3+ each one adds a per-part size lookup during +PREWHERE planning. + +These filters now lead with `mapContains`, which the index serves and which +costs no per-part lookups. On a staging trace table the LLM span predicate went +from a 36s planning stall to 7ms, and a two-key filter dropped from 1,306 +granules to 3. + +Gates that pair with a value expression the dashboard groups by keep their +non-empty check, so an attribute set to `''` still cannot appear as a blank row. +There the value term defines the result and the presence term is pruning only, +so it is wrapped in `indexHint` — it reaches skip-index analysis without being +re-evaluated per surviving row. The value term costs no extra per-part lookups, +since it reads the same keys the group-by already reads. Gates that land in a +select-list aggregate are left unhinted, since skip-index analysis does not +reach the select list; the tool-call gate is used in both positions and so is +exposed in both forms. + +The one behavior change is LLM span detection, which is now presence-based: a +span carrying `gen_ai.system` at all is treated as an LLM span whatever the +value. Nothing groups by that predicate. + +One caveat for tables with materialized columns: a `SpanAttributes['key']` +subscript gets rewritten onto a materialized column when an operator has created +one, and `mapContains` is not matched by that rewrite. Such tables were never +affected by the planning cost either, since a rewritten subscript is no longer a +subcolumn reference — so this trades that rewrite for skip-index pruning, which +is the better deal only where those columns do not exist. + +JSON attribute columns are unchanged — their paths are real subcolumns, there is +no key index to prune with, and a presence term would only duplicate reads. diff --git a/.changeset/olive-cameras-repeat.md b/.changeset/olive-cameras-repeat.md new file mode 100644 index 0000000000..6d8385f665 --- /dev/null +++ b/.changeset/olive-cameras-repeat.md @@ -0,0 +1,15 @@ +--- +'@hyperdx/app': minor +--- + +feat: filter the LLM dashboard by end user + +Adds a user filter alongside the existing session filter. It lists the distinct +users seen on LLM spans in the searched range and scopes every tab to the one +selected, including the Errors tab's correlated log events. + +Users are resolved with the same cross-dialect expression the "Top Users" chart +groups by (`user.email`, `enduser.id`, `user.id`, +`ai.telemetry.metadata.userId`), so a value picked from the dropdown always +matches the rows that produced it. The selection lives in the URL, so a filtered +view can be shared. diff --git a/packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx b/packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx new file mode 100644 index 0000000000..8565aaf639 --- /dev/null +++ b/packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx @@ -0,0 +1,95 @@ +import { screen } from '@testing-library/react'; + +import { makeTraceSource } from '@/llm/__fixtures__/sources'; +import { DistinctValueSelect } from '@/llm/dashboard/DistinctValueSelect'; + +let mockRows: Record[] | undefined = []; +let mockLoading = false; +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: () => ({ + data: mockRows == null ? undefined : { data: mockRows }, + isLoading: mockLoading, + isError: false, + }), +})); + +const TRACE_SOURCE = makeTraceSource(); + +const baseProps = { + source: TRACE_SOURCE, + valueExpression: "SpanAttributes['user.email']", + gateExpression: '1=1', + queryKey: 'llm-user-select', + allLabel: 'All users', + dateRange: [new Date(0), new Date(1000)] as [Date, Date], +}; + +describe('DistinctValueSelect', () => { + beforeEach(() => { + mockRows = []; + mockLoading = false; + }); + + it('shows the applied value when it is present in the fetched options', () => { + mockRows = [{ value: 'alice@x.com' }, { value: 'bob@x.com' }]; + const onChange = jest.fn(); + renderWithMantine( + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('alice@x.com'); + expect(onChange).not.toHaveBeenCalled(); + }); + + // The charts read the value from the URL, so a control that falls back to + // its placeholder here would read "All users" over a filtered dashboard — + // which looks like an unfiltered view returning nothing. + it('keeps the applied value selected when it is absent from the options', () => { + mockRows = [{ value: 'bob@x.com' }]; + const onChange = jest.fn(); + renderWithMantine( + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('alice@x.com'); + // Clearing the parent here would discard a filter the user still has + // applied, so absence must never write back. + expect(onChange).not.toHaveBeenCalled(); + }); + + // Deep-linking into a filtered URL renders once before the distinct-value + // query resolves; the value is not stale then, just not fetched yet. + it('keeps the applied value selected while the options are still loading', () => { + mockRows = undefined; + mockLoading = true; + const onChange = jest.fn(); + renderWithMantine( + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('alice@x.com'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('falls back to the placeholder when no value is applied', () => { + mockRows = [{ value: 'bob@x.com' }]; + renderWithMantine( + , + ); + + expect(screen.getByRole('combobox')).toHaveValue(''); + expect(screen.getByPlaceholderText('All users')).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/llm/__tests__/ErrorsTab.test.tsx b/packages/app/src/llm/__tests__/ErrorsTab.test.tsx index 819bc2e79a..211bcd9275 100644 --- a/packages/app/src/llm/__tests__/ErrorsTab.test.tsx +++ b/packages/app/src/llm/__tests__/ErrorsTab.test.tsx @@ -41,6 +41,7 @@ describe('ErrorsTab', () => { logSource={LOG_SOURCE} logExpressions={logExpressions} sessionId="ses_123" + userId="alice@x.com" />, ); @@ -56,6 +57,9 @@ describe('ErrorsTab', () => { expect(traceConditions.some((c: string) => c.includes("'ses_123'"))).toBe( true, ); + expect( + traceConditions.some((c: string) => c.includes("'alice@x.com'")), + ).toBe(true); expect(traceConfig.orderBy[0].ordering).toBe('DESC'); const logConfig = rowTableProps[1].config; @@ -70,6 +74,12 @@ describe('ErrorsTab', () => { c.includes("'ses_123'"), ); expect(sessionCondition).toContain("LogAttributes['session.id']"); + // Same for the user scope: matched against the log source's own column, + // not the trace source's. + const userCondition = logConditions.find((c: string) => + c.includes("'alice@x.com'"), + ); + expect(userCondition).toContain("LogAttributes['user.email']"); }); it('renders only the trace tile without a log source', () => { diff --git a/packages/app/src/llm/__tests__/LatencyTab.test.tsx b/packages/app/src/llm/__tests__/LatencyTab.test.tsx index 60e21fc88e..abb4c2de15 100644 --- a/packages/app/src/llm/__tests__/LatencyTab.test.tsx +++ b/packages/app/src/llm/__tests__/LatencyTab.test.tsx @@ -38,8 +38,13 @@ describe('LatencyTab', () => { jest.clearAllMocks(); }); - it('scopes the heatmap to LLM spans (and session) without the cost alias', () => { - renderWithMantine(); + it('scopes the heatmap to LLM spans (and session/user) without the cost alias', () => { + // This tab passes the scopes into baseLLMChartConfig by hand rather than + // spreading props, and memoizes on them — a scope missing from either + // list leaves the heatmap silently unscoped. + renderWithMantine( + , + ); expect(screen.getByTestId('search-heatmap-chart')).toBeInTheDocument(); const { chartConfig, source } = heatmapChartProps[0]; @@ -47,6 +52,7 @@ describe('LatencyTab', () => { const conditions = chartConfig.filters.map((f: any) => f.condition); expect(conditions[0]).toBe(expressions.isLLMSpan); expect(conditions[1]).toContain("'ses_123'"); + expect(conditions[2]).toContain("'alice@x.com'"); // Delta sampling queries never reference cost; the WITH binding is // skipped to keep them small. expect(chartConfig.with).toBeUndefined(); diff --git a/packages/app/src/llm/__tests__/SessionsTab.test.tsx b/packages/app/src/llm/__tests__/SessionsTab.test.tsx index 659dacab69..08dc3810b8 100644 --- a/packages/app/src/llm/__tests__/SessionsTab.test.tsx +++ b/packages/app/src/llm/__tests__/SessionsTab.test.tsx @@ -46,6 +46,16 @@ describe('SessionsTab', () => { expect(conditions).toContain(expressions.isLLMSpan); expect(conditions).toContain(expressions.hasSessionId); + // aggCondition is a select-list position, so it must use the unhinted + // gate: skip-index analysis never reaches the select list, and the hinted + // form would fold to a constant inside countIf while lengthening every + // Sessions query. + const toolCallsSelect = config.select.find( + (s: any) => s.alias === 'Tool Calls', + ); + expect(toolCallsSelect.aggCondition).toBe(expressions.isToolSpanUnhinted); + expect(toolCallsSelect.aggCondition).not.toContain('indexHint'); + // Token/cost sums use the per-service provided-cost election so apps // emitting several instrumentation dialects in parallel don't double // count (and token-only apps aren't dropped by cost-reporting ones). diff --git a/packages/app/src/llm/__tests__/chartConfig.test.ts b/packages/app/src/llm/__tests__/chartConfig.test.ts index 3ed89460d8..cec84ae563 100644 --- a/packages/app/src/llm/__tests__/chartConfig.test.ts +++ b/packages/app/src/llm/__tests__/chartConfig.test.ts @@ -3,7 +3,7 @@ import { appendWhereClause, baseLLMChartConfig, buildDeltaFilterClause, - buildSessionCondition, + buildScopeCondition, buildTrimmedDeltaSelect, DELTA_ATTRIBUTE_VALUE_MAX_LENGTH, } from '@/llm/dashboard/chartConfig'; @@ -21,15 +21,24 @@ const baseProps = { whereLanguage: 'sql' as const, }; -describe('buildSessionCondition', () => { +describe('buildScopeCondition', () => { it('escapes the session id value', () => { - const condition = buildSessionCondition( + const condition = buildScopeCondition( expressions.sessionId, "ses_1'; DROP TABLE x --", ); expect(condition).toContain(expressions.sessionId); expect(condition).toContain("'ses_1\\'; DROP TABLE x --'"); }); + + it('escapes the user value', () => { + const condition = buildScopeCondition( + expressions.userId, + "alice'; DROP TABLE x --", + ); + expect(condition).toContain(expressions.userId); + expect(condition).toContain("'alice\\'; DROP TABLE x --'"); + }); }); describe('buildDeltaFilterClause', () => { @@ -123,7 +132,7 @@ describe('baseLLMChartConfig session scoping', () => { expect(config.filters).toHaveLength(2); expect(config.filters[1]).toEqual({ type: 'sql', - condition: buildSessionCondition(expressions.sessionId, 'ses_123'), + condition: buildScopeCondition(expressions.sessionId, 'ses_123'), }); }); @@ -136,6 +145,47 @@ describe('baseLLMChartConfig session scoping', () => { expect(config.filters).toHaveLength(3); expect(config.filters[2]).toEqual({ type: 'sql', condition: '1=1' }); }); +}); + +describe('baseLLMChartConfig user scoping', () => { + it('adds no user filter by default', () => { + const config = baseLLMChartConfig(baseProps); + expect(config.filters).toEqual([ + { type: 'sql', condition: expressions.isLLMSpan }, + ]); + }); + + it('appends the user filter to every chart when userId is set', () => { + const config = baseLLMChartConfig({ ...baseProps, userId: 'alice@x.com' }); + expect(config.filters).toHaveLength(2); + expect(config.filters[1]).toEqual({ + type: 'sql', + condition: buildScopeCondition(expressions.userId, 'alice@x.com'), + }); + }); + + // The two scopes are independent, so both must survive together and keep a + // stable order — extra filters are appended relative to them. + it('applies session and user together, session first, extras last', () => { + const config = baseLLMChartConfig({ + ...baseProps, + sessionId: 'ses_123', + userId: 'alice@x.com', + extraFilters: [{ type: 'sql', condition: '1=1' }], + }); + expect(config.filters).toEqual([ + { type: 'sql', condition: expressions.isLLMSpan }, + { + type: 'sql', + condition: buildScopeCondition(expressions.sessionId, 'ses_123'), + }, + { + type: 'sql', + condition: buildScopeCondition(expressions.userId, 'alice@x.com'), + }, + { type: 'sql', condition: '1=1' }, + ]); + }); it('binds the cost expression once as a WITH expression alias when opted in', () => { // The cost expression embeds the model price catalog (~70 KiB of SQL); diff --git a/packages/app/src/llm/__tests__/detect.test.ts b/packages/app/src/llm/__tests__/detect.test.ts index 783c538528..d035de414c 100644 --- a/packages/app/src/llm/__tests__/detect.test.ts +++ b/packages/app/src/llm/__tests__/detect.test.ts @@ -44,19 +44,26 @@ describe('isLLMSpan', () => { }); describe('buildLLMSpanSqlPredicate', () => { - it('builds a map-column predicate', () => { + // mapContains, not `col['k'] != ''`: only the former can be served by a + // mapKeys() skip index, and it is not a subcolumn reference so it stays out + // of the per-part size lookups PREWHERE planning does on CH 26.3+. + it('builds a map-column predicate with mapContains', () => { const predicate = buildLLMSpanSqlPredicate({ attributeField: 'SpanAttributes', isJsonColumn: false, }); expect(predicate).toContain( - "SpanAttributes['gen_ai.operation.name'] != ''", + "mapContains(SpanAttributes, 'gen_ai.operation.name')", ); - expect(predicate).toContain("SpanAttributes['llm.model_name'] != ''"); + expect(predicate).toContain( + "mapContains(SpanAttributes, 'llm.model_name')", + ); + expect(predicate).not.toContain("!= ''"); expect(predicate.startsWith('(')).toBe(true); expect(predicate.endsWith(')')).toBe(true); }); + // JSON paths are real subcolumns; mapContains does not apply to them. it('builds a JSON-column predicate with backtick paths', () => { const predicate = buildLLMSpanSqlPredicate({ attributeField: 'SpanAttributes', @@ -65,5 +72,6 @@ describe('buildLLMSpanSqlPredicate', () => { expect(predicate).toContain( "toString(SpanAttributes.`gen_ai.operation.name`) != ''", ); + expect(predicate).not.toContain('mapContains'); }); }); diff --git a/packages/app/src/llm/__tests__/expressions.test.ts b/packages/app/src/llm/__tests__/expressions.test.ts index 3f04ce58d5..5852854ccc 100644 --- a/packages/app/src/llm/__tests__/expressions.test.ts +++ b/packages/app/src/llm/__tests__/expressions.test.ts @@ -30,7 +30,7 @@ describe('getLLMExpressions', () => { `${expressions.effectiveInputTokens} + ${expressions.outputTokens}`, ); expect(expressions.isLLMSpan).toContain( - "SpanAttributes['gen_ai.operation.name'] != ''", + "mapContains(SpanAttributes, 'gen_ai.operation.name')", ); expect(expressions.isError).toBe("lower(StatusCode) = 'error'"); expect(expressions.durationInMillis).toBe('Duration/1e6'); @@ -51,6 +51,37 @@ describe('getLLMExpressions', () => { expect(expressions.isLLMSpan).toContain( "toString(SpanAttributes.`gen_ai.operation.name`) != ''", ); + // mapContains does not apply to JSON paths. + expect(expressions.isLLMSpan).not.toContain('mapContains'); + expect(expressions.isToolSpan).not.toContain('mapContains'); + + // On JSON there is no key index to prune with, so the gates carry the + // value test alone rather than doubling their subcolumn reads with a + // presence term that says the same thing. + expect(expressions.isToolSpan).not.toContain('indexHint'); + expect(expressions.hasTtft).toBe( + 'greatest(toFloat64OrZero(toString(SpanAttributes.`ttft_ms`)), ' + + 'toFloat64OrZero(toString(SpanAttributes.`ai.response.msToFirstChunk`)), ' + + 'toFloat64OrZero(toString(SpanAttributes.`copilot_chat.time_to_first_token`))) > 0', + ); + expect(expressions.hasSessionId).toBe( + "coalesce(nullif(toString(SpanAttributes.`gen_ai.conversation.id`), ''), " + + "nullif(toString(SpanAttributes.`session.id`), ''), " + + "nullif(toString(SpanAttributes.`ai.telemetry.metadata.sessionId`), ''), '') != ''", + ); + // Unhinted on both branches, so pin the JSON form itself — a + // not.toContain('indexHint') here would hold either way and prove nothing. + expect(expressions.hasReportedTokens).toBe( + "coalesce(nullif(toString(SpanAttributes.`gen_ai.usage.input_tokens`), ''), " + + "nullif(toString(SpanAttributes.`gen_ai.usage.prompt_tokens`), ''), " + + "nullif(toString(SpanAttributes.`gen_ai.usage.output_tokens`), ''), " + + "nullif(toString(SpanAttributes.`gen_ai.usage.completion_tokens`), ''), " + + "nullif(toString(SpanAttributes.`llm.token_count.prompt`), ''), " + + "nullif(toString(SpanAttributes.`llm.token_count.completion`), ''), " + + "nullif(toString(SpanAttributes.`llm.token_count.total`), ''), " + + "nullif(toString(SpanAttributes.`input_tokens`), ''), " + + "nullif(toString(SpanAttributes.`output_tokens`), ''), '') != ''", + ); }); it('derives session id and reported-token gate expressions', () => { @@ -65,17 +96,35 @@ describe('getLLMExpressions', () => { expect(expressions.sessionId).toContain( "nullif(SpanAttributes['ai.telemetry.metadata.sessionId'], '')", ); - expect(expressions.hasSessionId).toContain("!= ''"); + // Presence terms for the index, value term for the meaning: a key set to + // '' must not pass, or SessionsTab groups it as a blank row whose link + // carries no session id. + expect(expressions.hasSessionId).toBe( + "(indexHint((mapContains(SpanAttributes, 'gen_ai.conversation.id') OR " + + "mapContains(SpanAttributes, 'session.id') OR " + + "mapContains(SpanAttributes, 'ai.telemetry.metadata.sessionId'))) AND " + + "coalesce(nullif(SpanAttributes['gen_ai.conversation.id'], ''), " + + "nullif(SpanAttributes['session.id'], ''), " + + "nullif(SpanAttributes['ai.telemetry.metadata.sessionId'], ''), '') != '')", + ); // The gate keys on authoritative usage reporters only — wrapper spans // carrying just ai.usage.* must not satisfy it. expect(expressions.hasReportedTokens).toContain( - "SpanAttributes['gen_ai.usage.input_tokens'] != ''", + "nullif(SpanAttributes['gen_ai.usage.input_tokens'], '')", ); expect(expressions.hasReportedTokens).toContain( - "SpanAttributes['llm.token_count.total'] != ''", + "nullif(SpanAttributes['llm.token_count.total'], '')", ); expect(expressions.hasReportedTokens).not.toContain('ai.usage.inputTokens'); + // This gate decides which rows enter every token and cost aggregate, so + // pin the value test: reverting it to presence-only would let a key set + // to '' inflate the call count. + expect(expressions.hasReportedTokens).toMatch(/^coalesce\(nullif\(/); + expect(expressions.hasReportedTokens).toContain("!= ''"); + // Only ever embedded in select-list aggregates, where a hint cannot prune + // and would double the length of an already-long expression. + expect(expressions.hasReportedTokens).not.toContain('indexHint'); }); it('derives efficiency, attribution, and agent expressions', () => { @@ -126,7 +175,17 @@ describe('getLLMExpressions', () => { expect(expressions.ttftMs).toContain( "SpanAttributes['copilot_chat.time_to_first_token']", ); - expect(expressions.hasTtft).toContain('> 0'); + // Pinned exactly: the terms must be ANDed. With OR, zero-TTFT rows reach + // the p50/p95 the value check exists to keep them out of, and a + // toContain-style assertion would not notice. + expect(expressions.hasTtft).toBe( + "(indexHint((mapContains(SpanAttributes, 'ttft_ms') OR " + + "mapContains(SpanAttributes, 'ai.response.msToFirstChunk') OR " + + "mapContains(SpanAttributes, 'copilot_chat.time_to_first_token'))) AND " + + "greatest(toFloat64OrZero(SpanAttributes['ttft_ms']), " + + "toFloat64OrZero(SpanAttributes['ai.response.msToFirstChunk']), " + + "toFloat64OrZero(SpanAttributes['copilot_chat.time_to_first_token'])) > 0)", + ); // Tool name coalesce includes the flat form. expect(expressions.toolName).toContain("SpanAttributes['tool_name']"); @@ -136,16 +195,70 @@ describe('getLLMExpressions', () => { "SpanAttributes['gen_ai.agent.name']", ); expect(expressions.agentName).toContain("SpanAttributes['agent.name']"); - expect(expressions.hasAgentName).toContain("!= ''"); + expect(expressions.hasAgentName).toBe( + "(indexHint((mapContains(SpanAttributes, 'gen_ai.agent.name') OR " + + "mapContains(SpanAttributes, 'agent.name'))) AND " + + "coalesce(nullif(SpanAttributes['gen_ai.agent.name'], ''), " + + "nullif(SpanAttributes['agent.name'], ''), '') != '')", + ); // Finish reasons normalized out of their JSON-array encoding. expect(expressions.finishReason).toMatch(/^replaceRegexpAll\(/); expect(expressions.finishReason).toContain("SpanAttributes['stop_reason']"); + // Drives the finish-reason breakdown, so it needs the value term to keep + // a blank slice out of the chart. + expect(expressions.hasFinishReason).toBe( + "(indexHint((mapContains(SpanAttributes, 'gen_ai.response.finish_reasons') OR " + + "mapContains(SpanAttributes, 'stop_reason') OR " + + "mapContains(SpanAttributes, 'llm.finish_reason') OR " + + "mapContains(SpanAttributes, 'ai.response.finishReason'))) AND " + + "coalesce(nullif(SpanAttributes['gen_ai.response.finish_reasons'], ''), " + + "nullif(SpanAttributes['stop_reason'], ''), " + + "nullif(SpanAttributes['llm.finish_reason'], ''), " + + "nullif(SpanAttributes['ai.response.finishReason'], ''), '') != '')", + ); + // User attribution coalesce. expect(expressions.userId).toContain("SpanAttributes['user.email']"); expect(expressions.userId).toContain("SpanAttributes['enduser.id']"); - expect(expressions.hasUserId).toContain("!= ''"); + expect(expressions.hasUserId).toContain( + "mapContains(SpanAttributes, 'user.email')", + ); + // The value term keeps a blank user out of the Top Users group-by. + expect(expressions.hasUserId).toContain( + "AND coalesce(nullif(SpanAttributes['user.email'], '')", + ); + + // The span-kind term must stay an equality: widening it to a presence + // check would pull every OpenInference span into the tool charts. + // The hint covers the span-kind key and sits above the OR: a skip index + // cannot prune an OR whose other arm it can't decide, so nesting it inside + // would silently stop pruning the tool charts. + expect(expressions.isToolSpan).toBe( + "(indexHint((mapContains(SpanAttributes, 'openinference.span.kind') OR " + + "mapContains(SpanAttributes, 'gen_ai.tool.name') OR " + + "mapContains(SpanAttributes, 'ai.toolCall.name') OR " + + "mapContains(SpanAttributes, 'tool_name') OR " + + "mapContains(SpanAttributes, 'gen_ai.tool.call.id'))) AND " + + "(SpanAttributes['openinference.span.kind'] = 'TOOL' OR " + + "coalesce(nullif(SpanAttributes['gen_ai.tool.name'], ''), " + + "nullif(SpanAttributes['ai.toolCall.name'], ''), " + + "nullif(SpanAttributes['tool_name'], ''), " + + "nullif(SpanAttributes['gen_ai.tool.call.id'], ''), '') != ''))", + ); + // Every key the tool-name expression can resolve must also satisfy the + // gate, or the span is dropped from the tool charts under a name the + // dashboard could have shown. + for (const key of ['gen_ai.tool.name', 'ai.toolCall.name', 'tool_name']) { + expect(expressions.toolName).toContain(`SpanAttributes['${key}']`); + expect(expressions.isToolSpan).toContain(`SpanAttributes['${key}']`); + } + // SessionsTab uses this one as an aggCondition, where a hint folds to a + // constant. It must stay the bare match, and must still be the same + // predicate the hinted form wraps. + expect(expressions.isToolSpanUnhinted).not.toContain('indexHint'); + expect(expressions.isToolSpan).toContain(expressions.isToolSpanUnhinted); expect(expressions.statusMessage).toBe('StatusMessage'); }); @@ -247,7 +360,7 @@ describe('getLLMLogExpressions', () => { "nullif(LogAttributes['session.id'], '')", ); expect(expressions.isLLMSpan).toContain( - "LogAttributes['gen_ai.provider.name'] != ''", + "mapContains(LogAttributes, 'gen_ai.provider.name')", ); // LLM-related = LLM markers OR any session id, so tool_result / // lifecycle log events that only carry session.id are included. diff --git a/packages/app/src/llm/dashboard/DistinctValueSelect.tsx b/packages/app/src/llm/dashboard/DistinctValueSelect.tsx new file mode 100644 index 0000000000..fe947d79b0 --- /dev/null +++ b/packages/app/src/llm/dashboard/DistinctValueSelect.tsx @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types'; +import { Select, SelectProps } from '@mantine/core'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; + +/** + * Dropdown over the distinct values of one LLM span expression in the + * searched range — the shape both dashboard-wide scope filters (session, + * user) share. + * + * Plain value/onChange: the dashboard keeps these scopes in the URL rather + * than in the search form, so they can also be set programmatically (e.g. the + * session drawer's "Filter dashboard") without two-way form sync. + */ +export function DistinctValueSelect({ + source, + valueExpression, + gateExpression, + queryKey, + allLabel, + value, + onChange, + dateRange, + ...props +}: { + source: TTraceSource | undefined; + /** Expression whose distinct values populate the dropdown. */ + valueExpression: string | undefined; + /** Scope predicate, ANDed with the LLM-span filter (e.g. hasUserId). */ + gateExpression: string | undefined; + /** Distinguishes this select's cached query from its siblings'. */ + queryKey: string; + /** Label for the "no filter" option, e.g. 'All users'. */ + allLabel: string; + value: string; + onChange: (value: string) => void; + dateRange: [Date, Date]; +} & Omit) { + const enabled = + source?.kind === SourceKind.Trace && !!valueExpression && !!gateExpression; + + const queriedConfig = { + source: source?.id, + timestampValueExpression: source?.timestampValueExpression || '', + from: { + databaseName: source?.from.databaseName || '', + tableName: source?.from.tableName || '', + }, + connection: source?.connection || '', + select: [ + { alias: 'value', valueExpression: `distinct(${valueExpression})` }, + ], + where: gateExpression || '', + whereLanguage: 'sql' as const, + limit: { limit: 10000 }, + dateRange, + }; + + const { data, isLoading, isError } = useQueriedChartConfig(queriedConfig, { + placeholderData: (prev: any) => prev, + queryKey: [queryKey, queriedConfig], + enabled, + }); + + const values = useMemo(() => { + const found: string[] = + data?.data + ?.map((d: any) => d.value) + .filter(Boolean) + .sort() || []; + // Keep the applied value selectable even when it is not in `found`, so the + // control can never read "all" while the charts are still filtered. It is + // missing in three cases, only one of which is staleness: the query has + // not resolved yet (deep link into a filtered URL), the value fell off the + // end of the row limit, or the searched window no longer contains it. + // + // Clearing the parent instead would silently discard a filter in the first + // two, and would mean writing to the URL from a render driven by fetch + // timing — which is what keeping these selects out of the search form is + // meant to avoid. + const applied = value && !found.includes(value) ? [value] : []; + return [{ value: '', label: allLabel }, ...applied, ...found]; + }, [data, allLabel, value]); + + return ( + onChange(v ?? '')} - disabled={isLoading || isError} - comboboxProps={{ withinPortal: false }} - searchable - clearable - placeholder="All sessions" - maxDropdownHeight={280} - nothingFoundMessage={isLoading ? 'Loading more...' : 'No matches found'} + source={source} + valueExpression={expressions?.sessionId} + gateExpression={ + expressions && + `${expressions.isLLMSpan} AND ${expressions.hasSessionId}` + } + queryKey="llm-session-select" + allLabel="All sessions" /> ); } diff --git a/packages/app/src/llm/dashboard/SessionsTab.tsx b/packages/app/src/llm/dashboard/SessionsTab.tsx index 9106e982fa..a0f3f3ee1e 100644 --- a/packages/app/src/llm/dashboard/SessionsTab.tsx +++ b/packages/app/src/llm/dashboard/SessionsTab.tsx @@ -114,7 +114,8 @@ export function SessionsTab(props: LLMChartProps) { alias: 'Tool Calls', aggFn: 'count', valueExpression: '', - aggCondition: expressions.isToolSpan, + // Select-list position: the hinted form cannot prune here. + aggCondition: expressions.isToolSpanUnhinted, aggConditionLanguage: 'sql', numberFormat: INTEGER_NUMBER_FORMAT, }, diff --git a/packages/app/src/llm/dashboard/UserSelect.tsx b/packages/app/src/llm/dashboard/UserSelect.tsx new file mode 100644 index 0000000000..b1e2ccf045 --- /dev/null +++ b/packages/app/src/llm/dashboard/UserSelect.tsx @@ -0,0 +1,40 @@ +import { TTraceSource } from '@hyperdx/common-utils/dist/types'; +import { SelectProps } from '@mantine/core'; + +import { LLMExpressions } from '@/llm/lib/expressions'; + +import { DistinctValueSelect } from './DistinctValueSelect'; + +/** + * End-user filter dropdown: distinct users seen on LLM spans in the searched + * range, resolved via the cross-dialect user expression (user.email, + * enduser.id, user.id, ai.telemetry.metadata.userId). + * + * Values come from the same expression the "Top Users" chart groups by, so a + * selection always matches the rows that produced it. + */ +export function UserSelect({ + source, + expressions, + ...props +}: { + /** Trace source: users are resolved from LLM spans. */ + source: TTraceSource | undefined; + expressions: LLMExpressions | undefined; + dateRange: [Date, Date]; + value: string; + onChange: (userId: string) => void; +} & Omit) { + return ( + + ); +} diff --git a/packages/app/src/llm/dashboard/chartConfig.ts b/packages/app/src/llm/dashboard/chartConfig.ts index 96fe69089d..188eaee50f 100644 --- a/packages/app/src/llm/dashboard/chartConfig.ts +++ b/packages/app/src/llm/dashboard/chartConfig.ts @@ -9,18 +9,21 @@ import { LLM_COST_SQL_ALIAS } from '@/llm/lib/expressions'; import { LLMChartProps } from './types'; -/** `sessionIdExpr = 'value'` condition, SQL-escaped. */ -export function buildSessionCondition( - sessionIdExpr: string, - sessionId: string, -): string { - return SqlString.format('? = ?', [SqlString.raw(sessionIdExpr), sessionId]); +/** + * `scopeExpr = 'value'` condition, SQL-escaped. + * + * Used for the dashboard-wide scope filters (session, user), whose values are + * matched exactly against a cross-dialect coalesced expression — the same + * expression the corresponding dropdown selected them from. + */ +export function buildScopeCondition(scopeExpr: string, value: string): string { + return SqlString.format('? = ?', [SqlString.raw(scopeExpr), value]); } /** * Base chart-config fields shared by every LLM dashboard chart: source * binding, the user's where clause, the LLM-span scope filter, the optional - * session-id scope, and the searched date range. + * session-id and user scopes, and the searched date range. */ export function baseLLMChartConfig({ source, @@ -29,6 +32,7 @@ export function baseLLMChartConfig({ where, whereLanguage, sessionId, + userId, extraFilters = [], withCostAlias = IS_LLM_COST_ENABLED, }: LLMChartProps & { @@ -72,10 +76,15 @@ export function baseLLMChartConfig({ ? [ { type: 'sql' as const, - condition: buildSessionCondition( - expressions.sessionId, - sessionId, - ), + condition: buildScopeCondition(expressions.sessionId, sessionId), + }, + ] + : []), + ...(userId + ? [ + { + type: 'sql' as const, + condition: buildScopeCondition(expressions.userId, userId), }, ] : []), diff --git a/packages/app/src/llm/dashboard/types.ts b/packages/app/src/llm/dashboard/types.ts index d78eeb74f0..82ddd05e00 100644 --- a/packages/app/src/llm/dashboard/types.ts +++ b/packages/app/src/llm/dashboard/types.ts @@ -18,6 +18,12 @@ export interface LLMChartProps { * via the cross-dialect session expression). */ sessionId?: string; + /** + * When set, every chart is additionally scoped to this end user (matched + * via the cross-dialect user expression, which coalesces user.email, + * enduser.id, user.id and ai.telemetry.metadata.userId). + */ + userId?: string; /** Correlated log source for LLM log events, when one is selected. */ logSource?: TLogSource; logExpressions?: LLMLogExpressions; diff --git a/packages/app/src/llm/lib/detect.ts b/packages/app/src/llm/lib/detect.ts index d658ad9627..a429f1d874 100644 --- a/packages/app/src/llm/lib/detect.ts +++ b/packages/app/src/llm/lib/detect.ts @@ -1,3 +1,5 @@ +import SqlString from 'sqlstring'; + import { hasKeyWithPrefix } from './attributeUtils'; import { LLMSpanEvent, SpanAttributeMap } from './types'; @@ -81,10 +83,73 @@ export function isLLMSpan( return (events ?? []).some(event => event.name?.startsWith('gen_ai.')); } +/** + * SQL testing whether an attribute key is present on a row. + * + * For map columns this is `mapContains`, not `col['key'] != ''`, for two + * reasons. It is one of the few shapes a `mapKeys(col)` skip index can serve + * (`MergeTreeIndexConditionText::isSupportedFunction`) — `!= ''` normalizes to + * `notEmpty()`, which no index supports, so it scans every granule. And a map + * subscript is a subcolumn reference, which on ClickHouse 26.3+ costs one + * per-part size lookup during PREWHERE planning; `mapContains` costs none. + * + * JSON columns keep the comparison: their paths are real subcolumns, and + * `mapContains` does not apply. + * + * Trade-off: `fastifySQL` rewrites a `Col['key']` subscript onto a + * materialized column when one exists, and it matches on the subscript AST + * node, so it cannot see this form. On a table where an operator materialized + * one of these keys, the subscript would have read that column instead. The + * subscript is also not a subcolumn reference once rewritten, so such tables + * never had the planning problem — this form trades that rewrite for skip-index + * pruning, which is the better deal only where the columns do not exist (the + * default). + * + * This tests presence only. Callers that pair a gate with a value expression + * they group by need non-emptiness as well, or a key set to '' becomes a blank + * row — see `anyKeyHasValue` in expressions.ts. + * + * queryParser's private `buildMapContains` emits the same call, but it takes a + * rendered `col['key']` subscript and parses it back apart; the field and key + * are already separate here. `attributeField` stays raw because it holds a + * source's `eventAttributesExpression`, which may be a compound expression + * rather than an identifier — the same reason `fieldAccess` interpolates it. + */ +function buildKeyExistsSql({ + attributeField, + key, + isJsonColumn, +}: { + attributeField: string; + key: string; + isJsonColumn: boolean; +}): string { + return isJsonColumn + ? `toString(${attributeField}.\`${key}\`) != ''` + : `mapContains(${attributeField}, ${SqlString.escape(key)})`; +} + +/** SQL matching rows carrying any of the given attribute keys. */ +export function buildAnyKeyExistsSql(args: { + attributeField: string; + keys: readonly string[]; + isJsonColumn: boolean; +}): string { + const { attributeField, keys, isJsonColumn } = args; + const conditions = keys.map(key => + buildKeyExistsSql({ attributeField, key, isJsonColumn }), + ); + return `(${conditions.join(' OR ')})`; +} + /** * Build a SQL predicate matching LLM spans, for use in search filters and * dashboard chart configs. Handles both `Map(String, String)` attribute - * columns (missing key reads as '') and JSON-typed columns. + * columns and JSON-typed columns. + * + * Presence is the right test here: a span carrying `gen_ai.system` at all was + * emitted by LLM instrumentation, whatever the value. Nothing groups by this + * predicate, so an empty value cannot produce a blank row. */ export function buildLLMSpanSqlPredicate({ attributeField, @@ -94,10 +159,9 @@ export function buildLLMSpanSqlPredicate({ attributeField: string; isJsonColumn: boolean; }): string { - const conditions = LLM_MARKER_ATTRIBUTE_KEYS.map(key => - isJsonColumn - ? `toString(${attributeField}.\`${key}\`) != ''` - : `${attributeField}['${key}'] != ''`, - ); - return `(${conditions.join(' OR ')})`; + return buildAnyKeyExistsSql({ + attributeField, + keys: LLM_MARKER_ATTRIBUTE_KEYS, + isJsonColumn, + }); } diff --git a/packages/app/src/llm/lib/expressions.ts b/packages/app/src/llm/lib/expressions.ts index 6b5494753a..72f901a18a 100644 --- a/packages/app/src/llm/lib/expressions.ts +++ b/packages/app/src/llm/lib/expressions.ts @@ -1,7 +1,7 @@ import { TLogSource, TTraceSource } from '@hyperdx/common-utils/dist/types'; import { generateCostSqlExpression } from './cost'; -import { buildLLMSpanSqlPredicate } from './detect'; +import { buildAnyKeyExistsSql, buildLLMSpanSqlPredicate } from './detect'; /** * SQL expression derivation for the LLM dashboard, mirroring @@ -21,7 +21,7 @@ function fieldAccess( /** First non-empty string among the given attribute keys ('' when none). */ function coalesceString( field: string, - keys: string[], + keys: readonly string[], isJsonColumn: boolean, ): string { const args = keys.map( @@ -37,7 +37,7 @@ function coalesceString( */ function greatestNumber( field: string, - keys: string[], + keys: readonly string[], isJsonColumn: boolean, ): string { const args = keys.map( @@ -222,6 +222,71 @@ function getLLMAttributeExpressions({ attributeField: string; isJsonColumn: boolean; }) { + /** Rows carrying any of these keys. Index-friendly — see buildKeyExistsSql. */ + const anyKeyExists = (keys: readonly string[]) => + buildAnyKeyExistsSql({ attributeField, keys, isJsonColumn }); + + /** + * Wrap a presence term that exists only to prune granules. + * + * `indexHint` feeds its argument to skip-index analysis but returns true at + * execution, so the keys are never re-tested per surviving row. This is the + * same thing queryParser.ts does with `mapKeyIndexExpression`. + * + * Only safe when a value term follows that already implies presence. A bare + * presence filter must not be wrapped — `indexHint` alone matches every row. + */ + const prunableKeys = (keys: readonly string[]) => + `indexHint(${anyKeyExists(keys)})`; + + /** + * Pair a value term with a pruning hint over the keys it reads. + * + * `valueTerm` alone defines the result; the hint only lets the index drop + * granules, so it must be implied by `valueTerm` to be sound. + * + * Only worth adding in WHERE position — skip-index analysis does not look at + * the select list, so a gate used solely inside an aggregate should pass its + * value term through unhinted rather than pay the query-length cost. + * + * On JSON columns there is no key index to prune with, so the value term is + * emitted alone. + */ + const withKeyPruning = (keys: readonly string[], valueTerm: string) => + isJsonColumn ? valueTerm : `(${prunableKeys(keys)} AND ${valueTerm})`; + + /** "Any of these keys holds a non-empty value", before any pruning hint. */ + const anyKeyNonEmpty = (keys: readonly string[]) => + `${coalesceString(attributeField, keys, isJsonColumn)} != ''`; + + /** + * Gate for "any of these keys holds a non-empty value". + * + * Presence alone would admit a key explicitly set to '', and every caller + * pairs this gate with a coalesced value expression it groups by, so such a + * row would show up as a blank bar/row whose drill-in link goes nowhere. + * + * The value term costs no extra per-part size lookups during planning: it + * reads the same keys the paired group-by expression already reads. + */ + const anyKeyHasValue = (keys: readonly string[]) => + withKeyPruning(keys, anyKeyNonEmpty(keys)); + + // Derived from TOOL_NAME_KEYS rather than restated: a span whose tool name + // resolves is a tool call, so the two must not drift. gen_ai.tool.call.id is + // the one addition — it marks a tool call without naming it. + // + // Deriving currently changes nothing observable: the flat `tool_name` it + // picks up is not in LLM_MARKER_ATTRIBUTE_KEYS, and every caller ANDs + // isLLMSpan, so a span carrying only that key is dropped before this gate is + // reached. Widening LLM detection to a key that generic is its own decision. + const TOOL_SPAN_KEYS = [...TOOL_NAME_KEYS, 'gen_ai.tool.call.id']; + const isToolSpanMatch = `(${fieldAccess( + attributeField, + 'openinference.span.kind', + isJsonColumn, + )} = 'TOOL' OR ${anyKeyNonEmpty(TOOL_SPAN_KEYS)})`; + const model = coalesceString(attributeField, MODEL_KEYS, isJsonColumn); const inputTokens = greatestNumber( attributeField, @@ -284,7 +349,7 @@ function getLLMAttributeExpressions({ ttftMs: greatestNumber(attributeField, TTFT_MS_KEYS, isJsonColumn), toolName: coalesceString(attributeField, TOOL_NAME_KEYS, isJsonColumn), agentName: coalesceString(attributeField, AGENT_NAME_KEYS, isJsonColumn), - hasAgentName: `${coalesceString(attributeField, AGENT_NAME_KEYS, isJsonColumn)} != ''`, + hasAgentName: anyKeyHasValue(AGENT_NAME_KEYS), // Emitters disagree on encoding ('stop' vs '["stop"]'); strip the JSON // array wrapper so the group-by buckets align. // The char class is written backslash-free (leading ] in an RE2 class @@ -311,22 +376,47 @@ function getLLMAttributeExpressions({ * an app's own authoritative per-call reporters — see llmGatedSumExpr. */ hasProvidedCost: `(${providedCost} > 0)`, - /** See REPORTED_TOKEN_KEYS: gates sums so wrapper spans don't double count. */ - hasReportedTokens: `(${REPORTED_TOKEN_KEYS.map(key => - isJsonColumn - ? `toString(${attributeField}.\`${key}\`) != ''` - : `${attributeField}['${key}'] != ''`, - ).join(' OR ')})`, - hasSessionId: `${coalesceString(attributeField, SESSION_ID_KEYS, isJsonColumn)} != ''`, - hasTtft: `${greatestNumber(attributeField, TTFT_MS_KEYS, isJsonColumn)} > 0`, - hasUserId: `${coalesceString(attributeField, USER_ID_KEYS, isJsonColumn)} != ''`, - hasFinishReason: `${coalesceString(attributeField, FINISH_REASON_KEYS, isJsonColumn)} != ''`, - isToolSpan: `(${[ - `${fieldAccess(attributeField, 'openinference.span.kind', isJsonColumn)} = 'TOOL'`, - `${fieldAccess(attributeField, 'gen_ai.tool.name', isJsonColumn)} != ''`, - `${fieldAccess(attributeField, 'gen_ai.tool.call.id', isJsonColumn)} != ''`, - `${fieldAccess(attributeField, 'ai.toolCall.name', isJsonColumn)} != ''`, - ].join(' OR ')})`, + /** + * See REPORTED_TOKEN_KEYS: gates sums so wrapper spans don't double count. + * Unhinted — perServiceElectionExpr only ever embeds this inside select-list + * aggregates, where a hint cannot prune and would just lengthen a query this + * file already keeps under max_query_size (see LLM_COST_SQL_ALIAS). + */ + hasReportedTokens: anyKeyNonEmpty(REPORTED_TOKEN_KEYS), + hasSessionId: anyKeyHasValue(SESSION_ID_KEYS), + // Numeric rather than non-empty: it feeds a latency percentile, and a + // zero would drag p50/p95 down. Same shape as anyKeyHasValue otherwise — + // `> 0` implies the key is present, so presence is pruning only. + hasTtft: withKeyPruning( + TTFT_MS_KEYS, + `${greatestNumber(attributeField, TTFT_MS_KEYS, isJsonColumn)} > 0`, + ), + hasUserId: anyKeyHasValue(USER_ID_KEYS), + hasFinishReason: anyKeyHasValue(FINISH_REASON_KEYS), + /** + * Tool-call rows, for WHERE position. + * + * The `= 'TOOL'` term stays a value comparison — it discriminates one + * OpenInference span kind from the others, and the query builder already + * rewrites equality onto the attribute-items index. The remaining terms + * gate a group-by on toolName, so they keep their value check too. + * + * The hint sits above the OR, not inside it: a skip index can only drop a + * granule when every arm of an OR is decidable by that same index, and the + * span-kind arm is not. Hoisting is sound because both arms imply one of + * the hinted keys. + */ + isToolSpan: withKeyPruning( + ['openinference.span.kind', ...TOOL_SPAN_KEYS], + isToolSpanMatch, + ), + /** + * isToolSpan for select-list aggregate positions (`aggCondition`), which + * renderChartConfig emits inside countIf/sumIf. Skip-index analysis never + * reaches the select list, so the hint would fold to a constant while + * lengthening every query — see withKeyPruning. + */ + isToolSpanUnhinted: isToolSpanMatch, }; } diff --git a/packages/common-utils/src/__tests__/rewriteSqlFilterWithKvItems.test.ts b/packages/common-utils/src/__tests__/rewriteSqlFilterWithKvItems.test.ts index fb81d16a91..ae7624f26a 100644 --- a/packages/common-utils/src/__tests__/rewriteSqlFilterWithKvItems.test.ts +++ b/packages/common-utils/src/__tests__/rewriteSqlFilterWithKvItems.test.ts @@ -425,4 +425,30 @@ describe('rewriteSqlFilterWithKvItems', () => { expect(result).not.toContain('hasAny('); }); }); + + describe('indexHint', () => { + // The LLM dashboard gates pair a value term with indexHint(a OR b), which + // is the first caller to put a bare boolean OR in an argument position. + // This function bails to the input verbatim on a parse error, so a grammar + // that rejected that shape would silently stop rewriting the rest of the + // condition rather than fail — hence asserting the sibling rewrite here. + it('rewrites a sibling equality and leaves the hint intact', () => { + const result = rewriteSqlFilterWithKvItems( + "(indexHint((mapContains(LogAttributes, 'openinference.span.kind') OR " + + "mapContains(LogAttributes, 'gen_ai.tool.name'))) AND " + + "(LogAttributes['openinference.span.kind'] = 'TOOL' OR " + + "coalesce(nullif(LogAttributes['gen_ai.tool.name'], ''), '') != ''))", + defaultLookup, + ); + expect(result).toContain( + "has(`LogAttributeItems`, concat('openinference.span.kind', '=', 'TOOL'))", + ); + // The hint's own mapContains calls must survive untouched — rewriting + // them to has() would point the hint at the wrong index. + expect(result).toContain( + "indexHint((mapContains(LogAttributes, 'openinference.span.kind') OR " + + "mapContains(LogAttributes, 'gen_ai.tool.name')))", + ); + }); + }); }); From 370b05da20e882a9d4bede81c948fcc395a7648b Mon Sep 17 00:00:00 2001 From: Warren Lee <5959690+wrn14897@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:27:16 -0700 Subject: [PATCH 5/6] fix: keep LLM dashboard scope filters clearable when their options fail to load (#3105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #3104, addressing a detail raised in review there that landed after the merge. #3104 fixed the scope dropdowns (session, user) falling back to their placeholder when the applied value wasn't in the fetched options — the control read "All users" while every chart stayed filtered. Pinning the value made it visible, but the control is still `disabled` whenever the distinct-value query is loading or has failed: ```tsx disabled={isLoading || isError} ``` So with a filter applied and a failing query, the user can see the scope but can't remove it — and `isError` doesn't clear on its own, so that state is permanent. Mantine also only renders the `clearable` × on an enabled input, so there is no escape from the control itself. Now only disabled when nothing is applied: ```tsx disabled={(isLoading || isError) && !value} ``` Loading with no filter still disables, which is the case the guard was for. ## Tests Two cases added to `DistinctValueSelect.test.tsx`: - applied value + failing query → input is enabled and still shows the value - loading with nothing applied → input is disabled (pins the behaviour that's kept) The first fails against the old `disabled={isLoading || isError}`. ## Verification - `yarn ci:unit` (app): 233 suites, 3,829 tests, pass - `make ci-lint`: 0 errors · `tsc --noEmit` clean · `knip` clean ## References - Follows: #3104 --- .changeset/quiet-pugs-clap.md | 11 +++++++ .../__tests__/DistinctValueSelect.test.tsx | 32 ++++++++++++++++++- .../src/llm/dashboard/DistinctValueSelect.tsx | 5 ++- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-pugs-clap.md diff --git a/.changeset/quiet-pugs-clap.md b/.changeset/quiet-pugs-clap.md new file mode 100644 index 0000000000..733a6f7d9b --- /dev/null +++ b/.changeset/quiet-pugs-clap.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep the LLM dashboard scope filters clearable when their options fail to +load + +The session and user dropdowns were disabled whenever their distinct-value query +was loading or had failed. With a filter applied that left the user looking at a +scope they could see but could not remove — permanently, if the query kept +failing. They now stay interactive whenever a value is applied. diff --git a/packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx b/packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx index 8565aaf639..57647a9028 100644 --- a/packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx +++ b/packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx @@ -5,11 +5,12 @@ import { DistinctValueSelect } from '@/llm/dashboard/DistinctValueSelect'; let mockRows: Record[] | undefined = []; let mockLoading = false; +let mockError = false; jest.mock('@/hooks/useChartConfig', () => ({ useQueriedChartConfig: () => ({ data: mockRows == null ? undefined : { data: mockRows }, isLoading: mockLoading, - isError: false, + isError: mockError, }), })); @@ -28,6 +29,7 @@ describe('DistinctValueSelect', () => { beforeEach(() => { mockRows = []; mockLoading = false; + mockError = false; }); it('shows the applied value when it is present in the fetched options', () => { @@ -83,6 +85,34 @@ describe('DistinctValueSelect', () => { expect(onChange).not.toHaveBeenCalled(); }); + // Otherwise a failing distinct query strands the user with a filter they + // can see but cannot remove. + it('stays interactive while a value is applied even if the query fails', () => { + mockRows = undefined; + mockError = true; + renderWithMantine( + , + ); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('alice@x.com'); + expect(input).not.toBeDisabled(); + }); + + it('disables the control while loading with nothing applied', () => { + mockRows = undefined; + mockLoading = true; + renderWithMantine( + , + ); + + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + it('falls back to the placeholder when no value is applied', () => { mockRows = [{ value: 'bob@x.com' }]; renderWithMantine( diff --git a/packages/app/src/llm/dashboard/DistinctValueSelect.tsx b/packages/app/src/llm/dashboard/DistinctValueSelect.tsx index fe947d79b0..c760aa608e 100644 --- a/packages/app/src/llm/dashboard/DistinctValueSelect.tsx +++ b/packages/app/src/llm/dashboard/DistinctValueSelect.tsx @@ -89,7 +89,10 @@ export function DistinctValueSelect({ data={values} value={value || null} onChange={v => onChange(v ?? '')} - disabled={isLoading || isError} + // Never lock the control while a scope is applied: the options are of no + // use yet, but clearing the filter must stay possible. On a failing + // distinct query that is otherwise permanent. + disabled={(isLoading || isError) && !value} comboboxProps={{ withinPortal: false }} searchable clearable From 3e929f07d8e77434127155b4692257fca92ecf92 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 10 Sep 2026 13:48:41 -0400 Subject: [PATCH 6/6] chore(deps): bump next from 16.2.11 to 16.3.4 (#3108) ## Summary **Next.js goes 16.2.11 to 16.3.4 in `packages/app`**, picking up what shipped in 16.3.3 and 16.3.4. No application code moved. `packages/app` is Pages Router only, so the 16.3.0 App Router deprecations (edge runtime, `experimental.useCache`/`dynamicIO`, `partialFallbacks`) never come into play, and a grep confirmed none of those flags were set anywhere. Two ESLint packages ride along: `@next/eslint-plugin-next` and `eslint-config-next`, both 16.0.10 to 16.3.4. Both sat two minors behind the framework. Lint attributes zero warnings to `@next/next` rules, so the bump costs nothing. **Expect a big `yarn.lock` diff.** Next's own pins moved with it and nothing else did: `sharp` 0.34.5 to 0.35.4 plus its whole `@img/*` platform-binary set, `postcss` 8.4.31 to 8.5.23, `@swc/helpers` 0.5.15 to 0.5.23. `packages/app/AGENTS.md` is new, and Next wrote it. 16.3 added a `next dev` step that drops an agent-rules block into the project directory whenever it detects an AI coding agent. Committing the file satisfies its check, which kills the recurring uncommitted change and stops it creating a `packages/app/CLAUDE.md` alongside. Everything ran green. `make ci-lint` (0 errors, 559 warnings against a 564 cap), `yarn ci:unit` (3829 tests), the production Webpack build, the ClickStack static export, a Turbopack dev boot, and the navigation E2E suite (10 passed). ### How to test on Vercel preview **Preview routes:** /search, /dashboards, /chart **Steps:** 1. Open /search and verify the page renders with the search input and a results table. 2. Open /dashboards and verify the dashboard list renders. 3. Open /chart and verify the chart builder renders. ### References - Linear Issue: - Related PRs: --- agent_docs/tech_stack.md | 2 +- packages/app/AGENTS.md | 9 + packages/app/package.json | 6 +- yarn.lock | 456 ++++++++++++++++++++------------------ 4 files changed, 248 insertions(+), 225 deletions(-) create mode 100644 packages/app/AGENTS.md diff --git a/agent_docs/tech_stack.md b/agent_docs/tech_stack.md index 403e2cc602..1fb61f34e0 100644 --- a/agent_docs/tech_stack.md +++ b/agent_docs/tech_stack.md @@ -2,7 +2,7 @@ ## Frontend (`packages/app`) -- **Framework**: Next.js 14 with TypeScript +- **Framework**: Next.js 16 with TypeScript - **UI Components**: Mantine UI library (`@mantine/core`, `@mantine/dates`, `@mantine/hooks`) - **State Management**: Jotai (global client state), TanStack Query (server state), URL params (filters) - **Charts/Visualization**: Recharts, uPlot diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md new file mode 100644 index 0000000000..643577dfae --- /dev/null +++ b/packages/app/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/packages/app/package.json b/packages/app/package.json index 00248d7cf3..e668e4504b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -71,7 +71,7 @@ "ky-universal": "^0.10.1", "lodash": "^4.18.1", "ms": "^2.1.3", - "next": "^16.2.11", + "next": "^16.3.4", "next-runtime-env": "1", "next-seo": "^4.28.1", "numbro": "^2.4.0", @@ -105,7 +105,7 @@ "@chromatic-com/storybook": "^4.1.3", "@eslint-react/eslint-plugin": "^3.0.0", "@eslint/compat": "^2.0.0", - "@next/eslint-plugin-next": "^16.0.10", + "@next/eslint-plugin-next": "^16.3.4", "@playwright/test": "^1.57.0", "@storybook/addon-docs": "^10.1.4", "@storybook/addon-links": "^10.1.4", @@ -130,7 +130,7 @@ "@types/react-dom": "^19.2.3", "@types/react-grid-layout": "^1.3.2", "@types/sqlstring": "^2.3.2", - "eslint-config-next": "^16.0.10", + "eslint-config-next": "^16.3.4", "eslint-plugin-playwright": "^2.4.0", "eslint-plugin-react-hook-form": "^0.3.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/yarn.lock b/yarn.lock index 07a29e4c77..9946b4048c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3657,7 +3657,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.1.0, @emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0": +"@emnapi/runtime@npm:^1.1.0, @emnapi/runtime@npm:^1.4.3": version: 1.7.1 resolution: "@emnapi/runtime@npm:1.7.1" dependencies: @@ -3666,6 +3666,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.11.3": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.0.4": version: 1.0.4 resolution: "@emnapi/wasi-threads@npm:1.0.4" @@ -4232,6 +4241,17 @@ __metadata: languageName: node linkType: hard +"@eslint-community/eslint-utils@npm:4.9.1, @eslint-community/eslint-utils@npm:^4.9.1": + version: 4.9.1 + resolution: "@eslint-community/eslint-utils@npm:4.9.1" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.8.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -4254,17 +4274,6 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.9.1": - version: 4.9.1 - resolution: "@eslint-community/eslint-utils@npm:4.9.1" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 - languageName: node - linkType: hard - "@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.12.1": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" @@ -4717,7 +4726,7 @@ __metadata: "@mantine/hooks": "npm:^9.0.0" "@mantine/notifications": "npm:^9.0.0" "@mantine/spotlight": "npm:^9.0.0" - "@next/eslint-plugin-next": "npm:^16.0.10" + "@next/eslint-plugin-next": "npm:^16.3.4" "@playwright/test": "npm:^1.57.0" "@prometheus-io/codemirror-promql": "npm:^0.311.3" "@rrweb/replay": "npm:2.1.1" @@ -4757,7 +4766,7 @@ __metadata: crypto-randomuuid: "npm:^1.0.0" date-fns: "npm:^2.28.0" dayjs: "npm:^1.11.19" - eslint-config-next: "npm:^16.0.10" + eslint-config-next: "npm:^16.3.4" eslint-plugin-playwright: "npm:^2.4.0" eslint-plugin-react-hook-form: "npm:^0.3.1" eslint-plugin-react-hooks: "npm:^7.0.1" @@ -4776,7 +4785,7 @@ __metadata: ms: "npm:^2.1.3" msw: "npm:^2.3.0" msw-storybook-addon: "npm:^2.0.2" - next: "npm:^16.2.11" + next: "npm:^16.3.4" next-runtime-env: "npm:1" next-seo: "npm:^4.28.1" numbro: "npm:^2.4.0" @@ -5071,18 +5080,18 @@ __metadata: languageName: node linkType: hard -"@img/colour@npm:^1.0.0": - version: 1.0.0 - resolution: "@img/colour@npm:1.0.0" - checksum: 10c0/02261719c1e0d7aa5a2d585981954f2ac126f0c432400aa1a01b925aa2c41417b7695da8544ee04fd29eba7ecea8eaf9b8bef06f19dc8faba78f94eeac40667d +"@img/colour@npm:^1.1.0": + version: 1.1.0 + resolution: "@img/colour@npm:1.1.0" + checksum: 10c0/2ebea2c0bbaee73b99badcefa04e1e71d83f36e5369337d3121dca841f4569533c4e2faddda6d62dd247f0d5cca143711f9446c59bcce81e427ba433a7a94a17 languageName: node linkType: hard -"@img/sharp-darwin-arm64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-darwin-arm64@npm:0.34.5" +"@img/sharp-darwin-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-darwin-arm64": optional: true @@ -5090,11 +5099,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-x64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-darwin-x64@npm:0.34.5" +"@img/sharp-darwin-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-darwin-x64": "npm:1.2.4" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-darwin-x64": optional: true @@ -5102,81 +5111,90 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-darwin-arm64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4" +"@img/sharp-freebsd-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-freebsd-wasm32@npm:0.35.4" + dependencies: + "@img/sharp-wasm32": "npm:0.35.4" + conditions: os=freebsd + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@img/sharp-libvips-darwin-x64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4" +"@img/sharp-libvips-darwin-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@img/sharp-libvips-linux-arm64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4" +"@img/sharp-libvips-linux-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.3" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-arm@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4" +"@img/sharp-libvips-linux-arm@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm@npm:1.3.3" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-ppc64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4" +"@img/sharp-libvips-linux-ppc64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.3" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-riscv64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linux-riscv64@npm:1.2.4" +"@img/sharp-libvips-linux-riscv64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.3" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-s390x@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4" +"@img/sharp-libvips-linux-s390x@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.3" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linux-x64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4" +"@img/sharp-libvips-linux-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-x64@npm:1.3.3" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4" +"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-x64@npm:1.2.4": - version: 1.2.4 - resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4" +"@img/sharp-libvips-linuxmusl-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.3" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@img/sharp-linux-arm64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linux-arm64@npm:0.34.5" +"@img/sharp-linux-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-arm64": "npm:1.2.4" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-arm64": optional: true @@ -5184,11 +5202,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-arm@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linux-arm@npm:0.34.5" +"@img/sharp-linux-arm@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-arm": "npm:1.2.4" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-arm": optional: true @@ -5196,11 +5214,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-ppc64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linux-ppc64@npm:0.34.5" +"@img/sharp-linux-ppc64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-ppc64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-ppc64": optional: true @@ -5208,11 +5226,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-riscv64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linux-riscv64@npm:0.34.5" +"@img/sharp-linux-riscv64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-riscv64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-riscv64": optional: true @@ -5220,11 +5238,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-s390x@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linux-s390x@npm:0.34.5" +"@img/sharp-linux-s390x@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-s390x@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-s390x": "npm:1.2.4" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-s390x": optional: true @@ -5232,11 +5250,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-x64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linux-x64@npm:0.34.5" +"@img/sharp-linux-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linux-x64": "npm:1.2.4" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linux-x64": optional: true @@ -5244,11 +5262,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-arm64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5" +"@img/sharp-linuxmusl-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linuxmusl-arm64": optional: true @@ -5256,11 +5274,11 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-x64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5" +"@img/sharp-linuxmusl-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-x64@npm:0.35.4" dependencies: - "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" dependenciesMeta: "@img/sharp-libvips-linuxmusl-x64": optional: true @@ -5268,32 +5286,41 @@ __metadata: languageName: node linkType: hard -"@img/sharp-wasm32@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-wasm32@npm:0.34.5" +"@img/sharp-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-wasm32@npm:0.35.4" dependencies: - "@emnapi/runtime": "npm:^1.7.0" + "@emnapi/runtime": "npm:^1.11.3" + checksum: 10c0/7f5e394f168eb2f038b8ae4949fb5434085d1db931ede2ae1389479d7a1dbe11e2fc9d58e8e9830bff744ba996256c9e411be1d466dfcbf6e1f4d153b84f25ff + languageName: node + linkType: hard + +"@img/sharp-webcontainers-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.4" + dependencies: + "@img/sharp-wasm32": "npm:0.35.4" conditions: cpu=wasm32 languageName: node linkType: hard -"@img/sharp-win32-arm64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-win32-arm64@npm:0.34.5" +"@img/sharp-win32-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-arm64@npm:0.35.4" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@img/sharp-win32-ia32@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-win32-ia32@npm:0.34.5" +"@img/sharp-win32-ia32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-ia32@npm:0.35.4" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@img/sharp-win32-x64@npm:0.34.5": - version: 0.34.5 - resolution: "@img/sharp-win32-x64@npm:0.34.5" +"@img/sharp-win32-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-x64@npm:0.35.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -6554,83 +6581,75 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:16.2.11": - version: 16.2.11 - resolution: "@next/env@npm:16.2.11" - checksum: 10c0/fdb5ff54037f5e554c7452a31f745e40a02ea55416b80c9abf5ebb3b6fba54beaa15e080fc8470f9a59b3a0506c7ce775764134256d413f17e17e4f92802cee3 - languageName: node - linkType: hard - -"@next/eslint-plugin-next@npm:16.0.10": - version: 16.0.10 - resolution: "@next/eslint-plugin-next@npm:16.0.10" - dependencies: - fast-glob: "npm:3.3.1" - checksum: 10c0/fc9836536deacb095c442c67c22d963871c24e47d07721c2d08511cc8fa422b5aa19bf84dbd0d90b1b37783727e710d2cb6e16e0fd0d1ad4eee35f34cdf78785 +"@next/env@npm:16.3.4": + version: 16.3.4 + resolution: "@next/env@npm:16.3.4" + checksum: 10c0/6a9adf69b7aaa1d13e255aa158ee7accce81c6c23dfc2c786b3fc6f0f4c5354b3c67e7d60be5521d322d2164ba86fc01b9dc2f3f18282f6162760de99e19fd13 languageName: node linkType: hard -"@next/eslint-plugin-next@npm:^16.0.10": - version: 16.2.1 - resolution: "@next/eslint-plugin-next@npm:16.2.1" +"@next/eslint-plugin-next@npm:16.3.4, @next/eslint-plugin-next@npm:^16.3.4": + version: 16.3.4 + resolution: "@next/eslint-plugin-next@npm:16.3.4" dependencies: + "@eslint-community/eslint-utils": "npm:4.9.1" fast-glob: "npm:3.3.1" - checksum: 10c0/cb6870ee6846d1a683393bdb303ce0bfcd0f60389ac976c019bca90818bc1adba55c3c2d2896541f7607f92dfa348111fd04440042036ae6ba888329ceb17abe + checksum: 10c0/66ed6384cab5c13d09eff544a596e676b9ad7614a311f30366f273d29df569dfab007606a2e32ceba968bd1a61f505782b5aab1fd656b06503abf1912ec97573 languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-darwin-arm64@npm:16.2.11" +"@next/swc-darwin-arm64@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-darwin-arm64@npm:16.3.4" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-darwin-x64@npm:16.2.11" +"@next/swc-darwin-x64@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-darwin-x64@npm:16.3.4" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-arm64-gnu@npm:16.2.11" +"@next/swc-linux-arm64-gnu@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-arm64-gnu@npm:16.3.4" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-arm64-musl@npm:16.2.11" +"@next/swc-linux-arm64-musl@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-arm64-musl@npm:16.3.4" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-x64-gnu@npm:16.2.11" +"@next/swc-linux-x64-gnu@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-x64-gnu@npm:16.3.4" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-linux-x64-musl@npm:16.2.11" +"@next/swc-linux-x64-musl@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-linux-x64-musl@npm:16.3.4" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-win32-arm64-msvc@npm:16.2.11" +"@next/swc-win32-arm64-msvc@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-win32-arm64-msvc@npm:16.3.4" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:16.2.11": - version: 16.2.11 - resolution: "@next/swc-win32-x64-msvc@npm:16.2.11" +"@next/swc-win32-x64-msvc@npm:16.3.4": + version: 16.3.4 + resolution: "@next/swc-win32-x64-msvc@npm:16.3.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -9832,12 +9851,12 @@ __metadata: languageName: node linkType: hard -"@swc/helpers@npm:0.5.15": - version: 0.5.15 - resolution: "@swc/helpers@npm:0.5.15" +"@swc/helpers@npm:0.5.23": + version: 0.5.23 + resolution: "@swc/helpers@npm:0.5.23" dependencies: tslib: "npm:^2.8.0" - checksum: 10c0/33002f74f6f885f04c132960835fdfc474186983ea567606db62e86acd0680ca82f34647e8e610f4e1e422d1c16fce729dde22cd3b797ab1fd9061a825dabca4 + checksum: 10c0/02da7b4df465693933ecd4851cc193ec729c309939c8a84eccae5ec0010aafc3894e713b8ef8d13a6ba401759f0e900c88e2dcfef5872c27bb91e70f73275cce languageName: node linkType: hard @@ -16121,11 +16140,11 @@ __metadata: languageName: node linkType: hard -"eslint-config-next@npm:^16.0.10": - version: 16.0.10 - resolution: "eslint-config-next@npm:16.0.10" +"eslint-config-next@npm:^16.3.4": + version: 16.3.4 + resolution: "eslint-config-next@npm:16.3.4" dependencies: - "@next/eslint-plugin-next": "npm:16.0.10" + "@next/eslint-plugin-next": "npm:16.3.4" eslint-import-resolver-node: "npm:^0.3.6" eslint-import-resolver-typescript: "npm:^3.5.2" eslint-plugin-import: "npm:^2.32.0" @@ -16140,7 +16159,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/9a5650c91dfdf6853822e1d2062e1c4d10cee76187cf7723de87a0e44d3d26877a29af55cd2d453ccb8bc46708fa7261db786e852e0c51d9a8a80058c33b009d + checksum: 10c0/70d8a2e5232d14a2a56105a0957c57e57303f1d60746b4c2564fb52d39cb9b0c8ecef6fb8e97c2ec0c364ed2058818fd3385e5ad449b33a033e1f7c457284f33 languageName: node linkType: hard @@ -22628,7 +22647,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.11, nanoid@npm:^3.3.16, nanoid@npm:^3.3.6, nanoid@npm:^3.3.7": +"nanoid@npm:^3.3.11, nanoid@npm:^3.3.16, nanoid@npm:^3.3.7": version: 3.3.17 resolution: "nanoid@npm:3.3.17" bin: @@ -22711,24 +22730,24 @@ __metadata: languageName: node linkType: hard -"next@npm:^16.2.11": - version: 16.2.11 - resolution: "next@npm:16.2.11" +"next@npm:^16.3.4": + version: 16.3.4 + resolution: "next@npm:16.3.4" dependencies: - "@next/env": "npm:16.2.11" - "@next/swc-darwin-arm64": "npm:16.2.11" - "@next/swc-darwin-x64": "npm:16.2.11" - "@next/swc-linux-arm64-gnu": "npm:16.2.11" - "@next/swc-linux-arm64-musl": "npm:16.2.11" - "@next/swc-linux-x64-gnu": "npm:16.2.11" - "@next/swc-linux-x64-musl": "npm:16.2.11" - "@next/swc-win32-arm64-msvc": "npm:16.2.11" - "@next/swc-win32-x64-msvc": "npm:16.2.11" - "@swc/helpers": "npm:0.5.15" + "@next/env": "npm:16.3.4" + "@next/swc-darwin-arm64": "npm:16.3.4" + "@next/swc-darwin-x64": "npm:16.3.4" + "@next/swc-linux-arm64-gnu": "npm:16.3.4" + "@next/swc-linux-arm64-musl": "npm:16.3.4" + "@next/swc-linux-x64-gnu": "npm:16.3.4" + "@next/swc-linux-x64-musl": "npm:16.3.4" + "@next/swc-win32-arm64-msvc": "npm:16.3.4" + "@next/swc-win32-x64-msvc": "npm:16.3.4" + "@swc/helpers": "npm:0.5.23" baseline-browser-mapping: "npm:^2.9.19" caniuse-lite: "npm:^1.0.30001579" - postcss: "npm:8.4.31" - sharp: "npm:^0.34.5" + postcss: "npm:8.5.23" + sharp: "npm:^0.35.4" styled-jsx: "npm:5.1.6" peerDependencies: "@opentelemetry/api": ^1.1.0 @@ -22767,7 +22786,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10c0/81f85262cf01ad6b4fd4a8be369925dfe206806a33e967733033d7adcf367aa0ba1f911272de6169a3d09315821eddf6b57594337a456ad134ba5aa8870a80b1 + checksum: 10c0/e4f2f079159201c205ba373e34f9517d581947ed0cf67860d24a81073d8e388cb041b5fbc0d4a527e1523f234d14f1d3d17dee13f930ec29f2c23abb406d7508 languageName: node linkType: hard @@ -24459,14 +24478,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.4.31": - version: 8.4.31 - resolution: "postcss@npm:8.4.31" +"postcss@npm:8.5.23, postcss@npm:^8.5.23": + version: 8.5.23 + resolution: "postcss@npm:8.5.23" dependencies: - nanoid: "npm:^3.3.6" - picocolors: "npm:^1.0.0" - source-map-js: "npm:^1.0.2" - checksum: 10c0/748b82e6e5fc34034dcf2ae88ea3d11fd09f69b6c50ecdd3b4a875cfc7cdca435c958b211e2cb52355422ab6fccb7d8f2f2923161d7a1b281029e4a913d59acf + nanoid: "npm:^3.3.16" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/ed714e635b330bb42666ecdd0c0b4f30224ded15b0b0052d6bc2b92832f2cf17d1c1141359453f96f0a84f8fbf4c405a74df187f559163b8f31131b2535455aa languageName: node linkType: hard @@ -24481,17 +24500,6 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.23": - version: 8.5.23 - resolution: "postcss@npm:8.5.23" - dependencies: - nanoid: "npm:^3.3.16" - picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10c0/ed714e635b330bb42666ecdd0c0b4f30224ded15b0b0052d6bc2b92832f2cf17d1c1141359453f96f0a84f8fbf4c405a74df187f559163b8f31131b2535455aa - languageName: node - linkType: hard - "postcss@npm:^8.5.6": version: 8.5.6 resolution: "postcss@npm:8.5.6" @@ -26570,7 +26578,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.6.3, semver@npm:^7.8.0": +"semver@npm:^7.6.3, semver@npm:^7.8.0, semver@npm:^7.8.5": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -26764,42 +26772,45 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.34.5": - version: 0.34.5 - resolution: "sharp@npm:0.34.5" - dependencies: - "@img/colour": "npm:^1.0.0" - "@img/sharp-darwin-arm64": "npm:0.34.5" - "@img/sharp-darwin-x64": "npm:0.34.5" - "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" - "@img/sharp-libvips-darwin-x64": "npm:1.2.4" - "@img/sharp-libvips-linux-arm": "npm:1.2.4" - "@img/sharp-libvips-linux-arm64": "npm:1.2.4" - "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" - "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" - "@img/sharp-libvips-linux-s390x": "npm:1.2.4" - "@img/sharp-libvips-linux-x64": "npm:1.2.4" - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" - "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" - "@img/sharp-linux-arm": "npm:0.34.5" - "@img/sharp-linux-arm64": "npm:0.34.5" - "@img/sharp-linux-ppc64": "npm:0.34.5" - "@img/sharp-linux-riscv64": "npm:0.34.5" - "@img/sharp-linux-s390x": "npm:0.34.5" - "@img/sharp-linux-x64": "npm:0.34.5" - "@img/sharp-linuxmusl-arm64": "npm:0.34.5" - "@img/sharp-linuxmusl-x64": "npm:0.34.5" - "@img/sharp-wasm32": "npm:0.34.5" - "@img/sharp-win32-arm64": "npm:0.34.5" - "@img/sharp-win32-ia32": "npm:0.34.5" - "@img/sharp-win32-x64": "npm:0.34.5" +"sharp@npm:^0.35.4": + version: 0.35.4 + resolution: "sharp@npm:0.35.4" + dependencies: + "@img/colour": "npm:^1.1.0" + "@img/sharp-darwin-arm64": "npm:0.35.4" + "@img/sharp-darwin-x64": "npm:0.35.4" + "@img/sharp-freebsd-wasm32": "npm:0.35.4" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" + "@img/sharp-linux-arm": "npm:0.35.4" + "@img/sharp-linux-arm64": "npm:0.35.4" + "@img/sharp-linux-ppc64": "npm:0.35.4" + "@img/sharp-linux-riscv64": "npm:0.35.4" + "@img/sharp-linux-s390x": "npm:0.35.4" + "@img/sharp-linux-x64": "npm:0.35.4" + "@img/sharp-linuxmusl-arm64": "npm:0.35.4" + "@img/sharp-linuxmusl-x64": "npm:0.35.4" + "@img/sharp-webcontainers-wasm32": "npm:0.35.4" + "@img/sharp-win32-arm64": "npm:0.35.4" + "@img/sharp-win32-ia32": "npm:0.35.4" + "@img/sharp-win32-x64": "npm:0.35.4" detect-libc: "npm:^2.1.2" - semver: "npm:^7.7.3" + semver: "npm:^7.8.5" dependenciesMeta: "@img/sharp-darwin-arm64": optional: true "@img/sharp-darwin-x64": optional: true + "@img/sharp-freebsd-wasm32": + optional: true "@img/sharp-libvips-darwin-arm64": optional: true "@img/sharp-libvips-darwin-x64": @@ -26836,7 +26847,7 @@ __metadata: optional: true "@img/sharp-linuxmusl-x64": optional: true - "@img/sharp-wasm32": + "@img/sharp-webcontainers-wasm32": optional: true "@img/sharp-win32-arm64": optional: true @@ -26844,7 +26855,10 @@ __metadata: optional: true "@img/sharp-win32-x64": optional: true - checksum: 10c0/fd79e29df0597a7d5704b8461c51f944ead91a5243691697be6e8243b966402beda53ddc6f0a53b96ea3cb8221f0b244aa588114d3ebf8734fb4aefd41ab802f + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/dbcc0909200cbff98e82805c86085b94afbd45f373897a04b21e52e9bbc1671dc64970350dd3cb6b2d0970f3444be7100d728005dc6620e39b599269ed7e15eb languageName: node linkType: hard @@ -27136,7 +27150,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.2": +"source-map-js@npm:>=0.6.2 <2.0.0": version: 1.0.2 resolution: "source-map-js@npm:1.0.2" checksum: 10c0/32f2dfd1e9b7168f9a9715eb1b4e21905850f3b50cf02cf476e47e4eebe8e6b762b63a64357896aa29b37e24922b4282df0f492e0d2ace572b43d15525976ff8