Skip to content

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.

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 events

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.

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.

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:

utils/analytics.ts
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.

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.

Every event is tagged with moduleName, page, and feature:

SELECT count(*) FROM PageAction WHERE moduleName = 'sim-shipping' SINCE 1 hour ago
SELECT count(*) FROM PageAction WHERE moduleName = 'sim-shipping' AND feature = 'incidents' FACET actionName
SELECT count(*) FROM PageAction WHERE actionName = 'Export_Succeeded' FACET moduleName SINCE 1 day ago
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').

<PlatformAnalyticsProvider isProd={false} disabled>
<ModuleAnalyticsProvider moduleName="test-module">
<ComponentUnderTest />
</ModuleAnalyticsProvider>
</PlatformAnalyticsProvider>

Or omit the providers entirely — useModuleAnalytics warns once and no-ops.

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.

  • PlatformAnalyticsProvider — host-level provider props
  • ModuleAnalyticsProvider — module-level provider props
  • AnalyticsScope — route/feature scoping component
  • useModuleAnalytics() — the canonical component-level hook
  • analyticsService.forModule() — public factory for non-React callers
  • AnalyticsAdapter — interface to implement for a non-default backend