feat(analytics): Web Analytics page over the session data we already store - #383
Merged
Conversation
`.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.
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_replayscarried 20 unread analytics columns andsession_eventsheld ~150knavigationrows 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
ch/queries/web-analytics.ts: summary KPIs, visitor timeseries, page-view timeseries, top pages, and a 12-dimension breakdown union. Registered viadefineQuery, exposed on the existingQueryEngineApiGroup(no new group — that's where every warehouse aggregate lives), handled inquery-engine.http.ts./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 theAnalyzenav group.domain,path,cutQueryString,uniqIfadded tolib/clickhouse-builder.MAPLE_INGEST_TRUST_PROXY_GEO: "true"on the ECS task.What a reviewer should know
session_replayscolumns have three coverage tiers, not one. This drove most of the design:session_eventsType='navigation'session_replaysbaseBrowserName,OsName,DeviceType,Country,DurationMsVisitorId,Referrer,ReferrerHost,Utm*,Host,EntryPath,ExitPath,Language,PageViewsPageViewsis 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:
PageViews <= 1over 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 toVisitorId != ''on both sides of the ratio and renders as unknown when nothing reports page views.EntryPathis tier 3, so the filter matched nothing.host/pagePathnow reachsession_replaysthrough aSessionId 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.
Countrywas empty in 100% of rows becauseMAPLE_INGEST_TRUST_PROXY_GEOwas never set, soderive_countryalways returned"". Setting it is safe (the ALB only admits Cloudflare's proxy ranges, soCf-IPCountrycan'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'sQUERY_MODULESis 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 packagespackages/query-engine— 1000 tests pass/≫/pricing/≫/local/for the dogfood org; 280 sessions / 193 visitors / 12% bounce for/pricing/Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.