Skip to content

Core: provider-agnostic analytics plugin with Statsig adapter (full legacy parity) - #74384

Open
stephenliang wants to merge 3 commits into
stagingfrom
stephen/core-analytics-plugin
Open

Core: provider-agnostic analytics plugin with Statsig adapter (full legacy parity)#74384
stephenliang wants to merge 3 commits into
stagingfrom
stephen/core-analytics-plugin

Conversation

@stephenliang

@stephenliang stephenliang commented Aug 4, 2026

Copy link
Copy Markdown
Member

Adds @code-dot-org/core/plugins/analytics so Next Generation Frontend Platform packages can log product analytics without importing the legacy apps/ bundle (closes #73791). The public surface is deliberately small and provider-blind — sendEvent(name, payload), setUser(user), and analyticsPlugin for initializeCore — with Statsig as the first provider, loaded as a lazy chunk so @statsig/js-client stays out of bundles that don't opt in. The governing constraint throughout: full parity with apps/src/metrics/StatsigReporter.js, so the data flowing to Statsig does not change.

How it works

sequenceDiagram
    participant Page as Host page head
    participant OT as OneTrust stub and SDK
    participant A as analyticsPlugin
    participant S as StatsigClient lazy chunk

    Page->>OT: load stub, define oneTrustPromise
    Page->>A: initializeCore -> onCoreReady
    A->>A: read provider from app-config meta
    A->>A: install DeferredAdapter, buffer sendEvent and setUser
    OT-->>A: first report -> consent state + settled signal
    Note over A: no OneTrust on page: settles immediately.<br/>OneTrust present but never loads: never settles,<br/>nothing sends (legacy unresolved-promise parity)
    A->>A: single consent decision per page load:<br/>C0002 grant -> persist stable ID,<br/>else delete our copies, SDK mints its own
    A->>S: dynamic import, init with config and session
    A->>S: flush buffered calls in order
Loading

Consent model (parity, not policy change)

Consent never gates event sending — it gates only stable-ID persistence, decided once per page load at OneTrust's first report, exactly mirroring legacy's oneTrustPromise.then(initializeAfterConsent). A C0002 grant persists the legacy cookie (statsig_stable_id on .code.org) and localStorage key byte-for-byte, so IDs carry across legacy pages, new pages, and the Rails server-side reader. Anything else deletes our persisted copies and passes stableID: undefined so the SDK mints its own, as legacy does. Mid-session consent changes are ignored until the next page load (legacy behavior: OptanonWrapper re-fires but the promise is already resolved).

A new dependency-free consent/settled.ts leaf carries the "has the CMP finished speaking" signal: pages without OneTrust settle immediately and send; pages where OneTrust never loads never settle and never send — both faithful to legacy, verified against the production otSDKStub.js pipeline. The consent store itself becomes tri-state (true/false/absent per category) so an all-deny report is observable; the old Set shape deduped it away silently.

Legacy quirks replicated deliberately

logEvent(name, name, payload) keeps the event name in Statsig's value slot for dashboard continuity. Identity updates replicate setUserProperties exactly, including its omission of customIDs. Init-time user carries custom.enabledExperiments and geRegion (read from data-ge-region, which the new Studio shell does not stamp yet — documented gap, reads null there). SDK-side storage is left enabled as in legacy. disableErrorLogging was dropped: it never existed in @statsig/js-client and was dead config in the legacy reporter.

Config and environments

The Statsig client key travels in the app-config meta tag (parsed by SiteConfig beside observability), replacing the legacy script[data-statsig-api-client-key] transport for frontend/ pages. The transmit decision is server-side in StatsigAnalyticsHelper#analytics_config; AppConfigHelper#app_config composes the meta from the helpers that own each section, and the observability engine is untouched: production or managed-test emits provider: 'statsig', everything else 'none' — the same environments legacy's client-side local_mode allowed. Development gets a console-logging adapter mirroring legacy's local-mode log lines.

Consumers

Studio registers the plugin (after consentPlugin — order matters and is documented) and re-asserts identity from the router's beforeLoad with a same-user short-circuit, so SPA navigations don't trigger redundant updateUserAsync refetches. The users package fires the legacy Account Settings Page Visited event at mount; the other five legacy account events belong to features (LTI unlink, roster sync, AI-diff toggle) whose UI does not exist in the new page yet, so they intentionally stay on the legacy page.

Links

Testing story

Built TDD; 235 core / 186 users / 88 studio vitest tests pass (28 analytics, 27 consent), plus new Ruby tests for analytics_config in dashboard/test/helpers. yarn release:dryrun (build + lint + typecheck + test) green across all 68 turbo tasks; both yarn.locks regenerated and --immutable-clean (apps consumes core via a yarn portal). An adversarial code review (15 confirmed findings) drove a hardening round: generation-guarded async boot, cookie-decode guards, explicit @statsig/js-client peer declarations, bundle-graph verification that the analytics chunk pulls no OneTrust/observability code. Parity was verified against legacy source line-by-line and against the production OneTrust stub's actual initialization pipeline.

Not verifiable locally, needs a deployed environment: a real event landing in Statsig from a non-production deploy, and GDPR-geolocated banner behavior.

Deployment notes

No migrations or feature flags. Frontend Studio pages start reporting to Statsig on deploy (production/managed-test only), under the same event names and stable IDs legacy uses. Known gap: geRegion reads null on the new Studio shell until it stamps data-ge-region on the root element like the legacy layout does.

Privacy and security

No new PII and no change to what is collected: consent semantics are byte-for-byte parity with the legacy reporter (consent gates stable-ID persistence only, sending was never consent-gated), the stable-ID cookie contract is unchanged, and the Statsig SDK's own storage behavior is unchanged. The one behavioral tightening is bundle hygiene: the analytics subpath cannot drag the OneTrust provider or observability graph into consumer bundles.

🤖 Generated with Claude Code

@stephenliang
stephenliang marked this pull request as ready for review August 4, 2026 22:55
Add @code-dot-org/core/plugins/analytics so frontend/ packages can log
product analytics without importing the legacy apps/ bundle. The plugin
follows the observability plugin's shape: a module-level singleton
(sendEvent/setUser) backed by a no-op adapter until boot, a deferred
adapter buffering calls during async provider load, and a factory that
lazy-imports the Statsig adapter so @statsig/js-client stays out of
bundles that do not opt in.

Behavior is full parity with apps/src/metrics/StatsigReporter.js so the
data flowing to Statsig does not change:

- Consent never gates event sending. It gates only stable-ID
  persistence, decided once per page load at OneTrust's first report,
  exactly like the legacy oneTrustPromise flow. A C0002 grant persists
  the legacy cookie/localStorage ID; anything else deletes our copies
  and lets the SDK mint its own.
- A page with OneTrust that never reports sends nothing (legacy's
  unresolved-promise behavior); a page without OneTrust sends
  immediately. The new consent/settled.ts leaf carries this signal;
  connectOneTrust drives it.
- The consent store becomes tri-state (explicit true/false per category,
  absent = no CMP report) so an all-deny report is observable; the old
  Set shape deduped it away.
- Init user carries custom.enabledExperiments and geRegion; identity
  updates replicate legacy setUserProperties byte-for-byte, including
  its omission of customIDs. logEvent(name, name, payload) parity.
- Statsig config travels in the app-config meta; the transmit decision
  (production or managed-test) moves server-side into
  observability_config, replacing client-side local_mode. Development
  gets a console-logging adapter matching legacy's local mode.

The users package fires the legacy 'Account Settings Page Visited'
event at mount; the other five legacy account events belong to features
whose UI does not exist in the new page yet. Studio registers the
plugin and re-asserts identity from the router's beforeLoad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stephenliang
stephenliang force-pushed the stephen/core-analytics-plugin branch from 9284fb8 to 873ac21 Compare August 4, 2026 22:59
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🖼️ Storybook Visual Comparison Report

✅ No Storybook eyes differences detected!

stephenliang and others added 2 commits August 4, 2026 16:28
- Statsig config moves out of the observability engine: analytics_config
  lives in StatsigAnalyticsHelper, the engine exposes an
  observability-only hash variant, and the frontend_studio view composes
  the app-config meta with a plain hash merge. Helper tests move to
  dashboard/test/helpers accordingly; lib/cdo/statsig.rb reverts.
- The users package no longer declares @statsig/js-client; it
  externalizes core, so the provider chunk is never resolved at its
  build time. Studio keeps the declaration.
- Docs and comments describe the current contract only, and comments
  narrating mechanics are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AppConfigHelper#app_config composes the meta JSON from the helpers that
own each section; the observability engine helper returns to its
original single-section form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core: add shared browser analytics for Next Generation Frontend Platform apps

1 participant