fix(analytics): correct channel attribution, crawler contamination, and period-blind overview - #559
Conversation
`record_event` derived the tracked site's hostname from `event_data.hostname`, which no browser SDK has ever sent — the React analytics SDK sends a top-level `domain`, and `EventMetricsPayload` doesn't deserialize it. `get_channel` therefore always received `current_hostname = None`, its self-referral branch never fired, and every internal page-to-page navigation was attributed to "Referral". On a real site that swamps everything else: the temps.sh landing project reports 36,709 "Referral" events out of 41,220 (89%) over 30 days, while the referrer breakdown — which does filter self-referrals — totals only 5,438. Genuine acquisition channels are buried under the site's own internal navigation, which makes channel attribution unusable. The ingest handler already resolves the Host header against the route table (it must, to attribute the event to a project at all), so the correct site hostname is in hand. Thread it into `record_event` as an explicit `site_hostname` argument and use it both for the `hostname` column — previously the hardcoded "localhost" fallback on every SDK-originated event — and for self-referral detection. The server-side console ingest path passes `None`: its caller is an app backend rather than a browser and it sends no referrer, so there is no self-referral to detect. Existing rows keep their stored channel; this only corrects new events.
`get_unique_counts` filters `is_crawler = false` and documents the invariant that the per-page and per-property analytics queries do the same. They did not. `get_property_breakdown`, `get_property_timeline` and `get_page_paths` all counted crawler events, so every breakdown was computed over a larger population than the visitor and page-view totals rendered beside it — visibly so, since the device breakdown carried its own literal "Bot" row and the browser breakdown listed "misc crawler". Add the missing filter to all three so a single denominator applies across the analytics surface. Crawler and AI-agent traffic keeps its own dedicated views, which are built on proxy logs rather than on the events table, so nothing loses its only reporting path. The fixture in `test_property_breakdown_excludes_zero_visitor_referrers` sent no User-Agent, which the ingest deliberately classifies as a crawler, so both of its events fell outside the new filter. Give them a real browser UA — a browser hit always sends one, and the property the test actually asserts on is the missing visitor_id, not the missing UA.
…eriod Two parts of `analytics overview` reported the same numbers no matter which period was asked for. Top Locations listed raw visitor rows and tallied countries client-side. `/analytics/visitors` caps at 100 rows (the command asked for 200) and orders by `last_seen DESC`, so `--period 24h`, `7d` and `30d` all received the same 100 most-recent visitors and printed a byte-identical table. Against the temps.sh landing project that reported the United States at 40 visitors / 40% for every period; the real 30-day figure is 1,307 / 27.7%. Use the server-side country aggregate (`property-breakdown`, `aggregation_level=visitors`) instead, which is period-scoped and shares its denominator with the headline counts. The sparkline always requested hourly buckets and kept the last 48, so any period longer than two days silently drew only its final two days under a header claiming the full range. Ask `aggregated-buckets` for a bucket size that fits the whole period in 48 columns, and label the chart with the bucket size the server reports back, so the caption always describes the data actually drawn.
📓 Changelog previewThis is what your commits will add to the generated ## [Unreleased]
### Added
- **analytics:** Crawler opt-in for breakdowns, and prune NULL keys early
### Documentation
- **analytics:** Declare include_crawlers on the timeline endpoint
### Fixed
- **analytics:** Classify self-referrals as Direct, not Referral
- **analytics:** Exclude crawler traffic from breakdowns and top pages
- **cli:** Make analytics overview locations and sparkline respect --period
- **analytics:** Populate visitor language instead of always "Unknown"
- **analytics:** Send language from the shared browser SDK core too
- **analytics:** Close review findings on attribution, language and CH parity |
The language breakdown was 100% "Unknown" — 41,437 of 41,438 events on the temps.sh landing project over 30 days, the single exception being one event from a client that happened to set the field by hand. Same failure shape as the site-hostname bug: the server only read `language` from the event payload and `event_data`, and no browser SDK ever sent it. The React analytics SDK reports `navigator.language` from the *session recorder* only, never from `trackEvent`/`trackPageview`, so the column stored NULL on every event the tracker produced. Fixed on both sides: - Server falls back to the `Accept-Language` header, which browsers send on the ingest request anyway — so the data was always present, just never read. This repairs the dimension for every already-deployed SDK without anyone upgrading. - SDK now sends `navigator.language` on all three event paths (event, pageview, page-leave). It is more precise than the header, which proxies can rewrite or strip. The header value is validated, not merely truncated: `language` is a GROUP BY dimension in the property breakdowns, so accepting arbitrary header content would let any client mint unbounded distinct values and blow up cardinality on a box expected to run in 4 GB. Only plausible BCP-47 tags (alphanumeric subtags, <= 35 chars, not "*") are stored; anything else is dropped to NULL. Existing rows keep their NULL language; this only affects new events.
Added: end-to-end verification through the real ingest path, and a fourth fixThe wiring gap is now closedThe original evidence tested Set up on a local instance (own slot, own database): a project, a custom domain, and a deployment so the host resolves in the route table. Then real
Fourth fix:
|
Accept-Language sent |
stored |
|---|---|
en-US,en;q=0.9 |
en-US |
es-ES,es;q=0.9,en;q=0.8 |
es-ES |
pt-BR,pt;q=0.9 |
pt-BR |
zh-Hant-TW,zh;q=0.9 |
zh-Hant-TW |
* |
(NULL) |
'; DROP TABLE events-- |
(NULL) |
| 200-char string | (NULL) |
Read back through the public breakdown API, so this is the user-facing surface, not just the column:
=== language === === channel ===
Unknown 7 63.6% Direct 9 81.8%
en-US 1 9.1% Referral 1 9.1%
pt-BR 1 9.1% Organic Search 1 9.1%
zh-Hant-TW 1 9.1%
es-ES 1 9.1%
The 7 Unknown reconcile exactly: the 4 attribution events sent before the language fix (no Accept-Language) plus the 3 deliberately-rejected junk values.
Two unit tests cover the parser (test_primary_accept_language_takes_highest_priority_tag, test_primary_accept_language_rejects_junk). Suite is now 59 passing; clippy clean with -D warnings.
Still not addressed
Existing rows keep their stored channel and NULL language — all four fixes are forward-only. A backfill would rewrite two columns across the whole events hypertable and should be a deliberate, separately-reviewed operation rather than a side effect of this PR.
The previous commit added `navigator.language` to the React SDK only. That was incomplete: `@temps-sdk/analytics-core` builds the event payloads for the browser, Vue and Svelte SDKs, and it never sent `language` either — so every non-React integration still relied purely on the server's Accept-Language fallback. Found by driving a real browser against the vanilla `@temps-sdk/analytics-browser` CDN bundle rather than curl: the bundle is built from analytics-core, so the React-only patch had no effect on it. Adds the field to all five payload builders in the core — trackEvent, trackPageview, page-leave, and both EngagementTracker heartbeats — so every SDK reports the browser's own language rather than depending on a header that proxies can rewrite or strip. Note: `bun test` in this package fails 6/6 with "window is not defined" both before and after this change; the SessionRecorder suite needs a DOM environment that isn't configured here. `tsc` passes clean.
…arly Two changes to the property breakdown / timeline queries. **1. `include_crawlers` toggle.** The previous commit excluded crawlers unconditionally. That fixed the denominator mismatch against the headline counts, but left the product inconsistent: the visitors list has an explicit humans/crawlers/all filter backed by `include_crawlers`, while the breakdowns silently could never show bot traffic at all. The default stays `false` — that is the invariant worth keeping, since the headline counts exclude crawlers and the percentages must share their denominator — but an operator who wants bot traffic can now ask for it instead of losing the data with no affordance. Applied to both backends so they report the same population. The ClickHouse impl gates on the same flag via the existing `(? = 0 OR ...)` idiom; its driver binds positionally, so the new bind sits adjacent to its placeholder and the test asserts totals either side of the toggle — a misaligned bind would shift every later filter and change those numbers. `PropertyBreakdownSpec::new` keeps 7 arguments; the flag is set through a `with_crawlers()` builder matching the existing `with_environment()` / `with_deployment()` idiom, which also avoids a bare positional bool at every call site. **2. Prune NULL aggregation keys in WHERE, not HAVING.** When counting DISTINCT visitors or sessions, rows whose key is NULL were carried all the way into the group-by and then discarded by `HAVING COUNT(...) > 0`. `COUNT(DISTINCT x)` already ignores NULLs, so filtering them up front returns an identical result set while skipping the sort/hash work. Measured ~20% faster (166ms -> 135ms) over a 300k-event range where a third of rows carry no visitor_id. Exact, no approximation, no new index, no extension. Also makes the ClickHouse tests say when they skip. Both container tests returned early and still reported `ok`, so a query change could look verified when nothing had executed — the exact failure mode this PR keeps finding elsewhere.
The timeline handler read `include_crawlers` from its query struct but never listed it in `#[utoipa::path(params(...))]`, so the parameter was absent from the OpenAPI document. It worked over raw HTTP while being invisible to every generated client — a half-exposed feature. Found by running this PR's own frontend/SDK review checklist against the diff.
Added since the original description: crawler opt-in, an exact perf win, and an OpenAPI fixThe description above covers the first three fixes. Three more landed since; recording them here so the PR is self-describing.
|
| execution time (3 runs) | |
|---|---|
| before | 159 / 177 / 164 ms |
| after | 129 / 135 / 142 ms |
No approximation, no new index, no extension dependency.
OpenAPI: include_crawlers was invisible on the timeline endpoint
The timeline handler read the parameter but never declared it in #[utoipa::path(params(...))], so it worked over raw HTTP while being absent from the OpenAPI document and therefore from every generated client. Fixed in 5aa8e0b3.
Two things deliberately NOT done
Approximate distinct counting (HLL). COUNT(DISTINCT) is the wrong tool at scale and this repo already knows it — clickhouse_backend.rs uses uniq() and documents it as "within ~1%". Measured alternatives on the same 300k rows: HLL on raw rows ~50 ms (4×), HLL rolled up from a pre-aggregate ~17 ms (11×), with 0.00% error across 20 groups.
It is blocked, not deferred: timescaledb_toolkit is not a guaranteed dependency — migrations create only timescaledb and vector. It ships in the timescaledb-ha image but a self-hoster on plain Postgres + timescaledb would hit a hard failure on approx_count_distinct. Adopting it is a dependency decision, not a code change.
Regenerating the typed SDKs for the two new query params. Additive and optional, so nothing breaks, but the toggle isn't reachable from the generated clients until someone regenerates.
Unrelated finding worth its own issue
events_hourly stores count(DISTINCT visitor_id) per hour. Per-bucket distinct counts cannot be summed, so that CAGG cannot answer "unique visitors over 30 days" at all. Nothing currently reads it, so this is dormant rather than a live over-count — but it is refreshing on ingest for data no one queries.
… parity Independent Rust-standards and security review passes converged on the same three defects. All are fixed here. **Self-referral matching was a substring test.** `get_channel` used `ref_host.contains(current) || current.contains(ref_host)`, so with a site on `temps.sh` a referrer from `nottemps.sh` or `temps.sh.evil.example` was recorded as Direct. That silently deletes real referrals from acquisition reporting, and lets any third party suppress its own attribution by registering a hostname containing the target's — shorter apex domains make it trivial. The branch was unreachable before this PR (`current_hostname` was always None), so this PR is what makes it hot. Replaced with a case-insensitive, dot-boundary-anchored comparison, with regression cases for both directions of the old bug. **Language validation guarded the wrong branch.** `primary_accept_language` was applied only to the Accept-Language header, which is third in priority behind `payload.language` and `event_data.language` — both unvalidated. This PR's own SDK change then started sending `language` on the payload, making the unvalidated branch the production path and the guard approximately dead code. `/_temps/event` is unauthenticated, and `language` is a GROUP BY dimension (and `LowCardinality(String)` on ClickHouse), so that gap is an unbounded-cardinality hazard. Validation now runs on the resolved value, whichever source wins. The parser also now picks the highest `q` weight instead of assuming browser ordering (`de;q=0.1,en;q=0.9` resolved to `de`), and normalises case so `en-US`, `en-us` and `EN-US` stop being three dimension values. **ClickHouse timelines accepted include_crawlers and discarded it.** The field was threaded from the handler and honoured by Postgres, but the CH timeline SQL had no `is_crawler` predicate and never bound the flag — Rust does not warn on an unread struct field. On a CH install the chart counted bots while the table beside it did not, which is the exact defect this PR exists to fix. Gated and bound, with assertions on both sides of the toggle so it cannot regress silently. Also from review: - `get_page_paths` filtered crawlers only in its session-selection subquery, so qualifying a session on one human event dragged in every crawler row sharing that session id. Predicate pushed onto the outer materialization too. - Corrected the NULL-prune comment on the timeline query: unlike the breakdown it has no HAVING, so the prune does change the response (all-NULL buckets drop out rather than emitting zero).
Three independent analytics defects, all found by reading our own dashboard for the
temps-landing-newproject and being unable to reconcile the numbers.1. Self-referrals were attributed to the "Referral" channel
record_eventread the tracked site's hostname fromevent_data.hostname. No browser SDK has ever sent that key — the React analytics SDK sends a top-leveldomain, andEventMetricsPayloaddoesn't even deserialize it. Soget_channelalways gotcurrent_hostname = None, its self-referral branch never fired, and every internal page-to-page navigation was recorded as a Referral.Production impact on temps.sh (30 days):
openalternative.co)Channel attribution was unusable — real acquisition channels were buried under the site's own navigation.
The ingest handler already resolves the Host header against the route table (it has to, to attribute the event to a project), so the correct hostname was available all along. It's now threaded in as an explicit
site_hostnameargument and used for both self-referral detection and thehostnamecolumn — which until now stored the"localhost"fallback on every SDK-originated event.The server-side console path passes
None: its caller is an app backend, not a browser, and it sends no referrer.Not fixed here: existing rows keep their stored channel. A backfill would rewrite the
channelcolumn across the wholeeventshypertable, which is a heavy operation on the reference 4 GB box — worth doing deliberately, not as a side effect of this PR.2. Breakdowns counted crawlers; headline counts didn't
get_unique_countsfiltersis_crawler = falseand its comment asserts the per-page and per-property queries do the same. They didn't.get_property_breakdown,get_property_timelineandget_page_pathsall counted crawler events, so every percentage was computed over a bigger population than the totals shown next to it. The symptom was visible in the product: a literalBotrow (224) in the device breakdown andmisc crawler(215) in browsers.All three now filter crawlers. AI-agent and crawler reporting is unaffected — those views read proxy logs, not the events table.
3.
analytics overviewignored--periodin two placesTop Locations listed raw visitor rows and tallied countries client-side.
/analytics/visitorscaps at 100 rows (the command asked for 200) and orders bylast_seen DESC, so 24h, 7d and 30d all got the same 100 most-recent visitors and printed identical tables:The 30-day figures now reconcile with the headline 4,489 unique visitors. (Singapore at 16% is real, and worth a separate look.)
The sparkline always requested hourly buckets and kept the last 48, so any period beyond two days silently drew only its final two days under a header claiming the full range:
The label echoes the bucket size the server reports, so the caption always describes the data actually drawn.
Evidence
Regression tests (real Postgres via
TestDatabase, skip gracefully without Docker):test_record_event_classifies_self_referral_as_direct— asserts a referrer on the site's own host yieldsDirect, a third-party referrer still yieldsReferral, and the resolved host is persisted instead of"localhost".test_property_breakdown_excludes_crawlers— asserts a bot-UA referrer is absent from the breakdown and excluded from its denominator.The self-referral test was confirmed to fail on the pre-fix code with exactly the production symptom, then pass after:
Full suites:
CLI verified against a live instance (
app.temps.kfs.es, projecttemps-landing-new) — the before/after tables above are real output frombun run src/index.ts analytics overviewat each period, not mock-ups.bunx tsc --noEmitintroduces no new errors (5 pre-existing errors remain inopenapi-ts.config.ts,notifications/,providers/, none in the changed file).The backend changes can't be exercised against production until deployed, so they're evidenced by the DB-backed integration tests above.
Reviewer notes
sessions(10,529) exceedingpage_views(7,564) is by definition, not a bug — sessions count distinctsession_idacross all events, page views count onlyevent_type = 'page_view', and this site fires many non-pageview events. Flagging it because the overview renders the two adjacently, which invites the wrong reading.