Document Type: Engineering + Developer Reference
Scope: Full Monorepo — all apps and shared packages
Audience: Frontend engineers, tech leads, platform architects
Status: Current-state analysis and migration target
We started with a small NVC footprint and a simple analytics wiring pattern. Over time we added:
search, pdp, home, cart, checkout@shared/analytics, @capabilities/fsc, @capabilities/dealer-locator, @capabilities/universal-search-barThe analytics implementation evolved in parallel with each of these additions. It was never refactored into a unified platform model.
As a result, we now carry multiple analytics execution paths, overlapping provider models, and distributed event trigger logic that creates systemic fragility across the monorepo.
| App | Path | Analytics Entry Point |
|---|---|---|
@nvc/search |
apps/@nvc/search |
listPage.tsx, filterInteraction, card grid |
@nvc/pdp |
apps/@nvc/pdp |
pdpHome.tsx, onLoadPdpAnalytics |
@nvc/home |
apps/@nvc/home |
Page-level OnLoadAnalytics, collection grid |
@nvc/cart |
apps/@nvc/cart |
checkoutLayout.tsx, page components |
@nvc/checkout |
apps/@nvc/checkout |
checkoutLayout.tsx, form sections |
@nabuy/cart |
apps/@nabuy/cart |
Separate analytics context + client |
@nabuy/com |
apps/@nabuy/com |
analyticsManager, analyticsProvider |
@shop/fbc |
apps/@shop/fbc |
Multiple v3 hooks, IMG analytics, UEV |
@img/vehicleconfigurator |
apps/@img/vehicleconfigurator |
filterConfigurationWrapper, summaryContainer |
@img/ordermanagement |
apps/@img/ordermanagement |
imgAnalyticsUtils |
@img/discovery |
apps/@img/discovery |
analyticsScriptLoader |
@own/xhub |
apps/@own/xhub |
buttonWithAnalyticsV2, xhubAnalyticsFlexiCard |
@own/vsam |
apps/@own/vsam |
analyticsUtils (rebates) |
@own/shelp |
apps/@own/shelp |
richTextAnalyticsExitEvent |
@nashop/sitesearch |
apps/@nashop/sitesearch |
useAnalytics, analyticsUtils |
| Shared Package | Path | Role |
|---|---|---|
@shared/analytics |
packages/@shared/analytics |
Core runtime, hooks, providers, registry, validators |
@capabilities/fsc |
packages/@capabilities/fsc |
Standalone analytics for FSC calculator, multiPayment, pricingDetails |
@capabilities/dealer-locator |
packages/@capabilities/dealer-locator |
Dealer analytics helpers |
@capabilities/universal-search-bar |
packages/@capabilities/universal-search-bar |
Search metrics adapter |
@common/helpers |
packages/@common/helpers |
DIGITALDATA constant, analyticsHelper |
@common/interfaces |
packages/@common/interfaces |
Analytics interface types |
@e2e |
packages/@e2e |
E2E validation framework (Zod schema + route interception) |
We operate three overlapping analytics subsystems simultaneously:
ford/plans/ZA/en today)analyticsRegistryConfig → analyticsRegistryResolver → registryActionBuilderuseAnalyticsLoadEvents and useAnalyticsClickEvents after isRegistryEnabled() gategetGlobalLoad, getGlobalPdp, getCartCheckoutPayload buildersmergeDigitalData@capabilities/fsc has its own analytics initialization, validation, and satellite calls@nabuy/cart has its own analytics context, constants, and JavaScript payload files@shop/fbc has its own Metrics class and metrics page utils@img apps use their own DIGITALDATA constant and filter analytics@shared/analytics at allAll three subsystems write to the same global window.digitaldata and call the same window._satellite.track.
We run legacy and registry simultaneously. Same user action produces different payload shapes depending on context.
// packages/@shared/analytics/src/lib/hooks/useAnalyticsClickEvents.ts
const getAction = ({ type, filter, variant, authenticated, brand, nvcAnalytics, ctx }) => {
if (isRegistryEnabled(nvcAnalytics)) {
// PATH A: Registry
const registryAction = buildRegistryClickAction({
variant,
filter,
authenticated,
nvcAnalytics,
});
if (registryAction) return registryAction;
return undefined;
} else {
// PATH B: Legacy
const { category, subcategory } = getCategorySubcategory(type, filter, variant);
const data-removed= getOnClickLinkName({ ctx, args: { variant, category, subcategory } });
const data-removed= getOnClick(variant, filter);
switch (type) {
case 'websdk-onclick': {
const variantData = getOnclickVariantData({ ctx, variant, filter, brand });
return {
type: 'websdk-onclick',
payload: {
onclick: { onclick, onclickLinkName },
...getUserAndVehicleData(authenticated),
...variantData,
},
};
}
}
}
};
Problem: These two paths have different payload contracts. A tenant switching from legacy to registry will change what fields land in Adobe. There is no automated parity check before that switch.
We cannot enable a new market or language without a code change, test cycle, and deployment.
// packages/@shared/analytics/src/lib/config/analyticsRegistryResolver.ts
const REGISTRY_ENABLED_HIERARCHIES: Record<string, Record<string, Record<string, Set<string>>>> = {
ford: {
plans: {
ZA: new Set(['en']), // only one market is enabled
},
},
};
export const isRegistryEnabled = (ctx: NvcAnalyticsCtx): boolean => {
if (!ctx) return false;
const { brand, commodity, country, lang } = ctx;
return REGISTRY_ENABLED_HIERARCHIES[brand]?.[commodity]?.[country]?.has(lang) ?? false;
};
Problem: Adding ford/accessories/US/en requires editing this file, merging a PR, and deploying. It should be a config-only operation.
We have both merge mode and replace mode. A wrong replace call wipes unrelated fields silently.
// packages/@shared/analytics/src/lib/utils/index.ts
const mergeDigitalData = ({ args, ctx }) => {
// MODE A: full replacement — wipes all existing fields
if ('replaceDigitalData' in args && args.replaceDigitalData) {
globalThis.digitaldata = payload as Record<string, Details>;
callSatellite(rule as AdobeRule);
return;
}
// MODE B: selective merge
const nextDigitalData: Record<string, Details> = {
...existingData,
...(hasDefaults ? defaultValues[type] : {}),
};
// ... deep merge logic
};
Concrete failure scenario:
// Team A fires page load → page fields set
window.digitaldata = { page: { pageName: "search results", siteSection: "search" } };
// Team B fires onclick with replaceDigitalData: true
window.digitaldata = { onclick: { onclickLinkName: "clear filters" } };
// page.pageName is now gone
// Adobe downstream mapping fails silently
// FSC Capability — completely independent init
// packages/@capabilities/fsc/src/calculator/utils/analytics.ts
export const initializeAnalytics = (appName: AppNames) => {
validateDigitalData();
validateNameplate();
initializeWindow();
};
// NaBuy Cart — direct satellite call
// apps/@nabuy/cart/public/next-analytics/common-analytics/commonAnalytics.js
window._satellite.track('nabuy-cart-checkout-pageload');
// NVC Shared — through analytics.send
analytics.send({ args: action, ctx });
Problem: All three write to window.digitaldata. They have no coordination mechanism. Race conditions and field conflicts are possible, especially on shared pages.
Same commodity string resolves differently in different parts of the system.
// Layer A: NvcAnalyticsProvider normalizes commodity
// packages/@shared/analytics/src/lib/provider/nvcAnalyticsProvider.tsx
const ALIAS_MAP: Record<string, AnalyticsCommodity> = {
'performance-parts': 'accessories',
'global-search': 'accessories',
};
// Layer B: analyticsConfig treats them as separate entries
// packages/@shared/analytics/src/lib/provider/analyticsConfig.ts
export const COMMODITY_CONFIG_SEARCH: Record<Commodity, CommodityConfig> = {
accessories: { rulePrefix: '', eventDataPrefix: 'fa', site: 'accessories.ford.com' },
'performance-parts': { rulePrefix: '', eventDataPrefix: 'fa', site: 'accessories.ford.com' },
'global-search': { rulePrefix: '', eventDataPrefix: 'nvc', site: 'nvc.ford.com' },
};
Problem: performance-parts in NvcAnalyticsProvider becomes accessories but in analyticsConfig it has its own mapping. If the resolution order changes, eventDataPrefix changes from fa to nvc, breaking all link names.
Similar business actions (clicks) are wired differently across teams.
// Component A: search filter clear
// apps/@nvc/search/src/components/filterInteraction/filterClear.tsx
const { send } = useAnalyticsClickEvents('websdk-onclick', 'clear-filters');
if (isRegistryEnabled(nvcAnalyticsCtx)) {
send({ label: ANALYTICS_CONSTANTS.CLEAR_ALL_FILTERS });
}
// Component B: back to cart
// apps/@nvc/checkout/src/components/backToCart/backToCart.tsx
const { send } = useAnalyticsClickEvents('websdk-onclick', 'back-to-cart');
if (isRegistryEnabled(nvcAnalyticsCtx)) {
const productInfo = getProductAnalyticsInfo(cart, { locale, currencyCode, priceFormat });
send({ buttonLabel: t('backTocart'), ctaTitle: 'Back to Cart', cart: { productInfo } });
}
// Component C: fordPay
// apps/@nvc/checkout/src/components/fordPay/fordPay.tsx
if (isRegistryEnabled(nvcAnalyticsCtx)) {
const productInfo = getProductAnalyticsInfo(cart, { locale, currencyCode, priceFormat });
send({ fordPayEvent: 'open', cart: { productInfo } });
}
Problem: Each component decides what to include in the payload. The platform has no enforcement of required fields at the call site.
Almost every NVC component test mocks analytics hooks entirely.
// apps/@nvc/checkout/src/components/contactInformation/contactInformation.test.tsx
jest.mock('@shared/analytics/hooks', () => ({
useAnalyticsClickEvents: jest.fn().mockReturnValue({ send: jest.fn() }),
}));
jest.mock('@shared/analytics/provider', () => ({
isRegistryEnabled: jest.fn().mockReturnValue(false),
useNvcAnalytics: jest.fn().mockReturnValue({
brand: 'ford',
commodity: 'plans',
country: 'ZA',
lang: 'en',
}),
}));
jest.mock('@shared/analytics/utils', () => ({
getProductAnalyticsInfo: jest.fn().mockReturnValue({}),
}));
Problem: These tests confirm the component code calls send, but they cannot detect that the real payload sent to Adobe has wrong structure. Schema regressions pass unit test gates.
The core orchestration handles too many variants in one file.
// packages/@shared/analytics/src/lib/hooks/useAnalyticsLoadEvents.ts (927 lines)
const getAction = ({ args, screenType, authenticated, brand, ctx }) => {
switch (args.type) {
case 'websdk-pageload':
return { type: 'websdk-pageload', payload: getGlobalLoad({ args, screenType, authenticated, ctx }) };
case 'websdk-nvm-ecomm-pageload':
if (args.variant === 'pdp' || args.variant === 'review-form-load') {
return { type: 'websdk-nvm-ecomm-pageload', payload: getGlobalPdp({ args, screenType, authenticated, ctx }) };
}
const variantActions = {
'pdp-home': getGlobalPdp,
'cart-view': () => getCartCheckoutPayload({ variant: 'cart-view', ... }),
'checkout-confirmation': () => getCartCheckoutPayload({ variant: 'checkout-confirmation', ... }),
'checkout-landing': () => getCartCheckoutPayload({ variant: 'checkout-landing', ... }),
'checkout-shipping': () => getCartCheckoutPayload({ variant: 'checkout-shipping', ... }),
'checkout-payment': () => getCartCheckoutPayload({ variant: 'checkout-payment', ... }),
};
// ...
default:
throw new Error(`Unhandled analytics load rule`);
}
};
Problem: Adding one new variant requires understanding the full context of all existing cases. One wrong placement breaks other flows. ESLint complexity rule is suppressed on this function.
PV defines:
| Field | Path | Value |
|---|---|---|
| Site Section | page.siteSection |
ford protect |
| Hierarchy | page.hierarchy |
ford protect:product |
| Satellite track | productLoad |
product-load |
Developer implements:
// intended
payload.page.siteSection = "ford protect";
payload.page.hierarchy = "ford protect:product";
// actual mistake made under delivery pressure
payload.page.section = "ford protect"; // wrong key
payload.page.heirarchy = "ford protect:product"; // typo
Problem: TypeScript does not catch this because window.digitaldata is typed as Record<string, Details>. The field arrives silently missing in reporting.
// packages/@shared/analytics/src/lib/utils/index.ts
queue: {
add: (props) => {
const exists = analytics.queue.items.some((item) => isEqual(item.args, props.args));
if (!exists) {
analytics.queue.items.push(props);
}
},
}
Queue deduplication uses deep equality on args. If payload is even slightly different between fires (for example timestamp, dynamic product field), the duplicate passes through.
// E2E catches this late
// packages/@e2e/docs/analytics-validation.md
await analytics.listen("scAdd");
await clickAddToCart();
const call = await analytics.assertFiredOnce(); // sometimes catches 2 calls
Problem: Runtime queue deduplication is insufficient for all duplicate scenarios. E2E is the first hard gate that catches them, which is too late in the delivery pipeline.
| Anti-Pattern | Location | Risk Level |
|---|---|---|
| Dual execution paths (legacy + registry) | useAnalyticsClickEvents, useAnalyticsLoadEvents |
High |
| Hardcoded market enablement in source | analyticsRegistryResolver.ts |
High |
| Mixed replace/merge mode on global object | utils/index.ts |
High |
| Three independent subsystems on same global | FSC, NaBuy, NVC shared | High |
| Component-level payload assembly without contract | All NVC components | Medium |
| Commodity alias divergence between layers | nvcAnalyticsProvider, analyticsConfig |
Medium |
| Large orchestration with suppressed complexity lint | useAnalyticsLoadEvents.ts |
Medium |
| Test mocking instead of contract validation | All NVC tests | Medium |
| PV manual translation without schema check | PV implementation flow | High |
| Late duplicate detection in E2E only | E2E test layer | Medium |
All analytics events are defined as typed contracts before any implementation begins.
// Target: packages/@shared/analytics-contracts/src/events/SEARCH_FILTER_CLEAR_CLICK.ts
export const SEARCH_FILTER_CLEAR_CLICK = {
key: 'SEARCH_FILTER_CLEAR_CLICK',
track: 'ecommerce-onclick',
version: '1.0.0',
required: ['page.pageName', 'page.siteSection', 'onclick.onclickLinkName'],
schema: z.object({
page: z.object({
pageName: z.string(),
siteSection: z.string(),
}),
onclick: z.object({
onclickLinkName: z.string(),
onclick: z.string(),
}),
user: z.object({
loginStatus: z.string(),
}).optional(),
}),
defaults: {
onclick: {
onclick: 'filter cleared',
onclickLinkName: 'clear filters',
},
},
} as const;
Feature components emit intent only. Platform builds transport payload.
// Target: component usage
const { emit } = useAnalyticsEvent('SEARCH_FILTER_CLEAR_CLICK');
const handleClear = () => {
emit({ label: 'Clear All' });
router.push(newUrl);
};
// Target: packages/@shared/analytics-tenants/src/ford-ZA-en.ts
export const fordZaEnOverlays: TenantOverlay = {
'SEARCH_FILTER_CLEAR_CLICK': {
page: {
siteSection: 'ford protect',
hierarchy: 'ford protect',
},
onclick: {
onclickLinkName: 'fz:ford protect:filter cleared',
},
},
};
// Target: packages/@shared/analytics/src/lib/runtime/eventPipeline.ts
export function sendAnalyticsEvent(contractKey: EventKey, rawPayload: unknown, tenant: TenantContext) {
const contract = getContract(contractKey);
const tenantPayload = applyTenantOverlay(rawPayload, tenant, contract);
const enriched = enrichWithDefaults(tenantPayload, contract.defaults);
const validation = contract.schema.safeParse(enriched);
if (!validation.success) {
observability.reportValidationError(contractKey, validation.error, tenant);
return; // never send invalid payload
}
const envelope = buildEnvelope(validation.data, contract, tenant);
globalDataLayer.set(envelope, { mode: 'merge' }); // always merge, never raw replace
satellite.track(contract.track);
observability.recordFire(contractKey, tenant);
}
// Target: packages/@shared/analytics-rollout/src/rolloutPolicy.ts
export const rolloutPolicy: RolloutPolicy = {
'ford/accessories/US/en': 'registry',
'ford/plans/ZA/en': 'registry',
'ford/parts/US/en': 'dual-compare',
'lincoln/accessories/US/en': 'legacy',
'*': 'legacy',
};
export function resolveMode(tenant: TenantContext): AnalyticsMode {
const key = `${tenant.brand}/${tenant.commodity}/${tenant.country}/${tenant.lang}`;
return rolloutPolicy[key] ?? rolloutPolicy['*'] ?? 'legacy';
}
ford/plans/ZA/en (already registry-enabled)emit pattern| Metric | Current Baseline | Target |
|---|---|---|
| Contract conformance rate | Unknown | > 95% |
| Duplicate event rate | Detected in E2E only | < 0.1% |
| Analytics regression incidents per sprint | High | < 1 per quarter |
| Market onboarding lead time | Code + deploy cycle | Config only |
| Mean time to diagnose analytics incident | Days | Hours |
| Test mock vs real contract coverage | High mocking | < 20% mocked analytics |
| Schema validation failures before send | No gate | 100% validated before send |
Document maintained by: Analytics Platform Team
Last updated: 2026-06-12
Review cadence: Monthly or after major platform changes