Developer Demo

Event Tracking

A global tracking layer: one trackEvent() wrapper fans conversion events out to Feature Experimentation, ODP, and any other destination - closing the impression-to-conversion loop for experiments.

Live demo#

The button fires a demo_cta_click conversion through the tracking layer. The dispatch log below shows each event and which destinations received it - scroll the page and the automatic mb_scroll_depth events appear in the same log, because they flow through the same wrapper.

Conversions fired: 0

Visitor ID: (cookie not set yet)

Live dispatch log - every trackEvent() call on this page, including the automatic mb_* events fired by AutoTracker as you scroll and navigate.

No events yet. Click the button above, or scroll the page to trigger mb_scroll_depth.

The log is fed by subscribe() from src/lib/tracking - an in-memory observer on the same fan-out that delivers to the real destinations. ODP shows skipped unless the zaius script is configured.

One wrapper, many destinations#

Analytics calls scattered through components couple every feature to every vendor. The tracking layer inverts that: components call a single trackEvent(key, tags) and the layer resolves the visitor identity once (the optimizelyEndUserId cookie set by middleware), then fans the event out to every registered destination. This project ships three: the FX browser client, ODP via the zaius script, and a window.dataLayer push (the GTM/GA convention).

The destination contract and fan-out
// A destination is anything that can receive an event.
// src/lib/tracking/types.ts

export type TrackedEvent = {
  key: string;
  tags: Record<string, string | number | boolean>;
  userId: string;      // stable optimizelyEndUserId cookie
  timestamp: number;
};

export type TrackingDestination = {
  name: string;
  send(event: TrackedEvent): DeliveryStatus | Promise<DeliveryStatus>;
};

// The wrapper fans a single call out to every registered destination.
// One failing sink never blocks the others - each send() is isolated
// in its own try/catch, and errors are swallowed: tracking must never
// break the page.

await trackEvent("demo_cta_click", { source: "demo-page" });
// → FX:        user.trackEvent("demo_cta_click", tags)
// → ODP:       zaius.event("demo_cta_click", { ...tags })
// → dataLayer: window.dataLayer.push({ event: "demo_cta_click", ... })
Adding a new source
// Adding a source is one object - no call sites change.
import { registerDestination } from "@/lib/tracking";

registerDestination({
  name: "Segment",
  send(event) {
    if (!window.analytics) return "skipped";
    window.analytics.track(event.key, { ...event.tags, userId: event.userId });
    return "sent";
  },
});

// Every existing trackEvent() call - CTA clicks, form submits,
// scroll depth, outbound links - now also reaches Segment.

The impression-to-conversion loop#

An A/B test needs two events tied to the same visitor: an impression (this user saw variation B) and a conversion (this user then did the thing we care about). This project suppresses impressions at decision time with DISABLE_DECISION_EVENT - middleware and server components decide flags several times per request, and firing on every decide would double-count. Only the component that renders the variation fires the impression. Conversions then attribute correctly because the tracking layer uses the same stable visitor ID for trackEvent that middleware used for bucketing.

Impression suppression and conversion attribution
// 1. Middleware buckets the visitor server-side and SUPPRESSES the
//    impression (DISABLE_DECISION_EVENT) - deciding is not seeing.
const decision = user.decide("homepage");   // no event fired

// 2. The page that actually renders the variation fires the impression
//    client-side (FxBucketingEvent) with the same optimizelyEndUserId.
ctx.decide("homepage", []);                 // empty options = fire impression

// 3. The conversion fires through the tracking layer with the SAME
//    visitor ID, so FX can attribute it to the bucketed variation.
trackEvent("demo_cta_click", { source: "hero" });

// FX Results then compares conversion rates per variation:
//   variation A: 1,204 impressions, 87 demo_cta_click  → 7.2%
//   variation B: 1,198 impressions, 112 demo_cta_click → 9.3%

Declarative auto-tracking#

AutoTracker (mounted once in the root layout) listens for clicks, toggles, submits, and scrolling with document-level listeners, so most events need no component code at all - a data-track-event attribute is enough. Because AutoTracker calls the same wrapper, every destination gets these events too.

Data attributes AutoTracker understands
<!-- AutoTracker turns data attributes into events - no JS per element. -->

<!-- Click tracking -->
<a href="/savings" data-track-event="cta_click" data-track-tags='{"area":"hero"}'>
  Open an account
</a>

<!-- Accordion opens -->
<div data-track-toggle="faq_open">
  <details><summary>What are the fees?</summary>...</details>
</div>

<!-- Form submits -->
<div data-track-submit="contact_submit">
  <form>...</form>
</div>

<!-- Fired automatically, no attributes needed:
     mb_outbound_click - clicks on external links
     mb_scroll_depth   - 25 / 50 / 75 / 100% scroll marks
     mb_time_on_page   - 30 / 60 / 180 second marks
     mb_demo_page_view - /demo route views -->

FX setup: the event key must exist#

Silently dropped otherwise

Feature Experimentation only accepts events whose key is defined in the FX project - trackEvent calls with unknown keys are dropped without an error. Create the event in the FX UI (or via the Optimizely Experimentation MCP server), then attach it as a metric to a flag rule so results accumulate per variation. Like CMS Variations, this is a one-time manual setup step per project. The ODP and dataLayer destinations have no such registry - they accept any key.

Key Things to Know#

  • Components call one wrapper, never a vendor SDK. trackEvent(key, tags) resolves identity once and fans out to all destinations - adding a vendor is one TrackingDestination object, zero call-site changes.
  • Tracking must never break the page. Every destination send is isolated in its own try/catch; a failing or missing sink (ODP without the zaius script) reports skipped or error and the rest still deliver.
  • Impressions and conversions must share a visitor ID. Both use the optimizelyEndUserId cookie set by middleware - a fresh UUID per request would make conversions unattributable.
  • Suppress impressions at decide time, fire at render time. DISABLE_DECISION_EVENT everywhere except the component that renders the variation prevents double-counting.
  • FX drops events with unknown keys - silently. Define the event in the FX project and attach it as a metric to a flag rule before expecting results.
  • Prefer declarative tracking for content. data-track-event attributes work inside CMS-rendered markup where you can't add click handlers.
Source files6 files
src/lib/tracking/index.ts
"use client";

import { getVisitorId } from "./cookies";
import { dataLayerDestination } from "./destinations/dataLayer";
import { fxDestination } from "./destinations/fx";
import { odpDestination } from "./destinations/odp";
import type { TrackedEvent, TrackingDestination } from "./types";

export type { TrackedEvent, TrackingDestination } from "./types";

export type DispatchResult = {
  destination: string;
  status: "sent" | "skipped" | "error";
};

export type DispatchRecord = {
  event: TrackedEvent;
  results: DispatchResult[];
};

const destinations: TrackingDestination[] = [
  fxDestination,
  odpDestination,
  dataLayerDestination,
];

type Listener = (record: DispatchRecord) => void;
const listeners = new Set<Listener>();

export function registerDestination(destination: TrackingDestination): void {
  if (!destinations.some((d) => d.name === destination.name)) {
    destinations.push(destination);
  }
}

export function subscribe(listener: Listener): () => void {
  listeners.add(listener);
  return () => listeners.delete(listener);
}

export async function trackEvent(
  eventKey: string,
  tags?: Record<string, string | number | boolean | null | undefined>
): Promise<void> {
  if (typeof window === "undefined") return;

  const cleanTags: Record<string, string | number | boolean> = {};
  if (tags) {
    for (const [k, v] of Object.entries(tags)) {
      if (v === undefined || v === null) continue;
      cleanTags[k] = v;
    }
  }

  const event: TrackedEvent = {
    key: eventKey,
    tags: cleanTags,
    userId: getVisitorId(),
    timestamp: Date.now(),
  };

  // Every destination gets the event; one failing sink never blocks the others.
  const results = await Promise.all(
    destinations.map(async (destination): Promise<DispatchResult> => {
      try {
        const status = await destination.send(event);
        return { destination: destination.name, status };
      } catch {
        return { destination: destination.name, status: "error" };
      }
    })
  );

  for (const listener of listeners) {
    try {
      listener({ event, results });
    } catch {
      // Tracking must never break the page.
    }
  }
}
src/lib/tracking/destinations/fx.ts
import { getOptimizelyBrowserClient } from "@/lib/optimizely/browser-client";
import { readCookie } from "../cookies";
import type { TrackingDestination } from "../types";

export const fxDestination: TrackingDestination = {
  name: "Feature Experimentation",
  async send(event) {
    const client = await getOptimizelyBrowserClient();
    if (!client) return "skipped";
    await client.onReady();
    const attributes: Record<string, string> = { hostname: window.location.hostname };
    const persona = readCookie("demo_persona");
    if (persona) attributes.persona = persona;
    const user = client.createUserContext(event.userId, attributes);
    if (!user) return "skipped";
    user.trackEvent(event.key, event.tags);
    return "sent";
  },
};
src/lib/tracking/destinations/odp.ts
import type { TrackingDestination } from "../types";

declare global {
  interface Window {
    zaius?: {
      entity: (type: string, attrs: Record<string, string>) => void;
      event: (type: string, props?: Record<string, unknown>) => void;
    };
  }
}

export const odpDestination: TrackingDestination = {
  name: "Data Platform (ODP)",
  send(event) {
    if (!window.zaius) return "skipped";
    window.zaius.event(event.key, { ...event.tags, fs_user_id: event.userId });
    return "sent";
  },
};
src/lib/tracking/destinations/dataLayer.ts
import type { TrackingDestination } from "../types";

declare global {
  interface Window {
    dataLayer?: Record<string, unknown>[];
  }
}

export const dataLayerDestination: TrackingDestination = {
  name: "dataLayer",
  send(event) {
    window.dataLayer = window.dataLayer ?? [];
    window.dataLayer.push({ event: event.key, userId: event.userId, ...event.tags });
    return "sent";
  },
};
src/components/AutoTracker.tsx
"use client";

import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { trackEvent } from "@/lib/tracking";

function parseTags(raw: string | null): Record<string, unknown> {
  if (!raw) return {};
  try {
    return JSON.parse(raw);
  } catch {
    return {};
  }
}

function isOutbound(href: string): boolean {
  if (!href || href.startsWith("#") || href.startsWith("/")) return false;
  try {
    const url = new URL(href, window.location.origin);
    return url.host !== window.location.host;
  } catch {
    return false;
  }
}

export default function AutoTracker() {
  const pathname = usePathname();
  const scrollMarks = useRef(new Set<number>());
  const timeMarks = useRef(new Set<number>());
  const pageStart = useRef(Date.now());
  const lastPath = useRef<string | null>(null);

  useEffect(() => {
    function onClick(e: MouseEvent) {
      const target = e.target as Element | null;
      if (!target) return;
      const anchor = target.closest("a") as HTMLAnchorElement | null;
      const trackEl = target.closest("[data-track-event]") as HTMLElement | null;

      if (trackEl) {
        const key = trackEl.getAttribute("data-track-event")!;
        const tags = parseTags(trackEl.getAttribute("data-track-tags"));
        if (anchor && !("href" in tags)) (tags as Record<string, unknown>).href = anchor.getAttribute("href") ?? "";
        trackEvent(key, tags as Record<string, string | number | boolean>);
      }

      if (anchor) {
        const href = anchor.getAttribute("href") ?? "";
        if (isOutbound(href)) {
          trackEvent("mb_outbound_click", { href, label: anchor.textContent?.trim().slice(0, 120) ?? "" });
        }
      }
    }

    function onToggle(e: Event) {
      const el = e.target as HTMLElement | null;
      if (!el || !(el instanceof HTMLDetailsElement)) return;
      const wrap = el.closest("[data-track-toggle]") as HTMLElement | null;
      if (!wrap) return;
      if (!el.open) return;
      const key = wrap.getAttribute("data-track-toggle")!;
      const tags = parseTags(wrap.getAttribute("data-track-tags"));
      const summary = el.querySelector("summary")?.textContent?.trim().slice(0, 200);
      if (summary && !("label" in tags)) (tags as Record<string, unknown>).label = summary;
      trackEvent(key, tags as Record<string, string | number | boolean>);
    }

    function onSubmit(e: SubmitEvent) {
      const form = e.target as HTMLElement | null;
      if (!form) return;
      const wrap = form.closest("[data-track-submit]") as HTMLElement | null;
      if (!wrap) return;
      const key = wrap.getAttribute("data-track-submit")!;
      const tags = parseTags(wrap.getAttribute("data-track-tags"));
      trackEvent(key, tags as Record<string, string | number | boolean>);
    }

    function onScroll() {
      const doc = document.documentElement;
      const max = doc.scrollHeight - doc.clientHeight;
      if (max <= 0) return;
      const pct = Math.round((window.scrollY / max) * 100);
      for (const mark of [25, 50, 75, 100]) {
        if (pct >= mark && !scrollMarks.current.has(mark)) {
          scrollMarks.current.add(mark);
          trackEvent("mb_scroll_depth", { depth: mark, path: window.location.pathname });
        }
      }
    }

    document.addEventListener("click", onClick, true);
    document.addEventListener("toggle", onToggle, true);
    document.addEventListener("submit", onSubmit, true);
    window.addEventListener("scroll", onScroll, { passive: true });

    return () => {
      document.removeEventListener("click", onClick, true);
      document.removeEventListener("toggle", onToggle, true);
      document.removeEventListener("submit", onSubmit, true);
      window.removeEventListener("scroll", onScroll);
    };
  }, []);

  useEffect(() => {
    if (lastPath.current === pathname) return;
    lastPath.current = pathname;
    scrollMarks.current = new Set();
    timeMarks.current = new Set();
    pageStart.current = Date.now();

    if (pathname?.startsWith("/demo")) {
      trackEvent("mb_demo_page_view", { path: pathname });
    }

    const timers = [30, 60, 180].map((secs) =>
      window.setTimeout(() => {
        if (timeMarks.current.has(secs)) return;
        timeMarks.current.add(secs);
        trackEvent("mb_time_on_page", { seconds: secs, path: pathname ?? "" });
      }, secs * 1000)
    );
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, [pathname]);

  return null;
}
src/app/demo/event-tracking/ConversionDemo.tsx
"use client";
import { useEffect, useState } from "react";
import { trackEvent, subscribe, type DispatchRecord } from "@/lib/tracking";

const MAX_LOG_ENTRIES = 8;

const STATUS_STYLES: Record<string, string> = {
  sent:    "bg-green-50 text-green-700 border-green-200",
  skipped: "bg-amber-50 text-amber-700 border-amber-200",
  error:   "bg-red-50 text-red-700 border-red-200",
};

function readCookie(name: string): string {
  return document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))?.[1] ?? "";
}

export default function ConversionDemo() {
  const [userId, setUserId] = useState("");
  const [clicks, setClicks] = useState(0);
  const [log, setLog] = useState<DispatchRecord[]>([]);

  useEffect(() => {
    setUserId(readCookie("optimizelyEndUserId"));
    return subscribe((record) => {
      setLog((prev) => [record, ...prev].slice(0, MAX_LOG_ENTRIES));
    });
  }, []);

  return (
    <div data-component="ConversionDemo" className="bg-surface-lowest border border-ghost-border rounded-2xl p-6 space-y-5">
      <div className="flex flex-wrap items-center gap-4">
        <button
          type="button"
          onClick={() => {
            setClicks((c) => c + 1);
            void trackEvent("demo_cta_click", { source: "demo-page", clickNumber: clicks + 1 });
          }}
          className="bg-gradient-brand text-on-brand text-sm font-semibold px-6 py-3 rounded-xl hover-lift transition-all"
        >
          Open a savings account
        </button>
        <div className="text-xs text-on-surface-variant space-y-1">
          <p>
            Conversions fired: <strong className="text-on-surface font-mono">{clicks}</strong>
          </p>
          <p>
            Visitor ID:{" "}
            <code className="bg-surface px-1 rounded font-mono">{userId || "(cookie not set yet)"}</code>
          </p>
        </div>
      </div>

      <div>
        <p className="text-xs font-medium text-on-surface-variant mb-2">
          Live dispatch log - every trackEvent() call on this page, including the automatic{" "}
          <code className="bg-surface px-1 rounded font-mono">mb_*</code> events fired by AutoTracker
          as you scroll and navigate.
        </p>
        {log.length === 0 ? (
          <p className="text-xs text-on-surface-variant italic">
            No events yet. Click the button above, or scroll the page to trigger{" "}
            <code className="bg-surface px-1 rounded font-mono">mb_scroll_depth</code>.
          </p>
        ) : (
          <ul className="space-y-2">
            {log.map((record) => (
              <li
                key={`${record.event.key}-${record.event.timestamp}`}
                className="bg-surface rounded-xl px-4 py-3 text-xs flex flex-wrap items-center gap-2"
              >
                <span className="font-mono font-semibold text-on-surface">{record.event.key}</span>
                {Object.keys(record.event.tags).length > 0 && (
                  <span className="font-mono text-on-surface-variant truncate max-w-60">
                    {JSON.stringify(record.event.tags)}
                  </span>
                )}
                <span className="ml-auto flex gap-1.5">
                  {record.results.map((r) => (
                    <span
                      key={r.destination}
                      className={`px-2 py-0.5 rounded-full border font-medium ${STATUS_STYLES[r.status]}`}
                    >
                      {r.destination}: {r.status}
                    </span>
                  ))}
                </span>
              </li>
            ))}
          </ul>
        )}
      </div>

      <p className="text-xs text-on-surface-variant border-t border-ghost-border pt-3">
        The log is fed by <code className="bg-surface px-1 rounded font-mono">subscribe()</code> from{" "}
        <code className="bg-surface px-1 rounded font-mono">src/lib/tracking</code> - an in-memory
        observer on the same fan-out that delivers to the real destinations. ODP shows{" "}
        <em>skipped</em> unless the zaius script is configured.
      </p>
    </div>
  );
}