From f8cba1f826c3f2bbbe8e2794a3129f3808b33b00 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 26 Nov 2025 07:51:21 -0500 Subject: [PATCH 1/7] test: Add unit tests for formatResponseForTimeChart --- packages/app/src/__tests__/ChartUtils.test.ts | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 packages/app/src/__tests__/ChartUtils.test.ts diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts new file mode 100644 index 0000000000..294d806dbe --- /dev/null +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -0,0 +1,359 @@ +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; + +import { formatResponseForTimeChart } from '@/ChartUtils'; + +describe('ChartUtils', () => { + describe('formatResponseForTimeChart', () => { + it('should throw an error if there is no timestamp column', () => { + const res = { + data: [ + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233, + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182, + }, + ], + meta: [ + { + name: 'AVG(toFloat64OrDefault(toString(Duration)))', + type: 'Float64', + }, + ], + }; + + expect(() => + formatResponseForTimeChart({ + res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + }), + ).toThrow( + 'No timestamp column found with meta: [{"name":"AVG(toFloat64OrDefault(toString(Duration)))","type":"Float64"}]', + ); + }); + + it('should return empty results for an empty response', () => { + const res = { + data: [], + meta: [ + { + name: 'AVG(toFloat64OrDefault(toString(Duration)))', + type: 'Float64', + }, + { + name: '__hdx_time_bucket', + type: 'DateTime', + }, + ], + }; + + const actual = formatResponseForTimeChart({ + res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + }); + + expect(actual.graphResults).toEqual([]); + + expect(actual.timestampColumn).toEqual({ + name: '__hdx_time_bucket', + type: 'DateTime', + }); + expect(actual.lineColors).toEqual([undefined]); + expect(actual.lineNames).toEqual([]); + expect(actual.groupKeys).toEqual([]); + }); + + it('should format a response with a single value column and no group by', () => { + const res = { + data: [ + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233, + __hdx_time_bucket: '2025-11-26T11:12:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182, + __hdx_time_bucket: '2025-11-26T11:13:00Z', + }, + ], + meta: [ + { + name: 'AVG(toFloat64OrDefault(toString(Duration)))', + type: 'Float64', + }, + { + name: '__hdx_time_bucket', + type: 'DateTime', + }, + ], + }; + + const actual = formatResponseForTimeChart({ + res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + }); + + expect(actual.graphResults).toEqual([ + { + __hdx_time_bucket: 1764155520, + 'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233, + }, + { + __hdx_time_bucket: 1764155580, + 'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182, + }, + ]); + + expect(actual.timestampColumn).toEqual({ + name: '__hdx_time_bucket', + type: 'DateTime', + }); + expect(actual.lineColors).toEqual([undefined]); + expect(actual.lineNames).toEqual([ + 'AVG(toFloat64OrDefault(toString(Duration)))', + ]); + expect(actual.groupKeys).toEqual([ + 'AVG(toFloat64OrDefault(toString(Duration)))', + ]); + }); + + it('should format a response with multiple value columns and a group by', () => { + const res = { + data: [ + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 43828228.21181263, + max: 563518061, + ServiceName: 'checkout', + __hdx_time_bucket: '2025-11-26T12:23:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 6759697.6283185845, + max: 42092944, + ServiceName: 'shipping', + __hdx_time_bucket: '2025-11-26T12:23:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 36209980.6533264, + max: 795111023, + ServiceName: 'checkout', + __hdx_time_bucket: '2025-11-26T12:24:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 6479038.598323171, + max: 63136666, + ServiceName: 'shipping', + __hdx_time_bucket: '2025-11-26T12:24:00Z', + }, + ], + meta: [ + { + name: 'AVG(toFloat64OrDefault(toString(Duration)))', + type: 'Float64', + }, + { + name: 'max', + type: 'Float64', + }, + { + name: 'ServiceName', + type: 'LowCardinality(String)', + }, + { + name: '__hdx_time_bucket', + type: 'DateTime', + }, + ], + }; + + const actual = formatResponseForTimeChart({ + res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + }); + + expect(actual.graphResults).toEqual([ + { + __hdx_time_bucket: 1764159780, + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 43828228.21181263, + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6759697.6283185845, + 'max · checkout': 563518061, + 'max · shipping': 42092944, + }, + { + __hdx_time_bucket: 1764159840, + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 36209980.6533264, + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6479038.598323171, + 'max · checkout': 795111023, + 'max · shipping': 63136666, + }, + ]); + + expect(actual.timestampColumn).toEqual({ + name: '__hdx_time_bucket', + type: 'DateTime', + }); + expect(actual.lineColors).toEqual([undefined]); + expect(actual.lineNames).toEqual([ + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + 'max · checkout', + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + 'max · shipping', + ]); + expect(actual.groupKeys).toEqual([ + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + 'max · checkout', + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + 'max · shipping', + ]); + }); + + it('should assign colors to log levels', () => { + const res = { + data: [ + { + 'count()': '1', + SeverityText: 'info', + __hdx_time_bucket: '2025-11-26T12:23:00Z', + }, + { + 'count()': '3', + SeverityText: 'debug', + __hdx_time_bucket: '2025-11-26T12:23:00Z', + }, + { + 'count()': '1', + SeverityText: 'error', + __hdx_time_bucket: '2025-11-26T12:24:00Z', + }, + ], + meta: [ + { + name: 'count()', + type: 'UInt64', + }, + { + name: 'SeverityText', + type: 'LowCardinality(String)', + }, + { + name: '__hdx_time_bucket', + type: 'DateTime', + }, + ], + }; + + const source = { + kind: SourceKind.Log, + severityTextExpression: 'SeverityText', + } as TSource; + + const actual = formatResponseForTimeChart({ + res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + source, + }); + + expect(actual.lineNames).toEqual(['info', 'debug', 'error']); + expect(actual.lineColors).toEqual(['#20c997', '#20c997', '#F81358']); + }); + + it('should zero-fill missing time buckets', () => { + const res = { + data: [ + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 43828228.21181263, + max: 563518061, + ServiceName: 'checkout', + __hdx_time_bucket: '2025-11-26T12:23:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 6759697.6283185845, + max: 42092944, + ServiceName: 'shipping', + __hdx_time_bucket: '2025-11-26T12:23:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 6479038.598323171, + max: 63136666, + ServiceName: 'shipping', + __hdx_time_bucket: '2025-11-26T12:25:00Z', + }, + ], + meta: [ + { + name: 'AVG(toFloat64OrDefault(toString(Duration)))', + type: 'Float64', + }, + { + name: 'max', + type: 'Float64', + }, + { + name: 'ServiceName', + type: 'LowCardinality(String)', + }, + { + name: '__hdx_time_bucket', + type: 'DateTime', + }, + ], + }; + + const actual = formatResponseForTimeChart({ + res, + dateRange: [new Date(1764159780000), new Date(1764159900000)], + granularity: '1 minute', + generateEmptyBuckets: true, + }); + + expect(actual.graphResults).toEqual([ + { + __hdx_time_bucket: 1764159780, + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 43828228.21181263, + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6759697.6283185845, + 'max · checkout': 563518061, + 'max · shipping': 42092944, + }, + // Generated bucket with zeros + { + __hdx_time_bucket: 1764159840, + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout': 0, + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 0, + 'max · checkout': 0, + 'max · shipping': 0, + }, + { + __hdx_time_bucket: 1764159900, + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping': 6479038.598323171, + 'max · shipping': 63136666, + }, + ]); + + expect(actual.timestampColumn).toEqual({ + name: '__hdx_time_bucket', + type: 'DateTime', + }); + expect(actual.lineColors).toEqual([undefined]); + expect(actual.lineNames).toEqual([ + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + 'max · checkout', + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + 'max · shipping', + ]); + expect(actual.groupKeys).toEqual([ + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + 'max · checkout', + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + 'max · shipping', + ]); + }); + }); +}); From 9223ac366c0b1b07557abe861087ab2b556bb14b Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 26 Nov 2025 10:25:51 -0500 Subject: [PATCH 2/7] refactor: Simplify formatResponseForTimeChart color handling --- packages/app/src/ChartUtils.tsx | 84 +++++++++------ packages/app/src/HDXMultiSeriesTimeChart.tsx | 61 +++-------- packages/app/src/__tests__/ChartUtils.test.ts | 101 ++++++++++++------ packages/app/src/components/DBTimeChart.tsx | 51 ++++----- packages/app/src/utils.ts | 30 +----- 5 files changed, 153 insertions(+), 174 deletions(-) diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index a9e8517dc8..ee7d1e48a7 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -1,7 +1,8 @@ -import { useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { add } from 'date-fns'; import { z } from 'zod'; import { + ColumnMetaType, filterColumnMetaByType, inferTimestampColumn, JSDataType, @@ -17,7 +18,7 @@ import { SQLInterval, TSource, } from '@hyperdx/common-utils/dist/types'; -import { SegmentedControl, Select as MSelect } from '@mantine/core'; +import { SegmentedControl } from '@mantine/core'; import { getMetricNameSql } from './otelSemanticConventions'; import { @@ -29,7 +30,7 @@ import { TimeChartSeries, } from './types'; import { NumberFormat } from './types'; -import { logLevelColor, logLevelColorOrder } from './utils'; +import { getColorProps, logLevelColor, logLevelColorOrder } from './utils'; export const SORT_ORDER = [ { value: 'asc' as const, label: 'Ascending' }, @@ -520,6 +521,41 @@ function inferGroupColumns(meta: Array<{ name: string; type: string }>) { ]); } +export interface LineData { + dataKey: string; + displayName: string; + color: string; +} + +interface LineDataWithOptionalColor extends Omit { + color?: string; +} + +function setLineColors( + sortedLineData: LineDataWithOptionalColor[], +): LineData[] { + return sortedLineData.map((line, i) => { + if (!line.color) { + line.color = getColorProps(i, line.displayName ?? line.dataKey); + } + return line as LineData; + }); +} + +function firstGroupColumnIsLogLevel( + source: TSource | undefined, + groupColumns: ColumnMetaType[], +) { + return ( + source && + groupColumns.length === 1 && + groupColumns[0].name === + (source.kind === SourceKind.Log + ? source.severityTextExpression + : source.statusCodeExpression) + ); +} + // Input: { ts, value1, value2, groupBy1, groupBy2 }, // Output: { ts, [value1Name, groupBy1, groupBy2]: value1, [...]: value2 } export function formatResponseForTimeChart({ @@ -555,13 +591,7 @@ export function formatResponseForTimeChart({ // Timestamp -> { tsCol, line1, line2, ...} const tsBucketMap: Map> = new Map(); const lineDataMap: { - [keyName: string]: { - dataKey: string; - displayName: string; - maxValue: number; - minValue: number; - color: string | undefined; - }; + [keyName: string]: LineDataWithOptionalColor; } = {}; const isSingleValueColumn = valueColumns.length === 1; @@ -593,36 +623,22 @@ export function formatResponseForTimeChart({ // Mutate the existing bucket object to avoid repeated large object copies tsBucket[keyName] = value; + // Special handling for log level / trace severity colors let color: string | undefined = undefined; - if ( - source && - groupColumns.length === 1 && - groupColumns[0].name === - (source.kind === SourceKind.Log - ? source.severityTextExpression - : source.statusCodeExpression) - ) { + if (firstGroupColumnIsLogLevel(source, groupColumns)) { color = logLevelColor(row[groupColumns[0].name]); } - // TODO: Set name and color correctly + lineDataMap[keyName] = { dataKey: keyName, displayName: keyName, color, - maxValue: Math.max( - lineDataMap[keyName]?.maxValue ?? Number.NEGATIVE_INFINITY, - value, - ), - minValue: Math.min( - lineDataMap[keyName]?.minValue ?? Number.POSITIVE_INFINITY, - value, - ), }; } } // TODO: Custom sort and truncate top N lines - const sortedLineDataMap = Object.values(lineDataMap).sort((a, b) => { + const sortedLineData = Object.values(lineDataMap).sort((a, b) => { return ( logLevelColorOrder.findIndex(color => color === a.color) - logLevelColorOrder.findIndex(color => color === b.color) @@ -646,13 +662,13 @@ export function formatResponseForTimeChart({ [timestampColumn.name]: ts, }; - for (const line of sortedLineDataMap) { + for (const line of sortedLineData) { tsBucket[line.dataKey] = 0; } tsBucketMap.set(ts, tsBucket); } else { - for (const line of sortedLineDataMap) { + for (const line of sortedLineData) { if (tsBucket[line.dataKey] == null) { tsBucket[line.dataKey] = 0; } @@ -669,14 +685,12 @@ export function formatResponseForTimeChart({ (a, b) => a[timestampColumn.name] - b[timestampColumn.name], ); - // TODO: Return line color and names + const sortedLineDataWithColors = setLineColors(sortedLineData); + return { - // dateRange: [minDate, maxDate], graphResults, timestampColumn, - groupKeys: sortedLineDataMap.map(l => l.dataKey), - lineNames: sortedLineDataMap.map(l => l.displayName), - lineColors: sortedLineDataMap.map(l => l.color), + lineData: sortedLineDataWithColors, }; } diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index fe1648fd5d..4444f88fb2 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -1,15 +1,5 @@ -import { - memo, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; -import Link from 'next/link'; +import { memo, useCallback, useId, useMemo, useRef, useState } from 'react'; import cx from 'classnames'; -import { add } from 'date-fns'; import { withErrorBoundary } from 'react-error-boundary'; import { Area, @@ -18,10 +8,7 @@ import { BarChart, BarProps, CartesianGrid, - Label, Legend, - Line, - LineChart, ReferenceArea, ReferenceLine, ResponsiveContainer, @@ -36,6 +23,7 @@ import { notifications } from '@mantine/notifications'; import type { NumberFormat } from '@/types'; import { COLORS, formatNumber, getColorProps, truncateMiddle } from '@/utils'; +import { LineData } from './ChartUtils'; import { FormatTime, useFormatTime } from './useFormatTime'; import styles from '../styles/HDXLineChart.module.scss'; @@ -228,9 +216,7 @@ export const MemoChart = memo(function MemoChart({ setIsClickActive, isClickActive, dateRange, - groupKeys, - lineNames, - lineColors, + lineData, referenceLines, logReferenceTimestamp, displayType = DisplayType.Line, @@ -244,9 +230,7 @@ export const MemoChart = memo(function MemoChart({ setIsClickActive: (v: any) => void; isClickActive: any; dateRange: [Date, Date] | Readonly<[Date, Date]>; - groupKeys: string[]; - lineNames: string[]; - lineColors: Array; + lineData: LineData[]; referenceLines?: React.ReactNode; displayType?: DisplayType; numberFormat?: NumberFormat; @@ -265,25 +249,12 @@ export const MemoChart = memo(function MemoChart({ displayType === DisplayType.StackedBar ? BarChart : AreaChart; // LineChart; const lines = useMemo(() => { - const limitedGroupKeys = groupKeys.slice(0, HARD_LINES_LIMIT); - - // Check if any group is missing from any row - const isContinuousGroup = graphResults.reduce((acc, row) => { - limitedGroupKeys.forEach(key => { - acc[key] = row[key] != null ? acc[key] : false; - }); - return acc; - }, {}); + const limitedGroupKeys = lineData + .map(ld => ld.dataKey) + .slice(0, HARD_LINES_LIMIT); return limitedGroupKeys.map((key, i) => { - const { - color: _color, - opacity, - strokeDasharray, - strokeWidth, - } = getColorProps(i, lineNames[i] ?? key); - - const color = lineColors[i] ?? _color; + const color = lineData[i]?.color; const StackedBarWithOverlap = (props: BarProps) => { const { x, y, width, height, fill } = props; @@ -304,9 +275,9 @@ export const MemoChart = memo(function MemoChart({ key={key} type="monotone" dataKey={key} - name={lineNames[i] ?? key} + name={lineData[i]?.displayName ?? key} fill={color} - opacity={opacity} + opacity={1} stackId="1" isAnimationActive={false} shape={} @@ -323,20 +294,12 @@ export const MemoChart = memo(function MemoChart({ : { fill: `url(#time-chart-lin-grad-${id}-${color.replace('#', '').toLowerCase()})`, })} - name={lineNames[i] ?? key} + name={lineData[i]?.displayName ?? key} isAnimationActive={false} /> ); }); - }, [ - groupKeys, - graphResults, - displayType, - lineNames, - lineColors, - id, - isHovered, - ]); + }, [lineData, displayType, id, isHovered]); const sizeRef = useRef<[number, number]>([0, 0]); diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index 294d806dbe..79e7d0dff8 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -62,9 +62,7 @@ describe('ChartUtils', () => { name: '__hdx_time_bucket', type: 'DateTime', }); - expect(actual.lineColors).toEqual([undefined]); - expect(actual.lineNames).toEqual([]); - expect(actual.groupKeys).toEqual([]); + expect(actual.lineData).toEqual([]); }); it('should format a response with a single value column and no group by', () => { @@ -113,12 +111,12 @@ describe('ChartUtils', () => { name: '__hdx_time_bucket', type: 'DateTime', }); - expect(actual.lineColors).toEqual([undefined]); - expect(actual.lineNames).toEqual([ - 'AVG(toFloat64OrDefault(toString(Duration)))', - ]); - expect(actual.groupKeys).toEqual([ - 'AVG(toFloat64OrDefault(toString(Duration)))', + expect(actual.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + displayName: 'AVG(toFloat64OrDefault(toString(Duration)))', + }, ]); }); @@ -198,18 +196,27 @@ describe('ChartUtils', () => { name: '__hdx_time_bucket', type: 'DateTime', }); - expect(actual.lineColors).toEqual([undefined]); - expect(actual.lineNames).toEqual([ - 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', - 'max · checkout', - 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', - 'max · shipping', - ]); - expect(actual.groupKeys).toEqual([ - 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', - 'max · checkout', - 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', - 'max · shipping', + expect(actual.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + }, + { + color: '#8250dc', + dataKey: 'max · checkout', + displayName: 'max · checkout', + }, + { + color: '#cdad7a', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + }, + { + color: '#0d6efd', + dataKey: 'max · shipping', + displayName: 'max · shipping', + }, ]); }); @@ -261,8 +268,23 @@ describe('ChartUtils', () => { source, }); - expect(actual.lineNames).toEqual(['info', 'debug', 'error']); - expect(actual.lineColors).toEqual(['#20c997', '#20c997', '#F81358']); + expect(actual.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'info', + displayName: 'info', + }, + { + color: '#20c997', + dataKey: 'debug', + displayName: 'debug', + }, + { + color: '#F81358', + dataKey: 'error', + displayName: 'error', + }, + ]); }); it('should zero-fill missing time buckets', () => { @@ -341,18 +363,27 @@ describe('ChartUtils', () => { name: '__hdx_time_bucket', type: 'DateTime', }); - expect(actual.lineColors).toEqual([undefined]); - expect(actual.lineNames).toEqual([ - 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', - 'max · checkout', - 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', - 'max · shipping', - ]); - expect(actual.groupKeys).toEqual([ - 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', - 'max · checkout', - 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', - 'max · shipping', + expect(actual.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + }, + { + color: '#8250dc', + dataKey: 'max · checkout', + displayName: 'max · checkout', + }, + { + color: '#cdad7a', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + }, + { + color: '#0d6efd', + dataKey: 'max · shipping', + displayName: 'max · shipping', + }, ]); }); }); diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 8b09f2c86d..45b4766f99 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -80,33 +80,30 @@ function DBTimeChartComponent({ isLoading || !data?.isComplete || isPlaceholderData; const { data: source } = useSource({ id: sourceId }); - const { graphResults, timestampColumn, groupKeys, lineNames, lineColors } = - useMemo(() => { - const defaultResponse = { - graphResults: [], - timestampColumn: undefined, - groupKeys: [], - lineNames: [], - lineColors: [], - }; + const { graphResults, timestampColumn, lineData } = useMemo(() => { + const defaultResponse = { + graphResults: [], + timestampColumn: undefined, + lineData: [], + }; - if (data == null || !isSuccess) { - return defaultResponse; - } + if (data == null || !isSuccess) { + return defaultResponse; + } - try { - return formatResponseForTimeChart({ - res: data, - dateRange, - granularity, - generateEmptyBuckets: fillNulls !== false, - source, - }); - } catch (e) { - console.error(e); - return defaultResponse; - } - }, [data, dateRange, granularity, isSuccess, fillNulls, source]); + try { + return formatResponseForTimeChart({ + res: data, + dateRange, + granularity, + generateEmptyBuckets: fillNulls !== false, + source, + }); + } catch (e) { + console.error(e); + return defaultResponse; + } + }, [data, dateRange, granularity, isSuccess, fillNulls, source]); // To enable backward compatibility, allow non-controlled usage of displayType const [displayTypeLocal, setDisplayTypeLocal] = useState(displayTypeProp); @@ -347,11 +344,9 @@ function DBTimeChartComponent({ dateRange={dateRange} displayType={displayType} graphResults={graphResults} - groupKeys={groupKeys} + lineData={lineData} isClickActive={false} isLoading={isLoadingOrPlaceholder} - lineColors={lineColors} - lineNames={lineNames} logReferenceTimestamp={logReferenceTimestamp} numberFormat={config.numberFormat} onTimeRangeSelect={onTimeRangeSelect} diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts index f65024eb5d..f7f56fc72d 100644 --- a/packages/app/src/utils.ts +++ b/packages/app/src/utils.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/router'; -import { format as fnsFormat, formatDistanceToNowStrict } from 'date-fns'; -import { formatInTimeZone } from 'date-fns-tz'; +import { formatDistanceToNowStrict } from 'date-fns'; import numbro from 'numbro'; import type { MutableRefObject } from 'react'; import { TSource } from '@hyperdx/common-utils/dist/types'; @@ -410,12 +409,6 @@ export const COLORS = [ '#ffa600', // Yellow ]; -const STROKE_DASHARRAYS = ['0', '4 3', '5 5']; - -const STROKE_WIDTHS = [1.25]; - -const STROKE_OPACITIES = [1]; - export function hashCode(str: string) { let hash = 0, i, @@ -473,28 +466,11 @@ const getLevelColor = (logLevel?: string) => { : '#20c997'; // green; }; -export const getColorProps = ( - index: number, - level: string, -): { - color: string; - strokeDasharray: string; - opacity: number; - strokeWidth: number; -} => { +export const getColorProps = (index: number, level: string): string => { const logLevel = getLogLevelClass(level); const colorOverride = getLevelColor(logLevel); - // How many same colored lines we already have - const colorStep = Math.floor(index / COLORS.length); - - return { - color: colorOverride ?? COLORS[index % COLORS.length], - strokeDasharray: - STROKE_DASHARRAYS[Math.min(STROKE_DASHARRAYS.length, colorStep)], - opacity: STROKE_OPACITIES[Math.min(STROKE_OPACITIES.length, colorStep)], - strokeWidth: STROKE_WIDTHS[Math.min(STROKE_WIDTHS.length, colorStep)], - }; + return colorOverride ?? COLORS[index % COLORS.length]; }; export const truncateMiddle = (str: string, maxLen = 10) => { From 077b1de43d3d30641bc410f0b0a8bc2997375581 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Wed, 26 Nov 2025 11:55:08 -0500 Subject: [PATCH 3/7] feat: Add previous period comparisons to line chart --- packages/app/src/ChartUtils.tsx | 143 ++++++++++++++---- packages/app/src/HDXMultiSeriesTimeChart.tsx | 4 +- packages/app/src/__tests__/ChartUtils.test.ts | 134 +++++++++++++++- .../src/components/DBEditTimeChartForm.tsx | 31 +++- packages/app/src/components/DBTimeChart.tsx | 132 ++++++++++++---- .../app/src/components/InputControlled.tsx | 31 ++++ packages/common-utils/src/types.ts | 1 + 7 files changed, 403 insertions(+), 73 deletions(-) diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index ee7d1e48a7..2180940827 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -521,10 +521,23 @@ function inferGroupColumns(meta: Array<{ name: string; type: string }>) { ]); } +function getPreviousPeriodOffset(dateRange: [Date, Date]): number { + const [start, end] = dateRange; + return end.getTime() - start.getTime(); +} + +export function getPreviousDateRange(currentRange: [Date, Date]): [Date, Date] { + const [start, end] = currentRange; + const offset = getPreviousPeriodOffset(currentRange); + return [new Date(start.getTime() - offset), new Date(end.getTime() - offset)]; +} + export interface LineData { dataKey: string; + currentPeriodKey: string; displayName: string; color: string; + isDashed?: boolean; } interface LineDataWithOptionalColor extends Omit { @@ -534,10 +547,24 @@ interface LineDataWithOptionalColor extends Omit { function setLineColors( sortedLineData: LineDataWithOptionalColor[], ): LineData[] { - return sortedLineData.map((line, i) => { - if (!line.color) { - line.color = getColorProps(i, line.displayName ?? line.dataKey); + // Ensure that the current and previous period lines are the same color + const lineColorByCurrentPeriodKey = new Map(); + + let colorIndex = 0; + return sortedLineData.map(line => { + const currentPeriodKey = line.currentPeriodKey; + if (lineColorByCurrentPeriodKey.has(currentPeriodKey)) { + line.color = lineColorByCurrentPeriodKey.get(currentPeriodKey); + } else if (!line.color) { + line.color = getColorProps( + colorIndex++, + line.displayName ?? line.dataKey, + ); + lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color); + } else { + lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color); } + return line as LineData; }); } @@ -556,50 +583,46 @@ function firstGroupColumnIsLogLevel( ); } -// Input: { ts, value1, value2, groupBy1, groupBy2 }, -// Output: { ts, [value1Name, groupBy1, groupBy2]: value1, [...]: value2 } -export function formatResponseForTimeChart({ - res, - dateRange, - granularity, - generateEmptyBuckets = true, +function addResponseToFormattedData({ + response, + lineDataMap, + tsBucketMap, source, + currentPeriodDateRange, + isPreviousPeriod, }: { - dateRange: [Date, Date]; - granularity?: SQLInterval; - res: ResponseJSON>; - generateEmptyBuckets?: boolean; + tsBucketMap: Map>; + lineDataMap: { [keyName: string]: LineDataWithOptionalColor }; + response: ResponseJSON>; source?: TSource; + isPreviousPeriod: boolean; + currentPeriodDateRange: [Date, Date]; }) { - const meta = res.meta; - const data = res.data; - + const { meta, data } = response; if (meta == null) { throw new Error('No meta data found in response'); } const timestampColumn = inferTimestampColumn(meta); - const valueColumns = inferValueColumns(meta) ?? []; - const groupColumns = inferGroupColumns(meta) ?? []; - if (timestampColumn == null) { throw new Error( `No timestamp column found with meta: ${JSON.stringify(meta)}`, ); } - // Timestamp -> { tsCol, line1, line2, ...} - const tsBucketMap: Map> = new Map(); - const lineDataMap: { - [keyName: string]: LineDataWithOptionalColor; - } = {}; - + const valueColumns = inferValueColumns(meta) ?? []; + const groupColumns = inferGroupColumns(meta) ?? []; const isSingleValueColumn = valueColumns.length === 1; const hasGroupColumns = groupColumns.length > 0; for (const row of data) { const date = new Date(row[timestampColumn.name]); - const ts = date.getTime() / 1000; + + // Previous period data needs to be shifted forward to align with current period + const offset = isPreviousPeriod + ? getPreviousPeriodOffset(currentPeriodDateRange) + : 0; + const ts = Math.round((date.getTime() + offset) / 1000); for (const valueColumn of valueColumns) { let tsBucket = tsBucketMap.get(ts); @@ -608,11 +631,13 @@ export function formatResponseForTimeChart({ tsBucketMap.set(ts, tsBucket); } - const keyName = [ + const currentPeriodKey = [ // Simplify the display name if there's only one series and a group by ...(isSingleValueColumn && hasGroupColumns ? [] : [valueColumn.name]), ...groupColumns.map(g => row[g.name]), ].join(' · '); + const previousPeriodKey = `${currentPeriodKey} (previous)`; + const keyName = isPreviousPeriod ? previousPeriodKey : currentPeriodKey; // UInt64 are returned as strings, we'll convert to number // and accept a bit of floating point error @@ -631,13 +656,72 @@ export function formatResponseForTimeChart({ lineDataMap[keyName] = { dataKey: keyName, + currentPeriodKey, displayName: keyName, color, + isDashed: isPreviousPeriod, }; } } +} + +// Input: { ts, value1, value2, groupBy1, groupBy2 }, +// Output: { ts, [value1Name, groupBy1, groupBy2]: value1, [...]: value2 } +export function formatResponseForTimeChart({ + currentPeriodResponse, + previousPeriodResponse, + dateRange, + granularity, + generateEmptyBuckets = true, + source, +}: { + dateRange: [Date, Date]; + granularity?: SQLInterval; + currentPeriodResponse: ResponseJSON>; + previousPeriodResponse?: ResponseJSON>; + generateEmptyBuckets?: boolean; + source?: TSource; +}) { + const meta = currentPeriodResponse.meta; + + if (meta == null) { + throw new Error('No meta data found in response'); + } + + const timestampColumn = inferTimestampColumn(meta); + + if (timestampColumn == null) { + throw new Error( + `No timestamp column found with meta: ${JSON.stringify(meta)}`, + ); + } + + // Timestamp -> { tsCol, line1, line2, ...} + const tsBucketMap: Map> = new Map(); + const lineDataMap: { + [keyName: string]: LineDataWithOptionalColor; + } = {}; + + addResponseToFormattedData({ + response: currentPeriodResponse, + lineDataMap, + tsBucketMap, + source, + isPreviousPeriod: false, + currentPeriodDateRange: dateRange, + }); + + if (previousPeriodResponse != null) { + addResponseToFormattedData({ + response: previousPeriodResponse, + lineDataMap, + tsBucketMap, + source, + isPreviousPeriod: true, + currentPeriodDateRange: dateRange, + }); + } - // TODO: Custom sort and truncate top N lines const sortedLineData = Object.values(lineDataMap).sort((a, b) => { return ( logLevelColorOrder.findIndex(color => color === a.color) - @@ -646,7 +730,6 @@ export function formatResponseForTimeChart({ }); if (generateEmptyBuckets && granularity != null) { - // Zero fill TODO: Make this an option const generatedTsBuckets = timeBucketByGranularity( dateRange[0], dateRange[1], diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 4444f88fb2..303adbb822 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -255,6 +255,7 @@ export const MemoChart = memo(function MemoChart({ return limitedGroupKeys.map((key, i) => { const color = lineData[i]?.color; + const strokeDasharray = lineData[i]?.isDashed ? '4 3' : '0'; const StackedBarWithOverlap = (props: BarProps) => { const { x, y, width, height, fill } = props; @@ -290,9 +291,10 @@ export const MemoChart = memo(function MemoChart({ stroke={color} fillOpacity={1} {...(isHovered - ? { fill: 'none' } + ? { fill: 'none', strokeDasharray } : { fill: `url(#time-chart-lin-grad-${id}-${color.replace('#', '').toLowerCase()})`, + strokeDasharray, })} name={lineData[i]?.displayName ?? key} isAnimationActive={false} diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index 79e7d0dff8..c511f8c355 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -1,3 +1,4 @@ +import { c } from 'nuqs/dist/serializer-RqlbYgUW'; import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; import { formatResponseForTimeChart } from '@/ChartUtils'; @@ -24,7 +25,7 @@ describe('ChartUtils', () => { expect(() => formatResponseForTimeChart({ - res, + currentPeriodResponse: res, dateRange: [new Date(), new Date()], granularity: '1 minute', generateEmptyBuckets: false, @@ -50,7 +51,7 @@ describe('ChartUtils', () => { }; const actual = formatResponseForTimeChart({ - res, + currentPeriodResponse: res, dateRange: [new Date(), new Date()], granularity: '1 minute', generateEmptyBuckets: false, @@ -90,7 +91,7 @@ describe('ChartUtils', () => { }; const actual = formatResponseForTimeChart({ - res, + currentPeriodResponse: res, dateRange: [new Date(), new Date()], granularity: '1 minute', generateEmptyBuckets: false, @@ -115,7 +116,9 @@ describe('ChartUtils', () => { { color: '#20c997', dataKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))', displayName: 'AVG(toFloat64OrDefault(toString(Duration)))', + isDashed: false, }, ]); }); @@ -169,7 +172,7 @@ describe('ChartUtils', () => { }; const actual = formatResponseForTimeChart({ - res, + currentPeriodResponse: res, dateRange: [new Date(), new Date()], granularity: '1 minute', generateEmptyBuckets: false, @@ -200,22 +203,32 @@ describe('ChartUtils', () => { { color: '#20c997', dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + isDashed: false, }, { color: '#8250dc', dataKey: 'max · checkout', + currentPeriodKey: 'max · checkout', displayName: 'max · checkout', + isDashed: false, }, { color: '#cdad7a', dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + isDashed: false, }, { color: '#0d6efd', dataKey: 'max · shipping', + currentPeriodKey: 'max · shipping', displayName: 'max · shipping', + isDashed: false, }, ]); }); @@ -261,7 +274,7 @@ describe('ChartUtils', () => { } as TSource; const actual = formatResponseForTimeChart({ - res, + currentPeriodResponse: res, dateRange: [new Date(), new Date()], granularity: '1 minute', generateEmptyBuckets: false, @@ -272,17 +285,23 @@ describe('ChartUtils', () => { { color: '#20c997', dataKey: 'info', + currentPeriodKey: 'info', displayName: 'info', + isDashed: false, }, { color: '#20c997', dataKey: 'debug', + currentPeriodKey: 'debug', displayName: 'debug', + isDashed: false, }, { color: '#F81358', dataKey: 'error', + currentPeriodKey: 'error', displayName: 'error', + isDashed: false, }, ]); }); @@ -330,7 +349,7 @@ describe('ChartUtils', () => { }; const actual = formatResponseForTimeChart({ - res, + currentPeriodResponse: res, dateRange: [new Date(1764159780000), new Date(1764159900000)], granularity: '1 minute', generateEmptyBuckets: true, @@ -367,22 +386,125 @@ describe('ChartUtils', () => { { color: '#20c997', dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + isDashed: false, }, { color: '#8250dc', dataKey: 'max · checkout', + currentPeriodKey: 'max · checkout', displayName: 'max · checkout', + isDashed: false, }, { color: '#cdad7a', dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + isDashed: false, }, { color: '#0d6efd', dataKey: 'max · shipping', + currentPeriodKey: 'max · shipping', displayName: 'max · shipping', + isDashed: false, + }, + ]); + }); + + it('should plot previous period data when provided, shifted to align with current period', () => { + const currentPeriodResponse = { + data: [ + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233, + __hdx_time_bucket: '2025-11-26T11:12:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182, + __hdx_time_bucket: '2025-11-26T11:13:00Z', + }, + ], + meta: [ + { + name: 'AVG(toFloat64OrDefault(toString(Duration)))', + type: 'Float64', + }, + { + name: '__hdx_time_bucket', + type: 'DateTime', + }, + ], + }; + + const previousPeriodResponse = { + data: [ + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 123.45, + __hdx_time_bucket: '2025-11-26T11:10:00Z', + }, + { + 'AVG(toFloat64OrDefault(toString(Duration)))': 678.9, + __hdx_time_bucket: '2025-11-26T11:11:00Z', + }, + ], + meta: [ + { + name: 'AVG(toFloat64OrDefault(toString(Duration)))', + type: 'Float64', + }, + { + name: '__hdx_time_bucket', + type: 'DateTime', + }, + ], + }; + + const actual = formatResponseForTimeChart({ + currentPeriodResponse, + previousPeriodResponse, + dateRange: [ + new Date('2025-11-26T11:12:00Z'), + new Date('2025-11-26T11:14:00Z'), + ], + granularity: '1 minute', + generateEmptyBuckets: false, + }); + + expect(actual.graphResults).toEqual([ + { + __hdx_time_bucket: 1764155520, + 'AVG(toFloat64OrDefault(toString(Duration)))': 167783540.53459233, + 'AVG(toFloat64OrDefault(toString(Duration))) (previous)': 123.45, + }, + { + __hdx_time_bucket: 1764155580, + 'AVG(toFloat64OrDefault(toString(Duration)))': 182291463.92714182, + 'AVG(toFloat64OrDefault(toString(Duration))) (previous)': 678.9, + }, + ]); + + expect(actual.timestampColumn).toEqual({ + name: '__hdx_time_bucket', + type: 'DateTime', + }); + expect(actual.lineData).toEqual([ + { + color: '#20c997', + currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + displayName: 'AVG(toFloat64OrDefault(toString(Duration)))', + isDashed: false, + }, + { + color: '#20c997', + currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) (previous)', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) (previous)', + isDashed: true, }, ]); }); diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx index 67b283d8aa..93593908f6 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -49,7 +49,7 @@ import { } from '@mantine/core'; import { IconPlayerPlay } from '@tabler/icons-react'; -import { AGG_FNS } from '@/ChartUtils'; +import { AGG_FNS, getPreviousDateRange } from '@/ChartUtils'; import { AlertChannelForm, getAlertReferenceLines } from '@/components/Alerts'; import ChartSQLPreview from '@/components/ChartSQLPreview'; import DBTableChart from '@/components/DBTableChart'; @@ -62,6 +62,7 @@ import { useFetchMetricResourceAttrs } from '@/hooks/useFetchMetricResourceAttrs import SearchInputV2 from '@/SearchInputV2'; import { getFirstTimestampValueExpression, useSource } from '@/source'; import { parseTimeQuery } from '@/timeQuery'; +import { FormatTime } from '@/useFormatTime'; import { getMetricTableName, optionsToSelectData } from '@/utils'; import { ALERT_CHANNEL_OPTIONS, @@ -80,6 +81,7 @@ import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar'; import { CheckBoxControlled, InputControlled, + SwitchControlled, TextInputControlled, } from './InputControlled'; import { MetricNameSelect } from './MetricNameSelect'; @@ -603,6 +605,8 @@ export default function EditTimeChartForm({ const queryReady = isQueryReady(queriedConfig); + const previousDateRange = getPreviousDateRange(dateRange); + const sampleEventsConfig = useMemo( () => tableSource != null && queriedConfig != null && queryReady @@ -690,7 +694,6 @@ export default function EditTimeChartForm({ /> - {activeTab === 'markdown' ? (