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>
11 KiB
Product analytics
Prerequisite: This guide assumes
@repo/core-analyticsis installed. If your project started from the slim template, runpnpm turbo gen core-package analyticsfirst, 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
// @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:
// 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):
// 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.
// 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:
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:
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
// 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
// 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:
// 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
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.