@seifer-webapp-factory/analytics
v0.6.0
Published
analytics — negende capability-module: een volledig anoniem, event-first analytics-platform. Consent-gated meetlaag over de frontend analytics-kit, een validerende ontvangstlaag met code-gedeclareerd meetwoordenboek, gescheiden opslag voor ruwe gebeurteni
Readme
@seifer-webapp-factory/analytics
The analytics capability module — a first-party, event-first, privacy-by-design analytics platform. Ninth capability module, and the first to prove that a module can be fully anonymous end-to-end and still own data: it validates incoming events against a code-declared measurement dictionary, sessionises them, rolls them up, enforces a three-store retention split, and serves gated dashboards — all without a session, an account or a login anywhere in the pipeline.
- Design doc / decisions:
../analytics.md - Tier model:
../README.md - Manifest spec:
@seifer-webapp-factory/capability-spec(this module'smanifest.tsis a facade over it)
What it is
| Part | Where | Ownership |
|---|---|---|
| Contract (endpoints, event catalog, error taxonomy, events, config schema) | contract/ | dependency (semver) |
| Ingest/validation, normalisation, sessionisation, rollup, retention, report services | backend/src/ | dependency |
| useMeasurement() (thin layer over the frontend analytics kit), useAnalyticsReport() | frontend/src/ | dependency |
| Neutral default-stylesheet | frontend/src/analytics.css | dependency |
| Host-adapter controllers/DTOs, migrations, seed dictionary, ReportAccess reference adapters | backend/templates/ | project-owned after materialization |
| Six dashboard pages (one tabbed screen), tab bar + trend chart + table components, measurement wiring plugin, consent bindings, nav, i18n | frontend/templates/ | project-owned |
| manifest.ts | manifest.ts | provides / requires (no modules) / config / migrations / contract / templateVersion |
| scaffolder/ | scaffolder/ | version-aware materializer (presence-check is a no-op — see below) |
The anonymity guarantee
This is the module's central design constraint, not an incidental property:
- No session. Every endpoint descriptor in the contract carries
auth: false. There is nothing to authenticate against — the ingest path (POST /analytics/events) is public and unauthenticated by design, because most measurable traffic is logged-out traffic. - No account. The module has no concept of a user, a profile or a login. It cannot offer one.
- No identity linking.
visitor_idis a pseudonymous, salted hash rotated everyvisitorIdRotationHours(default 24h). The salt is never persisted alongside the hash, it is never derived from an account id, e-mail address or any stable identifier, and no column anywhere in the six owned tables references a user, account or subject id. The optional account-linking path the source spec describes (§9.4) is deliberately not implemented — a host that needs it builds the join above the module, where the legal basis for it also has to live. - Cookieless. There is no persistent cookie identifier. A rotating salted hash is the opposite design choice from a persistent cookie id, which is what would turn every host into a consent-required tracker. Full IP addresses are never stored — the ingest layer may read the request IP transiently to derive a coarse region and the rotating hash, and must discard it within the request.
Read access is a separate concern from all of the above: reports, session timelines and raw-event reads are
still gated — by an injected ReportAccess port, fail-closed (see below), not by identity.
Install
npm install @seifer-webapp-factory/analyticsMaterialize the surface with the scaffolder (materializeAnalyticsModule), point it at its own analytics
database — never the application database (see "Deployment topologies" below) — and bind a
JobScheduler and a ReportAccess adapter. Without the latter the module installs and runs, but every
dashboard stays closed until you bind one.
At a linked module: dedupe the kits
Working against a file: link to this module (local development, before publication)? Set in your Nuxt
config:
build: { transpile: ['@seifer-webapp-factory/kits', '@seifer-webapp-factory/analytics'] },
vite: { resolve: { dedupe: ['vue', '@seifer-webapp-factory/kits'] } },Without this, the linked module resolves its kit import to the monorepo's node_modules while your own
components resolve theirs from the project — two copies of a kit mean two different injection symbols, and
composables that expect a single provided instance fail. Use dedupe, not an alias on the bare package
name — an alias bypasses the exports map and breaks subpath imports. Not needed once the module is published.
No module dependencies
analytics declares no requires.modules — it is kit-only. This is a deliberate, load-bearing choice,
not an oversight: the module must install on a product that has no accounts, no sessions and no login at
all — a marketing site, a one-pager, a public information site. Depending on authentication would make the
single most common analytics host the one host that cannot install it.
The scaffolder's checkRequiredModules / assertAssemblable presence-check therefore has nothing to check
for this module — it is a no-op here. It is still exported from scaffolder/ for parity with the siblings
(account, authorization, organizations, …) that do have module dependencies.
Requires-ports (the host must provide)
| Port | Default the module ships | Overridable? |
|---|---|---|
| EventStore | postgresEventStore (reference adapter) | yes (required in practice) |
| RollupStore | postgresRollupStore (reference adapter) | yes (required in practice) |
| ReportAccess | denyAll — fail-closed. Every read endpoint answers 403 and no dashboard renders until a host adapter is bound | yes (required in practice) |
| Clock | system clock | yes |
| RateLimiter | the rate-limit kit's default limiter, configured per site | yes |
| JobScheduler | the jobs kit's scheduler; rollup + retention sweeps register on install | yes |
| ConsentStore | the privacy kit's consent registry (pgConsentStore) | yes |
| AuditLog | the audit kit's sink | yes |
| DesignSystem | class hooks + --analytics-* token contract + neutral default stylesheet | yes |
ReportAccess deserves the emphasis. It is a two-argument port —
can(scope, request, websiteId?): boolean | Promise<boolean> over the scopes report · raw ·
behaviour · session · dictionary · retention. Its default is deny-all, and that default is not a
placeholder to be forgotten about: a dashboard that fails closed is the intended first experience. Three
reference adapters ship as surface (materialized, host-owned, freely editable): denyAll (the default),
namedTokenAccess (per-human scoped bearer tokens — the no-identity-system path, see "How an operator sees
the numbers" below), and allowList (source-IP / private-network only). A host running authentication +
authorization writes its own adapter delegating to its permission check; the module neither knows nor
cares which it got.
Config
Every knob has a safe default (divergence level 1 — see ../analytics.md
for the full rationale behind each one). Two things have no working default on purpose: sites (an empty
list would make the module an open collector) and access.adapter (defaults to 'deny' — a dashboard that
is accidentally public is worse than one that does not work).
See contract/config.ts (analyticsConfigSchema) for the authoritative shape.
Summary, grouped as in the build plan:
- Sites —
sites: [{ id, origins[], label? }], required, non-empty. Doubles as the ingest CORS allow-list and backs theunknown_site → 404check. - Access —
access.adapter('deny'|'named-tokens'|'allow-list'|'custom', default'deny'),access.tokens(required, non-empty whenadapter: 'named-tokens'),access.allowedSources(CIDR list, required whenadapter: 'allow-list'). - Sessions —
sessions.inactivityMinutes(30),sessions.maxHours(24),sessions.multiTabPolicy('merge'). - Visitors —
visitors.idRotationHours(24),visitors.enabled(true; disabling degrades unique visitors to session counts rather than failing). - Retention —
retention.rawEventDays(90),retention.behaviourDays(14),retention.rollupMonths(25),retention.rejectionDays(30),retention.sessionTimelineDays(14).behaviourDaysmay never exceedrawEventDays— enforced at config load. - Consent —
consent.defaultState('unknown'),consent.bufferUntilDecision(true). - Exclusions —
exclusions.excludedPaths([]),exclusions.sensitivePaths(defaults to/login*,/register*,/account*,/settings*,/checkout*,/payment*,/messages*,/reset-password*),exclusions.allowedQueryParams(the fiveutm_*params). - Thresholds —
thresholds.scrollDepths([10, 25, 50, 75, 90, 100]),thresholds.sectionViewedMs(1000),thresholds.sectionVisibleRatio(0.5),thresholds.engagedSessionSeconds(10),thresholds.engagedSessionInteractions(2). - Conversion —
conversion.attributionWindowDays(30),conversion.attributionModel('last_non_direct'),conversion.multiplePerSession(false). - Ingest —
ingest.maxBatchSize(50),ingest.maxEventBytes(8 KB),ingest.perSourceRatePerMinute(600),ingest.samplingRate(1.0),ingest.clockSkewToleranceMinutes(10). - Freshness —
freshness.rollupIntervalMinutes(5),freshness.rollupFullRecomputeCron('0 3 * * *'),freshness.dashboardStaleAfterMinutes(15, must exceedrollupIntervalMinutes— enforced at config load),freshness.liveWindowMinutes(30). - Other —
botPolicy('flag'|'drop', default'flag'),locale(default'nl').
Required host env / secrets
When access.adapter is 'named-tokens', every declared token's value is supplied as a host secret, not
in config — ANALYTICS_TOKEN_<NAME> (uppercased token name, e.g. a token named owner reads
ANALYTICS_TOKEN_OWNER).
access: {
adapter: 'named-tokens',
tokens: [
{ name: 'owner', scopes: ['report', 'raw', 'session', 'dictionary', 'retention'] },
{ name: 'analyst', scopes: ['report'] },
{ name: 'auditor', scopes: ['report', 'quality'] },
],
}requires ANALYTICS_TOKEN_OWNER, ANALYTICS_TOKEN_ANALYST and ANALYTICS_TOKEN_AUDITOR all present in the
deployment environment.
The deployer must forward every one of these variables. A missed variable does not degrade gracefully: it arrives as an empty string, the
configkit's fail-fast schema validation rejects it at startup, and the process crash-loops before the migration gate even runs. If a token inaccess.tokenshas no working credential, treat it as a deploy-blocking configuration error, not a runtime concern to catch later — there is no code path in this module that tolerates a missing token value silently.
The storage ports — and what a non-relational adapter must support
The source spec requires the platform to prescribe no database. That only holds if the module never emits
SQL of its own, so storage is two narrow ports, not one Database:
| Port | Operations | What a store must support |
|---|---|---|
| EventStore | append(events) · scanSession(sessionId) · scanRange(filter) · deleteOlderThan(ts) · deleteByVisitor(id) | append-only writes, an ordered read of one session's events, a range scan by time + site, bulk delete |
| RollupStore | upsert(rollups) · query(metric, period, dimensions) · deleteOlderThan(ts) | keyed upsert and a flat filtered read — no joins, no window functions |
The genuinely hard analytics logic — sessionisation, funnels, routes, engagement — is sequential logic
over an ordered event list, and it lives in the module's own code (sessionise, rollup), operating on
batches the EventStore hands back. The store is never asked for a window function. Reports then read
RollupStore only, which essentially any store can serve.
Optional pushdown: an adapter may declare capabilities: { funnelPushdown, sequenceMatch, columnarScan }.
When present the module delegates the heavy sequence work to the store (ClickHouse's windowFunnel /
sequenceMatch, Postgres window functions); when absent it computes in-module. Correctness is identical
either way — only the scale ceiling differs.
In: PostgreSQL (the shipped reference adapter), ClickHouse (the natural second adapter for this workload), TimescaleDB, DuckDB, MongoDB, and any document store with a filtered aggregation read. Out: pure key-value stores with no range scan, and anything that cannot return one session's events in timestamp order. The constraint is ordered range reads, not SQL.
Deployment topologies
Because storage is a port, the deployment shape is a host decision, not a module one. Both use the identical
schema — the module only requires that website_id is present on every row and enforced on every read.
Own database per product — the default, and the chosen shape for the webapp-factory fleet. Each product runs its own
EventStore/RollupStore(typically its own Postgres database), declares onesitesentry, and its dashboards render under that product's own/admin/analytics. Isolation by construction: no site can read another's data even through a bug, because the other site's data is not in the store. A retention mistake, a schema change or a restore stays inside one product.The cost, stated plainly: a static marketing site that today runs with no database at all gains a Postgres container. On a small shared host that is a real sizing change, not a rounding error — check host capacity before the second such install.
One shared store per context,
website_id-scoped. A single store serves several sites; each declares its ownsitesentry and its own origin allow-list. Every read path filters onwebsite_idand passes it toReportAccess, so one site's operator cannot see another site's reports even though the rows live in the same tables. Dashboards can land either embedded in each site's own app or in one standalone analytics app (e.g.stats.<domain>) reading the shared store — same pages, same contract, only the host app differs.
How an operator sees the numbers
The module has no concept of a user, so it cannot offer a login. Instead, when access.adapter is
'named-tokens', each human gets a distinct scoped credential without an identity system: the operator opens
the dashboard, is prompted once for a token, and it is held in sessionStorage and sent as
Authorization: Bearer … on every report call. Comparison is constant-time; a wrong token yields 403 and
the dashboard's empty state, never a partial one.
This buys least privilege (an analyst token cannot reach session timelines or raw events),
revocation (drop one entry from access.tokens, redeploy — the others keep working), and
attribution (the audit log records the token name, so "who read the session data" has an answer). It
does not buy real identity: a named token is shared-secret authentication, it can be handed to a
colleague, and it does not expire on its own — the audit trail proves which token was used, not which person
used it. A host that needs a full role model binds a custom adapter over a real identity system instead;
that is precisely what the port exists for.
Data freshness. Ingest is immediate; reports read rollups, so a dashboard is only as fresh as the last rollup run.
| Layer | Freshness | Why |
|---|---|---|
| Raw events | immediate | written in the ingest request |
| "Right now" panel | immediate | reads raw over liveWindowMinutes (30), bypassing rollups |
| Today's figures | ≤ rollupIntervalMinutes (5) | incremental rollup of the affected period |
| Historical figures | nightly | full recompute — the correctness pass, rollupFullRecomputeCron |
Every dashboard shows its computed_at and visibly marks itself stale past dashboardStaleAfterMinutes
(15), so a silently dead rollup job reads as stale rather than as a quiet drop in traffic.
Divergence & eject
- Configure — every knob in "Config" above.
- Extend — add an event definition to the host's
analyticsEventCatalogextension (own name, allowed properties, retention class), typed on both stacks; bind a customReportAccessadapter delegating to an existing identity system. - Materialize & edit — eject any
frontend/templatesdashboard page (e.g./admin/analytics/conversionto show a bespoke attribution table) or abackend/templatesmigration to add a dimension; the mechanism keeps upgrading via semver, template upgrades become a three-way merge. - Fork — replace the sessionisation service with a bespoke identity-stitching implementation (no path back — last resort).
Herkomst van bezoeken — de GeoLookup-poort (module 0.4.0)
Standaard bepaalt de module niets over waar een bezoeker vandaan komt. Het bron-IP wordt precies één
keer gebruikt (voor het roterende pseudoniem) en daarna losgelaten; er is geen geo-lookup, en het
bereik-dashboard toont daarom unknown bij Regio's.
Wil je dat wel, dan bind je een adapter en zet je de precisie expliciet:
// config
geo: { precision: 'municipality', minimumGroupSize: 5 }
// wiring — de meegeleverde referentie-adapter leest de herkomst uit CDN-headers,
// zodat je geen geo-database in je container hoeft te zetten:
import { createCdnHeaderGeo } from './analytics/geo/cdn-header-geo';
const geo = createCdnHeaderGeo({
municipalityByCity: { amsterdam: 'NL-GM0363', zaanstad: 'NL-GM0479' },
});
createAnalyticsBackend({ /* … */ geo: geo.forRequest(request.headers) });Vier dingen om te weten voordat je dit aanzet:
- precisie is een ladder (
none→country→region→municipality) en wordt bij de ingest afgeknipt. Een adapter mag meer teruggeven dan je configureert; wat je niet vraagt, wordt niet opgeslagen; - er wordt een CODE opgeslagen, nooit een naam (
NL-GM0363). Het contract laat structureel niets anders toe. Namen komen vialabelForop de poort en alleen bij het tonen; - kleine gemeenten worden samengevoegd tot
overigzodra ze onderminimumGroupSizebezoekers blijven (standaard 5, minimaal 2 op gemeenteniveau). Dat is geen kosmetiek: bij lage aantallen is gemeente + apparaat + bezochte pagina's + tijdstip een aanwijsbaar persoon. De optelling blijft kloppen, want er wordt samengevoegd en niet weggelaten; - nauwkeurigheid: geo-IP haalt op gemeenteniveau 50–80% op vast internet en fors minder op mobiel (carrier-NAT) en achter VPN's. Richtinggevend, geen bewijs.
Zonder gebonden adapter verandert er niets: geen herkomst, geen fout, en de sectie "Gemeenten" blijft onzichtbaar in plaats van leeg.
Upgrading a host to module 0.6.0, package only — the numbers arrive with the token
The defect. An operator opened a dashboard, typed the token, submitted — and the gate opened onto a red "token required" message. Reloading the page showed the numbers that had been available all along. Two causes, neither visible in isolation, both unavoidable in the combination every dashboard page makes:
useReportToken()bootstrapped its own ref per call fromsessionStorage. The gate anduseAnalyticsReport()both call it during the samesetup(), so both started empty — and typing the token only ever reached the gate's copy.sessionStoragewas the shared state, but only at start-up;useAnalyticsReport()never tried again after its first, token-lessrefresh()bounced offforbidden. That first attempt is the normal case:onMountedfetches before anyone has typed anything.
What changed. One shared ref per storage key per tab, and the report composable refetches by itself the moment a token arrives. Clearing a token (log out, or a 403 the gate reacts to) does not trigger a fetch — the empty state the gate already shows is the right answer there.
What a host has to know:
- package bump only. No surface change, no new token, no migration, no API change.
templateVersionstays where it is for this half of 0.6.0 (theSegmentFilterhalf below does need materialising); - the token is now shared between every
useReportToken()call with the samestorageKeyin a tab. Host code that called it expecting an isolated copy now sees one token. Two dashboards that must stay separate still can — give them distinctstorageKeys, which remain fully isolated from each other; - server-side nothing is shared. The shared refs live at module level and would outlive a request, so
during server render every call gets a fresh, unshared ref — one visitor's token can never surface in the
next visitor's render. Guarded by a test in
tests/frontend/ssr-smoke.test.ts, the only suite that runs without a DOM.
Upgrading a host to templateVersion 0.8.0 (module 0.6.0) — the collapse button actually collapses
The defect. In 0.6.0 the filter became a disclosure on narrow screens. The component set only the class
analytics-segment-filter--collapsed; hiding the panel and removing the button above the breakpoint lived in
frontend/src/analytics.css. That stylesheet is part (c) of the style contract, and part (c) is
optional: DESIGN-SYSTEM-PORT.md explicitly lets a host style the class hooks
(part a) with its own design system instead. Both integrating hosts did exactly that — and both got a button
that is visible, announces aria-expanded, and does nothing when clicked. A dead control that also lies to a
screen reader.
The general lesson, and the reason this is a module defect rather than an integration mistake: the behaviour of a control must never live in the optional half of the style contract. Appearance may.
What changed. components/SegmentFilter.vue now owns the state: matchMedia decides wide or narrow,
v-if renders the button only below the breakpoint, and v-show hides the panel with an inline
display: none that no host stylesheet can accidentally override. The disclosure works with zero CSS.
What a host has to know:
- the breakpoint is now a token:
--analytics-filter-breakpoint, default48rem, documented infrontend/TOKENS.md. Bind it on a scope that covers the dashboards (:rootis safest). Moving the boundary is still a CSS change and still not an eject — it just travels the same road as every other--analytics-*token. An unusable value is ignored and the default applies: browsers do not normalise an unparseable media query (Chromium hands(min-width: oeps)straight back, matching nothing), so one typo would otherwise collapse every dashboard on every monitor; - the server render is now open, where 0.6.0–0.7.0 rendered it collapsed. There is no viewport to measure on the server, and the initial state has to be the one that breaks nothing if the JavaScript never arrives. A phone gets a wide first paint and collapses right after hydration;
- bump and materialise together. A host that bumps the package but keeps the 0.7.0 surface gets a filter that no longer collapses at all — the old component expects the stylesheet to do it, and the stylesheet no longer does. The reverse (new surface, old package) is harmless: the removed CSS rules were only ever redundant with the component;
- a host with its own stylesheet has nothing to do.
--collapsedsurvives as a class hook for styling the closed state, but it carries no behaviour any more.
Upgrading a host to templateVersion 0.7.0 (module 0.5.0) — pages get names
Two reports — "best bekeken pagina's" in reach and the page-to-page flow in routes — were unreadable for
every host using the reference sink, for the same reason: page_id. It is the only page dimension the
reporting side knows, and the sink filled it with a random id per path, with no hook to put anything else in.
Both reports therefore showed a list of UUIDs with one view each: technically correct, worthless to a reader.
What changed. createAnalyticsClient gained a pageId?: () => string option and now exports
normalisePageId(). The default did not change — it is still a random id. Making it path-like silently
would mix old random rows and new path rows in the same rollup dimension, and that is the host's call, not a
package bump's. The plugin template does set it, so the improvement arrives with the surface upgrade.
What the template now does. plugins/analytics.client.ts passes a pageId thunk that prefers the route
pattern (/product/:id()) over the path, falling back to window.location.pathname. The pattern matters
once you have dynamic routes: with the raw path, /product/123 and /product/456 are two pages, and a
thousand products give you a thousand rows of one view each. With only static routes the two coincide and you
will not notice the difference. The route is read from router.currentRoute inside the thunk rather than
tracked from a router hook, so it does not depend on the order in which hooks were registered.
Your existing rows. Rows written before the upgrade keep their random ids and will sit alongside the new path-like ones in the same dimension. Two honest options:
- Leave it. Raw events age out after
retention.rawEventDays(default 90), but rollups do not age out with them (erasureResponseSchema.rollups_unaffected: true), so the UUID rows stay in the reach report until you recompute. - Start clean. Delete the raw events you no longer want and run
rollup.run({ mode: 'full' }). That is the only route that actually clears the dimension.
One more behaviour change, and it is not cosmetic. The sink now sends page_url (the path only — no
origin, no query string). Without it, pathOf(event.page_url) was always undefined server-side, which
means exclusions.excludedPaths and exclusions.sensitivePaths never fired. Those are a privacy
guarantee (spec §6.3), not decoration — they simply did not exist while the client withheld the path. If you
have either list configured, expect events that arrive today to start being rejected with path_excluded.
That is intended, but it explains a step down in the dashboard.
The companion fix lives in kits 0.1.3: createRouteViewTracker attached the route name as props.name,
and name is on the measurement contract's forbidden list. The ingest did not drop the property — it
rejected the whole event with forbidden_property (422). A host on an older kit loses every automatic page
view, with a transport, consent gate and rollup that all look healthy. The route name is now opt-in as
route_name via includeRouteName.
Upgrading a host to templateVersion 0.6.0 (module 0.4.0) — the filter is rebuilt
components/SegmentFilter.vue changed in six ways. No migration and no endpoint path changed, and the
reporting query is byte-for-byte what it was; the config and the dictionary response each gained one
optional field (campaigns), and one mechanism bug was fixed along the way.
1. It collapses on narrow screens. A disclosure below 48rem: six fields in two boxed groups took most
of a phone screen before any number came into view. Collapsed, one button row remains, carrying the applied
date range and how many segment filters are on. Above the breakpoint the button is display: none and the
panel is always open — identical to what a desktop host sees today.
2. The period selector (day/week/month) is gone. A filter picks which data counts; the period does not
— it sets how finely the same data is summed. The date range is a filter and stays. The period value
still travels in SegmentFilterValue and through the URL (?period=), so deep links and the tab bar are
unaffected — there is simply no on-screen control for it any more. A host that wants one places it where
granularity belongs (next to the trend chart), or keeps its own copy of this component.
3. Source and device are select boxes. Both are derived to a closed set at ingest, so a free-text field
turned any other spelling ("organic", "mobiel") into a silently empty report. The options come from
ANALYTICS_SOURCE_GROUPS / ANALYTICS_DEVICE_CATEGORIES, new exports of the contract and now the single
source the backend's deriveChannelGroup/deriveDeviceCategory classify with. campaign_id stays free text
— that is a host-owned identifier whose value set the module cannot know. The query schema was deliberately
not tightened to an enum: an unknown value must yield an empty report, not a validation_error.
4. There is no Apply button — every field change filters. A button that only repeats what the form
already knows is a step to be learned and then ignored, and it allowed a state where the fields said one
thing and the numbers below them another. It listens to change, not input, so a <select> and a date
field report once a value exists and a text field on blur/Enter — at input every keystroke in the campaign
field would become a report call. The one change that does not leave is a half-filled or reversed date
range (unavoidable while moving a range); those fields carry aria-invalid until it makes sense again. The
panel no longer auto-collapses either: with live filtering there is no "done" moment to react to.
This also required a fix in the mechanism: useAnalyticsReport().refresh() used to drop a call while one
was in flight. That was defensible behind an Apply button; with live filtering it is the normal case, and it
left the operator looking at numbers that did not match the fields. The latest request now always wins, and
superseded responses are ignored rather than written over newer ones.
5. Campaigns are picked by name. A campaign_id is an opaque host identifier nobody knows by heart, and
a typo produced an empty report indistinguishable from "this campaign had no visitors". The host now
declares campaigns: [{ id, label }] in its analytics config; those names travel with GET
/analytics/dictionary (a new field on the dictionary response) exactly as funnels already do, and the
filter shows a text field with a native suggestion list (list + <datalist>). The name is displayed, the
id is sent. Declare nothing and it stays what it was: a free-text field on the id — and a non-declared id
remains typeable either way, which is why this is not a <select>. Duplicate ids or duplicate names are
rejected by config validation, because a chosen name has to resolve to exactly one id.
6. Dates are localised. New provideLocale() / useLocale() in runtime.ts (default nl-NL, wired by
plugins/analytics-dashboard.client.ts) and formatIsoDate(), used for the range on the collapsed button.
The input fields stay <input type="date"> — their value is and stays ISO — and carry lang, but how a
browser renders such a field is implementation-defined: Firefox follows lang, Chrome and Safari follow
the browser/system setting. There is no way for a page to force dd-mm-jjjj on a native date input.
What a diverged host has to carry over:
- class hooks: new
__toggle,__toggle-label,__summary,__paneland the modifieranalytics-segment-filter--collapsed;__group--periodis renamed to__group--dates, and__group--segmentstarts on its own row with extra space above it. Seefrontend/CLASS-HOOKS.md; - the breakpoint lives in exactly one place — the media query in
frontend/src/analytics.css. Superseded by templateVersion 0.8.0: that arrangement is exactly what left a dead button in every host that styles the hooks itself. The boundary is now the token--analytics-filter-breakpointand the behaviour is in the component; see the 0.8.0 section above; - i18n: new
analytics.filter.show,analytics.filter.hide,analytics.filter.activeSegments(interpolates{count}), one label per source group and device category (analytics.filter.sourceGroup.*,analytics.filter.deviceCategory.*, including.any).analytics.filter.legendnow reads "Datumbereik" / "Date range" andanalytics.filter.campaignIdis "Campagne" / "Campaign" (it is no longer an id the operator types). The fouranalytics.filter.period*keys andanalytics.filter.applyare removed. Missing keys render as the key name — visible, not silent, but worth carrying over into a diverged dictionary; - Apply and Reset now collapse the panel and return focus to the button, so keyboard focus does not fall
to
<body>with the panel. Above the breakpoint thatfocus()is a no-op.
Upgrading a host to templateVersion 0.3.0 (module 0.2.0) — requires chart.js
This one has a manual install step; skipping it fails the host's build, not a test:
npm install chart.js # new peer dependency, ^4.4.0components/TrendChart.vuedraws with Chart.js on a<canvas>instead of hand-written SVG. This is a deliberate flip of the module's zero-runtime-dependency stance — the reasoning, and what a canvas costs, are recorded in../analytics.mdunder "Lock flip";- the drawing's class hooks no longer exist (
__line,__dot,__tick,__grid,__hit,__crosshair,__value-label). A host stylesheet targeting them now styles nothing.__plotis now the height-bearing container with__canvasinside it; - chart colours are read in JS, not applied by CSS — a canvas inherits nothing. Bind
--analytics-chart-line,--analytics-surface,--analytics-text,--analytics-text-mutedand--analytics-borderon a scope that actually covers the dashboards; - the table equivalent, the text readout and the ←/→ keyboard handling are unchanged and are now the only accessible representation of the data — do not remove them when diverging this component.
Upgrading a host to templateVersion 0.2.0 (module 0.1.3)
The surface was restructured, so this upgrade is a real three-way merge on the dashboard pages, not a version bump:
- the six dashboards became one screen with a tab bar (
components/AnalyticsTabs.vue). They are still six routes with the same paths — onlynav/tabinroutes/analytics-routes.tschanged, so the host's admin navigation shows one entry instead of six; - the reporting period now travels between tabs through the URL (
?period=&from=&to=); segment filters stay local to the dashboard where they were set (SHARED_FILTER_KEYSinruntime.tsis the one place that draws the line); - new
components/TrendChart.vue: visitors per day as a line chart on the reach dashboard, carrying the trend table as its table equivalent; MetricTableno longer draws in-cell bars — breakdowns arrive sorted high → low instead, and--analytics-bar-track/--analytics-bar-fillare gone from the token bridge (--analytics-chart-linereplaces them). An already-materialized bridge keeps the old names harmlessly; drop them when convenient;- bugfix, applies to diverged pages too: the trend tables read dimension
date, while the report service emitsperiod_start. Dimension keys match exactly, so this produced no error — just a permanently empty trend table on reach, engagement, conversion and quality. A host that has already ejected those pages must make the same correction by hand; the merge will not do it for a rewritten file.
Phase map
| Version | Phases | Scope | |---|---|---| | v0.1.0 | 0–5 | contract-first · backend slice (ingest/validation/sessionisation/rollup/retention) · frontend slice (six dashboards) · manifest + scaffolder · vertical e2e · divergence proof | | v0.2.0 | 6 | heatmaps — click/scroll/element/visibility capture, page-version separation, sensitive-path refusal | | v0.3.0 | 7 | session analysis — chronological timelines, frustration signals, strict access control |
Phases 6 and 7 are in the build plan, not deferred out of it — they ship as later minor versions because
their own acceptance criteria are unverifiable until the base (phases 0–5) is demonstrably reliable. See
../analytics.md for the full phase table and gates.
Tests
npm test # contract, manifest, backend unit, frontend, scaffolder — no Docker
npm run test:e2e # backend testcontainer e2e — REQUIRES Docker
npx playwright test # vertical e2e, standalone with no other capability module and no auth — REQUIRES a running sample app + browser