---
id: product-analytics-channel
title: Product analytics as a fourth capture channel (ADR-024 implementation)
type: prd
status: approved
author: danijel
created: 2026-05-18T10:30:46Z
updated: 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-018** — `IAuditLog` 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-first** — `pnpm 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-shared` — `conformance/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 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`)

```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)` → `withAnalytics` → `withAudit` → `withCapture` → `withSpan`.
- `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.
