Skip to content

Commit f5eadca

Browse files
Merge branch 'main' into warren/fix-release-cli-workflow
2 parents b6f4a4e + 587819b commit f5eadca

23 files changed

Lines changed: 562 additions & 78 deletions

‎AGENTS.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,49 @@ formatting checks pass. Fix any issues before creating the commit.
206206
manual intervention rather than guessing. A wrong guess silently breaks
207207
things; asking is always cheaper than debugging later.
208208

209+
## Cursor Cloud specific instructions
210+
211+
### Docker requirement
212+
213+
Docker must be installed and running before starting the dev stack or running
214+
integration/E2E tests. The VM update script handles `yarn install` and
215+
`yarn build:common-utils`, but Docker daemon startup is a prerequisite that must
216+
already be available.
217+
218+
### Starting the dev stack
219+
220+
`yarn dev` uses `sh -c` to source `scripts/dev-env.sh`, which contains
221+
bash-specific syntax (`BASH_SOURCE`). On systems where `/bin/sh` is `dash`
222+
(e.g. Ubuntu), this fails with "Bad substitution". Work around it by running
223+
with bash directly:
224+
225+
```bash
226+
bash -c 'export PATH="/workspace/node_modules/.bin:$PATH" && source ./scripts/dev-env.sh && yarn build:common-utils && dotenvx run --convention=nextjs -- docker compose -p "$HDX_DEV_PROJECT" -f docker-compose.dev.yml up -d && yarn app:dev'
227+
```
228+
229+
Port isolation assigns a slot based on the worktree directory name. In the
230+
default `/workspace` directory, the slot is **76**, so services are at:
231+
232+
- **App**: http://localhost:30276
233+
- **API**: http://localhost:30176
234+
- **ClickHouse**: http://localhost:30576
235+
- **MongoDB**: localhost:30476
236+
237+
### Key commands reference
238+
239+
See `AGENTS.md` above and `agent_docs/development.md` for the full command
240+
reference. Quick summary:
241+
242+
- `make ci-lint` — lint + TypeScript type check
243+
- `make ci-unit` — unit tests (all packages)
244+
- `make dev-int FILE=<name>` — integration tests (spins up Docker services)
245+
- `make dev-e2e FILE=<name>` — E2E tests (Playwright)
246+
247+
### First-time registration
248+
249+
When the dev stack starts fresh (empty MongoDB), the app shows a registration
250+
page. Create any account to get started — no external auth provider is needed.
251+
209252
---
210253

211254
_Need more details? Check the `agent_docs/` directory or ask which documentation

‎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)