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.
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.
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.type | Fires when |
|---|---|
experience.open | The experience first becomes visible |
page.view | A page is shown, including in-place swaps |
component.click | A clickable component is clicked |
link.click | A link is followed |
component.hover | A component is hovered |
video.play | Video playback starts |
video.progress.milestone | Video playback crosses a progress milestone |
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.