Implementation seed for ADR-024. Nine user stories, ordered sequencing hints for the decomposer (brand+wrapper → manifest+wireUseCase → assertFeatureConformance → BindContext+ESLint → React provider → docs). Status: approved — ready for `pnpm work decompose --execute` to generate the epic + stories under `docs/work/epics/product-analytics-channel/`.
16 KiB
id, title, type, status, author, created, updated
| id | title | type | status | author | created | updated |
|---|---|---|---|---|---|---|
| product-analytics-channel | Product analytics as a fourth capture channel (ADR-024 implementation) | prd | approved | danijel | 2026-05-18T10:30:46Z | 2026-05-18T10:35:01.727Z |
Problem
Every consumer of this template who builds a user-facing product eventually has the same conversation: "how do I add PostHog / Segment / Mixpanel?" They end up bolting an analytics SDK on at the React component layer, bypassing the conformance system entirely. Use cases never know they're emitting analytics events. Manifests never record them. The ESLint rules that catch audits / publishes drift have no analog. Five gates of conformance protection collapse to zero the moment the SDK is dropped in.
The repo already has four capture channels (ITracer, ILogger, IMetrics, IAuditLog), and none of them is product analytics — Sentry is intentionally PII-stripped per ADR-017 §7, audit is compliance-driven, metrics are pre-aggregated. There is no path for funnel analysis, cohort tracking, conversion measurement, or any other identified-user event stream the typical product needs.
ADR-024 settled the architectural decision. This PRD is the implementation seed for the epic.
Goal
Ship @repo/core-analytics as an optional core package with IAnalytics interface, brand-based conformance integration, manifest field, ESLint rule, and React provider — mirroring the audit channel's structural shape with the three deliberate divergences ADR-024 specifies. Template stays vendor-neutral; consumers wire their backend (PostHog / Segment / etc.) through /evaluate-library per ADR-022.
In scope
- New optional core package
@repo/core-analytics, scaffolded viapnpm turbo gen core-package analytics IAnalyticsinterface with four methods:track,identify,pageView,flushNoopAnalyticsdefault implRecordingAnalyticstest double in@repo/core-testingAnalyzedbrand +withAnalyticswrapper composed intowireUseCase- Manifest schema extension:
analyticsEvents: string[]per use case assertFeatureConformanceextended to checkAnalyzedwhenanalyticsEvents.length > 0BindContext.analytics?: AnalyticsProtocol+AnalyticsProtocolinbind-protocols- ESLint rule
no-undeclared-analytics-eventat warn severity - React subpath
@repo/core-analytics/reactwith<AnalyticsProvider>+useAnalytics()hook - Documentation:
docs/guides/analytics.md, glossary already updated, CLAUDE.md +conformance-quickref.mdrule-count bumps,template-tiers.mdoptional-cores list update
Out of scope
- Picking a backend vendor. Vendor choice (PostHog / Segment / Mixpanel / etc.) is per-consumer and routes through ADR-022's
/evaluate-librarygate. Template ships only Noop + Recording impls. - Framework-specific router auto-wiring.
<AnalyticsProvider>exposespageView(path, ...)but does NOT auto-call it on Next App Router / TanStack Router events. Consumers wire their router's route-changed hook themselves. A futurepnpm turbo genmay scaffold per-framework adapters. - Migrating existing features to declare
analyticsEvents. This PRD adds the channel; no current feature (auth, blog, media, marketing-pages, navigation) gains an analytics event in this epic. EmptyanalyticsEvents: []everywhere is acceptable. - Unifying
audits+publishes+analyticsEventsinto a singleevents: [...]field. ADR-024 alternative B rejected this as out-of-proportion scope. Three orthogonal manifest fields. - CI guardrail beyond ADR-022/023. No new grep gate, no PII allowlist enforcement, no CodeQL-style scan specific to analytics. Consumer owns consent + retention.
- Server-side smoke test for
Analyzedbrand wiring. Smoke tests parallel tobind-production.smoke.test.tsare added by consumers who actually wire analytics. Template features won't.
Constraints
- ADR-024 — every decision in this PRD must match. If implementation finds a constraint that conflicts with ADR-024, surface it and amend the ADR before proceeding.
- ADR-017 §7 — observability PII policy (
sendDefaultPii: false,setUser({ id })) stays untouched. Analytics PII boundary is structurally distinct, documented per ADR-024. - ADR-022 — backend vendor choice (when a consumer makes one) goes through
/evaluate-libraryand produces a trace atdocs/library-decisions/<date>-<vendor>.md. - ADR-018 —
IAuditLogprecedent. Analytics mirrors its package layout, manifest field shape, brand pattern, and ESLint rule structure. Three deliberate divergences (nomutatesgate,flush()on interface, React provider scaffold) are spelled out in ADR-024. - Generator-first —
pnpm turbo gen core-package analyticsis the entry point for the package scaffold. Hand-rolling the directory is forbidden per CLAUDE.md / repo-wide rule. - Conformance ordering — manifest entry → contracts → tests → impl. Brand attachment composes at DI bind time only. No moving emission into the use case body unless the use case explicitly declares
analyticsEvents. - Conventional Commits — every commit follows the spec. Slice = task = PR = commit.
Success criteria
pnpm turbo gen core-package analyticsproduces a green@repo/core-analyticspackage containingIAnalytics+NoopAnalytics.pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:auditall pass at every commit boundary.- A test feature (synthetic in a unit test, not a real feature in this repo) that declares
analyticsEvents: ["X"]in its manifest but skipswireUseCasewith analytics fails the boot-time assertion AND theno-undeclared-analytics-eventESLint rule. assertFeatureConformancerejects an unwrapped binding whenanalyticsEvents.length > 0, with a message naming the missingAnalyzedbrand.<AnalyticsProvider>+useAnalytics()round-trip atrackcall throughRecordingAnalyticsin a React testing-library test.docs/guides/analytics.mddocuments the consumer wiring path for both server (BindContext.analytics) and client (<AnalyticsProvider>), plus the PII boundary deferral.- CLAUDE.md +
conformance-quickref.mdreflect 7 conformance ESLint rules.
User stories
- As a template author, I want analytics codified as a fourth capture channel so future agents and humans extend the conformance pattern rather than bolting SDKs on at the wrong layer.
- As a downstream consumer building a product on this template, I want an
IAnalyticsinterface I can implement against my chosen vendor (PostHog / Segment / etc.) so my use case bodies don't depend on a specific SDK. - As a downstream consumer, I want my server-side use cases to declare their analytics events in the manifest so I can audit "what does this feature emit" without reading every use case body.
- As a downstream consumer, I want an ESLint rule that catches calls to
analytics.track("undeclared.event", ...)so analytics-event sprawl doesn't accumulate silently. - As a downstream consumer, I want a React provider scaffold so server and client share the same
IAnalyticscontract andanalytics.track(...)reads the same way in both. - As a downstream consumer running in serverless, I want
flush()on the interface so I can drain the in-memory batch in my response-finish hook and stop losing events. - As an AI agent scaffolding a new feature, I want the manifest schema to include
analyticsEvents: []by default so I can declare events without rediscovering the channel each time. - As an AI agent modifying a use case, I want
assertFeatureConformanceto refuse to boot when I've declaredanalyticsEventsbut forgotten to wirewithAnalytics— same fast feedback that exists for tracing + capture + audit. - As a compliance reviewer auditing the codebase, I want the PII boundary divergence (observability id-only vs analytics traits-allowed) documented in code + ADR so the deliberate distinction isn't mistaken for drift.
Implementation decisions
Module layout
- New package
@repo/core-analytics— scaffolded viapnpm turbo gen core-package analytics. Mirrors@repo/core-auditlayout. Subpath export./reactfor the provider. No vendor backend bundled. - Modified package
@repo/core-testing— addsRecordingAnalyticsparallel toRecordingAuditLog. - Modified package
@repo/core-shared—conformance/brands.ts(Analyzedtype),conformance/brand-runtime.ts(isAnalyzed),conformance/assert-bindings.ts(brand check when manifest declares events),conformance/define-feature.ts(analyticsEventsfield),conformance/wire-use-case.ts(composewithAnalytics),di/bind-protocols.ts(AnalyticsProtocol),di/bind-context.ts(analytics?:field). - Modified package
@repo/core-eslint— new ruleno-undeclared-analytics-event,_manifest-ast.jsextended to parseanalyticsEvents,plugin.js+base.jsregister at warn. - Modified docs —
docs/guides/analytics.md(new),docs/guides/conformance-quickref.md(rule table + drift patterns),CLAUDE.md(rule count: 6 → 7),docs/architecture/template-tiers.md(optional-cores list). - Glossary — already updated in the same commit as ADR-024 (
IAnalytics,withAnalytics).
Interface contract (informative — authoritative copy lives in analytics.interface.ts)
export type AnalyticsAttributeValue = string | number | boolean;
export type AnalyticsUser = {
id: string;
traits?: Record<string, AnalyticsAttributeValue>;
};
export interface IAnalytics {
track(
event: string,
properties?: Record<string, AnalyticsAttributeValue>,
user?: AnalyticsUser,
): void;
identify(user: AnalyticsUser): void;
pageView(
path: string,
properties?: Record<string, AnalyticsAttributeValue>,
): void;
flush(): Promise<void>;
}
IAnalytics extends AnalyticsProtocol (in core-shared/di/bind-protocols.ts) for the structural type pattern used by IEventBus/IAuditLog/etc.
Manifest field
useCases.<name>.analyticsEvents: string[] — array of event slug literals. Default []. Same syntax as audits / publishes / consumes. Cross-checked by no-undeclared-analytics-event against analytics.track("<slug>", ...) literal call sites in the use case body.
Brand + wrapper
Analyzed<F> = F & { readonly __analyzed: true }inconformance/brands.tswithAnalytics(analytics, factory)incore-analytics(NOT incore-shared/instrumentation— analytics is optional core, can't pollute core-shared). The wrapper attaches the brand via the existingattachBrandhelper fromcore-shared/conformance/brand-runtime.- Composition order (innermost → outermost):
factory(deps)→withAnalytics→withAudit→withCapture→withSpan. wireUseCase({ ... analytics })— new optional arg. When provided AND the manifest declaresanalyticsEvents.length > 0, composeswithAnalyticsinto the wrapper chain.
assertFeatureConformance
When manifest.useCases[name].analyticsEvents.length > 0, the bound function MUST carry the Analyzed brand. Same shape as the existing Audited check, minus the mutates gate.
ESLint rule
conformance/no-undeclared-analytics-event — warn severity. Applies to *.use-case.ts. Finds analytics.track("X", ...) with string-literal first argument, cross-checks X against manifest. Implementation mirrors no-undeclared-audit and no-undeclared-event-publish.
React provider
@repo/core-analytics/react exports:
<AnalyticsProvider value={IAnalytics}>— React context provideruseAnalytics(): IAnalytics— context consumer hook
No auto-wired router events. Consumer wires their router's route-changed hook to call useAnalytics().pageView(path).
Optional-core requirements
Features that emit analytics declare requiredCores: ["analytics"] in their manifest. The existing required-cores-installed ESLint rule enforces the @repo/core-analytics package is present in pnpm-workspace.yaml.
Testing decisions
- Repository contract suite: none — analytics has no repository surface (it's a sink, not a store).
- Unit tests: every interface method on
NoopAnalytics+RecordingAnalyticshas a sibling.test.ts(per conformance ruleusecase-must-have-test-fileextended in spirit).withAnalyticsgets a brand-attachment test parallel towithAudit.test.ts. - Conformance test for
assertFeatureConformance: synthetic manifest + binder pair that omitswithAnalyticswhenanalyticsEvents > 0should throwConformanceErrorwith a message namingAnalyzed. Mirror the existingassert-bindings.test.tsshape. - ESLint rule unit tests: RuleTester-based, fixture-driven, parallel to
no-undeclared-audit.test.js. Cover: passes when call slug declared, fires when undeclared, no-op on non-use-case files, no-op when manifest has no use cases. - React provider test: React Testing Library — render with
<AnalyticsProvider value={recordingAnalytics}>, child component callsuseAnalytics().track(...), assertrecordingAnalytics.trackedcontains the event. - No integration / e2e: no real feature in this template wires analytics. Consumers add their own e2e.
- Prior art:
packages/core-audit/src/is the closest parallel.packages/core-shared/src/conformance/wire-use-case.test.tsshows the brand-test shape.packages/core-eslint/rules/no-undeclared-audit.test.jsis the ESLint-rule template.
Open questions
- Q1: Should
wireUseCase's analytics arg be required or optional when the manifest declaresanalyticsEvents? — Optional. If omitted, the boot-timeassertFeatureConformancewill throw on the missing brand; the helper itself shouldn't enforce. This mirrors howauditLogis handled. Defer enforcement to the assertion layer. - Q2: Should
NoopAnalytics.flush()resolve synchronously or with a microtask? — Microtask (Promise.resolve()). MirrorsRecordingAuditLogasync patterns and avoids consumers writingawait analytics.flush()expecting a tick that doesn't happen with sync-resolved promises. - Q3: Should the React provider hook throw if used outside
<AnalyticsProvider>, or fall back to Noop? — Throw with a clear error. Same shape asuseContexton a context with no default; fall-back-to-Noop hides wiring bugs. - Q4: Should we add a server-side smoke test parallel to the bind-production.smoke.test.ts pattern for analytics? — No. No template feature wires analytics, so the test would have nothing to assert. Consumers who adopt analytics add their own per-feature smoke tests; the pattern is documented in
docs/guides/analytics.md.
Out of scope (deferred)
- Per-framework router adapters (Next App Router, TanStack Router) — separate future PRD if a consumer needs them.
- Backend vendor evaluation — runs through
/evaluate-libraryskill per ADR-022 when a real consumer needs analytics. - Migration of existing manifests to add
analyticsEvents: []— handled by the conformance system's tolerance for missing optional fields; existing manifests stay untouched. - Unified
events: [...]manifest field — explicit alternative rejected in ADR-024 §B. Future ADR if real pain emerges. - PII allowlist enforcement at the interface level — explicit alternative rejected in ADR-024 §"PII boundary". Consumer policy.
- Storybook component for an analytics-instrumented button — useful demo but not load-bearing for the channel itself.
Further notes
- Builds on: ADR-018 (audit channel — closest structural precedent), ADR-022 (library evaluation policy — vendor choice gate), ADR-024 (the architectural decision this PRD implements).
- Pairs with: future "client-side observability harmonization" PRD if/when
ITracer/ILoggergrow client-side abstractions matching the<AnalyticsProvider>shape. - Stakeholders: template authors (most affected — adding a channel changes conformance count), downstream consumers (positively affected — gain a contract surface), AI agents operating in feature code (positively affected — manifest gates extend to a fourth signal).
- Sequencing: decomposer should chain stories so brand + wrapper land before manifest schema + wireUseCase, which lands before
assertFeatureConformanceextension, which lands before BindContext + ESLint rule, which lands before React provider, which lands before docs. The React subpath can technically land at any point after the interface ships, but docs should be last so they reference the final shape.