Skip to content

Analytics Usage Guide

This guide shows how the reusable analytics service should be used in the Releaf applications. The package is placed at packages/analytics and can be installed as @releafuk/analytics package.

Defining events, as well as the event names and payload structure, is the responsibility of the application, not the analytics service. The service is responsible for validating the input type, which is then transmitted as a payload.

import { createEvent } from "@releafuk/analytics";
type CtaClickedInput = {
location: "hero" | "header";
};
const ctaClicked = createEvent<CtaClickedInput, "CtaClicked">({
name: "CtaClicked",
});

Use stable, concise, and clear event names such as CtaClicked, Purchase, or GlobalNavItemClicked. Keep payload typed, and free from provider-specific field names.

The event registry can be composed from separate registrations. Register event definitions with one mapper per provider. The provider key must match the provider name.

import { createEventDefinition, createEventRegistry } from "@releafuk/analytics";
const events = createEventRegistry().register(
createEventDefinition(ctaClicked, {
providers: {
ga4: (data) => ({
event: "cta_clicked",
params: {
location: data.location,
},
}),
posthog: (data) => ({
event: "CTA Clicked",
properties: {
location: data.location,
},
}),
},
}),
);

If there is no event mapper for a specific provider, the provider is skipped for that event.

Configure the service once during the app startup. Applications should export the configured analytics instance which can be placed in a dedicated module.

import {
createAnalyticsService,
ga4Provider,
postHogProvider
} from "@releafuk/analytics";
export const analytics = createAnalyticsService({
app: "releaf",
events,
providers: [
ga4Provider({
requiredConsent: ["analytics"],
config: { measurementId: "G-MEASUREMENT-ID" },
}),
postHogProvider({
config: { id: "POSTHOG-ID" },
}),
],
consent: {
default: {
analytics: "denied",
advertising: "denied",
functionality: "denied",
adUserData: "denied",
adPersonalization: "denied",
},
},
onError(error, context) {
console.error("[analytics] error:", { error, context });
},
});
await analytics.init();

The object passed to createAnalyticsService() should satisfy the AnalyticsConfig type:

type AnalyticsConfig<TEvents extends EventMap = EventMap> = {
app: string;
providers: AnalyticsProvider[];
events: EventRegistry<TEvents>;
consent: {
default: ConsentState;
store?: ConsentStore;
};
dedupe?: {
store?: DedupeStore;
};
debug?: { enabled?: boolean; observer?: AnalyticsDebugObserver } & (
| { store?: DebugStore; size?: never }
| { store?: never; size?: number }
);
onError?: (
error: unknown,
context: AnalyticsErrorContext<AnalyticsProvider["name"]>,
) => void;
};

Methods such as page(), identify(), trackEvent(), or updateConsent() should be called after init(). Otherwise those methods throw an error if the service is not initialized.

There are multiple methods that Analytics Service API provides for tracking purposes: init(), trackEvent(), identify(), updateConsent(), and page().

Initialize the analytics service once during application startup. The service reads saved consent from the configured store and initializes providers that are allowed by the current consent state.

await analytics.init();

Call analytics.isInitialized() method to check if analytics service is initialized:

analytics.isInitialized();

Track application events through the shared event name and typed data. The service resolves provider payloads before dispatching.

Input structure:

type TrackEventInput = {
event: string;
data?: Record<string, unknown>;
meta?: {
eventId?: string;
key?: Array<string | number>;
version?: string;
source?: string;
};
};
await analytics.trackEvent({
event: "CtaClicked",
data: {
location: "hero",
},
meta: {
source: "home-page",
version: "1",
},
});

Identify the user after the authentication.

Input structure:

type IdentifyInput = {
userId: string;
traits?: Record<string, unknown>;
};
await analytics.identify({
userId: user.userId,
traits: {
email: user.email,
},
});

Update the current consent state after the user changes cookie preferences.

Input structure:

type ConsentState = {
analytics: "granted" | "denied";
advertising: "granted" | "denied";
functionality?: "granted" | "denied";
adUserData?: "granted" | "denied";
adPersonalization?: "granted" | "denied";
};
await analytics.updateConsent({
analytics: "granted",
advertising: "denied",
functionality: "granted",
adUserData: "denied",
adPersonalization: "denied",
});

Track page views after the service has been initialized.

Input structure:

type PageInput = {
name?: string;
data?: Record<string, unknown>;
meta?: {
eventId?: string;
};
};
await analytics.page({
name: "Pricing",
data: {
path: "/pricing",
},
});

If the input is undefined the analytics service generates a default page payload for browser environments:

function getPageData(): PageInput {
const { document, location } = window;
const { title, referrer } = document;
const { pathname, href } = location;
return {
name: title,
data: {
path: pathname,
url: href,
referrer,
title,
},
};
}

Consent controls which providers receive init, page, identify, and trackEvent calls. For example, a provider with requiredConsent: ["analytics", "advertising"] only receives those calls when both keys are granted.

Use a consent store to persist consent between page loads:

const analytics = createAnalyticsService({
// ...
consent: {
default: {
analytics: "denied",
advertising: "denied",
functionality: "denied",
adUserData: "denied",
adPersonalization: "denied",
},
store: consentStore,
},
});

A consent store should be defined per-app based on the application needs, and should satisfy the ConsentStore type:

type ConsentStore = {
has(key: string): boolean | Promise<boolean>;
set(key: string, value: string): void | Promise<void>;
delete(key: string): void | Promise<void>;
clear(): void | Promise<void>;
read(): (ConsentState | null) | Promise<ConsentState | null>;
};

On analytics.init(), the service reads the store before initialising providers. On analytics.updateConsent(), it updates the runtime state, calls every provider’s provider.updateConsent() hook, and writes each consent value to the store.

await analytics.updateConsent({
analytics: "granted",
advertising: "denied",
functionality: "granted",
adUserData: "denied",
adPersonalization: "denied",
});

analytics.init() does not re-run when the consent changes. If there is a provider-specific logic when consent is updated, that should be handled in the provider’s provider.updateConsent() method.

Use deduplication for events that need idempotency providing a stable logical key.

await analytics.trackEvent({
event: "Purchase",
data: {
orderId: "order-123",
total: 150,
currency: "GBP",
},
meta: {
key: ["purchase", "order-123"],
},
});

Deduplication requires both a configured dedupe store and meta.key. If the key exists, the service skips the event. If the event is processed, the key is stored, and will not be dispatched again until the key is cleared from the store.

Use the reset methods to clear the store:

await analytics.resetKey?.(["purchase", "order-123"]);
await analytics.resetAllKeys?.();

Similar to the consent store, a dedupe store should also be defined per-app based on the application needs. 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 dedupe store should satisfy the DedupeStore type:

export type DedupeKey = Array<string | number>;
export type DedupeStore = {
has(key: DedupeKey): boolean | Promise<boolean>;
set(key: DedupeKey): void | Promise<void>;
delete(key: DedupeKey): void | Promise<void>;
clear(): void | Promise<void>;
};

Enable debug mode to inspect how analytics.trackEvent() calls are routed and delivered. Debug entries include:

  • the original event input
  • the active consent state
  • the providers targeted for delivery
  • skipped providers and skip reasons, such as consent_denied, unknown_event, event_definition_not_found, or track_event_not_defined
  • the mapped payload per provider
  • provider delivery success or failure
const analytics = createAnalyticsService({
// ...
debug: {
enabled: true,
observer: (event) => {
console.info("[analytics]", event);
},
},
});

When debug mode is enabled, the service creates an AnalyticsDebugEvent and passes it to the internal emitDebug() function. emitDebug() writes the entry to the configured debug store and then calls every registered debug observer.

type AnalyticsDebugEvent = {
event: string;
timestamp: string;
consent: {
analytics: "granted" | "denied";
advertising: "granted" | "denied";
functionality?: "granted" | "denied";
adUserData?: "granted" | "denied";
adPersonalization?: "granted" | "denied";
};
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;
}>;
};

Debug observers can be configured up front with debug.observer or registered later with analytics.onDebug():

const unsubscribe = analytics.onDebug?.((event) => {
console.info("[analytics debug]", event);
});
unsubscribe?.();

If no custom store is configured, the service uses a default ring buffer debug store with a size of 100. The ring buffer keeps the most recent debug entries in memory and removes the oldest entry when the buffer is full.

Custom stores should satisfy the DebugStore type:

export type DebugStore = {
push(event: AnalyticsDebugEvent): void;
list(): AnalyticsDebugEvent[];
clear?(): void;
};

Use analytics.debugEvents() to read the entries from the configured store. Debug collection can be toggled at runtime with analytics.setDebug(true) or analytics.setDebug(false).

Use analytics.on() for higher-level app logs across init, page, identify, trackEvent, and updateConsent.

const unsubscribe = analytics.on({
event: "trackEvent",
callback: ({ event, payload }) => {
console.info(event, payload);
},
});
unsubscribe();

Providers are adapters between the analytics service and external analytics tools. The service owns routing, consent checks, deduplication, and event mapping. A provider owns provider-specific setup and events dispatch.

Each provider should satisfy the AnalyticsProvider type:

type AnalyticsProvider<
TConfig extends Record<string, unknown> = Record<string, unknown>,
TClient = unknown
> = {
name: string;
runtime: "browser" | "server" | "universal";
requiredConsent?: Array<keyof ConsentState>;
config?: TConfig;
markup?(config: TConfig): MarkupContribution[];
init?(config: TConfig, context: RuntimeContext): void | Promise<void>;
page?(input: PageInput, context: RuntimeContext): void | Promise<void>;
trackEvent?(
input: TrackEventInput,
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>;
getState?(): "idle" | "pending" | "ready" | "error";
getClient?(): TClient;
};

Provider methods receive a runtime context with the current app name and consent state:

type RuntimeContext = {
app: string;
consent: ConsentState;
};

Keep provider modules focused on provider behavior. Keep application event names and provider payload mappings in event definitions.

Some providers also require markup and/or consent defaults before runtime, such as script tags, inline bootstrap scripts, noscript fallbacks, or preconnect hints. Providers expose this through the optional markup() method.

const provider: AnalyticsProvider<{ measurementId: string }> = {
name: "ga4",
config: { measurementId: "G-MEASUREMENT-ID" },
markup: (config) => [
{
kind: "script-src",
src: `https://www.googletagmanager.com/gtag/js?id=${config.measurementId}`,
async: true,
},
{
kind: "inline-script",
id: "ga4-bootstrap",
content: "window.dataLayer = window.dataLayer || [];",
},
],
};

The analytics service exposes analytics.markup() to collect markup contributions from all configured providers:

const markup = analytics.markup?.();

Markup entries use this structure:

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 are responsible for rendering those entries in the right place for their framework. Consent-sensitive providers should make their bootstrap markup safe to render before consent.

The analytics example app demonstrates the analytics service in action in a small Astro application. Use it to test the full analytics flow before wiring behavior into a production app.

Run the example app from the repository root:

Terminal window
pnpm dev:analytics-example

The example app covers:

  • event contracts and provider mappings in apps/analytics-example/src/libs/analytics.ts
  • provider dispatch through mock providers
  • page views, user identification, and tracked UI actions
  • cookies consent store in apps/analytics-example/src/store/consent.ts
  • localStorage deduplication store in apps/analytics-example/src/store/dedupe.ts
  • debug entries for trackEvent() routing and delivery
  • visible logs for service lifecycle calls and event payloads

Use the app UI to grant or deny consent, trigger events, repeat deduplicated flows, clear dedupe state, and inspect the logs/debug sidebar.

More details can be found in the analytics-example README.md

The analytics service integrates with multiple analytics providers used across Releaf applications. These providers help measure user interactions, marketing performance, and conversions. These tools are:

  • Google Analytics 4 (GA4): Tracks user interactions, page views, and conversion events across the web applications.
  • PostHog: Supports product analytics, session recording, feature flags, experiments etc.
  • Meta Pixel: Tracks conversions and user actions for Meta advertising campaigns.
  • Taboola Pixel: Tracks conversions and engagement from Taboola campaigns.
  • Google Ads Conversion Tracking: Measures Google Ads campaign performance and conversions.
  • Triple Whale: Supports attribution and performance reporting for e-commerce flows.
  • Microsoft Advertising UET: Measures conversions for Microsoft Ads campaigns.
  • Quora Pixel: Tracks conversions from Quora campaigns.
  • Reddit Pixel: Tracks conversions from Reddit campaigns.
  • X Pixel: Tracks conversions from X advertising campaigns.
  • TikTok Pixel: Tracks conversions from TikTok campaigns.