## 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:

1. **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.

2. **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.setUser` to accept traits or by adding analytics methods to `ILogger` — 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

```ts
// @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;
};

export interface IAnalytics {
  /** Emit a named event. The conformance gate cross-checks `event` against the manifest's analyticsEvents. */
  track(
    event: string,
    attributes?: Record<string, AnalyticsAttributeValue>,
  ): void;

  /**
   * Associate the current analytics session with a user. The optional
   * `attributes` carry user traits (plan, signup date, etc.) — see
   * ADR-024 § "PII boundary". Not gated by the manifest — it's identity
   * establishment, not an event.
   */
  identify(
    user: AnalyticsUser,
    attributes?: Record<string, AnalyticsAttributeValue>,
  ): void;

  /** Client-side route change. Server impls no-op by convention. */
  pageView(
    path: string,
    attributes?: 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`). Events are attributed to the user established by the most recent `identify()` call — the standard SDK model — so `track()` carries only the event name and its attributes, and `AnalyticsUser` stays minimal (`id` only); traits ride the `attributes` argument of `identify()`. `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:

```ts
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, attributes?)` calls.

### Brand + wrapper

Parallel to `withAudit` + `Audited`:

- **Wrapper**: `withAnalytics(analytics, factory(deps))` attaches the `Analyzed` brand to the wrapped function at DI bind time.
- **Brand**: `Analyzed` — non-enumerable property `__analyzed: true`, identical implementation to `Audited`.
- **Conformance check**: `assertFeatureConformance` requires the `Analyzed` brand when `manifest.useCases[name].analyticsEvents.length > 0`. No `mutates` gate (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.ts` files
- Finds `analytics.track("X", ...)` calls with a string-literal first argument
- Cross-checks `X` against the sibling manifest's `useCases[<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

1. **No `mutates` gate on the brand check.** The audit channel only requires `Audited` when `mutates: true && audits.length > 0` because 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 on `analyticsEvents.length > 0` alone.

2. **`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 where `flush()` belongs on the interface — wired into the app's graceful-shutdown hook by the consumer.

3. **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/react` subpath 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**. The `attributes` argument of `identify()` accepts arbitrary `Record<string, AnalyticsAttributeValue>` user traits. 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 `identify()` 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 `IAnalytics` against 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 same `analytics.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-event` joins the set. CLAUDE.md and conformance-quickref.md need updates.
- **`assertFeatureConformance` symbol map grows.** Each feature's `bind-production.ts` adds the `Analyzed` brand check when the manifest declares analytics events.
- **Brand composition is now four-deep.** `withSpan(... withCapture(... withAudit(... withAnalytics(... factory(deps)))))`. Documented in `docs/glossary.md` but 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 in `analytics.md`. Same shape as `bus.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 `analyticsEvents` is 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)
