Repository navigation
feat: add an LLM dashboard user filter and make presence filters index-eligible #3104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
fb0c736
fix(llm): use mapContains for attribute-presence filters
wrn14897 d3eb7ad
fix(llm): unexport buildKeyExistsSql
wrn14897 1dc9a78
fix(llm): keep the non-empty check on gates that feed a group-by
wrn14897 ffb508d
perf(llm): hint the pruning terms instead of re-evaluating them
wrn14897 8fce066
fix(llm): hoist isToolSpan's hint, drop the inert one, escape keys
wrn14897 c140297
fix(llm): expose isToolSpan unhinted for select-list use
wrn14897 cd725f5
test: cover the indexHint shape through the kv-items rewrite
wrn14897 871137f
fix(llm): derive tool-span keys from the tool-name keys
wrn14897 d43af92
fix(llm): correct an overclaimed changeset, note the materialized-col…
wrn14897 cacc7d5
test: pin the Tool Calls aggCondition to the unhinted gate
wrn14897 5cc4b76
feat(llm): filter the dashboard by end user
wrn14897 32e9837
fix(llm): keep an applied scope value selected when it is not in the …
wrn14897 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
95 changes: 95 additions & 0 deletions
95
packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>[] | 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( | ||
| <DistinctValueSelect | ||
| {...baseProps} | ||
| value="alice@x.com" | ||
| onChange={onChange} | ||
| />, | ||
| ); | ||
|
|
||
| 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( | ||
| <DistinctValueSelect | ||
| {...baseProps} | ||
| value="alice@x.com" | ||
| onChange={onChange} | ||
| />, | ||
| ); | ||
|
|
||
| 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( | ||
| <DistinctValueSelect | ||
| {...baseProps} | ||
| value="alice@x.com" | ||
| onChange={onChange} | ||
| />, | ||
| ); | ||
|
|
||
| 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( | ||
| <DistinctValueSelect {...baseProps} value="" onChange={jest.fn()} />, | ||
| ); | ||
|
|
||
| expect(screen.getByRole('combobox')).toHaveValue(''); | ||
| expect(screen.getByPlaceholderText('All users')).toBeInTheDocument(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — The extracted select's query construction has no test — the mock discards the config
useQueriedChartConfigis mocked as a zero-argument arrow, so nothing asserts thatvalueExpressionreachesselect[0].valueExpressionasdistinct(...), thatgateExpressionreacheswhere, or thatenabledis false for a non-Trace source.DistinctValueSelectis now the single place both dropdowns' queries are built, and the four tests cover only the applied-value fallback: swappingvalueExpressionandgateExpression, or dropping theSourceKind.Traceguard, leaves every test green while the dropdown queries the wrong thing. Capture the mock's first argument and assert the select/where/enabled for one wrapper.