Skip to content

feat(api): support unit parameter in website list charts - #4455

Open
Revisor01 wants to merge 1 commit into
umami-software:devfrom
Revisor01:feat/website-charts-unit
Open

Revisor01 wants to merge 1 commit into
umami-software:devfrom
Revisor01:feat/website-charts-unit

Conversation

@Revisor01

@Revisor01 Revisor01 commented Aug 15, 2026

Copy link
Copy Markdown

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 unit I ran into two bugs in the existing code. They're both in
the 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 unit parameter

Every other chart endpoint already takes unit, so this uses the same
unitParam and the same values. Non-default units go through the existing
getDateSQL helper on both backends instead of hand-rolling more truncation SQL.

Leaving unit out keeps the current 12-hour bucketing, so the website list
in 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:

  • an endDate that falls between the old and the snapped bucket end can add
    one trailing zero bucket; the leading values are unchanged
  • the 10k limit now also applies here, which a default-path range would only
    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 explicit
unit=hour mean actual hours, the default case needed its own internal name —
I used a 'default' sentinel. If you'd rather have an explicit bucketHours
parameter 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 are
truncated in SQL. If startDate doesn't sit on a bucket boundary, the labels
match no row, every bucket reads back as 0, and total still looks right. It's
a 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 days hits this most of the time. Same website, same window, only
the start hour differs:

start 09:15, unpatched  →  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]   total 118
start 09:15, patched    →  [6, 16, 38, 19, 19, 11, 14]                  total 118

The patched values match count(distinct session_id) grouped by day from the
database exactly.

startOfBucket now snaps the start down to its bucket in the target timezone
before the labels are built. Lookups also go through a normalised key, because
Postgres pads to YYYY-MM-DD HH:00:00 while the UTC paths emit ISO strings with
a trailing Z.

3. Bug: ClickHouse loses the total for explicit units

This one only shows up once unit is in play, which is why it hasn't surfaced
before.

getDateSQL returns a DateTime on ClickHouse. With a DateTime column the
GROUPING SETS subtotal row comes back as the epoch instead of an empty value,
so the if (!x) check in formatResults doesn't recognise it. The row gets
treated as a normal data point, matches no bucket, and is dropped — total
stays 0 while values are fine.

Formatting the truncated value to a String keeps the subtotal empty, same as the
default path already does. Against ClickHouse 24.12:

DateTime  → {"x":"1970-01-01 01:00:00","y":"3"}   ← looks like a bucket
String    → {"x":"","y":"3"}                      ← recognised as the subtotal

Note %M is 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 + 24h drifts off midnight from the transition onwards and
every bucket after it matches no row:

Europe/Berlin, 2026-10-25 (25-hour day)
  fixed 24h    →  10-25 00:00   10-25 23:00   10-26 23:00   ← drifts
  calendar day →  10-25 00:00   10-26 00:00   10-27 00:00   ← stays put

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=minute keeps the minute in the label — the previous fixed format
would 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 values
series next to a total covering the full range — two numbers in the same
response 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:

day 1
12-hour buckets summed 5
unit=day 4
count(distinct session_id) grouped by day 4

Two 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:

  • hourly and daily values match count(distinct session_id) exactly, for all 18
    websites
  • without unit, the patched build returns an identical response to 3.3.0
  • the all-zero bug reproduces on 3.3.0 and is gone afterwards
  • invalid unit → 400, more than 20 ids → 400

Postgres 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.

@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

@Revisor01 is attempting to deploy a commit to the Umami Software Team on Vercel.

A member of the Team first needs to authorize it.

@Revisor01
Revisor01 changed the base branch from master to dev August 15, 2026 15:41
@Revisor01
Revisor01 force-pushed the feat/website-charts-unit branch 5 times, most recently from 3f4d409 to bb08861 Compare August 15, 2026 17:55
@Revisor01
Revisor01 marked this pull request as ready for review August 15, 2026 18:06
@greptile-apps

greptile-apps Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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.

  • Validates and forwards the optional unit query parameter.
  • Uses shared date-truncation helpers for explicit units while preserving default 12-hour buckets.
  • Adds bucket normalization, calendar-aware stepping, a 10,000-point cap, and extensive regression tests.
  • The cap currently returns a silent partial series while retaining the full-range total.

Confidence Score: 4/5

The 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

Important Files Changed

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
Loading

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.
@Revisor01
Revisor01 force-pushed the feat/website-charts-unit branch from bb08861 to 9b2e1f6 Compare August 15, 2026 18:33
@Revisor01

Copy link
Copy Markdown
Author

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 values and total can't disagree:

Requested range exceeds 10000 data points. Use a coarser unit or a shorter range.

countBuckets works out the size before any query runs, so an oversized request doesn't hit the database at all. The check inside the loop stays as a backstop for direct callers of the query function.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant