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¶
- Create (or select) the PostHog project on US Cloud.
- Copy the project API key (
phc_...). - Set
POSTHOG_PROJECT_KEYandNEXT_PUBLIC_POSTHOG_KEYto that value. - 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 innext.config.ts. - Redeploy the LMS.
- Accept the consent banner in a browser and confirm events appear in PostHog Activity.
Consent & Privacy¶
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. autocaptureis off, session recording is off, surveys are disabled, andperson_profilesisidentified_only.cross_subdomain_cookieis disabled so browser telemetry identity is not shared across tenant subdomains. The SDK still probes for the registrable domain when clearing cookies — seedmn_chkcookie 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.tsxandsrc/app/global-error.tsxreport to PostHog viacaptureBrowserException(route/boundary + digest), gated on consent. - Server: hard CMS failures are captured through the
serverAnalytics().captureException(...)seam inreportCmsError. - Handled CMS warnings: non-fatal CMS responses and fallback surfaces emit
cms_fetch_non_ok/cms_fallback_usedevents 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¶
- Confirm
NEXT_PUBLIC_POSTHOG_KEYis set and exposed to the browser bundle (redeploy after changes). - Confirm the consent banner was accepted; nothing is captured while consent is unset or declined.
- Check Do-Not-Track is not enabled in the browser.
- Verify the host matches your PostHog region.
- Confirm
/ingest/e/returns200/204from the app origin and is not redirected by middleware.
No server logs / exceptions¶
- Confirm
POSTHOG_PROJECT_KEYis set (server-side) and the app was redeployed. - Server data is batched — confirm the flush path (
after()) runs for the route/action. - Confirm the code path executes in the Node runtime (OTel does not load on Edge).
- Confirm the LMS key points to the intended PostHog project (local
.env.localoften 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.localwith the latest site project UUID fromapps/cmsseed output. - Keep
CMS_PROJECT_UUIDauthoritative and update it whenever local seed output reports a new site project id.
dmn_chk cookie rejected for invalid domain¶
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-jsdiscovers 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 (seekFirstNonPublicSubDomaininposthog-js/src/storage.ts). The browser rejecting the public-suffix candidate (.meon<tenant>.lvh.me,.comin production) is the signal the algorithm relies on. Chrome discards it silently; Firefox logs a warning. - Why
cross_subdomain_cookie: falsedoes not suppress it: the flag is honoured when writing PostHog's cookie, butPostHogPersistence.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 inpackages/observability/src/client/init.tsstill 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_KEYset. Plainlocalhostand127.0.0.1are 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, leaveNEXT_PUBLIC_POSTHOG_KEYunset (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.
Related References¶
- Canonical policy:
.cursor/rules/092-analytics-observability.mdc - Logger contract:
.cursor/rules/091-structured-logging.mdc - Error handling:
.cursor/rules/090-error-handling.mdc - Package:
@open-learning-hub/observability - Deployment
- Ticket: T-015 PostHog observability