diff --git a/.changeset/afraid-bananas-vanish.md b/.changeset/afraid-bananas-vanish.md new file mode 100644 index 0000000000..f422b7da4f --- /dev/null +++ b/.changeset/afraid-bananas-vanish.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/app": patch +--- + +feat: Add previous period comparisons to line chart diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index a9e8517dc8..8aa59a07e8 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,56 +521,109 @@ function inferGroupColumns(meta: Array<{ name: string; type: string }>) { ]); } -// Input: { ts, value1, value2, groupBy1, groupBy2 }, -// Output: { ts, [value1Name, groupBy1, groupBy2]: value1, [...]: value2 } -export function formatResponseForTimeChart({ - res, - dateRange, - granularity, - generateEmptyBuckets = true, +export 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; + previousPeriodKey: string; + displayName: string; + color: string; + isDashed?: boolean; +} + +interface LineDataWithOptionalColor extends Omit { + color?: string; +} + +function setLineColors( + sortedLineData: LineDataWithOptionalColor[], +): LineData[] { + // 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; + }); +} + +function firstGroupColumnIsLogLevel( + source: TSource | undefined, + groupColumns: ColumnMetaType[], +) { + return ( + source && + groupColumns.length === 1 && + groupColumns[0].name === + (source.kind === SourceKind.Log + ? source.severityTextExpression + : source.statusCodeExpression) + ); +} + +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]: { - dataKey: string; - displayName: string; - maxValue: number; - minValue: number; - color: string | undefined; - }; - } = {}; - + 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); @@ -578,11 +632,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 @@ -593,36 +649,82 @@ 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, + currentPeriodKey, + previousPeriodKey, displayName: keyName, color, - maxValue: Math.max( - lineDataMap[keyName]?.maxValue ?? Number.NEGATIVE_INFINITY, - value, - ), - minValue: Math.min( - lineDataMap[keyName]?.minValue ?? Number.POSITIVE_INFINITY, - value, - ), + 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 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) @@ -630,7 +732,6 @@ export function formatResponseForTimeChart({ }); if (generateEmptyBuckets && granularity != null) { - // Zero fill TODO: Make this an option const generatedTsBuckets = timeBucketByGranularity( dateRange[0], dateRange[1], @@ -646,13 +747,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 +770,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..fd7bf1ffe2 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, @@ -32,10 +19,12 @@ import { import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Popover } from '@mantine/core'; import { notifications } from '@mantine/notifications'; +import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react'; import type { NumberFormat } from '@/types'; -import { COLORS, formatNumber, getColorProps, truncateMiddle } from '@/utils'; +import { COLORS, formatNumber, truncateMiddle } from '@/utils'; +import { LineData } from './ChartUtils'; import { FormatTime, useFormatTime } from './useFormatTime'; import styles from '../styles/HDXLineChart.module.scss'; @@ -53,8 +42,50 @@ type TooltipPayload = { opacity?: number; }; +const percentFormatter = new Intl.NumberFormat('en-US', { + style: 'percent', + maximumFractionDigits: 2, +}); + +const calculatePercentChange = (current: number, previous: number) => { + if (previous === 0) { + return current === 0 ? 0 : undefined; + } + return (current - previous) / previous; +}; + +const PercentChange = ({ + current, + previous, +}: { + current: number; + previous: number; +}) => { + const percentChange = calculatePercentChange(current, previous); + if (percentChange == undefined) { + return null; + } + + const Icon = percentChange > 0 ? IconCaretUpFilled : IconCaretDownFilled; + + return ( + + ( + {percentFormatter.format(Math.abs(percentChange))}) + + ); +}; + export const TooltipItem = memo( - ({ p, numberFormat }: { p: TooltipPayload; numberFormat?: NumberFormat }) => { + ({ + p, + previous, + numberFormat, + }: { + p: TooltipPayload; + previous?: TooltipPayload; + numberFormat?: NumberFormat; + }) => { return (
@@ -74,32 +105,72 @@ export const TooltipItem = memo( {truncateMiddle(p.name ?? p.dataKey, 50)} - : {numberFormat ? formatNumber(p.value, numberFormat) : p.value} + : {numberFormat ? formatNumber(p.value, numberFormat) : p.value}{' '} + {previous && ( + + )}
); }, ); +type HDXLineChartTooltipProps = { + lineDataMap: { [keyName: string]: LineData }; + previousPeriodOffset?: number; + numberFormat?: NumberFormat; +} & Record; + const HDXLineChartTooltip = withErrorBoundary( - memo((props: any) => { - const { active, payload, label, numberFormat } = props; + memo((props: HDXLineChartTooltipProps) => { + const { + active, + payload, + label, + numberFormat, + lineDataMap, + previousPeriodOffset, + } = props; + const typedPayload = payload as TooltipPayload[]; + + const payloadByKey = useMemo( + () => new Map(typedPayload.map(p => [p.dataKey, p])), + [typedPayload], + ); + if (active && payload && payload.length) { return (
+ {previousPeriodOffset != null && ( + <> + {' (vs '} + + {')'} + + )}
{payload - .sort((a: any, b: any) => b.value - a.value) - .map((p: any) => ( - - ))} + .sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value) + .map((p: TooltipPayload) => { + const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey; + const isPreviousPeriod = previousKey === p.dataKey; + const previousPayload = + !isPreviousPeriod && previousKey + ? payloadByKey.get(previousKey) + : undefined; + + return ( + + ); + })}
); @@ -180,14 +251,38 @@ function ExpandableLegendItem({ entry, expanded }: any) { export const LegendRenderer = memo<{ payload?: { + dataKey: string; value: string; color: string; }[]; + lineDataMap: { [key: string]: LineData }; }>(props => { - const payload = props.payload ?? []; + const { payload = [], lineDataMap } = props; + + const sortedLegendItems = useMemo(() => { + // Order items such that current and previous period lines are consecutive + const currentPeriodKeyIndex = new Map(); + payload.forEach((line, index) => { + const currentPeriodKey = + lineDataMap[line.dataKey]?.currentPeriodKey || ''; + if (!currentPeriodKeyIndex.has(currentPeriodKey)) { + currentPeriodKeyIndex.set(currentPeriodKey, index); + } + }); + + return payload.sort((a, b) => { + const keyA = lineDataMap[a.dataKey]?.currentPeriodKey ?? ''; + const keyB = lineDataMap[b.dataKey]?.currentPeriodKey ?? ''; + + const indexA = currentPeriodKeyIndex.get(keyA) ?? 0; + const indexB = currentPeriodKeyIndex.get(keyB) ?? 0; - const shownItems = payload.slice(0, MAX_LEGEND_ITEMS); - const restItems = payload.slice(MAX_LEGEND_ITEMS); + return indexB - indexA || a.dataKey.localeCompare(b.dataKey); + }); + }, [payload, lineDataMap]); + + const shownItems = sortedLegendItems.slice(0, MAX_LEGEND_ITEMS); + const restItems = sortedLegendItems.slice(MAX_LEGEND_ITEMS); return (
@@ -228,9 +323,7 @@ export const MemoChart = memo(function MemoChart({ setIsClickActive, isClickActive, dateRange, - groupKeys, - lineNames, - lineColors, + lineData, referenceLines, logReferenceTimestamp, displayType = DisplayType.Line, @@ -239,14 +332,13 @@ export const MemoChart = memo(function MemoChart({ timestampKey = 'ts_bucket', onTimeRangeSelect, showLegend = true, + previousPeriodOffset, }: { graphResults: any[]; 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; @@ -255,6 +347,7 @@ export const MemoChart = memo(function MemoChart({ timestampKey?: string; onTimeRangeSelect?: (start: Date, end: Date) => void; showLegend?: boolean; + previousPeriodOffset?: number; }) { const _id = useId(); const id = _id.replace(/:/g, ''); @@ -265,25 +358,13 @@ 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 strokeDasharray = lineData[i]?.isDashed ? '4 3' : '0'; const StackedBarWithOverlap = (props: BarProps) => { const { x, y, width, height, fill } = props; @@ -304,9 +385,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={} @@ -319,24 +400,17 @@ 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={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]); @@ -370,6 +444,14 @@ export const MemoChart = memo(function MemoChart({ const [highlightStart, setHighlightStart] = useState(); const [highlightEnd, setHighlightEnd] = useState(); + const lineDataMap = useMemo(() => { + const map: { [key: string]: LineData } = {}; + lineData.forEach(ld => { + map[ld.dataKey] = ld; + }); + return map; + }, [lineData]); + return ( {lines} } + content={ + + } wrapperStyle={{ zIndex: 1, }} @@ -518,7 +606,7 @@ export const MemoChart = memo(function MemoChart({ } + content={} offset={-100} /> )} diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts new file mode 100644 index 0000000000..5fb8d1f239 --- /dev/null +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -0,0 +1,532 @@ +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({ + currentPeriodResponse: 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({ + currentPeriodResponse: 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.lineData).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({ + currentPeriodResponse: 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.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + previousPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) (previous)', + displayName: 'AVG(toFloat64OrDefault(toString(Duration)))', + isDashed: false, + }, + ]); + }); + + 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({ + currentPeriodResponse: 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.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + previousPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout (previous)', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + isDashed: false, + }, + { + color: '#8250dc', + dataKey: 'max · checkout', + currentPeriodKey: 'max · checkout', + previousPeriodKey: 'max · checkout (previous)', + displayName: 'max · checkout', + isDashed: false, + }, + { + color: '#cdad7a', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + previousPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping (previous)', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + isDashed: false, + }, + { + color: '#0d6efd', + dataKey: 'max · shipping', + currentPeriodKey: 'max · shipping', + previousPeriodKey: 'max · shipping (previous)', + displayName: 'max · shipping', + isDashed: false, + }, + ]); + }); + + 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({ + currentPeriodResponse: res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + source, + }); + + expect(actual.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'info', + currentPeriodKey: 'info', + previousPeriodKey: 'info (previous)', + displayName: 'info', + isDashed: false, + }, + { + color: '#20c997', + dataKey: 'debug', + currentPeriodKey: 'debug', + previousPeriodKey: 'debug (previous)', + displayName: 'debug', + isDashed: false, + }, + { + color: '#F81358', + dataKey: 'error', + currentPeriodKey: 'error', + previousPeriodKey: 'error (previous)', + displayName: 'error', + isDashed: false, + }, + ]); + }); + + 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({ + currentPeriodResponse: 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.lineData).toEqual([ + { + color: '#20c997', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + previousPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · checkout (previous)', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · checkout', + isDashed: false, + }, + { + color: '#8250dc', + dataKey: 'max · checkout', + currentPeriodKey: 'max · checkout', + previousPeriodKey: 'max · checkout (previous)', + displayName: 'max · checkout', + isDashed: false, + }, + { + color: '#cdad7a', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + currentPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + previousPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) · shipping (previous)', + displayName: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', + isDashed: false, + }, + { + color: '#0d6efd', + dataKey: 'max · shipping', + currentPeriodKey: 'max · shipping', + previousPeriodKey: 'max · shipping (previous)', + 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)))', + previousPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) (previous)', + dataKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + displayName: 'AVG(toFloat64OrDefault(toString(Duration)))', + isDashed: false, + }, + { + color: '#20c997', + currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration)))', + previousPeriodKey: + 'AVG(toFloat64OrDefault(toString(Duration))) (previous)', + 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..26c7cc8daa 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -1,12 +1,4 @@ -import { - memo, - RefObject, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { omit } from 'lodash'; import { Control, @@ -26,11 +18,9 @@ import { DateRange, DisplayType, Filter, - MetricsDataType, SavedChartConfig, SelectList, SourceKind, - TSource, } from '@hyperdx/common-utils/dist/types'; import { Accordion, @@ -49,7 +39,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 +52,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 +71,7 @@ import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar'; import { CheckBoxControlled, InputControlled, + SwitchControlled, TextInputControlled, } from './InputControlled'; import { MetricNameSelect } from './MetricNameSelect'; @@ -451,6 +443,7 @@ export default function EditTimeChartForm({ const whereLanguage = watch('whereLanguage'); const alert = watch('alert'); const seriesReturnType = watch('seriesReturnType'); + const compareToPreviousPeriod = watch('compareToPreviousPeriod'); const { data: tableSource } = useSource({ id: sourceId }); const databaseName = tableSource?.from.databaseName; @@ -601,8 +594,24 @@ export default function EditTimeChartForm({ }); }, [dateRange]); + // Trigger a search when "compare to previous period" changes + useEffect(() => { + setQueriedConfig((config: ChartConfigWithDateRange | undefined) => { + if (config == null) { + return config; + } + + return { + ...config, + compareToPreviousPeriod, + }; + }); + }, [compareToPreviousPeriod]); + const queryReady = isQueryReady(queriedConfig); + const previousDateRange = getPreviousDateRange(dateRange); + const sampleEventsConfig = useMemo( () => tableSource != null && queriedConfig != null && queryReady @@ -690,7 +699,6 @@ export default function EditTimeChartForm({ /> - {activeTab === 'markdown' ? (