Skip to content

Commit 4ef63cc

Browse files
feat: Auto-detect trace duration and render in adaptive time units (HDX-3909) (#2046)
## Summary When a chart uses a trace source with a Duration Expression, the chart now automatically defaults to adaptive time unit formatting (e.g., `120.41s`, `45ms`, `3µs`) instead of requiring users to manually select a format. Users can still override the format through the existing display settings. **Key changes:** 1. **New `duration` output type** in `NumberFormatSchema` — renders values adaptively as `µs`, `ms`, `s`, `min`, or `h` based on magnitude, instead of the clock-style `hh:mm:ss` format 2. **Auto-detection via exact match** — `getTraceDurationNumberFormat()` checks if any chart select `valueExpression` exactly equals the trace source's `durationExpression`. Only applies for unit-preserving aggregate functions (`avg`, `min`, `max`, `sum`, `quantile`, `any`, `last_value`, etc.) — skips `count` and `count_distinct` 3. **`useResolvedNumberFormat()` hook** — resolves the effective `numberFormat` for a chart: returns the user's explicit format if set, otherwise auto-detects duration format for trace sources 4. **UI form update** — Added "Duration" option to the number format selector with input unit picker (seconds/ms/µs/ns) 5. **Display settings drawer** — Shows the auto-detected format by default so users can see what's being applied 6. **Heatmap support** — Updated `DBHeatmapChart` tick formatter to use `formatDurationMs` for duration-formatted values **Components updated:** `DBTimeChart`, `DBNumberChart`, `DBListBarChart`, `DBPieChart`, `DBTableChart`, `DBHeatmapChart`, `DBSearchHeatmapChart`, `DBEditTimeChartForm`, `ChartDisplaySettingsDrawer` ### How to test locally or on Vercel 1. Create a chart from a trace source with a unit-preserving aggFn (e.g., avg/p95/p99/min/max of the Duration column) 2. Verify the chart y-axis and tooltips now show values like `120.41s` or `45ms` instead of raw numbers 3. Open the chart display settings and verify the "Duration" output format is shown as the default 4. Change the aggFn to `count` or `count_distinct` — verify duration formatting is NOT applied 5. Change the format to something else (e.g., "Number") and verify the override persists 6. Switch back to "Duration" and pick different input units (seconds, ms, µs, ns) — the preview should update correctly 7. Check that non-trace-source charts are unaffected (no auto-detection triggers) 8. Verify the search heatmap chart for traces still shows proper duration labels 9. Reset to defaults in the display settings drawer and verify it returns to the auto-detected duration format ### References - Linear Issue: HDX-3909 Linear Issue: [HDX-3909](https://linear.app/clickhouse/issue/HDX-3909/trace-duration-should-render-in-time-unit-by-default) <div><a href="https://cursor.com/agents/bc-c39f9186-2593-4675-8f23-190cd148818b"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-c39f9186-2593-4675-8f23-190cd148818b"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>
1 parent edb55b4 commit 4ef63cc

22 files changed

Lines changed: 519 additions & 78 deletions

packages/app/src/__tests__/DBSearchPageQueryKey.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jest.mock('@/hooks/useChartConfig', () => ({
3939

4040
jest.mock('@/source', () => ({
4141
useSource: () => ({ data: null, isLoading: false }),
42+
useResolvedNumberFormat: () => undefined,
4243
}));
4344

4445
jest.mock('@/ChartUtils', () => ({
Lines changed: 177 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,184 @@
1-
import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types';
1+
import {
2+
SourceKind,
3+
TLogSource,
4+
TTraceSource,
5+
} from '@hyperdx/common-utils/dist/types';
26

3-
import { getEventBody } from '../source';
7+
import { getEventBody, getTraceDurationNumberFormat } from '../source';
8+
9+
const TRACE_SOURCE: TTraceSource = {
10+
kind: SourceKind.Trace,
11+
from: {
12+
databaseName: 'default',
13+
tableName: 'otel_traces',
14+
},
15+
timestampValueExpression: 'Timestamp',
16+
connection: 'test-connection',
17+
name: 'Traces',
18+
id: 'test-source-id',
19+
spanNameExpression: 'SpanName',
20+
durationExpression: 'Duration',
21+
durationPrecision: 9,
22+
traceIdExpression: 'TraceId',
23+
spanIdExpression: 'SpanId',
24+
parentSpanIdExpression: 'ParentSpanId',
25+
spanKindExpression: 'SpanKind',
26+
defaultTableSelectExpression: 'Timestamp, ServiceName',
27+
} as TTraceSource;
428

529
describe('getEventBody', () => {
6-
// Added to prevent regression back to HDX-3361
730
it('returns spanNameExpression for trace kind source when both bodyExpression and spanNameExpression are present', () => {
8-
const source = {
9-
kind: SourceKind.Trace,
10-
from: {
11-
databaseName: 'default',
12-
tableName: 'otel_traces',
13-
},
14-
timestampValueExpression: 'Timestamp',
15-
connection: 'test-connection',
16-
name: 'Traces',
17-
id: 'test-source-id',
18-
spanNameExpression: 'SpanName',
19-
durationExpression: 'Duration',
20-
durationPrecision: 9,
21-
traceIdExpression: 'TraceId',
22-
spanIdExpression: 'SpanId',
23-
parentSpanIdExpression: 'ParentSpanId',
24-
spanKindExpression: 'SpanKind',
25-
} as TTraceSource;
26-
27-
const result = getEventBody(source);
28-
31+
const result = getEventBody(TRACE_SOURCE);
2932
expect(result).toBe('SpanName');
3033
});
3134
});
35+
36+
describe('getTraceDurationNumberFormat', () => {
37+
it('returns undefined for non-trace sources', () => {
38+
const logSource = {
39+
kind: SourceKind.Log,
40+
id: 'log-source',
41+
} as TLogSource;
42+
const result = getTraceDurationNumberFormat(logSource, [
43+
{ valueExpression: 'count()' },
44+
]);
45+
expect(result).toBeUndefined();
46+
});
47+
48+
it('returns undefined when source is undefined', () => {
49+
const result = getTraceDurationNumberFormat(undefined, [
50+
{ valueExpression: 'count()' },
51+
]);
52+
expect(result).toBeUndefined();
53+
});
54+
55+
it('returns undefined when select expressions do not reference duration', () => {
56+
const result = getTraceDurationNumberFormat(TRACE_SOURCE, [
57+
{ valueExpression: 'count()' },
58+
]);
59+
expect(result).toBeUndefined();
60+
});
61+
62+
// --- exact match ---
63+
64+
it('matches when valueExpression exactly equals durationExpression', () => {
65+
expect(
66+
getTraceDurationNumberFormat(TRACE_SOURCE, [
67+
{ valueExpression: 'Duration', aggFn: 'avg' },
68+
]),
69+
).toEqual({ output: 'duration', factor: 1e-9 });
70+
});
71+
72+
it('matches without aggFn (raw expression passed through)', () => {
73+
expect(
74+
getTraceDurationNumberFormat(TRACE_SOURCE, [
75+
{ valueExpression: 'Duration' },
76+
]),
77+
).toEqual({ output: 'duration', factor: 1e-9 });
78+
});
79+
80+
// --- non-matching expressions ---
81+
82+
it('does not match expressions that only contain the duration name', () => {
83+
expect(
84+
getTraceDurationNumberFormat(TRACE_SOURCE, [
85+
{ valueExpression: 'avg(Duration)' },
86+
]),
87+
).toBeUndefined();
88+
});
89+
90+
it('does not match division expressions', () => {
91+
expect(
92+
getTraceDurationNumberFormat(TRACE_SOURCE, [
93+
{ valueExpression: 'Duration/1e6' },
94+
]),
95+
).toBeUndefined();
96+
expect(
97+
getTraceDurationNumberFormat(TRACE_SOURCE, [
98+
{ valueExpression: '(Duration)/1e6' },
99+
]),
100+
).toBeUndefined();
101+
expect(
102+
getTraceDurationNumberFormat(TRACE_SOURCE, [
103+
{ valueExpression: 'Duration / 1e9' },
104+
]),
105+
).toBeUndefined();
106+
});
107+
108+
it('does not match modified or similar-named expressions', () => {
109+
expect(
110+
getTraceDurationNumberFormat(TRACE_SOURCE, [
111+
{ valueExpression: 'Duration * 2' },
112+
]),
113+
).toBeUndefined();
114+
expect(
115+
getTraceDurationNumberFormat(TRACE_SOURCE, [
116+
{ valueExpression: 'LongerDuration' },
117+
]),
118+
).toBeUndefined();
119+
expect(
120+
getTraceDurationNumberFormat(TRACE_SOURCE, [
121+
{ valueExpression: 'round(Duration / 1e6, 2)' },
122+
]),
123+
).toBeUndefined();
124+
});
125+
126+
// --- aggFn filtering ---
127+
128+
it('returns undefined for count aggFn', () => {
129+
expect(
130+
getTraceDurationNumberFormat(TRACE_SOURCE, [
131+
{ valueExpression: 'Duration', aggFn: 'count' },
132+
]),
133+
).toBeUndefined();
134+
});
135+
136+
it('returns undefined for count_distinct aggFn', () => {
137+
expect(
138+
getTraceDurationNumberFormat(TRACE_SOURCE, [
139+
{ valueExpression: 'Duration', aggFn: 'count_distinct' },
140+
]),
141+
).toBeUndefined();
142+
});
143+
144+
it.each(['sum', 'min', 'max', 'quantile', 'avg', 'any', 'last_value'])(
145+
'detects duration with %s aggFn',
146+
aggFn => {
147+
expect(
148+
getTraceDurationNumberFormat(TRACE_SOURCE, [
149+
{ valueExpression: 'Duration', aggFn },
150+
]),
151+
).toEqual({ output: 'duration', factor: 1e-9 });
152+
},
153+
);
154+
155+
it('detects duration with combinator aggFn like avgIf', () => {
156+
expect(
157+
getTraceDurationNumberFormat(TRACE_SOURCE, [
158+
{ valueExpression: 'Duration', aggFn: 'avgIf' },
159+
]),
160+
).toEqual({ output: 'duration', factor: 1e-9 });
161+
});
162+
163+
it('skips non-preserving aggFn and detects preserving one in mixed selects', () => {
164+
expect(
165+
getTraceDurationNumberFormat(TRACE_SOURCE, [
166+
{ valueExpression: 'Duration', aggFn: 'count' },
167+
{ valueExpression: 'Duration', aggFn: 'avg' },
168+
]),
169+
).toEqual({ output: 'duration', factor: 1e-9 });
170+
});
171+
172+
it('returns undefined when only non-preserving aggFns reference duration', () => {
173+
expect(
174+
getTraceDurationNumberFormat(TRACE_SOURCE, [
175+
{ valueExpression: 'Duration', aggFn: 'count' },
176+
{ valueExpression: 'Duration', aggFn: 'count_distinct' },
177+
]),
178+
).toBeUndefined();
179+
});
180+
181+
it('returns undefined when select is empty', () => {
182+
expect(getTraceDurationNumberFormat(TRACE_SOURCE, [])).toBeUndefined();
183+
});
184+
});

packages/app/src/__tests__/utils.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MetricsDataType, NumberFormat } from '../types';
66
import * as utils from '../utils';
77
import {
88
formatAttributeClause,
9+
formatDurationMs,
910
formatNumber,
1011
getAllMetricTables,
1112
getMetricTableName,
@@ -357,6 +358,68 @@ describe('formatNumber', () => {
357358
});
358359
});
359360

361+
describe('duration format', () => {
362+
it('formats seconds input as adaptive duration', () => {
363+
const format: NumberFormat = {
364+
output: 'duration',
365+
factor: 1,
366+
};
367+
expect(formatNumber(30.41, format)).toBe('30.41s');
368+
expect(formatNumber(0.045, format)).toBe('45ms');
369+
expect(formatNumber(3661, format)).toBe('1.02h');
370+
});
371+
372+
it('formats milliseconds input as adaptive duration', () => {
373+
const format: NumberFormat = {
374+
output: 'duration',
375+
factor: 0.001,
376+
};
377+
expect(formatNumber(30410, format)).toBe('30.41s');
378+
expect(formatNumber(45, format)).toBe('45ms');
379+
});
380+
381+
it('formats nanoseconds input as adaptive duration', () => {
382+
const format: NumberFormat = {
383+
output: 'duration',
384+
factor: 0.000000001,
385+
};
386+
expect(formatNumber(30410000000, format)).toBe('30.41s');
387+
expect(formatNumber(45000000, format)).toBe('45ms');
388+
expect(formatNumber(500, format)).toBe('0.5µs');
389+
});
390+
391+
it('handles zero value', () => {
392+
const format: NumberFormat = {
393+
output: 'duration',
394+
factor: 1,
395+
};
396+
expect(formatNumber(0, format)).toBe('0ms');
397+
});
398+
399+
it('defaults factor to 1 (seconds) when not specified', () => {
400+
const format: NumberFormat = {
401+
output: 'duration',
402+
};
403+
expect(formatNumber(1.5, format)).toBe('1.5s');
404+
});
405+
406+
it('formats sub-millisecond values as microseconds', () => {
407+
const format: NumberFormat = {
408+
output: 'duration',
409+
factor: 1,
410+
};
411+
expect(formatNumber(0.0003, format)).toBe('300µs');
412+
});
413+
414+
it('formats large values as hours', () => {
415+
const format: NumberFormat = {
416+
output: 'duration',
417+
factor: 1,
418+
};
419+
expect(formatNumber(7200, format)).toBe('2h');
420+
});
421+
});
422+
360423
describe('unit handling', () => {
361424
it('appends unit to formatted number', () => {
362425
const format: NumberFormat = {
@@ -596,6 +659,49 @@ describe('formatNumber', () => {
596659
});
597660
});
598661

662+
describe('formatDurationMs', () => {
663+
it('formats zero', () => {
664+
expect(formatDurationMs(0)).toBe('0ms');
665+
});
666+
667+
it('formats microseconds', () => {
668+
expect(formatDurationMs(0.5)).toBe('500µs');
669+
expect(formatDurationMs(0.003)).toBe('3µs');
670+
expect(formatDurationMs(0.01)).toBe('10µs');
671+
});
672+
673+
it('formats milliseconds', () => {
674+
expect(formatDurationMs(1)).toBe('1ms');
675+
expect(formatDurationMs(45)).toBe('45ms');
676+
expect(formatDurationMs(999)).toBe('999ms');
677+
expect(formatDurationMs(5.5)).toBe('5.5ms');
678+
});
679+
680+
it('formats seconds', () => {
681+
expect(formatDurationMs(1000)).toBe('1s');
682+
expect(formatDurationMs(1500)).toBe('1.5s');
683+
expect(formatDurationMs(30410)).toBe('30.41s');
684+
});
685+
686+
it('formats minutes', () => {
687+
expect(formatDurationMs(60000)).toBe('1min');
688+
expect(formatDurationMs(90000)).toBe('1.5min');
689+
});
690+
691+
it('formats hours', () => {
692+
expect(formatDurationMs(3600000)).toBe('1h');
693+
expect(formatDurationMs(7200000)).toBe('2h');
694+
});
695+
696+
it('handles negative values', () => {
697+
expect(formatDurationMs(-1500)).toBe('-1.5s');
698+
});
699+
700+
it('handles sub-microsecond precision', () => {
701+
expect(formatDurationMs(0.0005)).toBe('0.5µs');
702+
});
703+
});
704+
599705
describe('useLocalStorage', () => {
600706
// Create a mock for localStorage
601707
let localStorageMock: jest.Mocked<Storage>;

0 commit comments

Comments
 (0)