Analytics Platform — Technical Problem Statement

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


Table of Contents

  1. Background
  2. Monorepo App Inventory
  3. Current Architecture Overview
  4. Current Flow Chart
  5. Current Sequence Diagram
  6. Pain Points with Code Examples
  7. Anti-Patterns We Are Carrying
  8. Target Architecture
  9. Target Sequence Diagram
  10. Migration Strategy
  11. Engineering KPIs

Background

We started with a small NVC footprint and a simple analytics wiring pattern. Over time we added:

The 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.


Monorepo App Inventory

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)

Current Architecture Overview

We operate three overlapping analytics subsystems simultaneously:

Subsystem 1: NVC Registry Path (Newer)

Subsystem 2: NVC Legacy Path (Original)

Subsystem 3: Standalone Domain Analytics (Parallel)

All three subsystems write to the same global window.digitaldata and call the same window._satellite.track.


Current Flow Chart

[Diagram]

Current Sequence Diagram

[Diagram]

Pain Points with Code Examples

1. Dual Execution Paths in Production

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.


2. Registry Enablement Is Hardcoded in Source Code

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.


3. Global Data-Layer Overwrite Risk

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

4. Three Independent Analytics Subsystems Write to Same Global Object

// 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.


5. Commodity Alias Mismatch Across Layers

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.


6. Component-Level Payload Construction Is Inconsistent

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.


7. Heavy Test Mocking Reduces Contract Confidence

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.


8. Large Orchestration Switch With High Change Risk

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.


9. PV Manual Translation Creates Silent Field Errors

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.


10. Duplicate Fire Risk Is Real and Detected Late

// 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-Patterns We Are Carrying

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

Target Architecture

Principle 1: Contract First

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;

Principle 2: Typed Intent From App Code Only

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);
};

Principle 3: Tenant Overlay as Configuration

// 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',
    },
  },
};

Principle 4: Validate Before Send

// 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);
}

Principle 5: Policy-Driven Rollout

// 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';
}

Target Sequence Diagram

[Diagram]

Migration Strategy

Phase 1: Baseline (Now → 4 weeks)

Phase 2: Contract Introduction (Weeks 4–8)

Phase 3: SDK Rollout (Weeks 8–16)

Phase 4: Tenant Expansion (Weeks 16–24)

Phase 5: Legacy Decommission (After Phase 4 stability)


Engineering KPIs

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