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>
This commit is contained in:
360
docs/guides/analytics.md
Normal file
360
docs/guides/analytics.md
Normal file
@@ -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<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.
|
||||
Reference in New Issue
Block a user