Analytics Architecture
A three-layer, vendor-neutral analytics system gives the platform one
observability backend shared across host + modules, while letting every
event be queried by moduleName, page, and feature. The default backend
is New Relic (bundled inside @nike/whisker-component-library), but it can
be swapped at the host level by supplying a different AnalyticsAdapter —
modules and components are unaffected. This system is fully built and
shipped in WCL today.
Why three layers
Section titled “Why three layers”| Layer | Owns | Lives in |
|---|---|---|
| Platform | The analytics adapter + global attributes (user, store, environment) | Host app |
| Module | The module’s identity (name, version, feature flags) and route scope | Each federated module |
| Component | Just what happened (event names, per-event attributes) | Library / module components |
Mixing these into one provider would either spawn multiple analytics agents (they’d fight over globals and double-count), or force every component to know the platform’s identity (components stop being portable). Each layer stays responsible only for what it owns, and the system composes them into the final event automatically:
PlatformAnalyticsProvider ← host: install adapter + reactive globals └─ ModuleAnalyticsProvider ← module: register name + baseline attrs └─ AnalyticsScope (optional) ← route/feature: page + feature scope └─ useModuleAnalytics() ← components: track eventsOpenTelemetry attribute conventions
Section titled “OpenTelemetry attribute conventions”Attribute names follow OTEL semantic conventions
so a future OTLP-capable adapter can forward them unchanged: service.name,
service.version, deployment.environment, enduser.id,
http.response.status_code, url.path. Custom domain attributes without an
OTEL counterpart (storeId, urgency) stay as-is.
What goes where
Section titled “What goes where”Host: PlatformAnalyticsProvider (exactly one per app)
Section titled “Host: PlatformAnalyticsProvider (exactly one per app)”Installs the single adapter and registers platform-wide reactive attributes:
import { PlatformAnalyticsProvider } from '@nike/whisker-component-library';
<PlatformAnalyticsProvider config={{ accountID, agentID, applicationID, licenseKey, trustKey }} isProd={import.meta.env.MODE === 'production'} globalAttributes={{ 'service.name': 'sim-host', 'deployment.environment': 'prod', 'enduser.id': user?.id, // reactive — picked up automatically storeId: selectedStore?.id, // reactive custom attribute }}> <App /></PlatformAnalyticsProvider>;When config is omitted and the host is non-prod, the provider falls back
to a built-in console-only adapter automatically. For non-New-Relic
backends, implement AnalyticsAdapter and pass it via the adapter prop
instead of config. globalAttributes are diff-synced — only changed keys
are pushed; pass null to remove one.
Why it lives at the host: only the host knows who is using the app and where, and owns the analytics account credentials. Modules must not bring their own adapter.
Module: ModuleAnalyticsProvider (one per federated module)
Section titled “Module: ModuleAnalyticsProvider (one per federated module)”<ModuleAnalyticsProvider moduleName="sim-shipping" baselineAttributes={{ 'service.name': 'sim-shipping', betaFeatures: true }} page="shipping"> <ShippingModule /></ModuleAnalyticsProvider>Registers the module’s identity so events can be queried by moduleName.
Putting this on the platform layer would force the host to know about
every module; putting it on the component would mean every component
re-declares (and risks mis-declaring) its own module identity.
Route/feature: AnalyticsScope (zero or more)
Section titled “Route/feature: AnalyticsScope (zero or more)”<AnalyticsScope page="stockOnHand"> <AnalyticsScope feature="export"> <ExportButton /> {/* events get page='stockOnHand', feature='export' */} </AnalyticsScope></AnalyticsScope>A separate layer because page/feature are tree-position-dependent, not
global state — the same component can render under different pages.
Components: useModuleAnalytics()
Section titled “Components: useModuleAnalytics()”The only API library/module components should call — resolves the module name and scope from context and delegates to the active adapter:
function ExportButton() { const { trackEvent, trackError } = useModuleAnalytics(); return ( <button onClick={async () => { try { await exportXlsx(); trackEvent('Export_Succeeded', { rowCount: 1234 }); } catch (err) { trackError(err, { exportFormat: 'xlsx' }); } }} > Export </button> );}Without a provider: a one-time console.warn fires and every call
becomes a no-op — components render normally, Storybook and tests don’t
crash. With disabled: true on the platform provider, every emit
short-circuits the same way, from both the hook and non-React callers.
Non-React contexts: analyticsService.forModule()
Section titled “Non-React contexts: analyticsService.forModule()”Zustand stores, utility functions, and interceptors can’t call hooks — they bind a module name once and reuse the returned tracker:
import { analyticsService } from '@nike/whisker-component-library';export const analytics = analyticsService.forModule('sim-host');Same merge order, sanitization, and disabled behavior as the hook — the
hook is a thin wrapper around this factory.
Merge order (last write wins)
Section titled “Merge order (last write wins)”Platform globalAttributes → Module baselineAttributes (overrides Platform on key collision) → Scope page / feature (overrides Module page if nested) → Call-site attributes (overrides everything — escape hatch)Given PlatformAnalyticsProvider with storeId: 'store_512' →
ModuleAnalyticsProvider moduleName="sim-shipping" →
AnalyticsScope feature="incidents", a call to
trackEvent('IncidentDrawer_SubmitStarted', { urgency: '2' }) lands in the
backend with storeId, moduleName, feature, and urgency all present —
the component only supplied urgency; everything else came from the
providers it rendered under.
Querying in NRDB
Section titled “Querying in NRDB”Every event is tagged with moduleName, page, and feature:
SELECT count(*) FROM PageAction WHERE moduleName = 'sim-shipping' SINCE 1 hour agoSELECT count(*) FROM PageAction WHERE moduleName = 'sim-shipping' AND feature = 'incidents' FACET actionNameSELECT count(*) FROM PageAction WHERE actionName = 'Export_Succeeded' FACET moduleName SINCE 1 day agoSwapping backends
Section titled “Swapping backends”import { PlatformAnalyticsProvider, AnalyticsAdapter } from '@nike/whisker-component-library';
class OtlpAdapter implements AnalyticsAdapter { readonly name = 'otlp'; initialize(isProd: boolean) { /* ... */ } isReady() { /* ... */ } isLoaded() { /* ... */ } setGlobalAttribute(key, value) { /* ... */ } trackEvent(eventName, attrs) { /* ... */ } trackError(err, attrs) { /* ... */ } trackPageView(pageName, attrs) { /* ... */ }}
<PlatformAnalyticsProvider adapter={new OtlpAdapter()} isProd={true}> <App /></PlatformAnalyticsProvider>;Modules and components don’t change. Because attributes already follow OTEL semantic conventions, an OTLP-capable adapter can forward them unchanged.
Environment behavior (default New Relic backend)
Section titled “Environment behavior (default New Relic backend)”Production (isProd: true) |
Non-Production (isProd: false) |
|
|---|---|---|
| Events / errors / page views | Sent to NR | console.debug / console.error |
Force verbose logging anywhere with
localStorage.setItem('analytics_debug', 'true').
Testing
Section titled “Testing”<PlatformAnalyticsProvider isProd={false} disabled> <ModuleAnalyticsProvider moduleName="test-module"> <ComponentUnderTest /> </ModuleAnalyticsProvider></PlatformAnalyticsProvider>Or omit the providers entirely — useModuleAnalytics warns once and no-ops.
Sanitization
Section titled “Sanitization”Every payload is sanitized before reaching the adapter: sensitive field
names (password, token, secret, ssn, …) are redacted, Axios-style
error objects are flattened to safe primitives, errorInfo.componentStack
is truncated to 500 chars, and non-primitive values are JSON-stringified or
dropped. Adapters never see unscrubbed data.
Reference
Section titled “Reference”PlatformAnalyticsProvider— host-level provider propsModuleAnalyticsProvider— module-level provider propsAnalyticsScope— route/feature scoping componentuseModuleAnalytics()— the canonical component-level hookanalyticsService.forModule()— public factory for non-React callersAnalyticsAdapter— interface to implement for a non-default backend
