Skip to content

Analytics & Observability (Canonical)

This is the single normative rule for PostHog usage in the Open Learning Hub monorepo: product analytics, error tracking, and OpenTelemetry log forwarding. Error-handling policy is in 090-error-handling.mdc; the logger emit contract and log-sink seam are in 091-structured-logging.mdc.

Platform

  • PostHog is the single observability platform (US Cloud: https://us.i.posthog.com).
  • There is no Sentry. Do not add @sentry/* packages or sentry.*.config.ts.
  • All wiring lives in the shared package @open-learning-hub/observability (AGENTS.md). Apps inject config; the package never reads process.env.

Package boundary

  • @open-learning-hub/observability/config — types + constants (Edge-safe).
  • @open-learning-hub/observability/client"use client": PostHogProvider, useAnalytics, useConsent, captureBrowserException. Browser only.
  • @open-learning-hub/observability/servercreateServerAnalytics (capture / captureException / flush). Node only; never import from a 'use client' module.
  • @open-learning-hub/observability/otelcreateOtelLogBridge. Node only, imported only from instrumentation.ts.
  • @open-learning-hub/observability/log-sink — Edge-safe seam consumed by src/lib/log.ts (no OTel/Node imports).
  • Ship a consent gate. PostHog starts in persistence: 'memory' with capture opted-out until the user accepts; consent is versioned in localStorage (olh_ph_consent:v1) and Do-Not-Track is respected.
  • Browser init defaults: autocapture: false, disable_session_recording: true, person_profiles: 'identified_only', respect_dnt: true, cross_subdomain_cookie: false, disable_surveys: true.
  • The consent banner renders only while consent is unset and only after the client resolves the stored choice (no hydration flash).
  • The banner is gated on analytics being active (isObservabilityActive): with no PostHog key, analytics is inert and the banner never mounts. This keeps the fixed banner from intercepting pointer events in e2e/dev where no key is set.

No PII

  • Never send emails, names, or other PII as event/exception properties or log attributes. Pass ids/slugs (e.g. userId, tenantId, courseSlug).
  • Server logs forwarded via OTel reuse the redacting src/lib/log.ts output, so redaction happens before anything leaves the process.

Configuration (env, injected by apps)

  • Server: POSTHOG_PROJECT_KEY, POSTHOG_HOST (default US Cloud).
  • Browser: NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST (default US Cloud).
  • Browser transport must use same-origin /ingest/* rewrites in next.config.ts (@open-learning-hub/platform-config/posthog-proxy) so CSP can stay script-src 'self' / connect-src 'self'.
  • All optional: when the key is absent (or NODE_ENV=test), every factory degrades to a no-op. The same PostHog project token (phc_...) powers posthog-js, posthog-node, and the OTLP Authorization: Bearer header.
  • App adapters live in src/lib/analytics/ (config.ts, server.ts, events.ts).

Flush pattern

  • Server events/logs are batched. In route handlers and server actions, flush with after() from next/server calling flushObservability() so data is delivered before the serverless function freezes.

Event taxonomy (initial)

Event names are stable and snake_case. Extend the per-app events.ts maps.

  • LMS (LMS_EVENTS): sign_in, sign_up, course_viewed, enrolled, quiz_submitted, csp_violation.
  • CMS (CMS_EVENTS): page_created, page_published, widget_added, import_run, csp_violation.

Error tracking

  • Client: error.tsx / global-error.tsx call captureBrowserException in an effect. global-error.tsx renders outside providers/i18n (plain copy).
  • Server: capture via the serverAnalytics().captureException(...) seam (e.g. reportCmsError). No-op when PostHog is unconfigured.

Out of scope (follow-ups)

  • Session replay, dashboards/funnels config, feature flags, and A/B testing.
  • Migrating CLI/seed/migrate script logging.