Files
agentic-dev/docs/work/prds/product-analytics-channel.prd.md
Danijel Martinek 1595c9198e docs: seed PRD for product analytics channel epic (ADR-024)
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/`.
2026-05-18 12:35:00 +02:00

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 via pnpm turbo gen core-package analytics
  • IAnalytics interface with four methods: track, identify, pageView, flush
  • NoopAnalytics default impl
  • RecordingAnalytics test double in @repo/core-testing
  • Analyzed brand + withAnalytics wrapper composed into wireUseCase
  • Manifest schema extension: analyticsEvents: string[] per use case
  • assertFeatureConformance extended to check Analyzed when analyticsEvents.length > 0
  • BindContext.analytics?: AnalyticsProtocol + AnalyticsProtocol in bind-protocols
  • ESLint rule no-undeclared-analytics-event at warn severity
  • React subpath @repo/core-analytics/react with <AnalyticsProvider> + useAnalytics() hook
  • Documentation: docs/guides/analytics.md, glossary already updated, CLAUDE.md + conformance-quickref.md rule-count bumps, template-tiers.md optional-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-library gate. Template ships only Noop + Recording impls.
  • Framework-specific router auto-wiring. <AnalyticsProvider> exposes pageView(path, ...) but does NOT auto-call it on Next App Router / TanStack Router events. Consumers wire their router's route-changed hook themselves. A future pnpm turbo gen may 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. Empty analyticsEvents: [] everywhere is acceptable.
  • Unifying audits + publishes + analyticsEvents into a single events: [...] 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 Analyzed brand wiring. Smoke tests parallel to bind-production.smoke.test.ts are 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-library and produces a trace at docs/library-decisions/<date>-<vendor>.md.
  • ADR-018IAuditLog precedent. Analytics mirrors its package layout, manifest field shape, brand pattern, and ESLint rule structure. Three deliberate divergences (no mutates gate, flush() on interface, React provider scaffold) are spelled out in ADR-024.
  • Generator-firstpnpm turbo gen core-package analytics is 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 analytics produces a green @repo/core-analytics package containing IAnalytics + NoopAnalytics.
  • pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit all 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 skips wireUseCase with analytics fails the boot-time assertion AND the no-undeclared-analytics-event ESLint rule.
  • assertFeatureConformance rejects an unwrapped binding when analyticsEvents.length > 0, with a message naming the missing Analyzed brand.
  • <AnalyticsProvider> + useAnalytics() round-trip a track call through RecordingAnalytics in a React testing-library test.
  • docs/guides/analytics.md documents the consumer wiring path for both server (BindContext.analytics) and client (<AnalyticsProvider>), plus the PII boundary deferral.
  • CLAUDE.md + conformance-quickref.md reflect 7 conformance ESLint rules.

User stories

  1. 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.
  2. As a downstream consumer building a product on this template, I want an IAnalytics interface I can implement against my chosen vendor (PostHog / Segment / etc.) so my use case bodies don't depend on a specific SDK.
  3. 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.
  4. 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.
  5. As a downstream consumer, I want a React provider scaffold so server and client share the same IAnalytics contract and analytics.track(...) reads the same way in both.
  6. 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.
  7. 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.
  8. As an AI agent modifying a use case, I want assertFeatureConformance to refuse to boot when I've declared analyticsEvents but forgotten to wire withAnalytics — same fast feedback that exists for tracing + capture + audit.
  9. 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 via pnpm turbo gen core-package analytics. Mirrors @repo/core-audit layout. Subpath export ./react for the provider. No vendor backend bundled.
  • Modified package @repo/core-testing — adds RecordingAnalytics parallel to RecordingAuditLog.
  • Modified package @repo/core-sharedconformance/brands.ts (Analyzed type), conformance/brand-runtime.ts (isAnalyzed), conformance/assert-bindings.ts (brand check when manifest declares events), conformance/define-feature.ts (analyticsEvents field), conformance/wire-use-case.ts (compose withAnalytics), di/bind-protocols.ts (AnalyticsProtocol), di/bind-context.ts (analytics?: field).
  • Modified package @repo/core-eslint — new rule no-undeclared-analytics-event, _manifest-ast.js extended to parse analyticsEvents, plugin.js + base.js register at warn.
  • Modified docsdocs/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 } in conformance/brands.ts
  • withAnalytics(analytics, factory) in core-analytics (NOT in core-shared/instrumentation — analytics is optional core, can't pollute core-shared). The wrapper attaches the brand via the existing attachBrand helper from core-shared/conformance/brand-runtime.
  • Composition order (innermost → outermost): factory(deps)withAnalyticswithAuditwithCapturewithSpan.
  • wireUseCase({ ... analytics }) — new optional arg. When provided AND the manifest declares analyticsEvents.length > 0, composes withAnalytics into 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 provider
  • useAnalytics(): 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 + RecordingAnalytics has a sibling .test.ts (per conformance rule usecase-must-have-test-file extended in spirit). withAnalytics gets a brand-attachment test parallel to withAudit.test.ts.
  • Conformance test for assertFeatureConformance: synthetic manifest + binder pair that omits withAnalytics when analyticsEvents > 0 should throw ConformanceError with a message naming Analyzed. Mirror the existing assert-bindings.test.ts shape.
  • 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 calls useAnalytics().track(...), assert recordingAnalytics.tracked contains 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.ts shows the brand-test shape. packages/core-eslint/rules/no-undeclared-audit.test.js is the ESLint-rule template.

Open questions

  • Q1: Should wireUseCase's analytics arg be required or optional when the manifest declares analyticsEvents? — Optional. If omitted, the boot-time assertFeatureConformance will throw on the missing brand; the helper itself shouldn't enforce. This mirrors how auditLog is handled. Defer enforcement to the assertion layer.
  • Q2: Should NoopAnalytics.flush() resolve synchronously or with a microtask? — Microtask (Promise.resolve()). Mirrors RecordingAuditLog async patterns and avoids consumers writing await 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 as useContext on 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-library skill 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 / ILogger grow 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 assertFeatureConformance extension, 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.