Skip to content

Commit 34d829c

Browse files
authored
feat: add an LLM dashboard user filter and make presence filters index-eligible (#3104)
## 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. <details> <summary>Index and function references</summary> 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)). </details> ## 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)
1 parent 41eee7d commit 34d829c

21 files changed

Lines changed: 781 additions & 120 deletions

‎.changeset/great-hoops-shave.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
fix: use mapContains for LLM dashboard attribute-presence filters
6+
7+
The LLM dashboard tested attribute presence with `SpanAttributes['key'] != ''`,
8+
which no skip index can serve — the trace schema's `mapKeys(SpanAttributes)`
9+
index only answers `mapContains`, and `!= ''` normalizes to `notEmpty()`. Every
10+
tile therefore scanned all granules. Map subscripts are also subcolumn
11+
references, so on ClickHouse 26.3+ each one adds a per-part size lookup during
12+
PREWHERE planning.
13+
14+
These filters now lead with `mapContains`, which the index serves and which
15+
costs no per-part lookups. On a staging trace table the LLM span predicate went
16+
from a 36s planning stall to 7ms, and a two-key filter dropped from 1,306
17+
granules to 3.
18+
19+
Gates that pair with a value expression the dashboard groups by keep their
20+
non-empty check, so an attribute set to `''` still cannot appear as a blank row.
21+
There the value term defines the result and the presence term is pruning only,
22+
so it is wrapped in `indexHint` — it reaches skip-index analysis without being
23+
re-evaluated per surviving row. The value term costs no extra per-part lookups,
24+
since it reads the same keys the group-by already reads. Gates that land in a
25+
select-list aggregate are left unhinted, since skip-index analysis does not
26+
reach the select list; the tool-call gate is used in both positions and so is
27+
exposed in both forms.
28+
29+
The one behavior change is LLM span detection, which is now presence-based: a
30+
span carrying `gen_ai.system` at all is treated as an LLM span whatever the
31+
value. Nothing groups by that predicate.
32+
33+
One caveat for tables with materialized columns: a `SpanAttributes['key']`
34+
subscript gets rewritten onto a materialized column when an operator has created
35+
one, and `mapContains` is not matched by that rewrite. Such tables were never
36+
affected by the planning cost either, since a rewritten subscript is no longer a
37+
subcolumn reference — so this trades that rewrite for skip-index pruning, which
38+
is the better deal only where those columns do not exist.
39+
40+
JSON attribute columns are unchanged — their paths are real subcolumns, there is
41+
no key index to prune with, and a presence term would only duplicate reads.

‎.changeset/olive-cameras-repeat.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@hyperdx/app': minor
3+
---
4+
5+
feat: filter the LLM dashboard by end user
6+
7+
Adds a user filter alongside the existing session filter. It lists the distinct
8+
users seen on LLM spans in the searched range and scopes every tab to the one
9+
selected, including the Errors tab's correlated log events.
10+
11+
Users are resolved with the same cross-dialect expression the "Top Users" chart
12+
groups by (`user.email`, `enduser.id`, `user.id`,
13+
`ai.telemetry.metadata.userId`), so a value picked from the dropdown always
14+
matches the rows that produced it. The selection lives in the URL, so a filtered
15+
view can be shared.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { screen } from '@testing-library/react';
2+
3+
import { makeTraceSource } from '@/llm/__fixtures__/sources';
4+
import { DistinctValueSelect } from '@/llm/dashboard/DistinctValueSelect';
5+
6+
let mockRows: Record<string, unknown>[] | undefined = [];
7+
let mockLoading = false;
8+
jest.mock('@/hooks/useChartConfig', () => ({
9+
useQueriedChartConfig: () => ({
10+
data: mockRows == null ? undefined : { data: mockRows },
11+
isLoading: mockLoading,
12+
isError: false,
13+
}),
14+
}));
15+
16+
const TRACE_SOURCE = makeTraceSource();
17+
18+
const baseProps = {
19+
source: TRACE_SOURCE,
20+
valueExpression: "SpanAttributes['user.email']",
21+
gateExpression: '1=1',
22+
queryKey: 'llm-user-select',
23+
allLabel: 'All users',
24+
dateRange: [new Date(0), new Date(1000)] as [Date, Date],
25+
};
26+
27+
describe('DistinctValueSelect', () => {
28+
beforeEach(() => {
29+
mockRows = [];
30+
mockLoading = false;
31+
});
32+
33+
it('shows the applied value when it is present in the fetched options', () => {
34+
mockRows = [{ value: 'alice@x.com' }, { value: 'bob@x.com' }];
35+
const onChange = jest.fn();
36+
renderWithMantine(
37+
<DistinctValueSelect
38+
{...baseProps}
39+
value="alice@x.com"
40+
onChange={onChange}
41+
/>,
42+
);
43+
44+
expect(screen.getByRole('combobox')).toHaveValue('alice@x.com');
45+
expect(onChange).not.toHaveBeenCalled();
46+
});
47+
48+
// The charts read the value from the URL, so a control that falls back to
49+
// its placeholder here would read "All users" over a filtered dashboard —
50+
// which looks like an unfiltered view returning nothing.
51+
it('keeps the applied value selected when it is absent from the options', () => {
52+
mockRows = [{ value: 'bob@x.com' }];
53+
const onChange = jest.fn();
54+
renderWithMantine(
55+
<DistinctValueSelect
56+
{...baseProps}
57+
value="alice@x.com"
58+
onChange={onChange}
59+
/>,
60+
);
61+
62+
expect(screen.getByRole('combobox')).toHaveValue('alice@x.com');
63+
// Clearing the parent here would discard a filter the user still has
64+
// applied, so absence must never write back.
65+
expect(onChange).not.toHaveBeenCalled();
66+
});
67+
68+
// Deep-linking into a filtered URL renders once before the distinct-value
69+
// query resolves; the value is not stale then, just not fetched yet.
70+
it('keeps the applied value selected while the options are still loading', () => {
71+
mockRows = undefined;
72+
mockLoading = true;
73+
const onChange = jest.fn();
74+
renderWithMantine(
75+
<DistinctValueSelect
76+
{...baseProps}
77+
value="alice@x.com"
78+
onChange={onChange}
79+
/>,
80+
);
81+
82+
expect(screen.getByRole('combobox')).toHaveValue('alice@x.com');
83+
expect(onChange).not.toHaveBeenCalled();
84+
});
85+
86+
it('falls back to the placeholder when no value is applied', () => {
87+
mockRows = [{ value: 'bob@x.com' }];
88+
renderWithMantine(
89+
<DistinctValueSelect {...baseProps} value="" onChange={jest.fn()} />,
90+
);
91+
92+
expect(screen.getByRole('combobox')).toHaveValue('');
93+
expect(screen.getByPlaceholderText('All users')).toBeInTheDocument();
94+
});
95+
});

‎packages/app/src/llm/__tests__/ErrorsTab.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('ErrorsTab', () => {
4141
logSource={LOG_SOURCE}
4242
logExpressions={logExpressions}
4343
sessionId="ses_123"
44+
userId="alice@x.com"
4445
/>,
4546
);
4647

@@ -56,6 +57,9 @@ describe('ErrorsTab', () => {
5657
expect(traceConditions.some((c: string) => c.includes("'ses_123'"))).toBe(
5758
true,
5859
);
60+
expect(
61+
traceConditions.some((c: string) => c.includes("'alice@x.com'")),
62+
).toBe(true);
5963
expect(traceConfig.orderBy[0].ordering).toBe('DESC');
6064

6165
const logConfig = rowTableProps[1].config;
@@ -70,6 +74,12 @@ describe('ErrorsTab', () => {
7074
c.includes("'ses_123'"),
7175
);
7276
expect(sessionCondition).toContain("LogAttributes['session.id']");
77+
// Same for the user scope: matched against the log source's own column,
78+
// not the trace source's.
79+
const userCondition = logConditions.find((c: string) =>
80+
c.includes("'alice@x.com'"),
81+
);
82+
expect(userCondition).toContain("LogAttributes['user.email']");
7383
});
7484

7585
it('renders only the trace tile without a log source', () => {

‎packages/app/src/llm/__tests__/LatencyTab.test.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,21 @@ describe('LatencyTab', () => {
3838
jest.clearAllMocks();
3939
});
4040

41-
it('scopes the heatmap to LLM spans (and session) without the cost alias', () => {
42-
renderWithMantine(<LatencyTab {...baseProps} sessionId="ses_123" />);
41+
it('scopes the heatmap to LLM spans (and session/user) without the cost alias', () => {
42+
// This tab passes the scopes into baseLLMChartConfig by hand rather than
43+
// spreading props, and memoizes on them — a scope missing from either
44+
// list leaves the heatmap silently unscoped.
45+
renderWithMantine(
46+
<LatencyTab {...baseProps} sessionId="ses_123" userId="alice@x.com" />,
47+
);
4348

4449
expect(screen.getByTestId('search-heatmap-chart')).toBeInTheDocument();
4550
const { chartConfig, source } = heatmapChartProps[0];
4651
expect(source).toBe(TRACE_SOURCE);
4752
const conditions = chartConfig.filters.map((f: any) => f.condition);
4853
expect(conditions[0]).toBe(expressions.isLLMSpan);
4954
expect(conditions[1]).toContain("'ses_123'");
55+
expect(conditions[2]).toContain("'alice@x.com'");
5056
// Delta sampling queries never reference cost; the WITH binding is
5157
// skipped to keep them small.
5258
expect(chartConfig.with).toBeUndefined();

‎packages/app/src/llm/__tests__/SessionsTab.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ describe('SessionsTab', () => {
4646
expect(conditions).toContain(expressions.isLLMSpan);
4747
expect(conditions).toContain(expressions.hasSessionId);
4848

49+
// aggCondition is a select-list position, so it must use the unhinted
50+
// gate: skip-index analysis never reaches the select list, and the hinted
51+
// form would fold to a constant inside countIf while lengthening every
52+
// Sessions query.
53+
const toolCallsSelect = config.select.find(
54+
(s: any) => s.alias === 'Tool Calls',
55+
);
56+
expect(toolCallsSelect.aggCondition).toBe(expressions.isToolSpanUnhinted);
57+
expect(toolCallsSelect.aggCondition).not.toContain('indexHint');
58+
4959
// Token/cost sums use the per-service provided-cost election so apps
5060
// emitting several instrumentation dialects in parallel don't double
5161
// count (and token-only apps aren't dropped by cost-reporting ones).

‎packages/app/src/llm/__tests__/chartConfig.test.ts‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
appendWhereClause,
44
baseLLMChartConfig,
55
buildDeltaFilterClause,
6-
buildSessionCondition,
6+
buildScopeCondition,
77
buildTrimmedDeltaSelect,
88
DELTA_ATTRIBUTE_VALUE_MAX_LENGTH,
99
} from '@/llm/dashboard/chartConfig';
@@ -21,15 +21,24 @@ const baseProps = {
2121
whereLanguage: 'sql' as const,
2222
};
2323

24-
describe('buildSessionCondition', () => {
24+
describe('buildScopeCondition', () => {
2525
it('escapes the session id value', () => {
26-
const condition = buildSessionCondition(
26+
const condition = buildScopeCondition(
2727
expressions.sessionId,
2828
"ses_1'; DROP TABLE x --",
2929
);
3030
expect(condition).toContain(expressions.sessionId);
3131
expect(condition).toContain("'ses_1\\'; DROP TABLE x --'");
3232
});
33+
34+
it('escapes the user value', () => {
35+
const condition = buildScopeCondition(
36+
expressions.userId,
37+
"alice'; DROP TABLE x --",
38+
);
39+
expect(condition).toContain(expressions.userId);
40+
expect(condition).toContain("'alice\\'; DROP TABLE x --'");
41+
});
3342
});
3443

3544
describe('buildDeltaFilterClause', () => {
@@ -123,7 +132,7 @@ describe('baseLLMChartConfig session scoping', () => {
123132
expect(config.filters).toHaveLength(2);
124133
expect(config.filters[1]).toEqual({
125134
type: 'sql',
126-
condition: buildSessionCondition(expressions.sessionId, 'ses_123'),
135+
condition: buildScopeCondition(expressions.sessionId, 'ses_123'),
127136
});
128137
});
129138

@@ -136,6 +145,47 @@ describe('baseLLMChartConfig session scoping', () => {
136145
expect(config.filters).toHaveLength(3);
137146
expect(config.filters[2]).toEqual({ type: 'sql', condition: '1=1' });
138147
});
148+
});
149+
150+
describe('baseLLMChartConfig user scoping', () => {
151+
it('adds no user filter by default', () => {
152+
const config = baseLLMChartConfig(baseProps);
153+
expect(config.filters).toEqual([
154+
{ type: 'sql', condition: expressions.isLLMSpan },
155+
]);
156+
});
157+
158+
it('appends the user filter to every chart when userId is set', () => {
159+
const config = baseLLMChartConfig({ ...baseProps, userId: 'alice@x.com' });
160+
expect(config.filters).toHaveLength(2);
161+
expect(config.filters[1]).toEqual({
162+
type: 'sql',
163+
condition: buildScopeCondition(expressions.userId, 'alice@x.com'),
164+
});
165+
});
166+
167+
// The two scopes are independent, so both must survive together and keep a
168+
// stable order — extra filters are appended relative to them.
169+
it('applies session and user together, session first, extras last', () => {
170+
const config = baseLLMChartConfig({
171+
...baseProps,
172+
sessionId: 'ses_123',
173+
userId: 'alice@x.com',
174+
extraFilters: [{ type: 'sql', condition: '1=1' }],
175+
});
176+
expect(config.filters).toEqual([
177+
{ type: 'sql', condition: expressions.isLLMSpan },
178+
{
179+
type: 'sql',
180+
condition: buildScopeCondition(expressions.sessionId, 'ses_123'),
181+
},
182+
{
183+
type: 'sql',
184+
condition: buildScopeCondition(expressions.userId, 'alice@x.com'),
185+
},
186+
{ type: 'sql', condition: '1=1' },
187+
]);
188+
});
139189

140190
it('binds the cost expression once as a WITH expression alias when opted in', () => {
141191
// The cost expression embeds the model price catalog (~70 KiB of SQL);

‎packages/app/src/llm/__tests__/detect.test.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,19 +44,26 @@ describe('isLLMSpan', () => {
4444
});
4545

4646
describe('buildLLMSpanSqlPredicate', () => {
47-
it('builds a map-column predicate', () => {
47+
// mapContains, not `col['k'] != ''`: only the former can be served by a
48+
// mapKeys() skip index, and it is not a subcolumn reference so it stays out
49+
// of the per-part size lookups PREWHERE planning does on CH 26.3+.
50+
it('builds a map-column predicate with mapContains', () => {
4851
const predicate = buildLLMSpanSqlPredicate({
4952
attributeField: 'SpanAttributes',
5053
isJsonColumn: false,
5154
});
5255
expect(predicate).toContain(
53-
"SpanAttributes['gen_ai.operation.name'] != ''",
56+
"mapContains(SpanAttributes, 'gen_ai.operation.name')",
5457
);
55-
expect(predicate).toContain("SpanAttributes['llm.model_name'] != ''");
58+
expect(predicate).toContain(
59+
"mapContains(SpanAttributes, 'llm.model_name')",
60+
);
61+
expect(predicate).not.toContain("!= ''");
5662
expect(predicate.startsWith('(')).toBe(true);
5763
expect(predicate.endsWith(')')).toBe(true);
5864
});
5965

66+
// JSON paths are real subcolumns; mapContains does not apply to them.
6067
it('builds a JSON-column predicate with backtick paths', () => {
6168
const predicate = buildLLMSpanSqlPredicate({
6269
attributeField: 'SpanAttributes',
@@ -65,5 +72,6 @@ describe('buildLLMSpanSqlPredicate', () => {
6572
expect(predicate).toContain(
6673
"toString(SpanAttributes.`gen_ai.operation.name`) != ''",
6774
);
75+
expect(predicate).not.toContain('mapContains');
6876
});
6977
});

0 commit comments

Comments
 (0)