diff --git a/docs/guides/analytics.md b/docs/guides/analytics.md new file mode 100644 index 0000000..a34711f --- /dev/null +++ b/docs/guides/analytics.md @@ -0,0 +1,360 @@ +# Product analytics + +> **Prerequisite:** This guide assumes `@repo/core-analytics` is installed. If +> your project started from the slim template, run +> `pnpm turbo gen core-package analytics` first, then follow the wiring steps +> below. + +`@repo/core-analytics` is the fourth capture channel after tracing, error +capture, and audit. It provides a typed, vendor-neutral `IAnalytics` interface +that feature use cases call at the same call sites where they call +`auditLog.record()` or `bus.publish()`. The conformance system applies the same +five-latency drift detection (tsc → ESLint → boot assertion → conformance CI → +fallow) that already protects every other channel. + +For design rationale and alternatives considered see `docs/decisions/adr-024-product-analytics-channel.md`. + +--- + +## Interface + +```ts +// @repo/core-analytics + +export type AnalyticsAttributeValue = string | number | boolean; + +export type AnalyticsUser = { id: string }; + +export interface IAnalytics { + /** Emit a named event. Conforms to manifest's analyticsEvents. */ + track( + event: string, + attributes?: Record, + ): void; + + /** Associate attributes with a user. Not gated by the manifest. */ + identify( + user: AnalyticsUser, + attributes?: Record, + ): void; + + /** Client-side route change. Server impls no-op by convention. */ + pageView( + path: string, + attributes?: Record, + ): void; + + /** Drain the in-memory batch. Wire into graceful-shutdown and serverless-response-finish hooks. */ + flush(): Promise; +} +``` + +The template ships two implementations: + +| Implementation | Package | Use | +| -------------------- | ---------------------- | -------------------------------- | +| `NoopAnalytics` | `@repo/core-analytics` | Dev-seed and test fallback | +| `RecordingAnalytics` | `@repo/core-testing` | Unit and integration test double | + +Your production backend (PostHog, Segment, Mixpanel, …) is a third +implementation you add after the library evaluation step (§ Vendor evaluation +below). + +--- + +## Server-side wiring + +### 1. Declare events in the manifest + +Every analytics event a use case emits must be declared in +`src/feature.manifest.ts`: + +```ts +// packages/auth/src/feature.manifest.ts +export const authManifest = defineFeature({ + name: "auth", + requiredCores: ["analytics"], + useCases: { + signUp: { + mutates: true, + audits: ["user.created"], + publishes: ["auth.user-signed-up"], + consumes: [], + analyticsEvents: ["user.signed-up"], // declare here + }, + }, + realtimeChannels: [], + jobs: [], +} as const); +``` + +`requiredCores: ["analytics"]` tells the `required-cores-installed` ESLint rule +to verify that `@repo/core-analytics` is present in the workspace. + +### 2. Call `analytics.track()` in the use-case factory + +The use case receives `analytics` as a dep, guarded with `?.` so the feature +compiles when no analytics instance is provided (dev seed default): + +```ts +// packages/auth/src/application/use-cases/sign-up.use-case.ts +import type { AnalyticsProtocol } from "@repo/core-shared/di"; + +export function signUpUseCase(deps: { + usersRepo: IUsersRepository; + analytics?: AnalyticsProtocol; +}) { + return async (input: SignUpInput): Promise => { + const user = await deps.usersRepo.create(input); + deps.analytics?.track("user.signed-up", { plan: input.plan }); + return signUpOutputSchema.parse(user); + }; +} +``` + +Use `AnalyticsProtocol` (from `@repo/core-shared/di`) as the dep type — it +exposes only the `track` method, which is all a use case needs. Reserve +`IAnalytics` for call sites that need `identify`, `pageView`, or `flush`. + +### 3. Wrap with `withAnalytics` in `bind-production.ts` + +Apply `withAnalytics` inside the existing wrapper chain. Composition order +(innermost → outermost): `factory(deps)` → `withAnalytics` → `withAudit` → +`withCapture` → `withSpan`. + +```ts +// packages/auth/src/di/bind-production.ts +import { withAnalytics } from "@repo/core-analytics"; + +export function bindProductionAuth(ctx: BindProductionContext): void { + const analytics = ctx.analytics; + + authContainer + .bind(AUTH_SYMBOLS.ISignUpUseCase) + .toDynamicValue(() => + withSpan( + ctx.tracer, + { name: "auth.signUp" }, + withCapture( + ctx.logger, + { feature: "auth" }, + withAudit( + ctx.auditLog, + authManifest.useCases.signUp, + withAnalytics( + analytics, + signUpUseCase({ + usersRepo: new UsersRepository( + ctx.config, + ctx.tracer, + ctx.logger, + ), + analytics, + }), + ), + ), + ), + ), + ); + + assertFeatureConformance( + authContainer, + authManifest, + { + signUp: AUTH_SYMBOLS.ISignUpUseCase, + }, + ctx, + ); +} +``` + +`assertFeatureConformance` will throw at boot if a use case declares +`analyticsEvents` but its binding is missing the `__analyzed` brand — i.e. if +`withAnalytics` was omitted. + +### 4. Pass `analytics` from the app aggregator + +In `apps/web-next/src/server/bind-production.ts`, construct your vendor impl +(or `NoopAnalytics` for dev) and thread it through `ctx`: + +```ts +import { NoopAnalytics } from "@repo/core-analytics"; +// import { MyVendorAnalytics } from "@repo/core-analytics-posthog"; // after library eval + +const analytics = process.env.ANALYTICS_WRITE_KEY + ? new MyVendorAnalytics(process.env.ANALYTICS_WRITE_KEY) + : new NoopAnalytics(); + +const ctx: BindProductionContext = { + tracer, + logger, + config, + analytics, + // ... other optional cores +}; + +bindProductionAuth(ctx); +// ... other features +``` + +Wire `analytics.flush()` into your graceful-shutdown handler so in-flight +batches are drained before process exit: + +```ts +process.on("SIGTERM", async () => { + await analytics.flush(); + process.exit(0); +}); +``` + +--- + +## Client-side wiring + +The `@repo/core-analytics/react` subpath exports `` and +`useAnalytics()`. Both sides share the same `IAnalytics` contract the server +uses. + +### 1. Mount the provider at the app boundary + +```tsx +// apps/web-next/src/app/layout.tsx +import { AnalyticsProvider } from "@repo/core-analytics/react"; +import { clientAnalytics } from "@/lib/analytics"; // your vendor SDK wrapper + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} +``` + +`clientAnalytics` is an `IAnalytics` instance backed by your vendor SDK. It +lives at the app boundary — feature components never import the vendor SDK +directly. + +### 2. Call `useAnalytics()` in feature components + +```tsx +// packages/auth/src/ui/sign-up-button.tsx +import { useAnalytics } from "@repo/core-analytics/react"; + +export function SignUpButton() { + const analytics = useAnalytics(); + + return ( + + ); +} +``` + +`useAnalytics()` throws `AnalyticsContextError` if called outside a provider — +the error message tells you where to add the provider. + +### 3. Wire route-change events + +The provider does **not** auto-wire framework route events. Wire your +framework's route-changed hook to `pageView()` yourself: + +```ts +// apps/web-next/src/app/layout.tsx (inside a client component) +const analytics = useAnalytics(); +const pathname = usePathname(); + +useEffect(() => { + analytics.pageView(pathname); +}, [pathname]); +``` + +### 4. Call `identify()` after login + +```ts +const analytics = useAnalytics(); +analytics.identify({ id: session.user.id }); +``` + +Call `identify` once per session or when the user changes. The provider keeps +the same instance across renders so subsequent `track` calls share the same +context in SDKs that maintain internal user state. + +--- + +## Vendor evaluation + +The template ships only `NoopAnalytics` (and `RecordingAnalytics` for tests). +Before wiring a real backend, run the library evaluation gate: + +``` +/evaluate-library +``` + +This invokes the 9-filter + 3-prompt evaluation protocol (ADR-022) and writes +a decision trace to `docs/library-decisions/-.md`. The trace covers +EU residency, license compatibility, Socket.dev supply-chain flags, and weekly +revalidation requirements. Merge the trace alongside the implementation PR. + +Common vendor packages to evaluate: `posthog-node` / `posthog-js`, +`@segment/analytics-node` / `@segment/analytics-next`, +`mixpanel` / `mixpanel-browser`. + +The vendor implementation wraps the SDK and implements `IAnalytics` — it lives +in a dedicated package (e.g. `@repo/core-analytics-posthog`) so the rest of the +codebase stays vendor-neutral. + +--- + +## PII boundary + +The analytics surface has a **structurally different PII policy** from the +observability surface (ADR-017 §7). + +**Observability** (`ILogger`, `ITracer`) is id-only: `setUser({ id })` only, +`sendDefaultPii: false` everywhere, CI grep gate, server-side scrubbing at the +OTel processor layer. This policy is deliberate and remains untouched. + +**Analytics** is structurally permissive. `AnalyticsUser.id` is the only +required field; `attributes` accepts arbitrary values in `identify()`, +`track()`, and `pageView()`. The template makes no claim about what's allowed +in those attributes. + +**You are responsible for:** + +- Cookie consent and legal basis (e.g. GDPR Art. 6) +- Retention policy in your analytics backend +- Trait allowlist enforcement if you need stricter than permissive +- DSAR / right-to-erasure plumbing in the vendor backend + +There is no CI guardrail enforcing these — the actionable surface is covered +by ADR-022 (library evaluation covering the backend) and ADR-023 (supply-chain +scan). The separation of analytics from observability is explicit so that +neither surface's policy silently relaxes the other's. + +--- + +## Conformance reference + +| What you changed | Gate that catches drift | +| ----------------------------------------------------------- | -------------------------------------- | +| Added `analyticsEvents` but no `withAnalytics` | tsc TS2322 + boot assertion | +| Called `analytics.track("X")` but `X` not in manifest | `no-undeclared-analytics-event` (warn) | +| Declared `requiredCores: ["analytics"]` but package missing | `required-cores-installed` (error) | +| Manifest has use case, binder doesn't call `wireUseCase` | `usecase-must-be-wired` (error) | + +See `docs/guides/conformance-quickref.md` for the full rule table and drift +pattern catalogue.