Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/great-hoops-shave.md
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.
15 changes: 15 additions & 0 deletions .changeset/olive-cameras-repeat.md
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 packages/app/src/llm/__tests__/DistinctValueSelect.test.tsx
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', () => ({

Copy link
Copy Markdown
Contributor

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

useQueriedChartConfig is mocked as a zero-argument arrow, so nothing asserts that valueExpression reaches select[0].valueExpression as distinct(...), that gateExpression reaches where, or that enabled is false for a non-Trace source. DistinctValueSelect is now the single place both dropdowns' queries are built, and the four tests cover only the applied-value fallback: swapping valueExpression and gateExpression, or dropping the SourceKind.Trace guard, 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.

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();
});
});
10 changes: 10 additions & 0 deletions packages/app/src/llm/__tests__/ErrorsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('ErrorsTab', () => {
logSource={LOG_SOURCE}
logExpressions={logExpressions}
sessionId="ses_123"
userId="alice@x.com"
/>,
);

Expand All @@ -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;
Expand All @@ -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', () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/llm/__tests__/LatencyTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,21 @@ describe('LatencyTab', () => {
jest.clearAllMocks();
});

it('scopes the heatmap to LLM spans (and session) without the cost alias', () => {
renderWithMantine(<LatencyTab {...baseProps} sessionId="ses_123" />);
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(
<LatencyTab {...baseProps} sessionId="ses_123" userId="alice@x.com" />,
);

expect(screen.getByTestId('search-heatmap-chart')).toBeInTheDocument();
const { chartConfig, source } = heatmapChartProps[0];
expect(source).toBe(TRACE_SOURCE);
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();
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/llm/__tests__/SessionsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Comment thread
wrn14897 marked this conversation as resolved.
Resolved
);
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).
Expand Down
58 changes: 54 additions & 4 deletions packages/app/src/llm/__tests__/chartConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
appendWhereClause,
baseLLMChartConfig,
buildDeltaFilterClause,
buildSessionCondition,
buildScopeCondition,
buildTrimmedDeltaSelect,
DELTA_ATTRIBUTE_VALUE_MAX_LENGTH,
} from '@/llm/dashboard/chartConfig';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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'),
});
});

Expand All @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions packages/app/src/llm/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -65,5 +72,6 @@ describe('buildLLMSpanSqlPredicate', () => {
expect(predicate).toContain(
"toString(SpanAttributes.`gen_ai.operation.name`) != ''",
);
expect(predicate).not.toContain('mapContains');
});
});
Loading
Loading