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>
361 lines
11 KiB
Markdown
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.
|