fix: keep toStartOf* time filters inclusive regardless of dateRangeEndInclusive (#1915)

== Motivation ==

Time histograms on the search page silently drop data past an hour/minute boundary when the source `timestampValueExpression` includes a `toStartOf*` expression for primary key optimization.

== Details ==

When `convertToTimeChartConfig` aligns the date range to granularity it sets `dateRangeEndInclusive: false`, which is correct for the raw timestamp column (end was rounded up, so `<` gives equivalent coverage). But `timeFilterExpr` applies that same `<` uniformly to every expression in a compound `timestampValueExpression`. With a range ending at `04:08`, this yields `toStartOfHour(ts) < toStartOfHour(04:08)` = `< 04:00` — excluding the entire `04:xx` hour.

The coarse filter exists only for index pruning; the raw column already enforces exact bounds. Making it wider by one interval is harmless, making it narrower drops real rows.

== Testing ==

- `yarn jest renderChartConfig.test.ts` — 54 passed, 29 snapshots passed
- Added cases for `toStartOfHour` with exclusive end, compound expr with exclusive end, and exclusive start
This commit is contained in:
James 2026-03-20 11:21:16 -04:00 committed by GitHub
parent f5ce232976
commit 2fab76bfcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 44 additions and 10 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/common-utils": patch
---
fix: Keep toStartOf\* time filter bounds inclusive when dateRangeEndInclusive is false, preventing data from being dropped past hour/minute boundaries in time histograms

View file

@ -1188,6 +1188,39 @@ describe('renderChartConfig', () => {
'-toInt64(toStartOfInterval(timestamp, toIntervalMinute(15))), service_id, timestamp',
expected: `(timestamp >= fromUnixTimestamp64Milli(1739319154000) AND timestamp <= fromUnixTimestamp64Milli(1739491954000))`,
},
{
description:
'with toStartOfHour and dateRangeEndInclusive=false (must stay inclusive on coarse filter)',
timestampValueExpression: 'toStartOfHour(timestamp)',
dateRange: [
new Date('2025-02-12 03:53:38Z'),
new Date('2025-02-12 04:08:38Z'),
],
dateRangeEndInclusive: false,
expected: `(toStartOfHour(timestamp) >= toStartOfHour(fromUnixTimestamp64Milli(${new Date('2025-02-12 03:53:38Z').getTime()})) AND toStartOfHour(timestamp) <= toStartOfHour(fromUnixTimestamp64Milli(${new Date('2025-02-12 04:08:38Z').getTime()})))`,
},
{
description:
'with compound expression and dateRangeEndInclusive=false (raw col exclusive, toStartOf inclusive)',
timestampValueExpression: 'timestamp, toStartOfHour(timestamp)',
dateRange: [
new Date('2025-02-12 03:53:38Z'),
new Date('2025-02-12 04:08:38Z'),
],
dateRangeEndInclusive: false,
expected: `(timestamp >= fromUnixTimestamp64Milli(${new Date('2025-02-12 03:53:38Z').getTime()}) AND timestamp < fromUnixTimestamp64Milli(${new Date('2025-02-12 04:08:38Z').getTime()}))AND(toStartOfHour(timestamp) >= toStartOfHour(fromUnixTimestamp64Milli(${new Date('2025-02-12 03:53:38Z').getTime()})) AND toStartOfHour(timestamp) <= toStartOfHour(fromUnixTimestamp64Milli(${new Date('2025-02-12 04:08:38Z').getTime()})))`,
},
{
description:
'with toStartOfHour and dateRangeStartInclusive=false (must stay inclusive on coarse filter)',
timestampValueExpression: 'toStartOfHour(timestamp)',
dateRange: [
new Date('2025-02-12 03:53:38Z'),
new Date('2025-02-12 04:08:38Z'),
],
dateRangeStartInclusive: false,
expected: `(toStartOfHour(timestamp) >= toStartOfHour(fromUnixTimestamp64Milli(${new Date('2025-02-12 03:53:38Z').getTime()})) AND toStartOfHour(timestamp) <= toStartOfHour(fromUnixTimestamp64Milli(${new Date('2025-02-12 04:08:38Z').getTime()})))`,
},
];
beforeEach(() => {

View file

@ -634,19 +634,15 @@ export async function timeFilterExpr({
? chSql`${toStartOf.function}(fromUnixTimestamp64Milli(${{ Int64: endTime }})${toStartOf.formattedRemainingArgs})`
: chSql`fromUnixTimestamp64Milli(${{ Int64: endTime }})`;
// toStartOf* filters must stay inclusive — strict < on a rounded value drops a whole interval
const startOp = dateRangeStartInclusive || toStartOf ? '>=' : '>';
const endOp = dateRangeEndInclusive || toStartOf ? '<=' : '<';
// If it's a date type
if (columnMeta?.type === 'Date') {
return chSql`(${unsafeTimestampValueExpression} ${
dateRangeStartInclusive ? '>=' : '>'
} toDate(${startTimeCond}) AND ${unsafeTimestampValueExpression} ${
dateRangeEndInclusive ? '<=' : '<'
} toDate(${endTimeCond}))`;
return chSql`(${unsafeTimestampValueExpression} ${startOp} toDate(${startTimeCond}) AND ${unsafeTimestampValueExpression} ${endOp} toDate(${endTimeCond}))`;
} else {
return chSql`(${unsafeTimestampValueExpression} ${
dateRangeStartInclusive ? '>=' : '>'
} ${startTimeCond} AND ${unsafeTimestampValueExpression} ${
dateRangeEndInclusive ? '<=' : '<'
} ${endTimeCond})`;
return chSql`(${unsafeTimestampValueExpression} ${startOp} ${startTimeCond} AND ${unsafeTimestampValueExpression} ${endOp} ${endTimeCond})`;
}
}),
);