Skip to main content

Analytics events

On Flex Inline and Flex SSR, Ceros doesn't load Google Analytics, Google Tag Manager, or error monitoring inside the experience — that's your page's job for now.

What the experience does do is dispatch its analytics events directly onto your page. Same events, same payloads as the iframe embed SDK, with no iframe and no postMessage in between. If you already have listener code written for an iframe embed, it works unchanged.

Timing

Events begin once the experience has rendered (Flex Inline) or hydrated (Flex SSR). window.flexAnalytics is created as soon as the Ceros script executes, which is before any experience mounts — so you never miss an event by subscribing early.

But "executes" is not "appears in the HTML." The SSR runtime is emitted as <script type="module" defer> (that's what deliveryModes.ssr.scripts[] declares), and deferred scripts run after the parser finishes — after every inline <script> on the page. There is no pre-init queue, so an inline script that calls window.flexAnalytics.on(...) directly throws a TypeError.

Subscribe from a DOMContentLoaded listener, which fires after deferred scripts have run:

window.addEventListener('DOMContentLoaded', () => {
window.flexAnalytics.on(({ analyticsEvent }) => {
/* … */
})
})

This is only a concern for the SDK global. The raw DOM events below have no ordering problem — addEventListener works before the runtime loads.

The flexAnalytics SDK

The simplest way to subscribe. Both flex-client.js and flex-ssr.js expose window.flexAnalytics immediately.

// Every experience on the page.
const off = window.flexAnalytics.on(({ experience, analyticsEvent }) => {
gtag('event', analyticsEvent.type, analyticsEvent.data)
})

off() // every subscription returns an unsubscribe function

Filtering by experience

// A substring match…
window.flexAnalytics.onExperience('spring-launch', ({ analyticsEvent }) => {
dataLayer.push({ event: analyticsEvent.type, ...analyticsEvent.data })
})

// …a RegExp…
window.flexAnalytics.onExperience(/campaigns\/2026\//, handler)

// …or everything.
window.flexAnalytics.onExperience('*', handler)

The filter is matched against the experience's manifest URL and its slug. Filter on the slug for the most stable behaviour.

Migrating from the iframe embed SDK

flexEmbedSdk.onExperience matches against the iframe's src — the published experience URL. There is no iframe here, so the filter matches the manifest URL or the slug instead. A URL-pattern filter written for the iframe SDK won't necessarily match.

Event types

analyticsEvent.typeFires when
experience.openThe experience first becomes visible
page.viewA page is shown, including in-place swaps
component.clickA clickable component is clicked
link.clickA link is followed
component.hoverA component is hovered
video.playVideo playback starts
video.progress.milestoneVideo playback crosses a progress milestone
What counts as a click

component.click fires only for components the author made clickable — one carrying a click or pointer-press interaction (on itself or an ancestor), a carousel control, or a link. That's the same set that shows a pointer cursor in the viewer. Clicks on inert layers — a bare shape, or one whose only interaction is an entrance animation, hover effect, or scroll effect — are not reported.

Payload shape

Both the SDK and the flex.analytics.event window event deliver { experience, analyticsEvent }:

{
"experience": {
"url": "https://acme.ceros.site/product-showcase/features/manifest.v1.json",
},
"analyticsEvent": {
"type": "component.click",
"timestamp": 1718800000000,
"eventId": "…",
"data": {
"componentName": "Buy now",
"componentId": "…",
"componentType": "button",
"position": { "x": 10, "y": 20 },
},
"context": {
"page": { "id": "features", "slug": "features", "number": 2 },
"experience": {
"id": "acme/product-showcase",
"slug": "product-showcase",
"accountSlug": "acme",
},
},
},
}

Raw DOM events

If you'd rather not use the SDK — or you're reusing iframe-embed listener code verbatim — the same events are dispatched as DOM events.

ceros-analytics-event

A flat, legacy-compatible CustomEvent, dispatched on both document.body and window:

document.body.addEventListener('ceros-analytics-event', (e) => {
const data = e.detail
console.log(data.eventType, data)
})

eventType is one of page-view, component-click, component-hover, link-click, experience-open, video-play, video-progress-percent. Note these are the hyphenated legacy names, not the dotted analyticsEvent.type values above.

Always present: experienceName, pageName, pageNum, isFlexExperience. Per-type extras: componentName, page, url, videoProgressPct.

// page-view
{ "eventType": "page-view", "experienceName": "product-showcase", "pageName": "Features",
"pageNum": 2, "isFlexExperience": true, "page": "/product-showcase/features" }

// component-click
{ "eventType": "component-click", "experienceName": "product-showcase", "pageName": "features",
"pageNum": 2, "isFlexExperience": true, "componentName": "Buy now" }

// link-click
{ "eventType": "link-click", "experienceName": "product-showcase", "pageName": "features",
"pageNum": 2, "isFlexExperience": true, "componentName": "Docs", "url": "https://example.com/docs" }

flex.analytics.event

The structured event on window, carrying the same { experience, analyticsEvent } detail the SDK delivers:

window.addEventListener('flex.analytics.event', (e) => {
const { experience, analyticsEvent } = e.detail
console.log(analyticsEvent.type, analyticsEvent.data, analyticsEvent.context)
})

The SDK is a thin wrapper over this event, so the two never diverge. ceros.analytics.event is a deprecated alias — prefer flex.analytics.event.

Forwarding to GA4

<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
</script>
<script
src="https://assets.ceros.site/js/flex-ssr.js"
type="module"
defer
></script>
<script>
// DOMContentLoaded, not top level: the runtime above is deferred, so it
// hasn't run yet while this inline script is parsed. See Timing.
window.addEventListener('DOMContentLoaded', () => {
window.flexAnalytics.on(({ analyticsEvent }) => {
gtag('event', analyticsEvent.type.replace(/\./g, '_'), {
...analyticsEvent.data,
experience_slug: analyticsEvent.context?.experience?.slug,
page_slug: analyticsEvent.context?.page?.slug,
})
})
})
</script>

GA4 event names can't contain dots, hence the replace.

If you'd rather not think about ordering at all, listen for the window event instead — it needs no wrapper, because addEventListener is safe to call before the runtime loads:

<script>
window.addEventListener('flex.analytics.event', ({ detail }) => {
const { analyticsEvent } = detail
gtag('event', analyticsEvent.type.replace(/\./g, '_'), analyticsEvent.data)
})
</script>

On the iframe embed

The iframe embed runs analytics inside the experience and delivers host-page events through the embed SDK's flexEmbedSdk.on(...) / flexEmbedSdk.onExperience(...) instead. The event names and payloads match what's documented here.