Skip to content

Observability & Product Analytics

Audience: Operators configuring LMS observability and engineers diagnosing production errors or analytics gaps.

Scope: PostHog setup, environment variables, consent, event taxonomy, log forwarding, and troubleshooting. Policy is canonical in .cursor/rules/092-analytics-observability.mdc; the logger contract lives in .cursor/rules/091-structured-logging.mdc.

Platform

The LMS uses PostHog (US Cloud, https://us.i.posthog.com) as the single surface for:

  • Product analytics — how learners and admins use the LMS.
  • Error tracking — client and server exceptions.
  • Log ingestion — server logs forwarded via OpenTelemetry.

All wiring lives in the shared @open-learning-hub/observability package; the LMS injects config and its logger through adapters in src/lib/analytics/.

Environment Variables

Variable Scope Required Purpose
POSTHOG_PROJECT_KEY Server Optional Project token (phc_...) for posthog-node + OTel
POSTHOG_HOST Server Optional Ingestion host (default https://us.i.posthog.com)
NEXT_PUBLIC_POSTHOG_KEY Browser Optional Project token (phc_...) for posthog-js
NEXT_PUBLIC_POSTHOG_HOST Browser Optional Upstream ingestion host used by /ingest/* rewrites

All variables are optional: when the key is absent (or NODE_ENV=test), every factory degrades to a no-op and the app runs normally without analytics. Server variables are server-only — never move them to a NEXT_PUBLIC_ prefix. Restart or redeploy after changing them; clients are cached in-process.

The same project token (phc_...) powers the browser SDK, posthog-node, and the OTLP Authorization: Bearer header; no personal (phx_...) key is required.

Setup

  1. Create (or select) the PostHog project on US Cloud.
  2. Copy the project API key (phc_...).
  3. Set POSTHOG_PROJECT_KEY and NEXT_PUBLIC_POSTHOG_KEY to that value.
  4. Leave host variables unset to use US Cloud, or set them for self-hosted/EU. Browser calls stay same-origin (/ingest/*) and are rewritten to this host in next.config.ts.
  5. Redeploy the LMS.
  6. Accept the consent banner in a browser and confirm events appear in PostHog Activity.

The LMS ships a consent gate with privacy-safe defaults:

  • PostHog starts in persistence: 'memory' (no cookies) and opted-out until the user accepts.
  • Consent is versioned in localStorage (olh_ph_consent:v1) and Do-Not-Track is respected.
  • autocapture is off, session recording is off, surveys are disabled, and person_profiles is identified_only.
  • cross_subdomain_cookie is disabled so browser telemetry identity is not shared across tenant subdomains. The SDK still probes for the registrable domain when clearing cookies — see dmn_chk cookie rejected for invalid domain.
  • The banner (data-testid="consent-banner") renders only while consent is unset, after the stored choice resolves (no hydration flash).
  • The banner only appears when a PostHog key is configured. With no key, analytics is inert and the banner never mounts (so it cannot intercept clicks in e2e/dev).

No PII reaches PostHog: never send emails or names as event/exception properties. Pass ids/slugs (userId, tenantId, courseSlug). Server logs reuse the redacting logger, so redaction happens before anything leaves the process.

Event Taxonomy

Event names are stable snake_case, defined in src/lib/analytics/events.ts (LMS_EVENTS):

Event When
sign_in A user signs in
sign_up A new account is created
course_viewed A course landing/entry page is viewed
enrolled A learner enrolls in a course
quiz_submitted A quiz attempt is submitted
cms_fetch_non_ok A CMS request returned handled non-fatal status (for example, 4xx)
cms_fallback_used The LMS used a CMS fallback path (navigation/chrome/page loading)
csp_violation A browser CSP violation report is received at /api/csp-report

Browser events are gated on consent. Server events/logs are flushed via scheduleObservabilityFlush() (which uses after() when available and falls back to fire-and-forget in build-time contexts).

Error Tracking

  • Client: src/app/\[locale\]/error.tsx and src/app/global-error.tsx report to PostHog via captureBrowserException (route/boundary + digest), gated on consent.
  • Server: hard CMS failures are captured through the serverAnalytics().captureException(...) seam in reportCmsError.
  • Handled CMS warnings: non-fatal CMS responses and fallback surfaces emit cms_fetch_non_ok / cms_fallback_used events so they are visible outside the Logs product.

Log Forwarding

Server logs are always emitted as console.* JSON (Edge-safe, redacted). In the Node runtime, src/instrumentation.ts initializes the OpenTelemetry log bridge and registers a log sink so src/lib/log.ts additionally forwards each redacted record to PostHog. Edge and client code never load OTel.

Troubleshooting

No events in PostHog

  1. Confirm NEXT_PUBLIC_POSTHOG_KEY is set and exposed to the browser bundle (redeploy after changes).
  2. Confirm the consent banner was accepted; nothing is captured while consent is unset or declined.
  3. Check Do-Not-Track is not enabled in the browser.
  4. Verify the host matches your PostHog region.
  5. Confirm /ingest/e/ returns 200/204 from the app origin and is not redirected by middleware.

No server logs / exceptions

  1. Confirm POSTHOG_PROJECT_KEY is set (server-side) and the app was redeployed.
  2. Server data is batched — confirm the flush path (after()) runs for the route/action.
  3. Confirm the code path executes in the Node runtime (OTel does not load on Edge).
  4. Confirm the LMS key points to the intended PostHog project (local .env.local often differs from whichever project is active in a debugging session).

Repeated page-hierarchy 404 warnings in local dev

If local CMS seeds recreated the site-config project, CMS_PROJECT_UUID can become stale and trigger repeated projects/<uuid>/page-hierarchy 404s.

  • Update .env.local with the latest site project UUID from apps/cms seed output.
  • Keep CMS_PROJECT_UUID authoritative and update it whenever local seed output reports a new site project id.

Firefox may log Cookie "dmn_chk_<uuid>" has been rejected for invalid domain. This is expected posthog-js behaviour, not a misconfiguration.

  • Cause: browsers expose no API for the registrable domain, so posthog-js discovers it by writing a short-lived probe cookie (dmn_chk_<uuidv7>=1; domain=.<candidate>; path=/; max-age=3) for each hostname candidate, right to left, until one is accepted (seekFirstNonPublicSubDomain in posthog-js/src/storage.ts). The browser rejecting the public-suffix candidate (.me on <tenant>.lvh.me, .com in production) is the signal the algorithm relies on. Chrome discards it silently; Firefox logs a warning.
  • Why cross_subdomain_cookie: false does not suppress it: the flag is honoured when writing PostHog's cookie, but PostHogPersistence.remove() clears the entry under both domain scopes with the cross-subdomain flag hardcoded (this._storage._remove(this._name, true)), which forces the probe. The LMS setting in packages/observability/src/client/init.ts still applies to cookies PostHog writes.
  • When it appears: only once persistence is cookie-backed — that is, after the consent banner is accepted with NEXT_PUBLIC_POSTHOG_KEY set. Plain localhost and 127.0.0.1 are short-circuited by the SDK, so the warning never appears there.
  • Action: none. The probe cookie carries no data, expires within seconds, and does not affect capture, consent, or identity scope. To silence it locally, browse on http://localhost:3001, leave NEXT_PUBLIC_POSTHOG_KEY unset (analytics stays inert), or filter warnings in the Firefox console.

Analytics disabled unexpectedly

Keys are optional and default to no-op. A missing/empty key or NODE_ENV=test disables capture by design.