ADR-024 codifies IAnalytics as an optional-core channel parallel to IAuditLog. Mirrors the audit pattern (inline emission, manifest field, brand at bind time, ESLint cross-check) with three deliberate divergences: no `mutates` gate on the brand check, flush() on the interface for batched server-side SDKs, and a React provider scaffold in core-analytics/react so server + client share the same contract. PII boundary is explicitly distinct from ADR-017's observability policy: traits-allowed at the interface, consumer owns consent + retention. Glossary updated with IAnalytics and withAnalytics entries.
18 KiB
ADR-024 — Product analytics as a fourth capture channel
Status: Accepted
Date: 2026-05-18
Related: ADR-006 (vertical feature packages), ADR-014 (Sentry observability), ADR-015 (events + jobs), ADR-017 (OpenTelemetry + PII boundary), ADR-018 (audit + compliance), ADR-022 (library evaluation policy)
Companion PRD: docs/work/prds/product-analytics-channel.prd.md (decomposition seed)
Companion guide: docs/guides/analytics.md (human reading-room — emitted by epic story 09)
Context
The repo today has five capture channels that signal "something happened in a use case":
| Channel | Interface | Purpose | Backend |
|---|---|---|---|
| Tracing | ITracer (core-shared) |
distributed request tracing | OTel → Sentry / Datadog |
| Error capture | ILogger.captureException (core-shared) |
error reporting + crash messages | Sentry |
| Breadcrumbs | ILogger.addBreadcrumb (core-shared) |
trail attached to next exception | Sentry |
| Metrics | IMetrics (core-shared) |
aggregate numeric signals | OTel meter |
| Audit | IAuditLog.record (core-audit) |
DPA-compliant who-did-what trail | Payload audit_events collection |
None of these is product analytics. The Sentry surface is intentionally PII-stripped (sendDefaultPii: false, setUser({ id }) only — ADR-017 §7). The audit surface is compliance-driven (state changes with actor/subject/IP). The metrics surface is pre-aggregated. There is no path for funnel analysis, cohort tracking, conversion measurement, or any other identified-user event stream that a typical product needs.
The gap is concrete: every consumer of this template who builds something user-facing eventually has the same conversation — "how do I add PostHog / Segment / Mixpanel?" — and ends up bolting it on at the React component layer, bypassing the conformance system entirely. The use case never knows it's emitting an analytics event. The manifest never records it. The ESLint rule that catches audits / publishes drift has no analog for analytics. Five gates of conformance protection collapse to zero the moment the consumer adds an SDK directly.
Two pressures motivate codifying analytics as a first-class channel:
-
Symmetry with audit. Audit and analytics are structurally near-identical from the manifest's perspective: both are call-site driven (use case emits specific named events at specific moments), both have a manifest field listing event slugs, both have a dependency injected into the use case factory, both have an ESLint rule cross-checking call literals against the manifest. The repo already accepted this shape for audit; treating analytics differently is special-pleading.
-
PII boundary distinct from observability. ADR-017 §7 locks the observability surface to id-only user context. That policy is deliberate and load-bearing for Sentry's PII posture. Analytics has a fundamentally different job (funnel and retention analysis), needs identified users with traits (email, plan, signup date), and serves a different stakeholder (product, not engineering). Conflating the two surfaces — either by stretching
ILogger.setUserto accept traits or by adding analytics methods toILogger— would compromise both. They need separate interfaces with separate policies.
A third pressure is downstream: consumers building real products will pick a vendor. ADR-022 (library evaluation policy) requires that choice to go through a trace with EU-residency, license, and Socket.dev filters. The template can either offer a contract surface those vendor choices plug into, or force every consumer to invent their own contract. The audit precedent shows that providing the contract is the right move — IAuditLog doesn't mandate Payload, but every consumer wires Payload by default and the contract makes that wiring uniform.
Decision
Introduce IAnalytics as a fourth capture channel, living in a new optional core package @repo/core-analytics, scaffolded via pnpm turbo gen core-package analytics. Mirror the audit channel's structural shape with three deliberate divergences.
Package layout
@repo/core-analytics is an optional core package, not part of @repo/core-shared. Same tier as @repo/core-audit. Consumers add it when they want analytics; the template ships it as a scaffoldable but uninstalled option. The template-tiers.md table grows by one optional core.
Why optional rather than must-have: analytics is a product-policy decision (do you track? to where? with what consent?), not infrastructure. The audit precedent established that product-policy capture channels live in optional cores. Bundling analytics into core-shared would force every feature in every consumer to carry an analytics dep regardless of need.
Interface
// @repo/core-analytics/src/analytics.interface.ts
export type AnalyticsAttributeValue = string | number | boolean;
export type AnalyticsUser = {
/** Stable identifier for this user. Joins events across sessions + devices. */
id: string;
/**
* Traits associated with the user. Consumer-owned PII policy applies here —
* see ADR-024 § "PII boundary" + the consumer's privacy/cookie consent layer.
*/
traits?: Record<string, AnalyticsAttributeValue>;
};
export interface IAnalytics {
/** Emit a named event. The conformance gate cross-checks `event` against the manifest's analyticsEvents. */
track(
event: string,
properties?: Record<string, AnalyticsAttributeValue>,
user?: AnalyticsUser,
): void;
/** Associate traits with a user. Not gated by the manifest — it's identity establishment, not an event. */
identify(user: AnalyticsUser): void;
/** Client-side route change. Server impls no-op by convention. */
pageView(
path: string,
properties?: Record<string, AnalyticsAttributeValue>,
): void;
/** Drain the in-memory batch. Wire into SIGTERM / beforeExit / serverless-response-finish handlers. */
flush(): Promise<void>;
}
Four methods. Mirrors Segment Spec's core minus the rarely-needed (group, alias, screen). flush() is on the interface because every meaningful server-side SDK batches by default and event loss on shutdown is the most common analytics bug in serverless deployments.
Manifest field
Each use case in feature.manifest.ts grows a fourth event-channel field:
useCases: {
signUp: {
mutates: true,
audits: ["user.created"],
publishes: ["auth.user-signed-up"],
consumes: [],
analyticsEvents: ["user.signed-up"], // NEW
},
}
analyticsEvents is an array of event slug literals. Same syntax as audits / publishes / consumes. The use case body emits via analytics.track(slug, properties, user?) calls.
Brand + wrapper
Parallel to withAudit + Audited:
- Wrapper:
withAnalytics(analytics, factory(deps))attaches theAnalyzedbrand to the wrapped function at DI bind time. - Brand:
Analyzed— non-enumerable property__analyzed: true, identical implementation toAudited. - Conformance check:
assertFeatureConformancerequires theAnalyzedbrand whenmanifest.useCases[name].analyticsEvents.length > 0. Nomutatesgate (divergence from audit — see below).
wireUseCase({ ... }) extends to accept an optional analytics arg; when present plus manifest has analyticsEvents, it composes withAnalytics into the wrapper chain. Composition order (innermost → outermost): factory(deps) → withAnalytics → withAudit → withCapture → withSpan.
ESLint rule
New conformance rule no-undeclared-analytics-event at warn severity:
- Applies to
*.use-case.tsfiles - Finds
analytics.track("X", ...)calls with a string-literal first argument - Cross-checks
Xagainst the sibling manifest'suseCases[<name>].analyticsEvents - Reports on undeclared event slugs
Mirror of no-undeclared-audit and no-undeclared-event-publish. Severity matches them (warn) because product event sprawl is common during exploration and the warn level catches drift without blocking iteration.
React provider (client side)
@repo/core-analytics/react exports <AnalyticsProvider value={...}> + useAnalytics(). Both sides see IAnalytics. The consumer's app boundary constructs the impl (vendor-specific) and passes it into the provider; the rest of the React tree calls useAnalytics().track(...) / .pageView(...) against the same contract the server uses.
The provider does NOT auto-wire framework-specific route events (Next App Router events, TanStack Router events, etc.). Those vary per consumer; the provider exposes pageView() and the consumer wires their router's "route-changed" hook to call it. A future pnpm turbo gen could scaffold per-framework router adapters; out of scope for the initial epic.
Three deliberate divergences from audit
-
No
mutatesgate on the brand check. The audit channel only requiresAuditedwhenmutates: true && audits.length > 0because compliance audits exist only for state changes (reads are tracked via a different DPA mechanism). Analytics events are equally legitimate for reads (article viewed, search performed, page visited) and writes (signup, purchase). The brand check is therefore conditioned onanalyticsEvents.length > 0alone. -
flush()on the interface. Audit writes are synchronous to a DB collection; metrics are pushed on a meter cadence. Analytics is the first capture channel where async batching is the default vendor behavior, so it's the first one whereflush()belongs on the interface — wired into the app's graceful-shutdown hook by the consumer. -
React provider scaffold. Audit is server-only by nature. Analytics has a meaningful client side (button clicks, pageviews) that benefits from sharing the same typed contract. The
@repo/core-analytics/reactsubpath provides the provider so consumers don't reinvent it. Server-side conformance scope is unchanged — the manifest-gated path is use-case-level only.
PII boundary
This is the only place the analytics channel structurally diverges from ILogger policy.
The observability surface (ILogger, ITracer) is bound to ADR-017 §7: id-only user context, sendDefaultPii: false everywhere, CI grep gate, server-side PII scrubbing at the OTel processor layer. That policy is deliberate and remains untouched.
The analytics surface is structurally permissive. AnalyticsUser.traits accepts arbitrary Record<string, AnalyticsAttributeValue>. The template makes no claim about what's allowed in it. Consumer is responsible for:
- Cookie consent and legal basis (e.g. GDPR Art. 6)
- Retention policy in the analytics backend
- Trait allowlist enforcement in their application code (if they want stricter than "permissive")
- DSAR / right-to-erasure plumbing
This is documented in the interface's doc-comment on AnalyticsUser.traits and in docs/guides/analytics.md. No CI guardrail enforces it — the cross-cutting CI gates (ADR-022 library trace covering the backend, ADR-023 supply-chain scan) cover the actionable surface.
The reason this divergence is explicit rather than emergent: analytics PII is product/legal scope, not template scope. Conflating it with observability PII would either cripple analytics (id-only is unworkable for funnel analysis) or weaken observability (allowing traits in ILogger.setUser would erode ADR-017's grep gate).
Channel orthogonality
The fourth channel does NOT merge with existing channels. analyticsEvents, audits, publishes remain three independent manifest fields, each with its own dep, its own call site, its own ESLint cross-check. A use case may emit to one, two, three, or all four event channels (counting consumes); each declaration is local to its purpose.
Why three independent fields rather than a unified events: [{ slug, channels: [...] }] shape: unification would refactor defineFeature, every conformance rule, every binder, every consumer in the codebase — a much larger scope than analytics itself, with payoff only when the same slug overlaps across channels (common but not universal). A future ADR can revisit when a real consumer feels the pain.
Alternatives considered
A. Stretch ILogger to do analytics
Add track() / identify() to ILogger. Sentry has captureMessage already; arguments could carry user traits.
Rejected. ADR-017 §7's PII policy is load-bearing — relaxing setUser({ id }) to allow traits would either weaken Sentry's PII posture or require a parallel setUserForAnalytics method, which is the same divergence wearing a smaller hat. Two surfaces with two policies is cleaner than one surface with conditional policies.
B. Single unified events: [...] manifest field
Replace audits + publishes + consumes + analyticsEvents with a single events: [{ slug, channels: ["audit", "bus", "analytics"] }] declaration. Use case fires once, channels fan out.
Rejected. Touches every existing feature manifest, every conformance rule, every binder, the ESLint rule set, and defineFeature itself. Payoff is real (single source of truth for slug overlap) but scope is enormous for a refactor that's adjacent to the analytics work, not central to it. Defer to a future ADR if real consumers feel the pain.
C. Bus-as-bridge
Every analytics event is published to IEventBus as a normal event; an AnalyticsBusHandler listens to all events and forwards them to IAnalytics. Use case never knows about analytics.
Rejected. Couples analytics latency + retries to bus semantics. Forces analytics-only events to be wrapped in bus publishes that have no other consumer (wasted publishes). And the manifest gate becomes harder to express (the analytics handler subscribes to slugs declared in publishes, but only some bus events are also analytics events — the cross-check becomes two-step instead of one-step).
D. Skip the template channel, document /evaluate-library route
Don't ship IAnalytics. Consumers who want analytics walk through /evaluate-library, pick PostHog / Segment / etc., wire it directly at the React + Node boundary, and never benefit from the conformance gates.
Rejected. This is the status quo, and it produces the exact problem the ADR opens with — every product ends up bolting analytics on at the wrong layer, bypassing the gate set, and reinventing the contract per consumer. The template's job is to make the right shape the easy shape. IAuditLog proves the model works.
E. Bake in a default backend (e.g. PostHog)
Ship @repo/core-analytics with a PostHog impl included. Consumers who don't override get PostHog by default.
Rejected. Vendor lock without consumer signoff. The whole point of vendor-neutral interfaces (ADR-017's OTel + neutral ITracer) is that backend choices are downstream decisions, gated by ADR-022's library evaluation. Bundling PostHog (or any vendor) bypasses that gate.
Consequences
Positive
- Fourth capture channel codified as first-class. Use cases now declare their analytics events in the manifest; the conformance system applies the same five-latency drift detection that already protects audit + bus + tracing.
- PII boundary made explicit. ADR-017 §7's id-only observability policy stops being implicitly the analytics policy too. Two policies, two surfaces, no ambiguity.
- Vendor-neutral contract for downstream products. Consumers picking PostHog / Segment / Mixpanel implement
IAnalyticsagainst the existing contract — no contract reinvention, no wrong-layer bolting. The library evaluation gate (ADR-022) applies to the vendor choice. - Symmetric server + client surface. Both sides see
IAnalytics. Frontend team writes the sameanalytics.track(...)call site as the use case, gated by the same type contract. - Template-tier surface stays clean. Optional core package; consumers who don't want analytics don't install it.
Negative
- Manifest schema grows by one field. Every existing feature's manifest gains an optional
analyticsEvents: []field. The migration is cosmetic (empty arrays everywhere) but it's still a schema touch. - Six conformance ESLint rules become seven.
no-undeclared-analytics-eventjoins the set. CLAUDE.md and conformance-quickref.md need updates. assertFeatureConformancesymbol map grows. Each feature'sbind-production.tsadds theAnalyzedbrand check when the manifest declares analytics events.- Brand composition is now four-deep.
withSpan(... withCapture(... withAudit(... withAnalytics(... factory(deps))))). Documented indocs/glossary.mdbut still a real complexity step. - One more decision the consumer must make. Choosing a vendor is non-trivial; the template's only mitigation is to provide the contract + point at ADR-022 for the gate.
Neutral
flush()on the interface. Means the consumer's graceful-shutdown wiring must call it. Documented inanalytics.md. Same shape asbus.flush()would have if we ever added one.- React provider scaffold not auto-wiring route events. Consumers wire their framework's router events to
pageView()themselves. Acceptable; the same pattern exists for Sentry route tracking. - No CI guardrail beyond what already exists. ADR-022 + ADR-023 cover the meaningful cross-cutting surface; analytics has no single boolean to grep for.
Related
- ADR-006 — vertical feature packages (boundary tags this fits within)
- ADR-014 — Sentry observability (the channel this is structurally distinct from)
- ADR-015 — events + jobs (the channel
analyticsEventsis orthogonal to) - ADR-017 — OpenTelemetry + PII boundary (the policy this explicitly diverges from)
- ADR-018 — audit + compliance (the channel this most closely mirrors)
- ADR-022 — library evaluation policy (the gate that backend choice goes through)