From 32b4fd110d5f71f21a08bf848bbf070eeea48717 Mon Sep 17 00:00:00 2001 From: Nicholas Quandt <57432850+nquandt@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:50:20 -0500 Subject: [PATCH 1/5] fix: preserve sub-millisecond precision in trace waterfall timestamps --- .../src/components/DBTraceWaterfallChart.tsx | 7 ++++--- packages/app/src/utils.ts | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index dfca012dab..a278c7a183 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -53,6 +53,7 @@ import { getChartColorSuccessHighlight, getChartColorWarning, getChartColorWarningHighlight, + parseTimestampToMs, } from '@/utils'; import { getHighlightedAttributesFromData, @@ -753,7 +754,7 @@ export function DBTraceWaterfallChartContainer({ // All units in ms! const foundMinOffset = rows?.reduce((acc, result) => { - return Math.min(acc, new Date(result.Timestamp).getTime()); + return Math.min(acc, parseTimestampToMs(result.Timestamp)); }, Number.MAX_SAFE_INTEGER) ?? 0; const minOffset = foundMinOffset === Number.MAX_SAFE_INTEGER ? 0 : foundMinOffset; @@ -765,7 +766,7 @@ export function DBTraceWaterfallChartContainer({ () => flattenedNodes.map((result, i) => { const tookMs = (result.Duration || 0) * 1000; - const startOffset = new Date(result.Timestamp).getTime(); + const startOffset = parseTimestampToMs(result.Timestamp); const start = startOffset - minOffset; const end = start + tookMs; @@ -799,7 +800,7 @@ export function DBTraceWaterfallChartContainer({ const markers = showSpanEvents && result.SpanEvents ? result.SpanEvents.map(spanEvent => ({ - timestamp: new Date(spanEvent.Timestamp).getTime() - minOffset, + timestamp: parseTimestampToMs(spanEvent.Timestamp) - minOffset, name: spanEvent.Name, attributes: spanEvent.Attributes || {}, })) diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index a011234437..18ab5af5e4 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/router'; import { formatDistanceToNowStrict } from 'date-fns'; import numbro from 'numbro'; -import type { SetStateAction } from 'react'; +import TimestampNano from 'timestamp-nano'; +import type { MutableRefObject, SetStateAction } from 'react'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { NumericUnit, @@ -1113,3 +1114,20 @@ export const isElementClickable = (el: HTMLElement): boolean => { // or if the element at point is a descendant of the element passed in return el === elementAtPoint || el.contains(elementAtPoint); }; + +/** + * Parse a nanosecond-precision ISO 8601 timestamp string (as returned by + * ClickHouse DateTime64(9)) into milliseconds since the Unix epoch, preserving + * sub-millisecond precision as a fractional millisecond value. + * + * `new Date(str).getTime()` silently truncates sub-millisecond digits, which + * causes alignment errors in the waterfall when two events fall within the same + * millisecond. TimestampNano's public API gives us: + * - toDate().getTime() → integer ms from epoch (truncated) + * - getNano() % 1_000_000 → the remaining sub-millisecond nanoseconds (0–999_999) + * Dividing the remainder by 1_000_000 converts it to a fractional millisecond. + */ +export function parseTimestampToMs(isoString: string): number { + const ts = TimestampNano.fromString(isoString); + return ts.toDate().getTime() + (ts.getNano() % 1_000_000) / 1_000_000; +} From 6bda3683f8ea25581761b9a0bd747bcb844926eb Mon Sep 17 00:00:00 2001 From: Nicholas Quandt <57432850+nquandt@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:22:56 -0500 Subject: [PATCH 2/5] fix: address review feedback on trace waterfall timestamp parsing --- packages/app/src/__tests__/utils.test.ts | 29 +++++++++++++++++++ .../src/components/DBTraceWaterfallChart.tsx | 15 +++------- packages/app/src/utils.ts | 15 ++-------- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 5f9e334492..1562c0f588 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -12,6 +12,7 @@ import { getMetricTableName, mapKeyBy, orderByStringToSortingState, + parseTimestampToMs, sortingStateToOrderByString, stripTrailingSlash, useQueryHistory, @@ -1123,3 +1124,31 @@ describe('mapKeyBy', () => { expect(result.get('a')).toBe(data.at(1)); }); }); + +describe('parseTimestampToMs', () => { + it('returns integer ms when there are no sub-millisecond digits', () => { + // 2024-01-01T00:00:01.000000000Z → exactly 1 second past epoch start + const result = parseTimestampToMs('2024-01-01T00:00:01.000000000Z'); + expect(result).toBe(new Date('2024-01-01T00:00:01.000Z').getTime()); + }); + + it('preserves sub-millisecond precision as a fractional ms', () => { + // .000000500 → 500 ns → 0.5 µs → 0.0005 ms past the whole-ms boundary + const base = new Date('2024-01-01T00:00:01.000Z').getTime(); + const result = parseTimestampToMs('2024-01-01T00:00:01.000000500Z'); + expect(result).toBeCloseTo(base + 0.0005, 6); + }); + + it('handles max sub-millisecond value (999 µs + 999 ns)', () => { + // .000999999 → 999_999 ns sub-ms → 999_999 / 1_000_000 ≈ 0.999999 ms + const base = new Date('2024-01-01T00:00:01.000Z').getTime(); + const result = parseTimestampToMs('2024-01-01T00:00:01.000999999Z'); + expect(result).toBeCloseTo(base + 0.999999, 6); + }); + + it('produces correct relative ordering for two timestamps within the same millisecond', () => { + const earlier = parseTimestampToMs('2024-01-01T00:00:01.000000100Z'); + const later = parseTimestampToMs('2024-01-01T00:00:01.000000900Z'); + expect(earlier).toBeLessThan(later); + }); +}); diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index a278c7a183..683731d531 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -2,7 +2,6 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import _, { omit } from 'lodash'; import { useForm } from 'react-hook-form'; import SqlString from 'sqlstring'; -import TimestampNano from 'timestamp-nano'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { ChartConfig, @@ -516,16 +515,10 @@ export function DBTraceWaterfallChartContainer({ ...traceRowsData, ...logRowsData, ]; - nextRows.sort((a, b) => { - const aDate = TimestampNano.fromString(a.Timestamp); - const bDate = TimestampNano.fromString(b.Timestamp); - const secDiff = aDate.getTimeT() - bDate.getTimeT(); - if (secDiff === 0) { - return aDate.getNano() - bDate.getNano(); - } else { - return secDiff; - } - }); + nextRows.sort( + (a, b) => + parseTimestampToMs(a.Timestamp) - parseTimestampToMs(b.Timestamp), + ); return nextRows; }, [traceRowsData, logRowsData]); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index 18ab5af5e4..ec6cc3ee31 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/router'; import { formatDistanceToNowStrict } from 'date-fns'; import numbro from 'numbro'; -import TimestampNano from 'timestamp-nano'; import type { MutableRefObject, SetStateAction } from 'react'; +import TimestampNano from 'timestamp-nano'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { NumericUnit, @@ -1115,18 +1115,7 @@ export const isElementClickable = (el: HTMLElement): boolean => { return el === elementAtPoint || el.contains(elementAtPoint); }; -/** - * Parse a nanosecond-precision ISO 8601 timestamp string (as returned by - * ClickHouse DateTime64(9)) into milliseconds since the Unix epoch, preserving - * sub-millisecond precision as a fractional millisecond value. - * - * `new Date(str).getTime()` silently truncates sub-millisecond digits, which - * causes alignment errors in the waterfall when two events fall within the same - * millisecond. TimestampNano's public API gives us: - * - toDate().getTime() → integer ms from epoch (truncated) - * - getNano() % 1_000_000 → the remaining sub-millisecond nanoseconds (0–999_999) - * Dividing the remainder by 1_000_000 converts it to a fractional millisecond. - */ +// Parses nanosecond-precision ISO 8601 to fractional ms since epoch. export function parseTimestampToMs(isoString: string): number { const ts = TimestampNano.fromString(isoString); return ts.toDate().getTime() + (ts.getNano() % 1_000_000) / 1_000_000; From e785ea714d0ec8cdb4537425a27ddf8fa1e66d67 Mon Sep 17 00:00:00 2001 From: Nicholas Quandt <57432850+nquandt@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:02:29 -0500 Subject: [PATCH 3/5] fix: remove unused MutableRefObject import from utils.ts --- packages/app/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index ec6cc3ee31..d98708b3fa 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/router'; import { formatDistanceToNowStrict } from 'date-fns'; import numbro from 'numbro'; -import type { MutableRefObject, SetStateAction } from 'react'; +import type { SetStateAction } from 'react'; import TimestampNano from 'timestamp-nano'; import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { From c2011922ac428424b27934a854d256c2ec3913b3 Mon Sep 17 00:00:00 2001 From: Nicholas Quandt <57432850+nquandt@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:47:07 -0500 Subject: [PATCH 4/5] fix: correct Float64 precision floor in parseTimestampToMs tests --- packages/app/src/__tests__/utils.test.ts | 15 ++++++--------- packages/app/src/utils.ts | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 1562c0f588..182978924b 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1127,28 +1127,25 @@ describe('mapKeyBy', () => { describe('parseTimestampToMs', () => { it('returns integer ms when there are no sub-millisecond digits', () => { - // 2024-01-01T00:00:01.000000000Z → exactly 1 second past epoch start const result = parseTimestampToMs('2024-01-01T00:00:01.000000000Z'); expect(result).toBe(new Date('2024-01-01T00:00:01.000Z').getTime()); }); it('preserves sub-millisecond precision as a fractional ms', () => { - // .000000500 → 500 ns → 0.5 µs → 0.0005 ms past the whole-ms boundary const base = new Date('2024-01-01T00:00:01.000Z').getTime(); - const result = parseTimestampToMs('2024-01-01T00:00:01.000000500Z'); - expect(result).toBeCloseTo(base + 0.0005, 6); + const result = parseTimestampToMs('2024-01-01T00:00:01.000500000Z'); + expect(result).toBeCloseTo(base + 0.5, 4); }); it('handles max sub-millisecond value (999 µs + 999 ns)', () => { - // .000999999 → 999_999 ns sub-ms → 999_999 / 1_000_000 ≈ 0.999999 ms const base = new Date('2024-01-01T00:00:01.000Z').getTime(); const result = parseTimestampToMs('2024-01-01T00:00:01.000999999Z'); - expect(result).toBeCloseTo(base + 0.999999, 6); + expect(result).toBeCloseTo(base + 0.999999, 3); }); - it('produces correct relative ordering for two timestamps within the same millisecond', () => { - const earlier = parseTimestampToMs('2024-01-01T00:00:01.000000100Z'); - const later = parseTimestampToMs('2024-01-01T00:00:01.000000900Z'); + it('orders two timestamps within the same millisecond correctly', () => { + const earlier = parseTimestampToMs('2024-01-01T00:00:01.000400000Z'); + const later = parseTimestampToMs('2024-01-01T00:00:01.000800000Z'); expect(earlier).toBeLessThan(later); }); }); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index d98708b3fa..b62f9cff38 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1115,7 +1115,7 @@ export const isElementClickable = (el: HTMLElement): boolean => { return el === elementAtPoint || el.contains(elementAtPoint); }; -// Parses nanosecond-precision ISO 8601 to fractional ms since epoch. +// Parses an ISO 8601 string to fractional ms since epoch (~244 ns Float64 floor). export function parseTimestampToMs(isoString: string): number { const ts = TimestampNano.fromString(isoString); return ts.toDate().getTime() + (ts.getNano() % 1_000_000) / 1_000_000; From 3e95580672fdd5786e28140b388b4fec8e7a8554 Mon Sep 17 00:00:00 2001 From: Nicholas Quandt <57432850+nquandt@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:18:17 -0500 Subject: [PATCH 5/5] test: add non-zero ms coverage and drop misleading comment in parseTimestampToMs --- packages/app/src/__tests__/utils.test.ts | 6 ++++++ packages/app/src/utils.ts | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 182978924b..323646019c 100644 --- a/packages/app/src/__tests__/utils.test.ts +++ b/packages/app/src/__tests__/utils.test.ts @@ -1137,6 +1137,12 @@ describe('parseTimestampToMs', () => { expect(result).toBeCloseTo(base + 0.5, 4); }); + it('preserves whole-millisecond component when sub-ms digits are also present', () => { + const base = new Date('2024-01-01T00:00:01.500Z').getTime(); + const result = parseTimestampToMs('2024-01-01T00:00:01.500500000Z'); + expect(result).toBeCloseTo(base + 0.5, 4); + }); + it('handles max sub-millisecond value (999 µs + 999 ns)', () => { const base = new Date('2024-01-01T00:00:01.000Z').getTime(); const result = parseTimestampToMs('2024-01-01T00:00:01.000999999Z'); diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index b62f9cff38..e3a0ae90d3 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1115,7 +1115,6 @@ export const isElementClickable = (el: HTMLElement): boolean => { return el === elementAtPoint || el.contains(elementAtPoint); }; -// Parses an ISO 8601 string to fractional ms since epoch (~244 ns Float64 floor). export function parseTimestampToMs(isoString: string): number { const ts = TimestampNano.fromString(isoString); return ts.toDate().getTime() + (ts.getNano() % 1_000_000) / 1_000_000;