Skip to content

fix(billing): bill browser sessions per visit, not per tab - #360

Open
Makisuo wants to merge 1 commit into
mainfrom
fix/browser-session-billing-overcount
Open

fix(billing): bill browser sessions per visit, not per tab#360
Makisuo wants to merge 1 commit into
mainfrom
fix/browser-session-billing-overcount

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Why

browser_sessions was billing higher than real traffic justified, and the number in Settings didn't agree with what Autumn charged.

The root cause: the billed unit was one session-metadata row with version == 1. But version exists so argMax(field, Version) resolves the newest row of a ReplacingMergeTree — it was never designed to be a meter, and there is no server-side dedup at all. Billing was entirely delegated to a client-side counter written for a different job.

Four separate over-counts fell out of that.

What changed

1. Per-tab, per-origin scope → per visit

The session record lives in sessionStorage, which the browser scopes to one tab and one origin. Four open tabs were four charges; maple.devapp.maple.dev was two, for one person doing one thing.

SessionId stays per-tab — the merge invariant on (OrgId, SessionId) depends on it, and two origins sharing an id would overwrite rather than merge. Instead, a new claimVisit() (packages/browser-session/src/visit.ts) claims one billable visit per visitor per 30-minute idle window, against the cookie + localStorage pair the visitor id already rides — the one store in the SDK that spans tabs and subdomains.

The gateway now meters billable_start == 1 && version == 1. Both halves are load-bearing:

  • billable_start is sticky across every row of a billable session, so the row that survives the ReplacingMergeTree merge still records that the session was charged.
  • version == 1 is what stops those repeats from re-billing.

The 30-minute claim window is the exact IDLE_TIMEOUT_MS the session uses (now exported rather than duplicated) — if they drifted, a rotation would either go uncharged or charge twice.

2. replay.sampleRate now reduces the bill

It gated only the rrweb chunk, so an org on sampleRate: 0.1 was billed for 100% of its sessions — the one lever named "sample" moved only the part we don't charge for.

One draw now feeds two decisions: captureSession (metadata rows and the distilled event sink) and recordReplay, a strict subset. Both had to move together — session_events rows without a parent session_replays row are orphans the session UI can't render.

replayEnabled: false deliberately still captures: turning off video is not a request to turn off analytics.

Default is sampleRate: 1, so default behaviour is unchanged; only orgs that explicitly sampled down are affected, in the direction they asked for.

3. Quota exhaustion billed once a minute

readRecord preferred sessionStorage whenever getItem succeeded, while writeRecord swallowed setItem failures. On quota exhaustion — reads work, writes don't — metaVersion pinned at 1, so every 60s heartbeat re-posted version: 1 and was billed. One 30-minute session, 30 charges.

A writesFailed latch now hands control to the in-memory record, and clears when writes recover (a quota freed by another tab closing should put the durable store back in charge).

4. The spend chart wasn't showing the bill

dailySessionCountQuery used CH.count() with no FINAL against a ReplacingMergeTree, counting every unmerged heartbeat row — a 10-minute session rendered as ~12, and the number moved between refreshes. Its doc comment claimed the series "can't drift" from the cycle total; it was the only session query in the repo that hadn't adopted uniq(SessionId).

Reviewer notes

The gateway keeps a fallback, and it is not defensive. A row with no billable_start bills under the old version == 1 rule. Customers pin SDK versions, so bundles predating the field keep posting for months — reading their silence as "not billable" would silently stop billing those orgs entirely. Remove it only once the oldest SDK in the wild emits the field.

SCHEMA_VERSION bumps to 13. BYO-ClickHouse orgs need migration 0013 applied before they're routed direct ingest again. The ALTER is a trailing column with a constant DEFAULT, so it's metadata-only on ClickHouse and Tinybird alike — no MATERIALIZE COLUMN.

The chart is deliberately not on countIf(BillableStart = 1) yet, though that's what would reproduce the invoice exactly. BillableStart defaults to 0, and rows from pinned older SDKs are billed by the legacy fallback while reading as 0 — switching now would under-report for those customers, which is a worse lie than over-reporting. The mixed window isn't distinguishable in the warehouse: the surviving row after a merge is the last one posted, and legacy sessions never stamped the flag on it. Reasoning is in the doc comment; switch once the field is broadly deployed.

Cookie plumbing moved to packages/browser-session/src/cookie.ts, shared by visitor.ts and visit.ts. The visit claim needs the same Domain= the visitor id uses — a claim written host-only while the visitor id spans subdomains would stop deduplicating exactly where it matters most, on the marketing-site → app hop. configureVisitorCookie keeps its name and export site so no caller changes.

One tail this does not fix: storage-blocked browsers (Safari ITP, incognito, cookie-blocking extensions) can't hold a claim, so they still re-bill per page load. Not fixable client-side. A new ingest_billed_browser_sessions_total counter splits billed sessions by visitor_persisted so the size of that tail is a number before anyone prices around it.

Verification

  • bun typecheck — 36/36 packages
  • 119 @maple/browser-session tests, incl. multi-tab and multi-subdomain claim refusal, the quota-exhaustion metaVersion regression, billable_start stickiness across heartbeat/unload rows, and the wire key pinned in meta-row.test.ts
  • 9 @maple/browser tests, incl. sampleRate: 0 posting nothing (no meta row, no events) and enabled: false, sampleRate: 1 still capturing
  • 8 billing-usage query tests, 55 domain ClickHouse tests, 72 Rust ingest tests (incl. the new meter predicate and its legacy fallback)

Not run: the end-to-end check — 3 tabs plus a second subdomain resolving to 1 billed session in Settings — needs Postgres, the ingest gateway, and a live Autumn customer. Each seam is unit-tested, including the billable_start key across SDK → Rust → warehouse, but that is not the same as watching the number.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

The billed unit was one session-metadata row with `version == 1`. But
`version` exists so `argMax(field, Version)` resolves the newest row of a
ReplacingMergeTree — it was never designed to be a meter, and using it as
one over-charged in four separate ways.

**Per-tab, per-origin scope.** The session record lives in sessionStorage,
scoped to one tab *and* one origin, so four open tabs were four charges and
maple.dev -> app.maple.dev was two. SessionId stays per-tab (the merge
invariant on `(OrgId, SessionId)` depends on it); a new `claimVisit()` claims
one billable visit per visitor per 30-minute idle window against the cookie
the visitor id already rides, so tabs and subdomains collapse into one
charge. The gateway now meters `billable_start == 1 && version == 1`: the
flag is sticky across a session's rows so the row surviving the merge still
records the charge, and `version` keeps the charge singular.

**Sampling didn't reduce billing.** `replay.sampleRate` gated only the rrweb
chunk, so an org on 0.1 paid for 100% of its sessions — the one lever named
"sample" moved only the part we don't charge for. One draw now feeds
`captureSession` (metadata rows + distilled event sink) and `recordReplay`,
a strict subset. `replayEnabled: false` deliberately still captures:
turning off video is not turning off analytics.

**Quota exhaustion billed once a minute.** `readRecord` preferred
sessionStorage whenever `getItem` succeeded while `writeRecord` swallowed
`setItem` failures, so on quota exhaustion `metaVersion` pinned at 1 and
every 60s heartbeat re-billed. A `writesFailed` latch hands control to the
in-memory record, and clears when writes recover.

**The spend chart wasn't even showing the bill.** `dailySessionCountQuery`
used `count()` with no FINAL against a ReplacingMergeTree, counting every
unmerged heartbeat row — a 10-minute session rendered as ~12 and moved
between refreshes. Now `uniq(SessionId)`.

Adds `session_replays.BillableStart` (migration 0013) so the invoice is
reproducible from the warehouse for the first time, and a
`ingest_billed_browser_sessions_total` counter split by whether the visitor
id persisted, to size the one tail this model can't fix: storage-blocked
browsers can't hold a claim, so they still re-bill per page load.

SCHEMA_VERSION bumps to 13 — BYO-ClickHouse orgs need 0013 applied before
they are routed direct ingest again.
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