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
| Stage | Where it happens | What it produces |
|---|---|---|
| Collection | leo.proxy.js / leo.observer.js in the browser | Batches view/action/conversion/feedback events and profile updates, keyed by visitor/session identifiers. |
| Event ingestion | customer360-event-api POST /data/api/v1/tracking/logs | The 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 analytics | customer360-backend/analytics/source_analytics | The 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). |
| Understanding | Persona 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_activation | Segments 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
| File | Purpose |
|---|---|
observer/leo.proxy.js | Public page-side API, tag audit, iframe creation, and postMessage bridge. |
html/cdp-event-proxy.html | Hidden iframe bridge. It loads FingerprintJS2 and the observer implementation only while tracking consent is allowed. |
observer/leo.observer.js | Observer implementation: fingerprinting, visitor/session state, network requests, and batching. |
observer/hash.js | Fingerprint hashing/cache library source. The current iframe HTML loads FingerprintJS2 2.1.5 from CDN instead of this file directly. |
observer/leo.newsletter.modal.js | Optional 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:
| Argument | Payload field | Default |
|---|---|---|
transactionId | tsid | Empty string unless it is a string. |
shoppingCartItems | scitems | The cart is included only when this argument is an object. |
transactionValue | tsval | 0 unless it is a number. |
currencyCode | tscur | USD 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/ttplikecookie_id(an anonymous, first-party, browser-scoped identifier) andfbclid/ttclid/gclidlikecampaign/utm_campaignattribution metadata, not as identity-resolution matching keys on their own —cdp_profile_attributesonly treatsemail,phone_number,device_id,advertising_id,cookie_id, andexternal_customer_idas active CIR matching rules (seecustomer360-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/phhashed parameters) into LEO profile updates unless your privacy/consent review has explicitly approved that data source and hashing scheme — the CDP’s own hashing (seehash_pii()conventions incustomer360-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.
-
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 Domainas GTM constant or environment variables so staging/production containers can point at different observer IDs without editing tag HTML. -
Event tags — additional Custom HTML tags triggered by the
leo_observer_readycustom event (so they never fire before the iframe is attached) and by your normal GTM triggers (page view, click, form submit,dataLayercustom 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> -
Ecommerce/conversion tags — read from GTM’s built-in Ecommerce variables (
{{Ecommerce Transaction ID}},{{Ecommerce Value}},{{Ecommerce Currency Code}},{{Ecommerce Items}}) so a singlepurchasedataLayer 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
leoisnulluntil the hidden iframe reports ready, guard every call withleo?.(as above) instead of callingwindow.LeoObserverProxydirectly — 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’suseLocation/useEffect), since there is no full page load per navigation for the SDK to observe. - Call
LeoObserverProviderexactly 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
- The proxy waits approximately 300 ms by default (configurable with
window.leoProxyDelay), then appends a hidden iframe athttps://<log-domain>/cdp-sdk/html/cdp-event-proxy.html. In deployments that forward/data/*to the tracking service with prefix stripping, configurewindow.leoCdpProxyPathas/data/cdp-sdk/html/cdp-event-proxy.html. - The iframe starts with tracking disabled, reads the log domain and parent
origin, and sends
LeoConsentBridgeReadyto the parent. The parent sends an explicit grant only if consent is allowed; otherwise FingerprintJS2 and the observer scripts are not loaded. - After grant, the iframe loads FingerprintJS2 2.1.5 and the observer.
getContextSession()creates the session context locally; it does not callGET /cxs-pf-init. - 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.
- The iframe notifies the parent that the observer is ready. Calls from the
page are serialized and sent to the iframe with
postMessage. - 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
pagehideorbeforeunload.
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-srcand the log domain in the relevantframe-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-Origintogether 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 = falsebefore loading the proxy when consent has been denied, or callLeoObserverProxy.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, callLeoObserverProxy.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
postMessageevents against the configured log origin. Do not alter the iframe URL hash format unless the proxy and iframe implementations are updated together.
Troubleshooting
| Symptom | Checks |
|---|---|
LeoObserverProxy is undefined | Confirm the proxy script loaded, leoC360SourceId is a string, and the globals were assigned before the script tag. |
| Ready callback never runs | Inspect the consent bridge handshake, hidden iframe, CDN loading errors, CORS headers, and browser console errors. |
| Events disappear | Wait for leoObserverProxyReady, verify leoObserverBatchSize, and keep the page open long enough for the batch timer or unload flush. |
| Profile update has no effect | Confirm 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 unexpectedly | Check iframe-origin local storage, injected visitor ID configuration, and whether the observer log domain changed between environments. |