Files
agentic-dev-template/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

11 KiB

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

// @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)withAnalyticswithAuditwithCapturewithSpan.

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