Skip to content

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

Open
stephenliang wants to merge 1 commit into
stephen/experiments-pluginfrom
stephen/core-analytics-plugin
Open

Core: provider-agnostic analytics plugin with Statsig adapter (full legacy parity)#74384
stephenliang wants to merge 1 commit into
stephen/experiments-pluginfrom
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).

The settlement signal (whenConsentSettled, stack layer #74392) carries the "has the CMP finished speaking" state: 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 is unchanged (Set shape).

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 (via plugins/experiments, stack layer #74393) 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
@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!

Adds @code-dot-org/core/plugins/analytics so frontend/ packages can log
product analytics without importing the legacy apps/ bundle. Module
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.

Consent gates only stable-ID persistence, decided once per page load
when the consent source settles; events send regardless. A page whose
CMP never reports sends nothing; a page with no CMP sends immediately.
The stable-ID cookie/localStorage contract is shared with other
code.org pages and the Rails server-side session reader.

Statsig config travels in the app-config meta, composed by
FrontendAppConfigHelper from the section owners; production and
managed-test servers transmit, development logs to the console.

Studio registers the plugin and asserts identity from the router;
the users package reports 'Account Settings Page Visited' at mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stephenliang
stephenliang force-pushed the stephen/core-analytics-plugin branch from 69e7a15 to 4004feb Compare August 5, 2026 13:58
@stephenliang
stephenliang changed the base branch from staging to stephen/experiments-plugin August 5, 2026 13:58
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