Conversation
|
@Revisor01 is attempting to deploy a commit to the Umami Software Team on Vercel. A member of the Team first needs to authorize it. |
3f4d409 to
bb08861
Compare
Greptile SummaryThis PR adds unit-based bucketing to the batched website charts endpoint and revises result shaping to align database buckets across timezones, DST transitions, and both storage backends.
Confidence Score: 4/5The long-range truncation behavior should be fixed before merging because successful responses can present a partial series alongside a full-range total. Requests exceeding 10,000 buckets still query the complete interval, but result shaping discards every bucket beyond the cap without rejecting the request or identifying truncation. Files Needing Attention: src/queries/sql/getWebsiteListCharts.ts
|
| Filename | Overview |
|---|---|
| src/app/api/websites/charts/route.ts | Adds the shared unit validator and forwards the validated value without weakening the existing permission flow. |
| src/queries/sql/getWebsiteListCharts.ts | Implements cross-backend unit bucketing and DST-aware shaping, but silently truncates long series while preserving the full-range total. |
| src/queries/sql/getWebsiteListCharts.test.ts | Adds broad unit, backend, DST, alignment, and cap coverage, although the cap test codifies partial output without checking total-series consistency. |
Sequence Diagram
sequenceDiagram
participant C as Client
participant R as Website charts route
participant Q as getWebsiteListCharts
participant DB as PostgreSQL or ClickHouse
C->>R: GET charts with ids, dates, timezone, unit
R->>R: Validate query and permissions
R->>Q: Dates, timezone, unit
Q->>DB: Aggregate complete requested range
DB-->>Q: Bucket rows and subtotal
Q->>Q: Generate at most 10,000 labels
Q-->>R: Dense values series and total
R-->>C: Chart response
Reviews (1): Last reviewed commit: "feat(api): support unit parameter in web..." | Re-trigger Greptile
| buckets.push(formatInTimeZone(current, timezone, 'yyyy-MM-dd HH:00:00')); | ||
| for ( | ||
| let current = startOfBucket(startDate, unit, timezone); | ||
| current <= endDate && buckets.length < MAX_BUCKETS; |
There was a problem hiding this comment.
Bucket cap returns inconsistent totals
When a requested range contains more than 10,000 buckets, formatResults stops generating labels while the unrestricted query still aggregates the complete range, causing values to omit later counts even though total includes them.
Knowledge Base Used: Reports & Analytics
`GET /api/websites/charts` always returned fixed 12-hour buckets, which made it unusable for callers that need a different resolution: a daily chart had to be stitched back together client-side, and an hourly one was not available at all. Every other chart endpoint already accepts `unit`. Add `unit` to the endpoint, validated with the existing `unitParam` so the accepted values match `/pageviews` and friends. Omitting it keeps the current 12-hour bucketing, so the default response shape is unchanged. Non-hour units delegate to the existing `getDateSQL` helper on both the Prisma and ClickHouse paths rather than hand-rolling more truncation SQL. This also fixes a silent failure. Bucket labels were generated from the raw `startDate`, while the values were generated by truncating in SQL. A `startDate` whose hour is not itself a bucket boundary therefore produced labels that matched no row, and every bucket read back as 0 while `total` stayed correct — a 200 response with nothing to indicate the result was wrong. Because the label format drops minutes, only the hour matters: with the default 12-hour buckets a start at 00:xx or 12:xx still lines up, while the other 22 hours of the day return all zeros. Requesting a window as "now minus 7 days" therefore fails for most of the day. Verified against Postgres: a start at 09:15 returns 14 zero buckets with total 34. `startOfBucket` now snaps the start down to its bucket in the target timezone before labels are generated. Lookups additionally go through a normalised key, because the two backends render truncated dates differently: Postgres pads to `YYYY-MM-DD HH:00:00`, ClickHouse omits the time for day and coarser units, and the UTC paths emit ISO strings with a trailing `Z`. On the ClickHouse path the truncated value is additionally formatted to a String. `getDateSQL` returns a DateTime there, and a DateTime column renders the GROUPING SETS subtotal row as the epoch instead of an empty value — `formatResults` would then treat it as an ordinary data point, find no matching bucket, and silently drop it, leaving `total` at 0. Day and coarser buckets are advanced on the local calendar date rather than as fixed durations. A local day is 23 or 25 hours long across a DST transition, so stepping by a fixed 24 hours drifts the labels off midnight from the transition onwards — every bucket after it would match no row and read back as 0. Deriving each date from the previous instant is not enough either: where the transition falls on midnight (America/Santiago, America/Havana) that day's bucket resolves to 23:00 of the day before, which reads back as the earlier date and stalls the series. Labels keep the minute component when `unit=minute`; the previous fixed format collapsed every minute of an hour onto the same key. Calendar labels are built from the local date rather than from the instant. Where local midnight does not exist — a DST transition at 00:00, as in America/Santiago — the instant sits at 23:00 the previous day while the database still truncates to that day, so the whole day read back as 0. `startOfBucket` steps back an hour when snapping lands after the requested start, which happens in the repeated autumn hour because the ambiguous local time resolves to its second occurrence. The bucket index keeps the first of two identical labels. An autumn DST transition repeats an hour, and the database reports both as one row, so the value goes into the earlier bucket rather than leaving a gap before it. Ranges beyond 10k buckets are rejected with a 400. Unlike the sibling endpoints this one fills gaps server-side, so an unbounded fine-grained range would build very large dense arrays for up to 20 websites at once. Truncating instead would return a partial `values` series alongside a `total` covering the full range — two numbers in one response that disagree. Tests cover the default bucketing, hour/day/month units on both backends, the three date renderings, the ClickHouse subtotal row, both DST transitions, and a regression test for the unaligned start.
bb08861 to
9b2e1f6
Compare
|
Good catch — that was the one thing I wasn't happy with either. The route now checks the range up front and returns a 400 instead of truncating, so
The test that codified the truncated output is gone; there are now two tests around the limit instead — one over it, one under. Happy to change the limit itself, or to make it configurable, if 10k seems off. |
First off: the batch endpoint added in 3.3 is great. I maintain StatsFlow, an
iOS client for Umami, and it took loading the charts for all my websites from
one request per site down to a single request — for 18 sites that's 5.6s to
0.6s. Really nice addition.
That's what got me looking at hourly data. The endpoint only returns fixed
12-hour buckets, so I can't get an hourly chart for "today" out of it (I'd get
two data points instead of 24), and a daily chart has to be reassembled on the
client.
While adding
unitI ran into two bugs in the existing code. They're both inthe same function, so I fixed them here rather than opening separate PRs — happy
to split it up if you'd rather review them separately.
I have a self-hosted 3.3.0 instance with ~53k events across 18 websites, so I
could check all of this against real data rather than just fixtures.
1. The
unitparameterEvery other chart endpoint already takes
unit, so this uses the sameunitParamand the same values. Non-default units go through the existinggetDateSQLhelper on both backends instead of hand-rolling more truncation SQL.Leaving
unitout keeps the current 12-hour bucketing, so the website listin the UI and any existing caller sees the same response as before. I checked
that against my instance: byte-identical.
Two edge cases do change on the default path, both as a consequence of the
bugfix below:
endDatethat falls between the old and the snapped bucket end can addone trailing zero bucket; the leading values are unchanged
reach at roughly 13 years
One implementation detail worth flagging: the 12-hour bucket has no equivalent
in
getDateSQL, so it keeps its own truncation. To make an explicitunit=hourmean actual hours, the default case needed its own internal name —I used a
'default'sentinel. If you'd rather have an explicitbucketHoursparameter or something else, I'm fine with changing it.
2. Bug: all-zero values with a correct total
Bucket labels are generated from the raw
startDate, but the values aretruncated in SQL. If
startDatedoesn't sit on a bucket boundary, the labelsmatch no row, every bucket reads back as 0, and
totalstill looks right. It'sa 200 response with nothing indicating anything went wrong.
Since the label format drops minutes, only the hour matters: with 12-hour
buckets a start at 00:xx or 12:xx lines up, the other 22 hours of the day don't.
So
now - 7 dayshits this most of the time. Same website, same window, onlythe start hour differs:
The patched values match
count(distinct session_id)grouped by day from thedatabase exactly.
startOfBucketnow snaps the start down to its bucket in the target timezonebefore the labels are built. Lookups also go through a normalised key, because
Postgres pads to
YYYY-MM-DD HH:00:00while the UTC paths emit ISO strings witha trailing
Z.3. Bug: ClickHouse loses the total for explicit units
This one only shows up once
unitis in play, which is why it hasn't surfacedbefore.
getDateSQLreturns aDateTimeon ClickHouse. With a DateTime column theGROUPING SETSsubtotal row comes back as the epoch instead of an empty value,so the
if (!x)check informatResultsdoesn't recognise it. The row getstreated as a normal data point, matches no bucket, and is dropped —
totalstays 0 while
valuesare fine.Formatting the truncated value to a String keeps the subtotal empty, same as the
default path already does. Against ClickHouse 24.12:
Note
%Mis month in ClickHouse, not minutes — the format string uses%T,matching what the rest of the codebase does.
DST
Day and coarser buckets are advanced as calendar steps in the target timezone,
not as fixed durations. A local day is 23 or 25 hours long across a DST
transition, so
now + 24hdrifts off midnight from the transition onwards andevery bucket after it matches no row:
Deriving each date from the previous instant isn't enough either: where the
transition falls on midnight (America/Santiago, America/Havana) that day's
bucket resolves to 23:00 of the day before, which reads back as the earlier
date and stalls the series. The loop therefore carries the local calendar date
forward. All three cases have regression tests.
unit=minutekeeps the minute in the label — the previous fixed formatwould have collapsed every minute of an hour onto the same key.
Ranges beyond 10k buckets are rejected with a 400. Unlike the sibling
endpoints this one fills gaps server-side, so an unbounded fine-grained range
(a year of minutes is >500k points) would build very large dense arrays for up
to 20 websites at once. Truncating instead would hand back a partial
valuesseries next to a
totalcovering the full range — two numbers in the sameresponse that disagree. Happy to change the limit or the wording.
Why not just sum the buckets client-side
I tried that first. It's wrong: summing two 12-hour buckets into a day
double-counts sessions that cross the boundary, since
count(distinct session_id)is evaluated per bucket. On my data:unit=daycount(distinct session_id)grouped by dayTwo of the five sites I sampled show this. Only truncating in the database gives
the right number.
Testing
19 unit tests: default bucketing, hour/day/month on both backends, the different
date renderings, the ClickHouse subtotal row, five DST scenarios (both European
transitions, the repeated autumn hour, and a zone whose transition falls on
midnight), minute resolution, the range limit, and unaligned starts on both the
calendar and the duration path.
Beyond that I ran the patched build against my own instance's database and
compared three sources — unpatched 3.3.0, the patched build, and direct SQL —
across 39 assertions:
count(distinct session_id)exactly, for all 18websites
unit, the patched build returns an identical response to 3.3.0unit→ 400, more than 20 ids → 400Postgres was tested with the real dataset; ClickHouse 24.12 with the generated
SQL, which is how the subtotal issue turned up.
Happy to adjust anything here, including splitting this into separate PRs.