Skip to content

Commit 68ef3d6

Browse files
feat: deterministic sampling with adaptive sample size (#1849)
## Summary Closes #1827 Replaces non-deterministic `ORDER BY rand()` with deterministic `cityHash64(SpanId)` sampling and introduces sampling configuration constants. ### What this PR does - **Deterministic sampling**: `ORDER BY cityHash64(SpanId)` instead of `rand()` — same data always produces the same sample, so results are stable across re-renders - **Named constants**: `SAMPLE_SIZE`, `STABLE_SAMPLE_EXPR` replace hardcoded `1000` and `'rand()'` in query configs - **Adaptive sizing foundation**: `computeEffectiveSampleSize()` function with `MIN_SAMPLE_SIZE`/`MAX_SAMPLE_SIZE`/`SAMPLE_RATIO` constants, exported and tested (6 unit tests) ### What this PR does NOT do (follow-up) - **Count query for adaptive sizing**: Wiring `computeEffectiveSampleSize` into the actual queries requires adding a lightweight `count()` query. This is deferred to keep this PR focused on the deterministic sampling change. - **Dynamic column detection**: `STABLE_SAMPLE_EXPR` uses `SpanId` which is trace-specific. Event Deltas currently only renders on the traces search page where `SpanId` is always present. If the feature expands to logs/metrics, this should be parameterized per source (documented in code comment). ## Test plan - [ ] Same data + same hover always highlights the same heatmap cells (deterministic) - [ ] Run `npx jest src/components/__tests__/deltaChartSampling.test.ts` — 6 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 46daa63 commit 68ef3d6

5 files changed

Lines changed: 116 additions & 6 deletions

File tree

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+
feat: deterministic sampling with adaptive sample size for Event Deltas

‎packages/app/src/components/DBDeltaChart.tsx‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ import { getFirstTimestampValueExpression } from '@/source';
2323
import { SQLPreview } from './ChartSQLPreview';
2424
import {
2525
getPropertyStatistics,
26+
getStableSampleExpression,
2627
isDenylisted,
2728
isHighCardinality,
2829
mergeValueStatisticsMaps,
30+
SAMPLE_SIZE,
2931
} from './deltaChartUtils';
3032
import {
3133
CHART_GAP,
@@ -42,17 +44,22 @@ export default function DBDeltaChart({
4244
xMax,
4345
yMin,
4446
yMax,
47+
spanIdExpression,
4548
}: {
4649
config: ChartConfigWithDateRange;
4750
valueExpr: string;
4851
xMin: number;
4952
xMax: number;
5053
yMin: number;
5154
yMax: number;
55+
spanIdExpression?: string;
5256
}) {
5357
// Determine if the value expression uses aggregate functions
5458
const isAggregate = isAggregateFunction(valueExpr);
5559

60+
// Build deterministic ORDER BY expression from source's spanIdExpression
61+
const stableSampleExpr = getStableSampleExpression(spanIdExpression);
62+
5663
// Get the timestamp expression from config
5764
const timestampExpr = getFirstTimestampValueExpression(
5865
config.timestampValueExpression,
@@ -136,8 +143,8 @@ export default function DBDeltaChart({
136143
]
137144
: []),
138145
],
139-
orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }],
140-
limit: { limit: 1000 },
146+
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
147+
limit: { limit: SAMPLE_SIZE },
141148
},
142149
},
143150
];
@@ -191,17 +198,17 @@ export default function DBDeltaChart({
191198
with: buildWithClauses(true),
192199
select: '*',
193200
filters: buildFilters(true),
194-
orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }],
195-
limit: { limit: 1000 },
201+
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
202+
limit: { limit: SAMPLE_SIZE },
196203
});
197204

198205
const { data: inlierData } = useQueriedChartConfig({
199206
...config,
200207
with: buildWithClauses(false),
201208
select: '*',
202209
filters: buildFilters(false),
203-
orderBy: [{ ordering: 'DESC', valueExpression: 'rand()' }],
204-
limit: { limit: 1000 },
210+
orderBy: [{ ordering: 'DESC', valueExpression: stableSampleExpr }],
211+
limit: { limit: SAMPLE_SIZE },
205212
});
206213

207214
// Column metadata for field classification (from ClickHouse response)

‎packages/app/src/components/Search/DBSearchHeatmapChart.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export function DBSearchHeatmapChart({
119119
xMax={fields.xMax}
120120
yMin={fields.yMin}
121121
yMax={fields.yMax}
122+
spanIdExpression={source.spanIdExpression}
122123
/>
123124
) : (
124125
<Center mih={100} h="100%">
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
computeEffectiveSampleSize,
3+
getStableSampleExpression,
4+
MAX_SAMPLE_SIZE,
5+
MIN_SAMPLE_SIZE,
6+
SAMPLE_RATIO,
7+
SAMPLE_SIZE,
8+
} from '../deltaChartUtils';
9+
10+
describe('getStableSampleExpression', () => {
11+
it('returns cityHash64 of spanIdExpression when provided', () => {
12+
expect(getStableSampleExpression('SpanId')).toBe('cityHash64(SpanId)');
13+
});
14+
15+
it('uses custom spanId column name', () => {
16+
expect(getStableSampleExpression('my_span_id')).toBe(
17+
'cityHash64(my_span_id)',
18+
);
19+
});
20+
21+
it('falls back to rand() when spanIdExpression is undefined', () => {
22+
expect(getStableSampleExpression(undefined)).toBe('rand()');
23+
});
24+
25+
it('falls back to rand() when spanIdExpression is empty', () => {
26+
expect(getStableSampleExpression('')).toBe('rand()');
27+
});
28+
});
29+
30+
describe('computeEffectiveSampleSize', () => {
31+
it('returns SAMPLE_SIZE when totalCount is 0 (fallback)', () => {
32+
expect(computeEffectiveSampleSize(0)).toBe(SAMPLE_SIZE);
33+
});
34+
35+
it('returns SAMPLE_SIZE when totalCount is negative', () => {
36+
expect(computeEffectiveSampleSize(-1)).toBe(SAMPLE_SIZE);
37+
});
38+
39+
it('returns MIN_SAMPLE_SIZE for small datasets', () => {
40+
expect(computeEffectiveSampleSize(100)).toBe(MIN_SAMPLE_SIZE);
41+
});
42+
43+
it('returns SAMPLE_RATIO * totalCount for mid-size datasets', () => {
44+
const result = computeEffectiveSampleSize(200_000);
45+
expect(result).toBe(Math.ceil(200_000 * SAMPLE_RATIO));
46+
expect(result).toBeGreaterThan(MIN_SAMPLE_SIZE);
47+
expect(result).toBeLessThan(MAX_SAMPLE_SIZE);
48+
});
49+
50+
it('caps at MAX_SAMPLE_SIZE for very large datasets', () => {
51+
expect(computeEffectiveSampleSize(10_000_000)).toBe(MAX_SAMPLE_SIZE);
52+
});
53+
54+
it('returns exact 1% for datasets where 1% falls in the valid range', () => {
55+
expect(computeEffectiveSampleSize(100_000)).toBe(1000);
56+
});
57+
});

‎packages/app/src/components/deltaChartUtils.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,43 @@ export function isHighCardinality(
283283

284284
return effectiveUniqueness > 0.9;
285285
}
286+
287+
// ---------------------------------------------------------------------------
288+
// Sampling configuration
289+
// ---------------------------------------------------------------------------
290+
291+
/** Default number of rows sampled when the total count is unknown */
292+
export const SAMPLE_SIZE = 1000;
293+
294+
/** Minimum number of rows to sample */
295+
export const MIN_SAMPLE_SIZE = 500;
296+
297+
/** Maximum number of rows to sample */
298+
export const MAX_SAMPLE_SIZE = 5000;
299+
300+
/** Fraction of total rows to sample (e.g., 0.01 = 1%) */
301+
export const SAMPLE_RATIO = 0.01;
302+
303+
/**
304+
* Builds a deterministic ORDER BY expression for stable sampling.
305+
* Uses the source's spanIdExpression when available, falls back to rand().
306+
*/
307+
export function getStableSampleExpression(spanIdExpression?: string): string {
308+
if (spanIdExpression) {
309+
return `cityHash64(${spanIdExpression})`;
310+
}
311+
return 'rand()';
312+
}
313+
314+
/**
315+
* Computes the effective sample size based on total row count.
316+
* Adaptive formula: clamp(MIN_SAMPLE_SIZE, ceil(totalCount * SAMPLE_RATIO), MAX_SAMPLE_SIZE).
317+
* Returns SAMPLE_SIZE as fallback when totalCount is 0 or unavailable.
318+
*/
319+
export function computeEffectiveSampleSize(totalCount: number): number {
320+
if (totalCount <= 0) return SAMPLE_SIZE;
321+
return Math.min(
322+
MAX_SAMPLE_SIZE,
323+
Math.max(MIN_SAMPLE_SIZE, Math.ceil(totalCount * SAMPLE_RATIO)),
324+
);
325+
}

0 commit comments

Comments
 (0)