diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts index 5f9e334492..323646019c 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,34 @@ describe('mapKeyBy', () => { expect(result.get('a')).toBe(data.at(1)); }); }); + +describe('parseTimestampToMs', () => { + it('returns integer ms when there are no sub-millisecond digits', () => { + 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', () => { + const base = new Date('2024-01-01T00:00:01.000Z').getTime(); + const result = parseTimestampToMs('2024-01-01T00:00:01.000500000Z'); + 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'); + expect(result).toBeCloseTo(base + 0.999999, 3); + }); + + 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/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index dfca012dab..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, @@ -53,6 +52,7 @@ import { getChartColorSuccessHighlight, getChartColorWarning, getChartColorWarningHighlight, + parseTimestampToMs, } from '@/utils'; import { getHighlightedAttributesFromData, @@ -515,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]); @@ -753,7 +747,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 +759,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 +793,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..e3a0ae90d3 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -3,6 +3,7 @@ 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 { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; import { NumericUnit, @@ -1113,3 +1114,8 @@ 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); }; + +export function parseTimestampToMs(isoString: string): number { + const ts = TimestampNano.fromString(isoString); + return ts.toDate().getTime() + (ts.getNano() % 1_000_000) / 1_000_000; +}