Skip to content

Commit 30debf9

Browse files
vinzeepulpdrew
andauthored
perf(app): scope the sessions trace scan to RUM services (#3358)
## Summary Speeds up the `/sessions` query by constraining the trace scan to the services that emit RUM sessions. Split out of #2992 at review request so it can be discussed on its own. The default OTel trace schema is sorted by `(ServiceName, SpanName, Timestamp)`. With no `ServiceName` predicate, the sessions aggregation's time filter can't prune via the primary index, so it scans every mark to evaluate the `rum.sessionId` skip index. This PR first reads the distinct RUM service names from the session source (time-sorted, cheap) and adds `ServiceName IN (...)` to the trace aggregation, turning the scan into a small primary-key range. - Service names come from the session table's `ServiceName` column over the selected range padded by 1h. Recording rows are written throughout a session, so any session active in range has rows in range. If the session table has no `ServiceName` column, or the trace source has no service-name expression, the query runs unscoped. - The `IN` filter is built with `filtersToQuery`, which escapes names; empty names are kept. - On lookup failure or an empty result, the query falls back to the unscoped scan. ### Known trade-offs - A service that emits RUM spans but writes no session-recording rows (recording disabled, shorter session-table TTL) would have its sessions dropped from the list. Open to gating this behind a setting or retrying unscoped on an empty scoped result if that's a concern. - Assumes the session and trace tables report the same `ServiceName` for a service (true for the default OTel schemas). - The lookup runs serially before the main query on every refresh. With the range-sized window it's cheap; caching it in its own `useQuery` could be a follow-up. ### How to test on Vercel preview N/A - no visible UI change; `/sessions` should list the same sessions as before. ### References - Related PRs: #2992 Co-authored-by: Drew Davis <6097246+pulpdrew@users.noreply.github.com>
1 parent ecddea2 commit 30debf9

3 files changed

Lines changed: 341 additions & 10 deletions

File tree

‎.changeset/quick-rum-scope.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
speed up the sessions query by scoping the trace scan to RUM services

‎packages/app/src/__tests__/sessions.test.ts‎

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
1-
import { buildRRWebStreamChartConfig } from '@/sessions';
1+
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/browser';
2+
import {
3+
Metadata,
4+
MetadataCache,
5+
} from '@hyperdx/common-utils/dist/core/metadata';
6+
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
7+
import {
8+
SourceKind,
9+
TSessionSource,
10+
TTraceSource,
11+
} from '@hyperdx/common-utils/dist/types';
12+
13+
import {
14+
buildRRWebStreamChartConfig,
15+
buildSessionsQueryFilters,
16+
fetchServiceScopeFilters,
17+
} from '@/sessions';
18+
19+
jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({
20+
...jest.requireActual('@hyperdx/common-utils/dist/core/renderChartConfig'),
21+
renderChartConfig: jest.fn(async () => ({ sql: 'SQL', params: { p: 1 } })),
22+
}));
223

324
describe('buildRRWebStreamChartConfig', () => {
425
// Mirrors useFieldExpressionGenerator's output for Map columns
@@ -51,3 +72,207 @@ describe('buildRRWebStreamChartConfig', () => {
5172
expect(config.limit).toEqual({ limit: 1000000, offset: 0 });
5273
});
5374
});
75+
76+
describe('fetchServiceScopeFilters', () => {
77+
const traceSource: TTraceSource = {
78+
id: 'traces',
79+
name: 'Traces',
80+
kind: SourceKind.Trace,
81+
from: { databaseName: 'default', tableName: 'otel_traces' },
82+
connection: 'trace-conn',
83+
timestampValueExpression: 'Timestamp',
84+
defaultTableSelectExpression: 'Timestamp',
85+
durationExpression: 'Duration',
86+
durationPrecision: 9,
87+
traceIdExpression: 'TraceId',
88+
spanIdExpression: 'SpanId',
89+
parentSpanIdExpression: 'ParentSpanId',
90+
spanNameExpression: 'SpanName',
91+
spanKindExpression: 'SpanKind',
92+
// Differs from the session table's ServiceName column so the tests catch
93+
// the two being swapped.
94+
serviceNameExpression: "ResourceAttributes['service.name']",
95+
};
96+
const sessionSource: TSessionSource = {
97+
id: 'sessions',
98+
name: 'Sessions',
99+
kind: SourceKind.Session,
100+
from: { databaseName: 'default', tableName: 'hyperdx_sessions' },
101+
connection: 'conn',
102+
timestampValueExpression: 'TimestampTime',
103+
traceSourceId: 'traces',
104+
};
105+
const dateRange: [Date, Date] = [
106+
new Date('2026-01-01T01:00:00Z'),
107+
new Date('2026-01-01T02:00:00Z'),
108+
];
109+
110+
function jsonResultSet(data: unknown[]) {
111+
const text = JSON.stringify({ data });
112+
return {
113+
text: async () => text,
114+
// JSON.parse returns any, which satisfies json<T>() without a cast.
115+
json: async () => JSON.parse(text),
116+
stream: () => {
117+
throw new Error('not implemented');
118+
},
119+
close: () => {},
120+
query_id: 'query-id',
121+
response_headers: {},
122+
};
123+
}
124+
125+
function setup({
126+
columns = ['ServiceName', 'TimestampTime'],
127+
rows = [{ serviceName: 'web' }],
128+
}: { columns?: string[]; rows?: unknown[] } = {}) {
129+
const clickhouseClient = new ClickhouseClient({ host: '' });
130+
const metadata = new Metadata(clickhouseClient, new MetadataCache());
131+
const getColumns = jest.spyOn(metadata, 'getColumns').mockResolvedValue(
132+
columns.map(name => ({
133+
name,
134+
type: 'String',
135+
codec_expression: '',
136+
comment: '',
137+
default_expression: '',
138+
default_type: '',
139+
ttl_expression: '',
140+
})),
141+
);
142+
const query = jest
143+
.spyOn(clickhouseClient, 'query')
144+
.mockImplementation(async () => jsonResultSet(rows));
145+
const run = (overrides: { traceSource?: TTraceSource } = {}) =>
146+
fetchServiceScopeFilters({
147+
traceSource,
148+
sessionSource,
149+
dateRange,
150+
metadata,
151+
clickhouseClient,
152+
...overrides,
153+
});
154+
return { run, metadata, getColumns, query };
155+
}
156+
157+
it('queries the session table services over the padded range', async () => {
158+
const { run, metadata, getColumns, query } = setup();
159+
160+
expect(await run()).toEqual([
161+
{
162+
type: 'sql',
163+
condition: "ResourceAttributes['service.name'] IN ('web')",
164+
},
165+
]);
166+
expect(getColumns).toHaveBeenCalledWith({
167+
databaseName: 'default',
168+
tableName: 'hyperdx_sessions',
169+
connectionId: 'conn',
170+
});
171+
expect(renderChartConfig).toHaveBeenCalledWith(
172+
{
173+
select: [
174+
{ valueExpression: 'DISTINCT ServiceName', alias: 'serviceName' },
175+
],
176+
from: { databaseName: 'default', tableName: 'hyperdx_sessions' },
177+
dateRange: [
178+
new Date('2026-01-01T00:00:00Z'),
179+
new Date('2026-01-01T03:00:00Z'),
180+
],
181+
where: '',
182+
timestampValueExpression: 'TimestampTime',
183+
connection: 'conn',
184+
},
185+
metadata,
186+
undefined,
187+
);
188+
expect(query).toHaveBeenCalledWith({
189+
query: 'SQL',
190+
query_params: { p: 1 },
191+
connectionId: 'conn',
192+
format: 'JSON',
193+
});
194+
});
195+
196+
it('escapes and keeps empty service names', async () => {
197+
const { run } = setup({
198+
rows: [{ serviceName: "x') OR 1=1 --" }, { serviceName: '' }],
199+
});
200+
expect(await run()).toEqual([
201+
{
202+
type: 'sql',
203+
condition:
204+
"ResourceAttributes['service.name'] IN ('x'') OR 1=1 --', '')",
205+
},
206+
]);
207+
});
208+
209+
it('drops null service names', async () => {
210+
const { run } = setup({
211+
rows: [{ serviceName: 'web' }, { serviceName: null }],
212+
});
213+
expect(await run()).toEqual([
214+
{
215+
type: 'sql',
216+
condition: "ResourceAttributes['service.name'] IN ('web')",
217+
},
218+
]);
219+
});
220+
221+
it('returns no filter when the lookup finds no services', async () => {
222+
const { run } = setup({ rows: [] });
223+
expect(await run()).toEqual([]);
224+
});
225+
226+
it('returns no filter and warns when the lookup throws', async () => {
227+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
228+
const { run, query } = setup();
229+
query.mockRejectedValue(new Error('boom'));
230+
expect(await run()).toEqual([]);
231+
expect(warn).toHaveBeenCalled();
232+
});
233+
234+
it('skips the lookup when the session table has no ServiceName column', async () => {
235+
const { run, query } = setup({ columns: ['TimestampTime'] });
236+
expect(await run()).toEqual([]);
237+
expect(query).not.toHaveBeenCalled();
238+
});
239+
240+
it('skips the lookup when the trace source has no service name expression', async () => {
241+
const { run, getColumns, query } = setup();
242+
expect(
243+
await run({
244+
traceSource: { ...traceSource, serviceNameExpression: undefined },
245+
}),
246+
).toEqual([]);
247+
expect(getColumns).not.toHaveBeenCalled();
248+
expect(query).not.toHaveBeenCalled();
249+
});
250+
});
251+
252+
describe('buildSessionsQueryFilters', () => {
253+
const scope = [{ type: 'sql', condition: "ServiceName IN ('web')" }] as const;
254+
255+
it('puts the scope before a lucene query', () => {
256+
expect(buildSessionsQueryFilters([...scope], 'foo', 'lucene')).toEqual([
257+
...scope,
258+
{ type: 'lucene', condition: 'foo' },
259+
]);
260+
});
261+
262+
it('keeps a SQL query as SQL', () => {
263+
expect(buildSessionsQueryFilters([...scope], 'a = 1', 'sql')).toEqual([
264+
...scope,
265+
{ type: 'sql', condition: 'a = 1' },
266+
]);
267+
});
268+
269+
it('treats a missing language as lucene', () => {
270+
expect(buildSessionsQueryFilters([], 'foo', undefined)).toEqual([
271+
{ type: 'lucene', condition: 'foo' },
272+
]);
273+
});
274+
275+
it('returns no filters with no scope and no query', () => {
276+
expect(buildSessionsQueryFilters([], '', 'lucene')).toEqual([]);
277+
});
278+
});

‎packages/app/src/sessions.ts‎

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import {
55
chSql,
66
streamToAsyncIterator,
77
} from '@hyperdx/common-utils/dist/clickhouse';
8+
import type { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/browser';
9+
import type { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
810
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
11+
import { filtersToQuery } from '@hyperdx/common-utils/dist/filters';
912
import {
1013
DateRange,
14+
Filter,
1115
pickSampleWeightExpressionProps,
1216
SearchCondition,
1317
SearchConditionLanguage,
@@ -41,6 +45,96 @@ export type Session = {
4145
userName: string;
4246
};
4347

48+
// Recording rows are written throughout a session, so the range only needs
49+
// padding for skew between recording and span timestamps.
50+
const SERVICE_SCOPE_PADDING_MS = 60 * 60 * 1000;
51+
52+
// Trace tables sorted by ServiceName first can't prune on time alone, so the
53+
// sessions query is scoped to the services in the session table. Returns []
54+
// (unscoped) when that isn't possible or the lookup fails.
55+
export async function fetchServiceScopeFilters({
56+
traceSource,
57+
sessionSource,
58+
dateRange,
59+
metadata,
60+
clickhouseClient,
61+
}: {
62+
traceSource: TTraceSource;
63+
sessionSource: TSessionSource;
64+
dateRange: [Date, Date];
65+
metadata: Metadata;
66+
clickhouseClient: ClickhouseClient;
67+
}): Promise<Filter[]> {
68+
if (!traceSource.serviceNameExpression) {
69+
return [];
70+
}
71+
try {
72+
const columns = await metadata.getColumns({
73+
databaseName: sessionSource.from.databaseName,
74+
tableName: sessionSource.from.tableName,
75+
connectionId: sessionSource.connection,
76+
});
77+
if (!columns.some(c => c.name === 'ServiceName')) {
78+
return [];
79+
}
80+
const query = await renderChartConfig(
81+
{
82+
select: [
83+
{ valueExpression: 'DISTINCT ServiceName', alias: 'serviceName' },
84+
],
85+
from: sessionSource.from,
86+
dateRange: [
87+
new Date(dateRange[0].getTime() - SERVICE_SCOPE_PADDING_MS),
88+
new Date(dateRange[1].getTime() + SERVICE_SCOPE_PADDING_MS),
89+
],
90+
where: '',
91+
timestampValueExpression: sessionSource.timestampValueExpression,
92+
connection: sessionSource.connection,
93+
},
94+
metadata,
95+
sessionSource.querySettings,
96+
);
97+
const json = await clickhouseClient
98+
.query({
99+
query: query.sql,
100+
query_params: query.params,
101+
connectionId: sessionSource.connection,
102+
format: 'JSON',
103+
})
104+
.then(res => res.json<{ serviceName: string }>());
105+
const serviceNames = (json.data ?? [])
106+
.map(row => row.serviceName)
107+
.filter(name => name != null);
108+
return filtersToQuery({
109+
[traceSource.serviceNameExpression]: {
110+
included: new Set(serviceNames),
111+
excluded: new Set(),
112+
},
113+
});
114+
} catch (e) {
115+
console.warn('Sessions service scope lookup failed', e);
116+
return [];
117+
}
118+
}
119+
120+
export function buildSessionsQueryFilters(
121+
serviceScopeFilters: Filter[],
122+
where: SearchCondition | undefined,
123+
whereLanguage: SearchConditionLanguage | undefined,
124+
): Filter[] {
125+
return [
126+
...serviceScopeFilters,
127+
...(where
128+
? [
129+
{
130+
type: whereLanguage === 'sql' ? 'sql' : 'lucene',
131+
condition: where,
132+
} satisfies Filter,
133+
]
134+
: []),
135+
];
136+
}
137+
44138
export function useSessions(
45139
{
46140
traceSource,
@@ -97,6 +191,20 @@ export function useSessions(
97191
'rum.sessionId',
98192
);
99193

194+
const serviceScopeFilters = await fetchServiceScopeFilters({
195+
traceSource,
196+
sessionSource,
197+
dateRange,
198+
metadata,
199+
clickhouseClient,
200+
});
201+
202+
const sessionsQueryFilters = buildSessionsQueryFilters(
203+
serviceScopeFilters,
204+
where,
205+
whereLanguage,
206+
);
207+
100208
const [
101209
sessionsQuery,
102210
sessionIdsWithRecordingsQuery,
@@ -157,15 +265,8 @@ export function useSessions(
157265
dateRange,
158266
where: `${traceSource.resourceAttributesExpression}.rum.sessionId:*`,
159267
whereLanguage: 'lucene',
160-
...(where && {
161-
filters: [
162-
{
163-
type:
164-
(whereLanguage === 'promql' ? 'lucene' : whereLanguage) ??
165-
'lucene',
166-
condition: where,
167-
},
168-
],
268+
...(sessionsQueryFilters.length > 0 && {
269+
filters: sessionsQueryFilters,
169270
}),
170271
timestampValueExpression: traceSource.timestampValueExpression,
171272
implicitColumnExpression: traceSource.implicitColumnExpression,

0 commit comments

Comments
 (0)