Skip to content

Commit 725dbc2

Browse files
authored
feat: Align date ranges to chart and MV granularity (Line/Bar charts) (#1533)
Closes HDX-3067 Closes #1331 Closes #1212 Closes #1468 # Summary This PR makes a number of improvements around the way we handle date ranges and granularities, in an effort to minimize discrepancies between aggregate values queried from original data and aggregate values queried from materialized views. 1. Date ranges for Line and Bar chart queries are now (by default) auto-aligned to the chart's granularity. **This is not limited to materialized view queries.** Since the chart granularity is a multiple of the MV granularity, this ensures that the date range is aligned to the MV granularity as well. This also address a number of related issues that point out 0-values or low-values in the first or last data points. This PR also includes an option to disable this behavior for charts in Chart Explorer or Dashboard Tiles. 2. All materialized view queries and all time chart queries are now end-exclusive, to avoid selecting the entirety of the next "time bucket" from the materialized view when the date range is aligned with the materialized view granularity 3. Materialized views are only used for a query with a granularity if the chart query granularity is a multiple of the MV granularity. Previously, we'd use the MV as long as the chart query granularity was at least as large as the MV granularity, but this could cause unequal distributions of data across time buckets. Nearly all available granularities are multiples of all smaller available granularities - so this should only impact queries with granularity 15 minutes with MVs with granularity 10 minutes. 10m granularity support is being removed in #1551 ## Demo <details> <summary>Show Complete Intervals Option</summary> https://github.com/user-attachments/assets/4b903adb-4edf-4481-93d6-2a0c42589a37 </details>
1 parent fd81c4c commit 725dbc2

11 files changed

Lines changed: 323 additions & 54 deletions

File tree

.changeset/itchy-zebras-train.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/api": patch
4+
"@hyperdx/app": patch
5+
---
6+
7+
feat: Align line/bar chart date ranges to chart granularity

packages/app/src/ChartUtils.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useMemo } from 'react';
2-
import { add } from 'date-fns';
2+
import { add, differenceInSeconds } from 'date-fns';
33
import { omit } from 'lodash';
44
import SqlString from 'sqlstring';
55
import { z } from 'zod';
@@ -135,20 +135,45 @@ export const DEFAULT_CHART_CONFIG: Omit<
135135
whereLanguage: 'lucene',
136136
displayType: DisplayType.Line,
137137
granularity: 'auto',
138+
alignDateRangeToGranularity: true,
138139
};
139140

140141
export const isGranularity = (value: string): value is Granularity => {
141142
return Object.values(Granularity).includes(value as Granularity);
142143
};
143144

145+
export function getAlignedDateRange(
146+
[originalStart, originalEnd]: [Date, Date],
147+
granularity: SQLInterval,
148+
): [Date, Date] {
149+
// Round the start time down to the previous interval boundary
150+
const alignedStart = toStartOfInterval(originalStart, granularity);
151+
152+
// Round the end time up to the next interval boundary
153+
let alignedEnd = toStartOfInterval(originalEnd, granularity);
154+
if (alignedEnd.getTime() < originalEnd.getTime()) {
155+
const intervalSeconds = convertGranularityToSeconds(granularity);
156+
alignedEnd = add(alignedEnd, { seconds: intervalSeconds });
157+
}
158+
159+
return [alignedStart, alignedEnd];
160+
}
161+
144162
export function convertToTimeChartConfig(config: ChartConfigWithDateRange) {
145163
const granularity =
146164
config.granularity === 'auto' || config.granularity == null
147165
? convertDateRangeToGranularityString(config.dateRange, 80)
148166
: config.granularity;
149167

168+
const dateRange =
169+
config.alignDateRangeToGranularity === false
170+
? config.dateRange
171+
: getAlignedDateRange(config.dateRange, granularity);
172+
150173
return {
151174
...config,
175+
dateRange,
176+
dateRangeEndInclusive: false,
152177
granularity,
153178
limit: { limit: 100000 },
154179
};
@@ -549,16 +574,9 @@ function inferGroupColumns(meta: Array<{ name: string; type: string }>) {
549574
]);
550575
}
551576

552-
export function getPreviousPeriodOffsetSeconds(
553-
dateRange: [Date, Date],
554-
): number {
555-
const [start, end] = dateRange;
556-
return Math.round((end.getTime() - start.getTime()) / 1000);
557-
}
558-
559577
export function getPreviousDateRange(currentRange: [Date, Date]): [Date, Date] {
560578
const [start, end] = currentRange;
561-
const offsetSeconds = getPreviousPeriodOffsetSeconds(currentRange);
579+
const offsetSeconds = differenceInSeconds(end, start);
562580
return [
563581
new Date(start.getTime() - offsetSeconds * 1000),
564582
new Date(end.getTime() - offsetSeconds * 1000),
@@ -622,7 +640,7 @@ function addResponseToFormattedData({
622640
lineDataMap,
623641
tsBucketMap,
624642
source,
625-
currentPeriodDateRange,
643+
previousPeriodOffsetSeconds,
626644
isPreviousPeriod,
627645
hiddenSeries = [],
628646
}: {
@@ -631,7 +649,7 @@ function addResponseToFormattedData({
631649
response: ResponseJSON<Record<string, any>>;
632650
source?: TSource;
633651
isPreviousPeriod: boolean;
634-
currentPeriodDateRange: [Date, Date];
652+
previousPeriodOffsetSeconds: number;
635653
hiddenSeries?: string[];
636654
}) {
637655
const { meta, data } = response;
@@ -655,9 +673,7 @@ function addResponseToFormattedData({
655673
const date = new Date(row[timestampColumn.name]);
656674

657675
// Previous period data needs to be shifted forward to align with current period
658-
const offsetSeconds = isPreviousPeriod
659-
? getPreviousPeriodOffsetSeconds(currentPeriodDateRange)
660-
: 0;
676+
const offsetSeconds = isPreviousPeriod ? previousPeriodOffsetSeconds : 0;
661677
const ts = Math.round(date.getTime() / 1000 + offsetSeconds);
662678

663679
for (const valueColumn of valueColumns) {
@@ -712,6 +728,7 @@ export function formatResponseForTimeChart({
712728
generateEmptyBuckets = true,
713729
source,
714730
hiddenSeries = [],
731+
previousPeriodOffsetSeconds = 0,
715732
}: {
716733
dateRange: [Date, Date];
717734
granularity?: SQLInterval;
@@ -720,6 +737,7 @@ export function formatResponseForTimeChart({
720737
generateEmptyBuckets?: boolean;
721738
source?: TSource;
722739
hiddenSeries?: string[];
740+
previousPeriodOffsetSeconds?: number;
723741
}) {
724742
const meta = currentPeriodResponse.meta;
725743

@@ -750,7 +768,7 @@ export function formatResponseForTimeChart({
750768
tsBucketMap,
751769
source,
752770
isPreviousPeriod: false,
753-
currentPeriodDateRange: dateRange,
771+
previousPeriodOffsetSeconds,
754772
hiddenSeries,
755773
});
756774

@@ -761,7 +779,7 @@ export function formatResponseForTimeChart({
761779
tsBucketMap,
762780
source,
763781
isPreviousPeriod: true,
764-
currentPeriodDateRange: dateRange,
782+
previousPeriodOffsetSeconds,
765783
hiddenSeries,
766784
});
767785
}

packages/app/src/DBSearchPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,9 @@ function DBSearchPage() {
13671367
with: aliasWith,
13681368
// Preserve the original table select string for "View Events" links
13691369
eventTableSelect: searchedConfig.select,
1370+
// In live mode, when the end date is aligned to the granularity, the end date does
1371+
// not change on every query, resulting in cached data being re-used.
1372+
alignDateRangeToGranularity: !isLive,
13701373
...variableConfig,
13711374
};
13721375
}, [
@@ -1375,6 +1378,7 @@ function DBSearchPage() {
13751378
aliasWith,
13761379
searchedTimeRange,
13771380
searchedConfig.select,
1381+
isLive,
13781382
]);
13791383

13801384
const onFormSubmit = useCallback<FormEventHandler<HTMLFormElement>>(

packages/app/src/__tests__/ChartUtils.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
convertToTableChartConfig,
1010
convertToTimeChartConfig,
1111
formatResponseForTimeChart,
12+
getAlignedDateRange,
1213
} from '@/ChartUtils';
1314

1415
if (!globalThis.structuredClone) {
@@ -503,6 +504,7 @@ describe('ChartUtils', () => {
503504
],
504505
granularity: '1 minute',
505506
generateEmptyBuckets: false,
507+
previousPeriodOffsetSeconds: 120,
506508
});
507509

508510
expect(actual.graphResults).toEqual([
@@ -652,4 +654,111 @@ describe('ChartUtils', () => {
652654
expect(convertedConfig.limit).toEqual({ limit: 200 });
653655
});
654656
});
657+
658+
describe('getAlignedDateRange', () => {
659+
it('should align start time down to the previous minute boundary', () => {
660+
const dateRange: [Date, Date] = [
661+
new Date('2025-11-26T12:23:37Z'), // 37 seconds
662+
new Date('2025-11-26T12:25:00Z'),
663+
];
664+
665+
const [alignedStart, alignedEnd] = getAlignedDateRange(
666+
dateRange,
667+
'1 minute',
668+
);
669+
670+
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
671+
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
672+
});
673+
674+
it('should align end time up to the next minute boundary', () => {
675+
const dateRange: [Date, Date] = [
676+
new Date('2025-11-26T12:23:00Z'),
677+
new Date('2025-11-26T12:25:42Z'), // 42 seconds
678+
];
679+
680+
const [alignedStart, alignedEnd] = getAlignedDateRange(
681+
dateRange,
682+
'1 minute',
683+
);
684+
685+
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
686+
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
687+
});
688+
689+
it('should align both start and end times with 5 minute granularity', () => {
690+
const dateRange: [Date, Date] = [
691+
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:20:00
692+
new Date('2025-11-26T12:27:42Z'), // Should round up to 12:30:00
693+
];
694+
695+
const [alignedStart, alignedEnd] = getAlignedDateRange(
696+
dateRange,
697+
'5 minute',
698+
);
699+
700+
expect(alignedStart.toISOString()).toBe('2025-11-26T12:20:00.000Z');
701+
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:30:00.000Z');
702+
});
703+
704+
it('should align with 30 second granularity', () => {
705+
const dateRange: [Date, Date] = [
706+
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:23:00
707+
new Date('2025-11-26T12:25:42Z'), // Should round up to 12:26:00
708+
];
709+
710+
const [alignedStart, alignedEnd] = getAlignedDateRange(
711+
dateRange,
712+
'30 second',
713+
);
714+
715+
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
716+
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
717+
});
718+
719+
it('should align with 1 day granularity', () => {
720+
const dateRange: [Date, Date] = [
721+
new Date('2025-11-26T12:23:17Z'), // Should round down to start of day
722+
new Date('2025-11-28T08:15:00Z'), // Should round up to start of next day
723+
];
724+
725+
const [alignedStart, alignedEnd] = getAlignedDateRange(
726+
dateRange,
727+
'1 day',
728+
);
729+
730+
expect(alignedStart.toISOString()).toBe('2025-11-26T00:00:00.000Z');
731+
expect(alignedEnd.toISOString()).toBe('2025-11-29T00:00:00.000Z');
732+
});
733+
734+
it('should not change range when already aligned to the interval', () => {
735+
const dateRange: [Date, Date] = [
736+
new Date('2025-11-26T12:23:00Z'), // Already aligned
737+
new Date('2025-11-26T12:25:00Z'), // Already aligned
738+
];
739+
740+
const [alignedStart, alignedEnd] = getAlignedDateRange(
741+
dateRange,
742+
'1 minute',
743+
);
744+
745+
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
746+
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
747+
});
748+
749+
it('should align with 15 minute granularity', () => {
750+
const dateRange: [Date, Date] = [
751+
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:15:00
752+
new Date('2025-11-26T12:47:42Z'), // Should round up to 13:00:00
753+
];
754+
755+
const [alignedStart, alignedEnd] = getAlignedDateRange(
756+
dateRange,
757+
'15 minute',
758+
);
759+
760+
expect(alignedStart.toISOString()).toBe('2025-11-26T12:15:00.000Z');
761+
expect(alignedEnd.toISOString()).toBe('2025-11-26T13:00:00.000Z');
762+
});
763+
});
655764
});

packages/app/src/__tests__/DBSearchPageQueryKey.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jest.mock('@/ChartUtils', () => ({
5252
new Date('2023-12-31'),
5353
new Date('2024-01-01'),
5454
],
55-
getPreviousPeriodOffsetSeconds: () => 86400,
55+
getAlignedDateRange: (dateRange: [Date, Date]) => dateRange,
5656
convertToTimeChartConfig:
5757
jest.requireActual('@/ChartUtils').convertToTimeChartConfig,
5858
}));

packages/app/src/components/DBEditTimeChartForm.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,10 @@ export default function EditTimeChartForm({
485485
control,
486486
name: 'compareToPreviousPeriod',
487487
});
488+
const alignDateRangeToGranularity = useWatch({
489+
control,
490+
name: 'alignDateRangeToGranularity',
491+
});
488492
const groupBy = useWatch({ control, name: 'groupBy' });
489493
const displayType =
490494
useWatch({ control, name: 'displayType' }) ?? DisplayType.Line;
@@ -496,9 +500,6 @@ export default function EditTimeChartForm({
496500
const databaseName = tableSource?.from.databaseName;
497501
const tableName = tableSource?.from.tableName;
498502

499-
// const tableSource = tableSourceWatch();
500-
// const databaseName = tableSourceWatch('from.databaseName');
501-
// const tableName = tableSourceWatch('from.tableName');
502503
const activeTab = useMemo(() => {
503504
switch (displayType) {
504505
case DisplayType.Search:
@@ -523,19 +524,6 @@ export default function EditTimeChartForm({
523524
const showGeneratedSql = ['table', 'time', 'number'].includes(activeTab); // Whether to show the generated SQL preview
524525
const showSampleEvents = tableSource?.kind !== SourceKind.Metric;
525526

526-
// const queriedConfig: ChartConfigWithDateRange | undefined = useMemo(() => {
527-
// if (queriedTableSource == null) {
528-
// return undefined;
529-
// }
530-
531-
// return {
532-
// ...chartConfig,
533-
// from: queriedTableSource.from,
534-
// timestampValueExpression: queriedTableSource?.timestampValueExpression,
535-
// dateRange,
536-
// };
537-
// }, [dateRange, chartConfig, queriedTableSource]);
538-
539527
// Only update this on submit, otherwise we'll have issues
540528
// with using the source value from the last submit
541529
// (ex. ignoring local custom source updates)
@@ -707,6 +695,20 @@ export default function EditTimeChartForm({
707695
});
708696
}, [dateRange]);
709697

698+
// Trigger a search when "Show Complete Intervals" changes
699+
useEffect(() => {
700+
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
701+
if (config == null) {
702+
return config;
703+
}
704+
705+
return {
706+
...config,
707+
alignDateRangeToGranularity,
708+
};
709+
});
710+
}, [alignDateRangeToGranularity]);
711+
710712
// Trigger a search when "compare to previous period" changes
711713
useEffect(() => {
712714
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
@@ -1213,6 +1215,11 @@ export default function EditTimeChartForm({
12131215
</Flex>
12141216
{activeTab === 'time' && (
12151217
<Group justify="end" mb="xs">
1218+
<SwitchControlled
1219+
control={control}
1220+
name="alignDateRangeToGranularity"
1221+
label="Show Complete Intervals"
1222+
/>
12161223
<SwitchControlled
12171224
control={control}
12181225
name="compareToPreviousPeriod"

0 commit comments

Comments
 (0)