Skip to content

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

Open
stephenliang wants to merge 6 commits 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 6 commits 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 same cookie (statsig_stable_id on .code.org, session-scoped, SameSite=Lax, Secure) and the same localStorage key that other code.org pages use, so IDs carry across legacy pages, new pages, and the Rails server-side session reader. Reads and writes go through js-cookie (added in #74393), which is also what apps/ uses for this cookie. 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; FrontendAppConfigHelper#frontend_app_config states the meta's shape as a literal, composed from the helpers that own each section (the observability engine gains a hash-returning section method; its existing JSON API is unchanged). An environment transmits iff it provisions a client key — production and the managed test server do, everywhere else the key defaults to blank and the page is served 'none', the same environments legacy's client-side local_mode allowed. A developer sets a real statsig_api_client_key in locals.yml to exercise the provider locally. Development without a key gets a console-logging adapter mirroring legacy's local-mode log lines.

Consumers

Studio registers the plugin (after consentPlugin — order matters and is documented). Identity is seeded from the app-config meta when the server renders it (user: {userId, userType}, signed-in pages only), so first events carry userID; pages without the seed boot anonymous and identify via setUser from the router's beforeLoad, which re-asserts 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!

@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
@stephenliang
stephenliang force-pushed the stephen/core-analytics-plugin branch from d27880e to 3df136f Compare August 5, 2026 18:12
stephenliang and others added 3 commits August 5, 2026 11:30
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>
The observability engine exposes its section as a hash, so
FrontendAppConfigHelper states the meta's shape directly instead of
parsing JSON back apart.

The analytics module no longer exports test-only handles; its tests
isolate per case with vi.resetModules() and assert through the public
surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the hand-rolled reader, writer, and delete with js-cookie
calls carrying the same cookie attributes. The library never throws on
a malformed value, so the explicit decode guard goes away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stephenliang
stephenliang force-pushed the stephen/core-analytics-plugin branch from 3df136f to c1bed66 Compare August 5, 2026 18:31
stephenliang and others added 3 commits August 5, 2026 15:04
statsig_force_transmit (locals.yml) serves frontend/ pages a 'statsig'
analytics provider in development, for browser testing of the full
provider path. Successor to the legacy build-time STATSIG_LOCAL_MODE_OFF
escape hatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The environment predicate duplicated what the config layering already
encodes: production and the managed test server are exactly the
environments provisioned with a real client key. Gate on the key alone,
blank the placeholder defaults elsewhere, and a real
statsig_api_client_key in locals.yml enables the provider locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A signed-in page's app-config carries user {userId, userType}, and the
Statsig boot user is built from it so the first events carry identity.
The seed is optional end to end: a page without it boots anonymous and
identity arrives through setUser, so shells that render no identity
keep working unchanged.

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