Files
agentic-dev/docs/guides/analytics.md
Danijel Martinek 47600bad59 docs(core-analytics): add analytics.md guide
Covers server-side wiring (BindContext.analytics → withAnalytics brand
wrapping), client-side wiring (AnalyticsProvider + useAnalytics), vendor
evaluation gate (ADR-022 /evaluate-library), and the PII boundary
divergence from ADR-017 observability policy (ADR-024 §PII boundary).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:01:26 +00:00

361 lines
11 KiB
Markdown

# 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<string, AnalyticsAttributeValue>,
): void;
/** Associate attributes with a user. Not gated by the manifest. */
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 graceful-shutdown and serverless-response-finish hooks. */
flush(): Promise<void>;
}
```
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<SignUpOutput> => {
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<ISignUpUseCase>(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 `<AnalyticsProvider>` 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 (
<html>
<body>
<AnalyticsProvider value={clientAnalytics}>
{children}
</AnalyticsProvider>
</body>
</html>
);
}
```
`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 (
<button
onClick={() => {
analytics.track("user.signed-up-click");
}}
>
Sign up
</button>
);
}
```
`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 <vendor-package-name>
```
This invokes the 9-filter + 3-prompt evaluation protocol (ADR-022) and writes
a decision trace to `docs/library-decisions/<date>-<name>.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.