# 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.
