Skip to content

feat(analytics): Web Analytics page over the session data we already store - #383

Merged
Makisuo merged 5 commits into
mainfrom
chore/drop-stale-alchemy-effect-vendor
Aug 10, 2026
Merged

feat(analytics): Web Analytics page over the session data we already store#383
Makisuo merged 5 commits into
mainfrom
chore/drop-stale-alchemy-effect-vendor

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Why

The browser SDK has been capturing visitor ids, referrers, UTM, entry/exit paths, device/browser/OS and page views since migration 0011, and nothing queried any of it — session_replays carried 20 unread analytics columns and session_events held ~150k navigation rows per 14 days that nothing aggregated. This is the page that reads them: unique visitors and page views over time, top pages, referrers, UTM, countries, devices. Org-scoped like every other route.

What

  • Queriesch/queries/web-analytics.ts: summary KPIs, visitor timeseries, page-view timeseries, top pages, and a 12-dimension breakdown union. Registered via defineQuery, exposed on the existing QueryEngineApiGroup (no new group — that's where every warehouse aggregate lives), handled in query-engine.http.ts.
  • Page/analytics, with the filter-sidebar + toolbar + card chrome the Cloudflare pages established. Every filter round-trips through the URL; clicking any breakdown row narrows the whole page. Added to the Analyze nav group.
  • DSLdomain, path, cutQueryString, uniqIf added to lib/clickhouse-builder.
  • IngestMAPLE_INGEST_TRUST_PROXY_GEO: "true" on the ECS task.

What a reviewer should know

session_replays columns have three coverage tiers, not one. This drove most of the design:

Tier Columns Coverage (prod, 7d)
session_events Type='navigation' SessionId, Timestamp, Url every session, every SDK build
session_replays base BrowserName, OsName, DeviceType, Country, DurationMs ~100%
analytics block (0011) VisitorId, Referrer, ReferrerHost, Utm*, Host, EntryPath, ExitPath, Language, PageViews 1 org of 7, ~18% of sessions

PageViews is the trap — it sits above the "Analytics dimensions (migration 0011)" comment in the schema but behaves like tier 3.

Treating these uniformly produced two numbers that contradicted each other on one screen, both caught in browser verification and both fixed here:

  1. 100% bounce ratePageViews <= 1 over all sessions counted every analytics-block-less session as a bounce, while top pages on the same screen showed 27k views for the page. Bounce is now confined to VisitorId != '' on both sides of the ratio and renders as unknown when nothing reports page views.
  2. "Sessions 0" beside "27.2K page views" when filtering by page — EntryPath is tier 3, so the filter matched nothing. host/pagePath now reach session_replays through a SessionId IN (SELECT … FROM session_events WHERE Type='navigation') semi-join. Full coverage, and the better definition anyway: entering or exiting on a page is narrower than viewing it. Now reads 9.7K, matching the panel.

The coverage caveat is per-dimension rather than per-panel — a blanket one mislabelled Device/Browser/OS as low-coverage while the sidebar counted 41k Chrome sessions beside it.

Geo is forward-only. Country was empty in 100% of rows because MAPLE_INGEST_TRUST_PROXY_GEO was never set, so derive_country always returned "". Setting it is safe (the ALB only admits Cloudflare's proxy ranges, so Cf-IPCountry can't be client-supplied), but this needs an ingest redeploy, and the gateway stores no client IP — nothing to backfill. The countries panel says so rather than showing a zero row.

sql-catalog.test.ts's QUERY_MODULES is a manual registry. A new query module escapes the SQL sweep silently, so the module is registered there with 10 builder fixtures — including ones that force the semi-join branches, which are a whole second SQL shape no unfiltered fixture reaches.

This branch also carries chore: drop the stale alchemy-effect vendor subtree, which predates this work and is unrelated to it. Happy to split it out if you'd rather review them separately.

Verification

  • bun typecheck — 37/37 packages
  • packages/query-engine — 1000 tests pass
  • SQL baseline: +599 insertions, 0 deletions — no existing SQL shape moved
  • Numbers cross-checked against production Tinybird: 2,064 visitors and //pricing//local/ for the dogfood org; 280 sessions / 193 visitors / 12% bounce for /pricing/
  • Browser: all five endpoints 200, one request per panel, filters round-trip through the URL

Open in Devin Review

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

Makisuo and others added 5 commits August 10, 2026 13:50
`.context/alchemy-effect` held alchemy-effect@0.11.0, vendored 2026-04-13.
Upstream renamed that package into alchemy@2.x and restructured to
packages/alchemy; npm shows alchemy-effect frozen at 0.12.0, last touched
the same day it was vendored. We run alchemy@2.0.0-beta.70.

Four months of drift landed exactly where it hurts: the vendored tree has
AWS/StageConfig.ts where the live package has AWS/Environment.ts and
AWS/AuthProvider.ts — the credential-resolution path at the centre of the
current deploy investigation. A review has already quoted its line numbers
as fact about the running code, and they described a different
implementation.

The installed package ships its own src/, always matching the version
actually running, so the copy bought nothing but 19 MB of drift. Its
vendor/alchemy entry was also recorded as a submodule gitlink with no
.gitmodules anywhere, which is what printed

    fatal: No url found for submodule path '.context/alchemy-effect/vendor/alchemy'
    ##[warning]The process '/usr/bin/git' failed with exit code 128

on every checkout in every workflow. Both go away with it.
…y store

The browser SDK has been capturing visitor ids, referrers, UTM, entry/exit
paths, device/browser/OS and page views since migration 0011, and nothing
queried any of it: `session_replays` carried 20 unread analytics columns and
`session_events` held 150k `navigation` rows per 14 days that nothing
aggregated. This adds the page that reads them — unique visitors and page views
over time, top pages, referrers, UTM, countries, devices — org-scoped like every
other route.

Five query builders in `ch/queries/web-analytics.ts`, registered through
`defineQuery`, exposed on the existing `QueryEngineApiGroup`, and rendered at
/analytics with the filter-sidebar + toolbar + card chrome the Cloudflare pages
established. Every filter round-trips through the URL and clicking any breakdown
row narrows the whole page.

Two things drove most of the design, both verified against production:

`session_replays` columns fall into three coverage tiers, not one.
`BrowserName`, `OsName`, `DeviceType`, `Country` and `DurationMs` predate
migration 0011 and are populated for every session; `VisitorId`, `Referrer`,
`Utm*`, `Host`, `EntryPath`, `Language` — and in practice `PageViews`, despite
sitting in the base schema block — are populated by one org of seven. Treating
them uniformly produced two numbers that contradicted each other on one screen:
a 100% bounce rate (from `PageViews <= 1` over all sessions) and 0 sessions for
a page filter (from `EntryPath = x`), both beside a top-pages panel showing 27k
views for that very page. So bounce is confined to `VisitorId != ''` on both
sides of its ratio and renders as unknown when nothing reports page views, and
the `host`/`pagePath` filters reach `session_replays` through a navigation-event
semi-join rather than through its own sparse path columns — which is also the
better definition, since entering or exiting on a page is narrower than viewing
it. The UI reports the covered share instead of presenting a partial count as
the whole, and only on the dimensions the caveat actually applies to.

`Country` was empty in 100% of rows because `MAPLE_INGEST_TRUST_PROXY_GEO` was
never set on the ingest task, so `derive_country` always returned "". Setting it
is safe because the ALB only admits Cloudflare's proxy ranges, but geo is
forward-only — the gateway stores no client IP, so there is nothing to backfill
and the countries panel says so.

Also adds `domain`/`path`/`cutQueryString`/`uniqIf` to the ClickHouse DSL, and
registers the new query module in `sql-catalog.test.ts`'s `QUERY_MODULES` with
10 builder fixtures — that gate is a manual registry, so a new module otherwise
escapes the SQL sweep silently.
…ted visitor counts

Two things found re-auditing the page, neither caught by CI.

The breakdown panel seeded its active tab into `useState(firstPopulated)`, which
freezes the answer at mount. It happens to work today only because the parent
renders the panel from `Result.builder(...).onSuccess`, so it always mounts with
rows already in hand — but a panel that mounted before its rows arrived, or whose
rows changed under a new filter or time range, would keep pointing at a tab that
is now empty. The tab is derived during render instead, with `null` meaning
"nobody has picked one yet" so an explicit pick still wins over the fallback.

The sidebar's Visitor section passed `count: 0` for New and Returning because
`FilterSection` requires a count per option and always renders it. There is no
per-option count to give — every other count in that sidebar comes from a
server-side facet branch — and a hard-coded 0 beside "New" reads as "zero new
visitors" rather than as "not counted". It is two exclusive
`SingleCheckboxFilter`s now, which render no count at all.

Also resolves the label formatter through a module-level identity constant so it
is a stable memo dependency; the previous `?? ((name) => name)` fallback was a
fresh closure each render, invalidating the row memo every time for the majority
of dimensions that have no `formatValue`.
Web Analytics was reachable from the sidebar and the command palette and
nowhere else, which leaves it discoverable only if you already knew to look for
it. The two pages read the same session data from opposite ends — one plays a
session back, the other aggregates them — so each is the obvious next question
from the other, and that is the pairing worth surfacing in the product.

A header button in each direction, using the established Button-as-Link idiom
(`render={<Link/>}`, as on services detail and the alerts index) rather than a
new affordance. The time range travels along: arriving at a different window
than the one you were just looking at is what makes a cross-link feel like it
lost your place.

Deliberately not a tile on the Overview page. There is no navigational card grid
there today — ServiceUsageCards and MetricsGrid are stat and chart panels with no
links in them — so adding one would mean inventing a pattern for a single entry.
@Makisuo
Makisuo merged commit 0715d3f into main Aug 10, 2026
30 checks passed
@Makisuo
Makisuo deleted the chore/drop-stale-alchemy-effect-vendor branch August 10, 2026 15:36
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit 5b988b1 · View workflow run

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