CDP Web SDK Tracking

The LEO Web SDK collects browser events and profile updates for Customer Data Platform (CDP). The public integration is the proxy script in leo.proxy.js. It creates a hidden cross-origin iframe, which loads the observer implementation and sends data to the configured LEO log domain.

Unified User & Unified Campaign Data Flow

The SDK is the acquisition layer of the platform. On its own it only knows about one browser/device; it becomes a “unified user” (a single cdp_master_profiles record spanning web, app, POS, and CRM touchpoints) once its events reach the server-side pipeline documented in CIR-improvement.md and TECHNICAL-DOCUMENTATION.md. At a high level:

flowchart LR
    subgraph Browser
        SDK["LEO Observer SDK\n(leo.proxy.js / leo.observer.js)"]
        GTM["Google Tag Manager\n(optional wrapper)"]
        FB["Meta Pixel / FB SDK"]
        TT["TikTok Pixel"]
    end

    subgraph Ingestion
        API["customer360-event-api\nPOST /data/api/v1/tracking/logs"]
        QUEUE["Redis Streams"]
        S3["S3 / MinIO Bronze\nimmutable JSONL event objects"]
    end

    subgraph Staging["Staging (customer360 schema)"]
        RAW["cdp_raw_profiles_stage\n(per-source identity + attribution)"]
        EVT["S3/MinIO event lake\n(raw and projected events)"]
        CAMPAIGN["crm_campaign_performance_daily\n(daily experiment aggregates)"]
    end

    subgraph Resolution["Identity & Understanding"]
        CIR["Customer Identity Resolution\n(customer360-backend/identity_resolution)"]
        MP["cdp_master_profiles\n= the Unified User"]
        PERSONA["Persona Resolution Engine\n(cdp_customer_personas)"]
    end

    subgraph Activation["Unified Campaign Activation"]
        SEG["cdp_segments\n(Audience Builder)"]
        CRM["crm_campaign / crm_campaign_member"]
		ORCH["customer360-backend/campaign_activation\n(Dagster job)"]
        CHAN["Ads / Email / Push / Web personalization"]
    end

    GTM --> SDK
    FB -.identity hints.-> SDK
    TT -.identity hints.-> SDK
    SDK -->|"visitor/session/custom events\nand profile_data"| API
    API --> QUEUE
    QUEUE --> S3
    S3 --> EVT
    S3 -->|"analytics job normalizes profile fields"| RAW
    S3 -->|"eligible campaign events"| CAMPAIGN
    RAW --> CIR
    EVT --> CIR
    CIR --> MP
    MP --> PERSONA
    MP --> SEG
    PERSONA --> SEG
    SEG --> CRM
    CRM --> ORCH
    ORCH --> CHAN
    CHAN -.re-targeting / personalization.-> Browser
StageWhere it happensWhat it produces
Collectionleo.proxy.js / leo.observer.js in the browserBatches view/action/conversion/feedback events and profile updates, keyed by visitor/session identifiers.
Event ingestioncustomer360-event-api POST /data/api/v1/tracking/logsThe configured observer/log domain routes to this API. The API validates identity hints, enqueues the batch in Redis Streams, and a worker writes the immutable event envelope to S3/MinIO.
Profile staging and analyticscustomer360-backend/analytics/source_analyticsThe analytics job reads S3 event objects, normalizes event/profile fields, upserts per-source rows in cdp_raw_profiles_stage, and aggregates eligible experiment metrics in crm_campaign_performance_daily. The staging row is not a full per-event history; the raw S3 objects remain the event record.
Identity resolution (CIR)customer360-backend/identity_resolution (resolver.py, run via daily_job.py or the Dagster job)Matches cdp_raw_profiles_stage rows onto a single cdp_master_profiles row per real person — the unified user — using the dynamic matching rules in cdp_profile_attributes (exact match on email/phone/device_id/advertising_id/cookie_id/external_customer_id, fuzzy match on name/address).
UnderstandingPersona Resolution Engine (leo_customer360_dao/agentic_engines/persona_engine.py)Computes behavior/engagement/financial/loyalty/relationship/risk scores and a persona per unified user, stored on cdp_customer_personas.
Activation (“unified campaign”)cdp_segments (Audience Builder), crm_campaign/crm_campaign_member, customer360-backend/campaign_activationSegments query master profiles (and their personas/domain attributes) across every source system to build one audience; a campaign then targets that single, deduplicated audience instead of one list per channel. Note: campaign_activation’s Dagster job is currently a placeholder (log → sleep → log) - real per-channel activation (email/push/ads) still needs to be implemented against it, see PLAN-CAMPAIGNS-DEV.md.

In short: the SDK does not call identity resolution directly. It sends event and profile data to the tracking API; the analytics job projects the supported profile fields from S3 into raw-profile staging, then CIR resolves them into cdp_master_profiles. updateProfileBySession does not synchronously update PostgreSQL. Keep identity fields consistent across events and profile updates so downstream resolution can link them.

SDK Files

FilePurpose
observer/leo.proxy.jsPublic page-side API, tag audit, iframe creation, and postMessage bridge.
html/cdp-event-proxy.htmlHidden iframe bridge. It loads FingerprintJS2 and the observer implementation only while tracking consent is allowed.
observer/leo.observer.jsObserver implementation: fingerprinting, visitor/session state, network requests, and batching.
observer/hash.jsFingerprint hashing/cache library source. The current iframe HTML loads FingerprintJS2 2.1.5 from CDN instead of this file directly.
observer/leo.newsletter.modal.jsOptional standalone newsletter subscription modal. It dispatches browser events but does not send CDP events by itself.

The checked-in proxy source is version 0.9.5. The checked-in observer source is version 0.9.3; the iframe currently loads the published minified observer build from the v0.9.5 jsDelivr package.

Installation

Set the configuration globals before loading the proxy script:

<script>
	window.leoC360SourceId = "YOUR_C360_SOURCE_ID";
	window.leoObserverLogDomain = "data.example.com";
 
	// Optional. Defaults to 10. Use 1 to send events individually.
	window.leoObserverBatchSize = 10;
 
	// Optional touchpoint metadata. Use unencoded values; the proxy encodes them.
	window.srcTouchpointName = document.title;
	window.srcTouchpointUrl = window.location.href;
 
	// Optional for reverse proxies that expose tracking API under /data/*.
	// Example public URI: https://c360.example.com/data/cdp-sdk/html/cdp-event-proxy.html
	window.leoCdpProxyPath = "/data/cdp-sdk/html/cdp-event-proxy.html";
 
	// The callback is invoked after the iframe has initialized its context session.
	window.leoObserverProxyReady = function () {
		console.log("LEO observer ready");
	};
</script>
<script src="https://YOUR_CDN_DOMAIN/js/leo-observer/leo.proxy.min.js"></script>

leoC360SourceId and leoObserverLogDomain are required. The CDN domain is the host serving leo.proxy.min.js; it is not read by the proxy as a runtime configuration value. The page must be served over HTTPS when using the normal HTTPS log and CDN endpoints.

Do not send events immediately from the proxy script tag. Calls made before the hidden iframe is attached can be dropped. Register leoObserverProxyReady first and start application tracking from that callback or after the integration has otherwise confirmed readiness.

Event API

All methods are available on window.LeoObserverProxy and accept a metric name plus an event data object. Event data is application-defined JSON; the SDK does not validate a business schema.

Data Layer integration

The SDK does not automatically read a data layer. After the proxy script loads, opt in to watching window.dataLayer or window.scopeDataLayer and map named data-layer events to LEO event types:

window.LeoObserverProxy.watchDataLayer({
	dataLayerName: "scopeDataLayer",
	eventMap: {
		leo_purchase: {
			metricName: "purchase",
			type: "conversion",
			dataPath: "ecommerce",
			transactionIdPath: "ecommerce.transaction_id",
			valuePath: "ecommerce.value",
			currencyPath: "ecommerce.currency",
			itemsPath: "ecommerce.items",
		},
	},
});

The watcher observes future calls to the array’s .push(); set includeExisting: true to also process entries already present. Nested objects and arrays are passed through as event JSON. Call the returned handle’s stop() to stop watching. Alternatively, set window.leoDataLayerConfig before loading the proxy to start one or more watchers automatically. Event names and fields should be allowlisted rather than forwarding an entire layer.

View events

LeoObserverProxy.recordViewEvent("pageview", {
	pageType: "product",
	contentId: "SKU-1001",
});

View events are sent to /etv.

Action events

LeoObserverProxy.recordActionEvent("click", {
	component: "recommendation",
	contentId: "SKU-1001",
});

Action events are sent to /eta.

Conversion events

LeoObserverProxy.recordConversionEvent(
	"checkout",
	{ orderStatus: "paid" },
	"ORDER-1001",
	[{ productId: "SKU-1001", quantity: 1, price: 29.99 }],
	29.99,
	"USD",
);

Conversion events are sent to /etc. The optional transaction arguments map to the following payload fields:

ArgumentPayload fieldDefault
transactionIdtsidEmpty string unless it is a string.
shoppingCartItemsscitemsThe cart is included only when this argument is an object.
transactionValuetsval0 unless it is a number.
currencyCodetscurUSD unless it is a string.

Feedback events

LeoObserverProxy.recordFeedbackEvent("submit-csat-form", {
	score: 5,
	surveyId: "post-purchase",
});

Feedback events are sent to /efb.

The proxy identifies the event type separately from the metric name. Common metric names in the source comments include pageview, screenview, click, play, add_to_cart, submit_form, checkout, join, and survey form names, but integrations may use their own metric names.

Profile Updates

Use updateProfileBySession for contact data collected by an embedded form or another authenticated interaction:

LeoObserverProxy.updateProfileBySession(
	{
		email: "customer@example.com",
		loginId: "customer-1001",
		phone: "+1 555 0100",
		facebookUserId: "fb-1001",
	},
	{
		source: "checkout",
	},
);

The first object is serialized as profile data and the second as extension data. The request includes the current visitor ID and cached session key and is sent to /cxs-pf-update.

Only include fields that the application is permitted to collect. The SDK provides a tracking consent gate, but does not provide a CMP/user-facing consent flow, field-level validation, or profile-schema validation.

Visitor ID Synchronization

The iframe creates a visitor ID and stores it in iframe-origin local storage. The ID is reused on later pages served through the same observer origin:

LeoObserverProxy.synchLeoVisitorId(function (visitorId) {
	console.log("LEO visitor ID:", visitorId);
});

An application or server may provide an ID before the proxy loads with window.injectedVisitorId. The proxy also supports a leosyn query parameter and forwards it to the iframe when present. The supplied ID must be treated as an identifier, not as a secret.

The observer separately computes a browser fingerprint using FingerprintJS2. The fingerprint is cached as leocdp_fgp and sent as fgp when available. It is an implementation detail and should not be used by page code as the visitor ID.

Tag Audit

The proxy exposes a read-only check for common third-party tags:

const tags = LeoTagAudit.checkTrackingTags();
// { ga4, gtm, metaPixel, tiktokPixel, checkedAt, dataLayer }

The check looks for known global functions and script URLs. It does not verify that a tag has successfully sent data or that its configuration is correct.

Extending with Facebook (Meta) Pixel and TikTok Pixel Data

LeoTagAudit.checkTrackingTags() only detects that a third-party pixel is present; it does not read data out of it. To enrich the CDP’s identity graph and attribution with Meta/TikTok data, forward the same click IDs and cookies those pixels already use as extra event/profile fields — the SDK itself has no built-in Meta/TikTok connector, so this is application code that sits next to your existing LeoObserverProxy calls.

Meta and TikTok store their own first-party identifiers in cookies once their pixel scripts run: _fbp/_fbc (Meta) and _ttp (TikTok), plus the fbclid/ttclid URL query parameters on ad-click landing pages. Read them and attach them to LEO events so identity resolution can use them as additional exact-match keys (cookie_id/advertising_id-style values) and so attribution reports can tie a conversion back to the originating ad:

function readCookie(name) {
	const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
	return match ? decodeURIComponent(match[1]) : undefined;
}
 
function getAdAttributionContext() {
	const params = new URLSearchParams(window.location.search);
	return {
		fbp: readCookie("_fbp"),
		fbc: readCookie("_fbc") || (params.get("fbclid") ? `fb.1.${Date.now()}.${params.get("fbclid")}` : undefined),
		ttp: readCookie("_ttp"),
		ttclid: params.get("ttclid") || undefined,
		gclid: params.get("gclid") || undefined,
	};
}
 
// Include on every event so the raw source stage carries the ad-click context.
LeoObserverProxy.recordConversionEvent(
	"checkout",
	{ orderStatus: "paid", ...getAdAttributionContext() },
	"ORDER-1001",
	[{ productId: "SKU-1001", quantity: 1, price: 29.99 }],
	29.99,
	"USD",
);
 
// Also attach on profile updates so it lands on cdp_raw_profiles_stage's
// media_source/campaign/utm_* style attribution fields via your ingestion
// mapping, alongside identity fields.
LeoObserverProxy.updateProfileBySession(
	{ email: "customer@example.com", loginId: "customer-1001" },
	{ source: "checkout", ...getAdAttributionContext() },
);

Guidance for wiring this into the CDP’s own identity model:

  • Treat fbp/fbc/ttp like cookie_id (an anonymous, first-party, browser-scoped identifier) and fbclid/ttclid/gclid like campaign/ utm_campaign attribution metadata, not as identity-resolution matching keys on their own — cdp_profile_attributes only treats email, phone_number, device_id, advertising_id, cookie_id, and external_customer_id as active CIR matching rules (see customer360-database/init-core-database.sql); adding a new matching key requires a corresponding schema/resolver change, not just sending the field.
  • If you also use the Meta/TikTok server-side Conversions API (to recover events blocked by browser tracking prevention), send the same fbp/fbc/ ttp/click-ID values from your backend so both the ad platform and the CDP see a consistent identifier for the same visitor.
  • Never forward Meta/TikTok’s own hashed PII (e.g. their em/ph hashed parameters) into LEO profile updates unless your privacy/consent review has explicitly approved that data source and hashing scheme — the CDP’s own hashing (see hash_pii() conventions in customer360-seeding/dev-backend-seeding/init_sample_data.py) expects plain values in, and hashing an already-hashed value is not equivalent to hashing the original.

Google Tag Manager Integration

The SDK is a plain script, so it can be deployed as a Custom HTML tag in Google Tag Manager (GTM) instead of a hardcoded <script> tag on the page. This lets marketing/analytics teams manage the observer ID and log domain from the GTM UI and fire LEO events from GTM triggers alongside GA4/Ads tags.

  1. Base tag — a Custom HTML tag that sets the config globals and loads the proxy script, firing on All Pages (or Initialization - All Pages if your container uses a consent-gated trigger group):

    <script>
     window.leoC360SourceId = "{{LEO C360 Source ID}}"; // GTM variable
    	window.leoObserverLogDomain = "{{LEO Log Domain}}"; // GTM variable
    	window.leoObserverProxyReady = function () {
    		window.dataLayer.push({ event: "leo_observer_ready" });
    	};
    </script>
    <script src="https://YOUR_CDN_DOMAIN/js/leo-observer/leo.proxy.min.js"></script>

    Define LEO C360 Source ID / LEO Log Domain as GTM constant or environment variables so staging/production containers can point at different observer IDs without editing tag HTML.

  2. Event tags — additional Custom HTML tags triggered by the leo_observer_ready custom event (so they never fire before the iframe is attached) and by your normal GTM triggers (page view, click, form submit, dataLayer custom events pushed by the site’s own code):

    <script>
    	LeoObserverProxy.recordViewEvent("pageview", {
    		pageType: "{{Page Type}}",
    		contentId: "{{Content ID}}",
    	});
    </script>
    <script>
    	LeoObserverProxy.recordActionEvent("{{Click Text}}", {
    		component: "{{Click Element}}",
    	});
    </script>
  3. Ecommerce/conversion tags — read from GTM’s built-in Ecommerce variables ({{Ecommerce Transaction ID}}, {{Ecommerce Value}}, {{Ecommerce Currency Code}}, {{Ecommerce Items}}) so a single purchase dataLayer event drives both GA4/Ads and LEO without duplicating the checkout page’s own tracking code:

    <script>
    	LeoObserverProxy.recordConversionEvent(
    		"checkout",
    		{ orderStatus: "paid" },
    		{{Ecommerce Transaction ID}},
    		{{Ecommerce Items}},
    		{{Ecommerce Value}},
    		{{Ecommerce Currency Code}}
    	);
    </script>

Trigger ordering matters: set tag priority so the base tag fires before any event tag on the same page. Set window.leoTrackingConsent from the CMP before loading the proxy, then call LeoObserverProxy.setConsent() when that decision changes. The SDK’s compatibility default is allow when no decision was configured. Also gate the base tag itself in GTM/CMP when denied consent must prevent even downloading the proxy script (see Security and Deployment Requirements).

Using with React.js

The SDK is framework-agnostic vanilla JS; wrap it in a small provider/hook so React components can call LeoObserverProxy without re-injecting the script on every render or race against leoObserverProxyReady.

// leo-observer-provider.tsx
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
 
type LeoObserverProxy = {
	recordViewEvent: (metric: string, data: Record<string, unknown>) => void;
	recordActionEvent: (metric: string, data: Record<string, unknown>) => void;
	recordConversionEvent: (
		metric: string,
		data: Record<string, unknown>,
		transactionId?: string,
		shoppingCartItems?: unknown,
		transactionValue?: number,
		currencyCode?: string,
	) => void;
	recordFeedbackEvent: (metric: string, data: Record<string, unknown>) => void;
	updateProfileBySession: (profile: Record<string, unknown>, extension?: Record<string, unknown>) => void;
	synchLeoVisitorId: (callback: (visitorId: string) => void) => void;
};
 
declare global {
	interface Window {
		LeoObserverProxy?: LeoObserverProxy;
		leoC360SourceId?: string;
		leoObserverLogDomain?: string;
		leoObserverProxyReady?: () => void;
	}
}
 
const LeoObserverContext = createContext<LeoObserverProxy | null>(null);
 
export function LeoObserverProvider({
	sourceId,
	logDomain,
	cdnDomain,
	children,
}: {
	sourceId: string;
	logDomain: string;
	cdnDomain: string;
	children: ReactNode;
}) {
	const [proxy, setProxy] = useState<LeoObserverProxy | null>(null);
	const loadedRef = useRef(false);
 
	useEffect(() => {
		if (loadedRef.current) return;
		loadedRef.current = true;
 
		window.leoC360SourceId = sourceId;
		window.leoObserverLogDomain = logDomain;
		window.leoObserverProxyReady = () => setProxy(window.LeoObserverProxy ?? null);
 
		const script = document.createElement("script");
		script.src = `https://${cdnDomain}/js/leo-observer/leo.proxy.min.js`;
		script.async = true;
		document.body.appendChild(script);
 
		return () => {
			// Intentionally not removing the script/iframe on unmount: the
			// observer is a page-lifetime singleton, matching the SDK's own
			// "load once" design (re-injecting it on route changes creates
			// duplicate iframes/visitor IDs).
		};
	}, [sourceId, logDomain, cdnDomain]);
 
	return <LeoObserverContext.Provider value={proxy}>{children}</LeoObserverContext.Provider>;
}
 
// Returns null until leoObserverProxyReady has fired -- callers must check
// for null (e.g. queue calls, or disable a "submit" button) rather than
// assume the proxy is always available immediately after mount.
export function useLeoObserver(): LeoObserverProxy | null {
	return useContext(LeoObserverContext);
}

Usage in the app root (mount once, e.g. in App.tsx or a root layout) and in a page/component that needs to send events:

// App.tsx
export default function App() {
	return (
		<LeoObserverProvider
			sourceId={import.meta.env.VITE_LEO_OBSERVER_ID}
			logDomain={import.meta.env.VITE_LEO_LOG_DOMAIN}
			cdnDomain={import.meta.env.VITE_LEO_CDN_DOMAIN}
		>
			<ProductPage />
		</LeoObserverProvider>
	);
}
 
// ProductPage.tsx
function ProductPage({ sku }: { sku: string }) {
	const leo = useLeoObserver();
 
	useEffect(() => {
		leo?.recordViewEvent("pageview", { pageType: "product", contentId: sku });
		// Re-fire on route/param changes in an SPA -- the SDK has no
		// automatic route-change listener, unlike a traditional server-rendered
		// page-load model.
	}, [leo, sku]);
 
	function handleAddToCart() {
		leo?.recordActionEvent("add_to_cart", { contentId: sku });
	}
 
	return <button onClick={handleAddToCart}>Add to cart</button>;
}

Notes specific to React/SPA usage:

  • Because leo is null until the hidden iframe reports ready, guard every call with leo?. (as above) instead of calling window.LeoObserverProxy directly — the object may not exist yet on first render, especially on a fast client-side navigation right after the app boots.
  • In an SPA, pageview-equivalent events must be sent manually on each route change (e.g. from a router’s useLocation/useEffect), since there is no full page load per navigation for the SDK to observe.
  • Call LeoObserverProvider exactly once near the app root. Mounting it per-page (e.g. inside a route component that unmounts/remounts) reloads the proxy script and iframe repeatedly, which is unnecessary and can churn the visitor ID.

Optional Newsletter Modal

leo.newsletter.modal.js is independent of event tracking and exposes window.LeoNewsletterModal:

<script src="https://YOUR_CDN_DOMAIN/js/leo-newsletter-modal.js"></script>
<button type="button" data-leo-newsletter="footer">Subscribe</button>
<script>
	const newsletter = new LeoNewsletterModal({
		collectPhone: true,
		theme: "auto",
		triggerSelector: "[data-leo-newsletter]",
	});
 
	document.addEventListener("leo.newsletter.success", function (event) {
		const profile = event.detail;
		LeoObserverProxy.updateProfileBySession({
			email: profile.email,
			phone: profile.phone,
			name: profile.name,
		}, { source: "newsletter" });
	});
</script>

The modal supports open(source), close(), locale, theme, collectPhone, autoCloseDelay, triggerSelector, and localized text through i18n. It dispatches leo.newsletter.open, leo.newsletter.close, leo.newsletter.submit, and leo.newsletter.success document events. Phone validation accepts digits, spaces, plus signs, and dashes, with 7 to 15 digits.

Request Lifecycle

  1. The proxy waits approximately 300 ms by default (configurable with window.leoProxyDelay), then appends a hidden iframe at https://<log-domain>/cdp-sdk/html/cdp-event-proxy.html. In deployments that forward /data/* to the tracking service with prefix stripping, configure window.leoCdpProxyPath as /data/cdp-sdk/html/cdp-event-proxy.html.
  2. The iframe starts with tracking disabled, reads the log domain and parent origin, and sends LeoConsentBridgeReady to the parent. The parent sends an explicit grant only if consent is allowed; otherwise FingerprintJS2 and the observer scripts are not loaded.
  3. After grant, the iframe loads FingerprintJS2 2.1.5 and the observer. getContextSession() creates the session context locally; it does not call GET /cxs-pf-init.
  4. On consent withdrawal, the parent blocks new events and sends a revoke message. The iframe clears its queues/cache, aborts active XHRs and acknowledges before the parent removes it; a bounded timeout removes a non-responding iframe.
  5. The iframe notifies the parent that the observer is ready. Calls from the page are serialized and sent to the iframe with postMessage.
  6. The observer sends profile updates immediately. Tracking events are queued per endpoint and flushed when the configured batch size is reached, every 5555 ms, and during pagehide or beforeunload.

The observer uses form-encoded requests with XMLHttpRequest and enables withCredentials. Batches contain an encoded events JSON value and may include a session key; the event count is provided as the evc query parameter. If no local session key exists, the observer uses XHR so it can read the response; the tracking API currently does not return a session key, so the SDK normally keeps using its locally generated key. Once a session key exists, supported browsers may use navigator.sendBeacon for tracking batches. Beacon delivery is queued by the browser and does not expose a response to page JavaScript; a Beacon already accepted by the browser cannot be recalled after consent is withdrawn.

Security and Deployment Requirements

  • Serve the page, proxy script, iframe, and log endpoint over HTTPS.
  • Allow the CDN in script-src and the log domain in the relevant frame-src, connect-src, and CORS policies.
  • The log service must allow credentialed cross-origin requests from the page origin. Do not use a wildcard Access-Control-Allow-Origin together with credentials.
  • Keep observer IDs and visitor IDs out of secrets-management assumptions; they are necessarily visible to browser code.
  • Review consent and privacy requirements before enabling fingerprinting or sending profile fields.
  • Set window.leoTrackingConsent = false before loading the proxy when consent has been denied, or call LeoObserverProxy.setConsent(false) when the CMP revokes consent. The SDK retains its legacy allow behavior if consent is unknown. A denied state prevents iframe creation, fingerprinting, profile updates, personalization, queued events and XHR/Beacon flushes; it also clears pending events and local visitor/session/profile cache. When consent is granted, call LeoObserverProxy.setConsent(true). Gate loading the proxy script itself in the CMP for zero LEO-origin requests: JavaScript cannot prevent a script download the page already initiated.
  • The proxy validates incoming postMessage events against the configured log origin. Do not alter the iframe URL hash format unless the proxy and iframe implementations are updated together.

Troubleshooting

SymptomChecks
LeoObserverProxy is undefinedConfirm the proxy script loaded, leoC360SourceId is a string, and the globals were assigned before the script tag.
Ready callback never runsInspect the consent bridge handshake, hidden iframe, CDN loading errors, CORS headers, and browser console errors.
Events disappearWait for leoObserverProxyReady, verify leoObserverBatchSize, and keep the page open long enough for the batch timer or unload flush.
Profile update has no effectConfirm the profile object is a plain object, the log domain is reachable, and the request to /cxs-pf-update is accepted by the server.
Visitor ID changes unexpectedlyCheck iframe-origin local storage, injected visitor ID configuration, and whether the observer log domain changed between environments.