Skip to content

ADR-002: Reusable Analytics Service

  • Status: Proposed
  • Date: 21st April 2026
  • Participants: Sofiia Trokhymchuk, Ian Lovell

This ADR covers a reusable analytics abstraction for Releaf web applications, with immediate focus on the current UK and DE sites and likely future marketing/app surfaces.

This ADR covers:

  • Analytics event dispatch
  • Consent-aware analytics bootstrapping
  • Provider integration patterns
  • Provider mapping and routing
  • Application-facing analytics API
  • Migration approach from current UK and DE implementations

This ADR does not define:

  • Reusable feature flags architecture
  • Reusable experiments architecture
  • Business-wide event taxonomy for every surface

Feature flags and experiments are closely related to analytics and PostHog, but they will be documented separately. This ADR references them only where the analytics design must leave space for them.

Analytics implementation currently differs significantly between Releaf applications. The two active reference implementations, releaf (UK) and releaf-lite (DE), solve similar problems in materially different ways:

  • Different app-facing APIs
  • Different consent assumptions
  • Different bootstrapping strategies
  • Different approaches to GTM, GA4, PostHog, and pixels
  • Different event naming conventions and dedupe behavior

This makes analytics difficult to reason about, difficult to migrate, and difficult to standardise as more applications and regional surfaces are added.

This expands the original ADR framing that analytics implementation currently differs across applications.

UK:

  • No equivalent DE-style consent requirement currently drives the main implementation
  • GTM is not the primary orchestration layer
  • Tracking pixels are integrated directly via plugins, including GA4
  • Uses a context/provider approach
  • PostHog is also used via app-specific services and hooks for experimentation-related behavior

DE:

  • Consent is mandatory
  • Both GTM and direct plugins are integrated, including GA4
  • Uses a more library/module-based approach with nanostores
  • PostHog is already treated as more than a standard tracking tool

The original plugin comparison is still useful context for this decision.

ProviderUK AppDE App
Meta (Facebook)
Microsoft
PostHog
Quora
Reddit
Taboola
TikTok
Twitter (X)
Triple Whale

The UK site uses a React context provider around the analytics library, plus a set of custom provider plugins and additional direct vendor integrations:

flowchart TD
    A[UI / Components] --> B[AnalyticsProvider Context]
    B --> C[analytics library instance]
    C --> D[GA plugin]
    C --> E[PostHog plugin]
    C --> F[Meta / Twitter / Quora / Microsoft / Taboola / Reddit / TikTok plugins]
    A --> G[Direct vendor calls in some flows]
    G --> H[window.gtag / other SDK APIs]
    A --> I[UK PostHog hooks]
    I --> K[PostHog client]
    E --> K

Key characteristics:

  • Event names are defined in a large shared enum, including both domain events and vendor-shaped names
  • sendAnalyticEvent(...) is the main app-facing API
  • PostHog has additional UK-specific client helpers and experiment hooks
  • Some critical flows still bypass the abstraction and call vendor APIs directly
  • Provider selection hints such as analyticsType exist at call sites, but the core dispatch path is still a broad analytics.track(...)

The DE site already has a more centralised analytics module, explicit GTM helper, consent-aware plugin enable/disable flow, and a separate PostHog service/store for feature flag readiness:

flowchart TD
    A[UI / Components] --> B[sendAnalyticEvent]
    B --> C[Central analytics module]
    C --> D[analytics library instance]
    D --> E[GA plugin]
    D --> F[PostHog plugin]
    D --> G[Meta / Taboola / Triple Whale plugins]
    A --> H[sendGTMEvent]
    H --> I[dataLayer / GTM]
    A --> J[PostHog flag hooks]
    J --> K[PostHog service + store]
    F --> K

Key characteristics:

  • Event naming is cleaner and closer to domain and ecommerce semantics
  • Event dedupe is built into the central analytics module via state keys
  • Plugin enable/disable is aligned with consent
  • GTM is a separate event path, not just another provider adapter inside the same abstraction
  • PostHog is already treated as more than analytics because it also powers flag and experiment-related behavior

UK and DE expose different shapes and expectations:

  • UK: context-driven sendAnalyticEvent({ event, ...payload })
  • DE: module-driven sendAnalyticEvent({ name, payload, stateKey })
  • DE also separately uses sendGTMEvent(...)

This slows cross-app reuse and makes migration harder than it needs to be.

2. Vendor concepts leak into application code

Section titled “2. Vendor concepts leak into application code”

Current application code still contains vendor-specific concerns such as:

  • Provider-shaped event names
  • GTM-specific event payload shapes
  • Direct gtag conversion calls
  • Plugin or transport selection hints in the UI layer

This increases lock-in and makes migration risky.

Section titled “3. Consent and bootstrap behavior are not unified”

Consent requirements differ by region and provider.

Important constraints include:

  • GA4 consent mode requires default consent to be set before measurement commands are run
  • Pixel providers may need early bootstrap code in the document head
  • Consent updates must happen at runtime after user action
  • Some providers should never initialize in some regions or products

Today, this logic is partly distributed across app-specific layouts, services, and plugins.

4. Mixed transport strategies cause duplication risk

Section titled “4. Mixed transport strategies cause duplication risk”

The current systems mix:

  • Direct SDK/plugin dispatch
  • GTM event dispatch
  • Direct vendor escape hatches

If the same business event is expressed differently in multiple places, double-firing and payload drift become likely.

5. PostHog has two responsibilities, not one

Section titled “5. PostHog has two responsibilities, not one”

PostHog is used for:

  • Event capture
  • Page views
  • Person identification
  • Feature flags
  • Experiments / exposure support

If analytics abstraction is designed as “just another pixel wrapper”, it will not match actual usage.

The implemented analytics service should be reusable across Releaf applications, and provide a unified approach to tracking pixel integration, consent handling, and event mapping. This should reduce duplication, improve maintainability, and ensure consistency in analytics tracking as we scale to more applications.

  • Provide a single, minimal analytics API for consumer applications
  • Remove vendor-specific code from UI and application handlers
  • Support consent-aware initialisation and runtime consent updates
  • Support multiple providers with clear mapping rules
  • Support gradual migration from UK and DE implementations
  • Keep application code event-driven and easy to read
  • Make current and future regional differences configurable rather than hard-coded into app code
  • Creating a single giant event enum for all applications
  • Solving feature flags and experiments in this ADR
  • Moving all experimentation semantics into analytics
  • Centralising all business meaning in a platform-owned package
  • Application code declares what happened. It should not decide how providers receive it.
  • Mappings live centrally. Provider-specific event names and payload transforms belong in the analytics layer.
  • Bootstrap is separate from runtime tracking. Providers that require head scripts or default consent must declare this separately.
  • Consent is first-class. Initialisation and routing must respect consent state.
  • Migration must be incremental. Existing UK and DE tracking must be able to coexist during transition.
  • Analytics and feature flags are separate concerns. Analytics records outcomes; feature flags decide experience.

Option A - Keep app-specific analytics implementations

Section titled “Option A - Keep app-specific analytics implementations”

Continue evolving UK and DE analytics separately, while sharing ideas informally but not introducing a reusable package.

flowchart LR
    A[UK app] --> B[UK analytics implementation]
    C[DE app] --> D[DE analytics implementation]
    E[Future apps] --> F[New per-app implementation]
  • Lowest short-term implementation cost
  • Minimal disruption to current apps
  • Each application can optimise for local constraints
  • Duplicates architecture work across apps
  • Does not solve inconsistency
  • Increases long-term migration cost
  • Encourages further drift in event naming and provider usage
  • Makes future regional launches slower

Create a shared analytics API, but use GTM as the primary orchestration layer for most event delivery. Application code emits standard events into a shared service, which mainly pushes to GTM and lets GTM distribute to providers.

flowchart TD
    A[UI / App Code] --> B[Shared analytics API]
    B --> C[GTM / dataLayer]
    C --> D[GA4]
    C --> E[Meta]
    C --> F[Other pixels]
    B --> G[Direct PostHog support where needed]
  • Centralises much provider setup outside application code
  • Can reduce engineering effort for some vendor integrations
  • Aligns reasonably well with DE’s existing GTM usage
  • Can speed up some pixel integrations
  • GTM becomes the operational centre of truth instead of the codebase
  • More difficult to type, test, and review event mappings
  • Poor fit for providers with richer SDK usage, especially PostHog
  • Some providers do not fit cleanly into GTM-first orchestration
  • Still likely to need parallel non-GTM integrations
  • Debugging becomes harder when ownership is split between code and GTM

Option C - Shared analytics package with direct provider adapters and declarative bootstrap

Section titled “Option C - Shared analytics package with direct provider adapters and declarative bootstrap”

Create a reusable analytics package used by apps. Application code calls a small event-driven API. The package owns provider registration, consent-aware runtime behavior, event mapping, and provider markup contributions such as head scripts, noscript blocks, or default consent snippets.

flowchart TD
    A[UI / App Code] --> B[App-level analytics API]
    B --> C[packages/analytics]
    C --> D[Event contract]
    C --> E[Mapping layer]
    C --> F[Consent manager]
    C --> G[Markup contributions]
    E --> H[GA4 adapter]
    E --> I[Meta adapter]
    E --> J[PostHog adapter]
    E --> K[GTM adapter if needed]
    E --> L[Other adapters]

Bootstrap behavior is handled separately from event dispatch:

flowchart LR
    A[App layout / document] --> B[markup]
    B --> C[Default consent scripts]
    B --> D[Provider bootstrap scripts]
    B --> E[Noscript / preconnect / config]
  • Full control and predictability of event firing
  • Strong control and debuggability
  • Best fit for consent-aware startup behavior
  • Best fit for PostHog’s richer role in the platform
  • Vendor lock-in stays out of application code
  • Keeps mappings in version-controlled code
  • Supports typed contracts and incremental migration
  • Highest initial implementation effort
  • Requires migration work in both UK and DE apps
  • Requires engineers to maintain provider integrations in code
  • Still needs discipline to prevent direct vendor escape hatches from reappearing

Option D - Shared analytics package with typed domain events and a central business event registry

Section titled “Option D - Shared analytics package with typed domain events and a central business event registry”

Create the shared package from Option C, but additionally require all applications to adopt one global event map and one global shared event vocabulary from day one.

flowchart TD
    A[All apps] --> B[Single global event registry]
    B --> C[Shared analytics package]
    C --> D[All provider adapters]
  • Strongest consistency guarantees
  • Easiest to compare events across applications in theory
  • Too rigid for current state of the platform
  • Pushes business meaning into the platform layer too early
  • Likely to create a large, hard-to-govern shared enum
  • Increases migration friction substantially

Proceed with Option C - Shared analytics package with direct provider adapters and declarative bootstrap.

Option C best fits the current platform for the following reasons:

  • It aligns with the stronger parts of the DE implementation without requiring GTM to become the primary system of truth
  • It fixes the main weaknesses of the UK implementation, especially vendor leakage and direct escape hatches
  • It explicitly models bootstrap and consent as first-class concerns
  • It keeps mappings and provider behavior inside the codebase, where they can be reviewed and versioned
  • It supports PostHog as both an analytics provider and a richer platform dependency without forcing feature flags into the analytics package
  • It can be introduced incrementally via wrappers and adapter migration rather than requiring a big-bang rewrite
flowchart TD
    A[UI / App Code] --> B[App event helpers]
    B --> C[packages/analytics API]
    C --> D[Runtime analytics service]
    C --> E[Bootstrap renderer]
    D --> F[Consent state]
    D --> G[Provider router]
    G --> H[GA4 adapter]
    G --> I[Meta adapter]
    G --> J[PostHog adapter]
    G --> K[GTM adapter when required]
    G --> L[Other adapters]
    E --> M[head script / noscript contributions]
LayerResponsibility
App codeDeclare that something happened
App analytics helpersDefine app-owned event names and payload shapes
Shared analytics packageInitialize service, manage consent, route events, map payloads, expose small API
Provider adaptersTranslate standard analytics calls into provider-specific calls
Bootstrap rendererEmit required head and noscript content before runtime

This continues the original ADR direction of a shared library that provides:

  • A unified API for initialisation and tracking
  • A plugin system for provider integrations
  • Runtime consent management
  • Event mapping

The sections below expand those implementation details further so the ADR captures the proposed architecture more fully.

The application-facing API should:

  • Be small
  • Be event-driven
  • Avoid provider names
  • Be easy to call from handlers
  • Support consent and identification
  • Allow incremental adoption
analytics.init(config);
analytics.trackEvent({
event: "AboutCtaClicked",
data: {
location: "hero",
},
});
analytics.page({
name: "AboutPageViewed",
data: {
path: "/about",
},
});
analytics.identify({
userId: "user_123",
traits: {
email: "user@example.com",
},
});
analytics.updateConsent({
analytics: "granted",
advertising: "denied",
functionality: "granted",
});

The shared package should expose something close to the following:

type ConsentValue = "granted" | "denied";
type ConsentState = {
analytics: ConsentValue;
advertising: ConsentValue;
functionality?: ConsentValue;
adUserData?: ConsentValue;
adPersonalization?: ConsentValue;
};
type TrackEventInput<TEvent extends string = string, TData = Record<string, unknown>> = {
event: TEvent;
data?: TData;
meta?: {
eventId?: string;
key?: Array<string | number>;
version?: string;
source?: string;
};
};
type PageInput = {
name?: string;
data?: Record<string, unknown>;
meta?: {
eventId?: string;
};
};
type IdentifyInput = {
userId: string;
traits?: Record<string, unknown>;
};
type AnalyticsApi<TEvents extends EventMap = EventMap> = {
init(config: AnalyticsConfig): Promise<void>;
trackEvent<TEvent extends keyof TEvents>(input: {
event: TEvent;
data: TEvents[TEvent];
meta?: TrackEventInput["meta"];
}): void | Promise<void>;
page(input?: PageInput): void | Promise<void>;
identify(input: IdentifyInput): void | Promise<void>;
updateConsent(consent: ConsentState): void | Promise<void>;
resetKey?(key: Array<string | number>): void | Promise<void>;
resetAllKeys?(): void | Promise<void>;
reset?(): void | Promise<void>;
setDebug?(enabled: boolean): void;
debugEvents?(): AnalyticsDebugEvent[];
onDebug?(handler: AnalyticsDebugObserver): () => void;
};

Suggested responsibilities for each function:

  • init(config): boot the runtime analytics service, initialize providers allowed by config/consent, and prepare any shared state.
  • trackEvent(input): accept an application event, resolve mappings, optionally apply key-based idempotency, and dispatch to the relevant providers (described in the Event Mapping section).
  • page(input?): emit a page-view style event using the same routing and consent rules as trackEvent.
  • identify(input): associate a user identity and traits with providers that support identification.
  • updateConsent(consentState): update in-memory consent state and notify providers that need to enable, disable, or reconfigure themselves.
  • resetKey(key): clear one idempotency key when a flow should be allowed to emit that logical event again.
  • resetAllKeys(): clear all tracked idempotency keys for cases like logout, session reset, or test setup.
  • reset(): clear in-memory identity/session state for flows like logout or test cleanup.
  • setDebug(enabled): enable or disable runtime debug capture without changing the core tracking API used by applications.
  • debugEvents(): return the currently retained debug events for local inspection, tests, or developer tooling.
  • onDebug(handler): subscribe to debug events as they occur and return an unsubscribe function for cleanup.

Rather than constructing analytics implicitly, the package should expose a factory function. That keeps setup explicit, avoids global singleton magic, and makes provider registration straightforward.

Example:

const analytics = createAnalyticsService({
app: "releaf-uk",
providers: [ga4Provider(...), posthogProvider(...), metaPixelProvider(...)],
events: ukEvents,
consent: {
default: {
analytics: "denied",
advertising: "denied",
functionality: "granted",
},
},
onError(error, context) {
console.error("analytics error", { error, context });
},
});

Suggested shape:

type AnalyticsConfig<TEvents extends EventMap = EventMap> = {
app: string;
providers: AnalyticsProvider[];
events: EventRegistry<TEvents>;
consent: {
default: ConsentState;
};
dedupe?: {
store?: DedupeStore;
};
debug?: {
enabled?: boolean;
observer?: AnalyticsDebugObserver;
store?: AnalyticsDebugStore;
};
onError?: (error: unknown, context: AnalyticsErrorContext) => void;
};
type DedupeStore = {
has(key: Array<string | number>): boolean | Promise<boolean>;
set(key: Array<string | number>): void | Promise<void>;
delete(key: Array<string | number>): void | Promise<void>;
clear(): void | Promise<void>;
};
type AnalyticsDebugObserver = (event: AnalyticsDebugEvent) => void;
type AnalyticsDebugStore = {
push(event: AnalyticsDebugEvent): void;
list(): AnalyticsDebugEvent[];
clear?(): void;
};
declare function createAnalyticsService<TEvents extends EventMap>(
config: AnalyticsConfig<TEvents>,
): AnalyticsApi<TEvents>;

Example debug observer setup:

const analytics = createAnalyticsService({
app: "releaf-uk",
providers: [ga4Provider(...), posthogBrowserProvider(...)],
events: ukEvents,
consent: { default: defaultConsent },
debug: {
enabled: process.env.NODE_ENV !== "production",
observer(event) {
console.debug("[analytics]", event);
},
},
});

Applications should own their event definitions, not the platform package. However, the package should support incremental, strongly typed event registration rather than requiring one giant event-map type up front.

Example:

const aboutCtaClicked = createEvent<{
location: "hero" | "footer";
}>({
name: "AboutCtaClicked",
});
const globalNavItemClicked = createEvent<{
item: string;
surface: "desktop" | "mobile";
}>({
name: "GlobalNavItemClicked",
});
const purchaseCompleted = createEvent<{
orderId: string;
total: number;
currency: "GBP";
}>({
name: "PurchaseCompleted",
});

The event registry can be composed from separate registrations:

const ukEvents = createEventRegistry()
.register(
createEventDefinition(aboutCtaClicked, {
providers: {
ga4: (data) => ({
event: "generate_lead",
params: { location: data.location },
}),
posthog: (data) => ({
event: "about_cta_clicked",
properties: { location: data.location },
}),
},
}),
)
.register(
createEventDefinition(purchaseCompleted, {
providers: {
ga4: (data) => ({
event: "purchase",
params: data,
}),
},
}),
);

Then trackEvent remains strongly typed:

const analytics = createAnalyticsService({
app: "releaf-uk",
providers: [ga4Provider(...), posthogProvider(...)],
events: ukEvents,
consent: { default: defaultConsent },
});
analytics.trackEvent({
event: "AboutCtaClicked",
data: {
location: "hero",
},
});

For events that need idempotency, the caller can optionally provide a stable logical key:

analytics.trackEvent({
event: "CheckoutStarted",
data: {
cartId,
},
meta: {
key: ["checkout", "begin", cartId],
},
});

This keeps:

  • Business meaning in the app
  • Transport logic in the shared package
  • Provider mapping out of application code

It also gives us separate function calls for event registration while preserving strong typing:

  • createEvent(...) describes one event at a time.
  • createEventDefinition(...) attaches provider mappings for that event.
  • .register(...) adds one event definition at a time into the registry passed to createAnalyticsService(...).

The shared analytics service should own idempotency behavior, not app-specific storage helpers.

The current DE stateKey model is closer to “send once until reset” than true duplicate detection. It works only because the caller provides a stable identity for what should count as the same logical event. If that identity is too coarse, for example just "begin_checkout", then later legitimate events may also get suppressed.

If we keep this capability, it should therefore be:

  • Optional, not required for every event
  • Explicitly caller-supplied via meta.key
  • Scoped to the logical thing being protected, for example cart, step, session, or attempt
  • Owned by the shared service rather than hidden inside app-specific modules

Most action events should not need a key at all. They should simply be tracked from handlers. Keys are most useful for impression/state-driven events such as:

  • Page views
  • Step views
  • Milestone-entered events
  • Experiment exposures

The service should not hard-code where keys are stored. Instead, it should allow a pluggable store so products can choose the right durability and scope.

Example:

const analytics = createAnalyticsService({
app: "releaf-uk",
providers: [ga4Provider(...), posthogProvider(...)],
events: ukEvents,
consent: { default: defaultConsent },
dedupe: {
store: createSessionStorageDedupeStore("analytics"),
},
});

Possible store implementations include:

  • In-memory store for the current page lifecycle only
  • sessionStorage for per-tab persistence
  • localStorage for persistence across browser sessions

The important point is that the shared service owns the idempotency contract, while storage remains pluggable.

Why event-driven instead of method-per-event

Section titled “Why event-driven instead of method-per-event”

We explicitly prefer:

analytics.trackEvent({
event: "AboutCtaClicked",
data: { location: "hero" },
});

Over:

analytics.about.ctaClicked({ location: "hero" });

Because the event-driven approach:

  • Avoids endless service surface growth
  • Keeps the shared package stable as event volume increases
  • Makes mapping and migration simpler
  • Better supports application-owned event maps

Providers should not be modelled only as runtime SDK wrappers. Some providers also require markup and/or consent defaults before runtime.

Recommended provider shape:

type AnalyticsProvider = {
name: string;
runtime: "browser" | "server" | "universal";
markup?(context: BootstrapContext): MarkupContribution[];
init?(context: RuntimeContext): void | Promise<void>;
page?(input: PageInput, context: RuntimeContext): void | Promise<void>;
trackEvent?(input: ResolvedProviderEvent, context: RuntimeContext): void | Promise<void>;
identify?(input: IdentifyInput, context: RuntimeContext): void | Promise<void>;
updateConsent?(consent: ConsentState, context: RuntimeContext): void | Promise<void>;
reset?(): void | Promise<void>;
};

This should be plain-object based, not class based. A provider should just be a factory function returning an object that implements the hooks it needs.

The package itself should support universal usage:

  • a core module that is safe to run in browser, SSR, edge, and backend contexts
  • browser providers/adapters for SDK-backed client integrations
  • server providers/adapters for backend and edge dispatch

The core package should not assume window, document, localStorage, or any other DOM API exists. Environment-specific behavior should live inside adapters or explicitly injected stores/helpers.

Example:

function ga4Provider(config: Ga4Config): AnalyticsProvider {
return {
name: "ga4",
markup(context) {
return [
{
kind: "script-src",
src: `https://www.googletagmanager.com/gtag/js?id=${config.measurementId}`,
async: true,
},
];
},
init(context) {
// Initialize runtime GA4 behavior
},
trackEvent(event, context) {
// Dispatch resolved GA4 event
},
updateConsent(consent, context) {
// Update GA4 consent mode
},
};
}

Registering a new provider should therefore just mean:

  • write a new provider factory like redditPixelProvider(...) or taboolaProvider(...)
  • add it to the providers array passed to createAnalyticsService(...)
  • add event mappings for that provider where needed

When a destination has meaningfully different browser and server implementations, it is reasonable to expose separate adapters such as:

  • posthogBrowserProvider(...)
  • posthogServerProvider(...)
  • metaPixelProvider(...)
  • metaConversionsApiProvider(...)

This keeps runtime concerns explicit and avoids hiding environment branching inside one adapter. Shared payload transformation helpers can still be reused internally where that meaningfully reduces duplication.

Some providers need behavior before the runtime analytics instance is ready:

  • GA4 default consent must be established before measurement commands
  • GTM or other providers may require early head scripts
  • Meta Pixel and similar providers may require base script insertion and consent-aware initialisation
  • Layouts may need noscript, preconnect, or inline config output

This should be modelled via declarative markup contributions, not ad hoc DOM mutation from inside runtime tracking code.

markup(...) keeps the distinction from init(...) simple:

  • markup(...) returns what must be rendered into the page
  • init(...) runs runtime setup once the app is executing

In SSR contexts, markup(...) should be pure and serialisable. It should describe what to render, not directly touch the DOM.

Recommended shape:

type MarkupContribution =
| { kind: "script-src"; src: string; async?: boolean; defer?: boolean }
| { kind: "inline-script"; id: string; content: string }
| { kind: "noscript"; id: string; html: string }
| { kind: "preconnect"; href: string };

Applications can then render these at layout level in a stable order.

Consent must be first-class in the service, not a provider-specific afterthought.

Recommended flow:

sequenceDiagram
    participant Layout
    participant Analytics
    participant Providers
    participant User

    Layout->>Analytics: render markup with default consent
    Analytics->>Providers: initialize only allowed runtime behavior
    User->>Analytics: updateConsent(...)
    Analytics->>Providers: propagate updated consent
    User->>Analytics: trackEvent(...)
    Analytics->>Providers: send only to permitted providers

The shared service should support:

  • Default consent state at bootstrap
  • Runtime updates after user action
  • Region/app-specific defaults
  • Provider-specific consent mapping

This keeps the original ADR’s intent that default consent should be handled before analytics scripts are loaded and that tracking should not fire to providers unless consent allows it.

Provider-specific names and payloads should be centralised.

Example:

flowchart LR
    A[AboutCtaClicked] --> B[Mapping layer]
    B --> C[GA4: generate_lead]
    B --> D[Meta: Lead]
    B --> E[PostHog: about_cta_clicked]

This mapping layer is critical because it:

  • Prevents vendor names leaking into UI code
  • Makes migration possible
  • Allows one business event to be represented correctly per provider
  • Keeps payload transforms reviewable
const aboutCtaClicked = createEvent<{
location: "hero" | "footer";
}>({
name: "AboutCtaClicked",
});
const aboutCtaClickedDefinition = createEventDefinition(aboutCtaClicked, {
providers: {
ga4: (data) => ({
event: "generate_lead",
params: { location: data.location },
}),
meta: () => ({
event: "Lead",
}),
posthog: (data) => ({
event: "about_cta_clicked",
properties: { location: data.location },
}),
},
});

This keeps the original ADR’s intent to provide a standardised way to define and map event payloads across applications while still allowing application-specific payload extensions where needed.

At a high level, trackEvent(...) should do roughly this:

async function trackEvent(input) {
assertServiceInitialized();
const definition = eventRegistry.get(input.event);
if (!definition) return handleUnknownEvent(input);
if (input.meta?.key && (await dedupeStore.has(input.meta.key))) {
return;
}
const allowedProviders = getConsentAllowedProviders(currentConsent, providers);
for (const provider of allowedProviders) {
const mappedEvent = definition.resolve(provider.name, input.data);
if (!mappedEvent) continue;
await provider.trackEvent?.(
{
sourceEvent: input.event,
mappedEvent,
meta: input.meta,
},
runtimeContext,
);
}
if (input.meta?.key) {
await dedupeStore.set(input.meta.key);
}
}

The important steps are:

  • verify the service has been initialized
  • look up the application event definition
  • apply key-based idempotency rules if a key is present
  • check current consent and provider availability
  • resolve the provider-specific mapping
  • dispatch only to providers that both support and are allowed to receive that event
  • persist the key after successful dispatch when idempotency is being used

Debugging should be designed into the analytics service from the start rather than treated as an operational afterthought.

  • Keep provider mappings in code rather than making GTM the main source of truth
  • Provide a debug mode in the shared analytics service
  • Isolate provider adapter failures so one broken destination does not block all tracking
  • Make important provider mappings testable in unit tests
  • Expose enough structured debug information to diagnose routing, consent, and mapping issues locally

When debug mode is enabled, the service should make it easy to inspect:

  • the original event input
  • resolved metadata
  • the providers targeted for delivery
  • the providers skipped
  • the reason a provider was skipped, such as consent, missing config, disabled provider, or missing mapping
  • the mapped payload per provider
  • provider delivery success or failure

Recommended capabilities:

  • analytics.setDebug(enabled)
  • analytics.debugEvents()
  • analytics.onDebug(handler)

Debugging should be optional, disabled by default, and production-safe.

Recommended behavior:

  • do not emit debug records unless explicitly enabled
  • do not rely on debug mode for correctness
  • do not log directly to the console by default
  • treat debug payloads as potentially sensitive and redact where appropriate
  • ensure debug collection failure never blocks analytics delivery

An observer-style model is a good fit because it supports both local inspection and app-specific tooling without hard-wiring the package to one UI or storage mechanism.

Recommended shape:

type AnalyticsDebugEvent = {
event: string;
timestamp: string;
consent: ConsentState;
runtime: "browser" | "server" | "edge";
input: TrackEventInput;
routedProviders: string[];
skippedProviders: Array<{
provider: string;
reason: string;
}>;
mappedPayloads: Record<string, unknown>;
deliveries: Array<{
provider: string;
status: "sent" | "skipped" | "failed";
error?: string;
}>;
};

Recommended default implementation:

  • an in-memory ring buffer for recent debug events
  • optional observer subscription for live inspection
  • optional pluggable debug store when an application wants custom persistence or transport

This should mirror the idempotency-store decision: the package owns the debug event contract, but storage remains pluggable. Most applications should not need a custom store initially.

Example service-side wiring:

function createAnalyticsService<TEvents extends EventMap>(
config: AnalyticsConfig<TEvents>,
): AnalyticsApi<TEvents> {
let debugEnabled = config.debug?.enabled ?? false;
const debugStore =
config.debug?.store ?? createRingBufferDebugStore({ size: 100 });
const debugObservers = new Set<AnalyticsDebugObserver>();
if (config.debug?.observer) {
debugObservers.add(config.debug.observer);
}
function emitDebug(event: AnalyticsDebugEvent) {
if (!debugEnabled) return;
try {
debugStore.push(event);
} catch {}
for (const observer of debugObservers) {
try {
observer(event);
} catch {}
}
}
return {
setDebug(enabled) {
debugEnabled = enabled;
},
debugEvents() {
return debugStore.list();
},
onDebug(handler) {
debugObservers.add(handler);
return () => {
debugObservers.delete(handler);
};
},
async trackEvent(input) {
// Resolve consent, mappings, and provider deliveries.
// Then emit one structured debug event summarising the outcome.
emitDebug({
event: input.event,
timestamp: new Date().toISOString(),
runtime: "browser",
consent: currentConsent,
input,
routedProviders,
skippedProviders,
mappedPayloads,
deliveries,
});
},
};
}

This architecture is intentionally moving provider behavior and mappings into code. That gives better control than GTM-first orchestration, but only if engineers can easily answer:

  • what event was sent
  • what it was mapped to
  • where it went
  • why it did not go somewhere

The reusable analytics architecture should support a hybrid client/server event model.

Use:

  • client-side events for UI interactions, page views, step views, and experiment exposure
  • server-side events for confirmed business outcomes and provider integrations that rely on backend truth

The same package should support browser-only, server-only, and mixed deployments. The difference should come from the configured providers and runtime context, not from separate application-facing APIs.

Typical client-side events:

  • CTA clicked
  • product viewed
  • add to cart
  • checkout started
  • experiment exposure
  • step viewed

Typical server-side events:

  • purchase completed
  • booking completed
  • order refunded
  • order shipped
  • other backend-verified milestones

Request-time server events vs domain-event fanout

Section titled “Request-time server events vs domain-event fanout”

Not all server-side analytics should follow the same path.

Use direct server-side service calls when the provider payload depends on request/browser metadata available at the time of the request, for example:

  • source URL
  • user agent
  • IP address
  • fbc / fbp (Meta attribution identifiers: fbc is the Facebook click ID, typically derived from fbclid; fbp is the Facebook browser ID, typically derived from the _fbp cookie)
  • request-scoped consent or attribution context

This is a good fit for destination-specific conversions endpoints such as Meta Conversions API.

Recommended implementation approach:

  • construct the analytics service inside the request scope or call it with request-scoped metadata
  • pass request-derived context such as headers, URL, IP, user agent, and attribution identifiers explicitly
  • evaluate consent using the request’s consent state before dispatch
  • use server adapters only; do not depend on browser globals or browser persistence

Use domain events and background handlers when the event represents backend truth and may need retries, fanout, or asynchronous delivery.

Recommended examples:

  • order_created
  • order_refunded
  • order_shipped
  • user_account_created
  • product_inventory_updated

This is a good fit for Inngest-style processing.

Recommended implementation approach:

  • emit one domain event from the application service that owns the business action
  • let background handlers call the analytics package using server adapters
  • include stable business identifiers for idempotency and reconciliation
  • attach any legally required consent or suppression flags to the emitted event when request-time consent will no longer be available downstream

Prefer:

  • one emitted domain event per business occurrence
  • one handler per downstream responsibility

Example:

flowchart TD
    A[order_created] --> B[Klaviyo placed-order handler]
    A --> C[Triple Pixel order handler]
    A --> D[Other backend destination handler]

Avoid:

  • one giant analytics_event_received handler with a large switch over every event and destination
  • destination-shaped emitted events such as order_created_for_klaviyo

Shared registry and mappings across browser and server

Section titled “Shared registry and mappings across browser and server”

Backend and edge dispatch should use the same event registry and mapping approach as browser dispatch wherever the business event is the same.

That means:

  • applications still declare canonical business events once
  • browser and server runtimes both resolve those events through the registry
  • provider mappings stay version-controlled and reviewable in one model

When browser and server payload requirements differ, the registry can still remain shared while the mapping resolves differently per adapter. The important boundary is that event meaning is shared even when transport details are not.

Server-side dispatch must still respect consent. Backend truth does not remove the need for consent-aware routing.

Recommended rules:

  • use request-scoped consent when the event is emitted during a user request
  • persist or propagate the minimum consent/suppression state needed for async handlers when the event will be processed later
  • suppress providers that are not permitted for the relevant consent category
  • prefer deny-by-default when consent state is unavailable for consent-sensitive destinations

This is especially important for advertising and attribution destinations such as Meta Conversions API or similar server-side conversion endpoints.

This gives:

  • clearer ownership
  • better typing
  • simpler retries
  • easier testing
  • cleaner evolution of integrations over time

It also matches the stronger pattern already present in the DE implementation, where backend truth events are emitted once and separate handlers own separate downstream responsibilities.

For server-side events, idempotency should be based on stable business identifiers rather than frontend session storage or page lifecycle guards.

Examples:

  • order id
  • booking id
  • payment intent id
  • refund id

The analytics service and related handlers should prefer these durable identifiers whenever a server-side event must only be processed once.

Migration should be incremental.

Phase 1 - Shared package and Releaf UK integration

Section titled “Phase 1 - Shared package and Releaf UK integration”
  • Create packages/analytics
  • Support a minimal provider set
  • Integrate the shared package into Releaf UK first
  • Use wrappers/adapters in Releaf UK so app code can start converging without a full provider rewrite

Phase 2 - Remove vendor leakage from application code

Section titled “Phase 2 - Remove vendor leakage from application code”
  • Replace direct gtag calls with mapped events or dedicated adapter support
  • Remove UI-level provider hints such as analyticsType
  • Move provider transforms into the mapping layer in Releaf UK
  • Move Releaf UK onto the target small API consistently
  • Retain app-owned event maps
  • Migrate any required idempotency behavior into the shared package using optional meta.key semantics where appropriate

Phase 4 - Extend the approach to Releaf Lite (DE)

Section titled “Phase 4 - Extend the approach to Releaf Lite (DE)”
  • Evaluate the differences in Releaf Lite (DE) after the UK implementation has settled
  • Reuse the shared package and the learned provider/event patterns where they fit
  • Decide event-by-event whether GTM is an adapter or an app-specific secondary transport in DE
  • Avoid duplicate expression of the same business event through multiple uncontrolled paths

Feature flags and experiments are intentionally not included in the reusable analytics service.

They should be handled separately because:

  • Feature flags decide experience
  • Analytics records interactions and outcomes
  • Experiments sit across both concerns

Recommended direction:

  • Create a separate packages/feature-flags abstraction as a thin wrapper over PostHog feature flags
  • Consider a small experiments layer on top of feature flags plus analytics for exposure dedupe and standard experiment metadata

This ADR only requires that the analytics package does not block those future abstractions.

  • Default consent is required for GA4 regardless of whether GTM is used
  • Tracking should not fire events to providers unless consent allows it
  • Events should usually fire in handler functions rather than large useEffect tracking blocks
  • PostHog must be supported as more than a simple analytics transport because it is already used for flags and experimentation-related behavior
  • One analytics architecture for multiple applications
  • Clearer separation between application events and provider implementations
  • Improved control, debuggability, and reviewability
  • Explicit consent-aware bootstrap model
  • Reduced vendor lock-in in application code
  • Clean migration path from current UK and DE implementations
  • Non-trivial initial implementation and migration effort
  • Requires discipline to keep direct vendor usage out of new application code
  • Introduces an additional shared package that must be maintained carefully
  • Define the initial packages/analytics public API
  • Define the provider interface including markup contributions
  • Define the initial consent model and region-specific defaults
  • Select the first supported providers for migration
  • Document a small set of shared cross-app event conventions
  • Prepare migration wrappers for the current UK and DE apps
  • Create separate ADRs for reusable feature flags and experiments