@conetic/prestoplay-analytics
A PRESTOplay plugin that reports the seven Voyager IFE analytics events defined in the Voyager analytics event specification (v0.3) — with the 0.2.0 payload amendments listed in CHANGELOG.md — as JSON POSTs to an analytics endpoint, one event per request or batched.
The plugin owns everything the player can tell it — playback state, seeks, track
changes, errors, timings — plus event_id, sequence, playback_id, clock
correction, and device detection. The host application supplies the flight,
session and catalogue context, which the player cannot know.
Integrating this for the first time? Start with INSTALL.md.
Install
npm install @conetic/prestoplay-analytics
Published to the public npm registry — no registry configuration or token needed. INSTALL.md has the full setup.
@castlabs/prestoplay is a peer dependency (v6). The plugin has no runtime
dependencies of its own.
Quick start
import { clpp } from '@castlabs/prestoplay'
import '@castlabs/prestoplay/cl.mse'
import '@castlabs/prestoplay/cl.dash'
import { registerVoyagerAnalytics } from '@conetic/prestoplay-analytics'
// 1. Register BEFORE any player is constructed.
registerVoyagerAnalytics(clpp)
// 2. Static context goes in the player constructor config.
const player = new clpp.Player('#video', {
license: CLPP_LICENSE,
voyagerAnalytics: {
endpoint: 'https://<analytics-host>/api/v1/analytics/events',
serverTime: bootstrap.serverTime,
batchSize: 5, // optional; 1 (one POST per event) by default
flight: {
tail_number: 'N123AZ',
flight_number: 'XY0189',
departure_iata: 'GIG',
arrival_iata: 'OPO',
takeoff_time: '2026-01-08T16:13:00.000Z',
},
session: {
session_id: bootstrap.sessionId,
device_id: bootstrap.deviceId, // onboard router ID, host-supplied
},
system: { oem: 'ACME', app_version: '2.4.1' },
},
})
// 3. Per-playback context goes in the load() config.
await player.load({
source: { url: asset.manifestUrl, type: clpp.Type.DASH },
startTime: resumePositionSeconds,
voyagerAnalytics: {
gui_path: 'home/categories/kids',
media_id: '9090929',
media: {
media_title: 'Bart Gets Famous',
media_type: 'SERIES_EPISODE',
series_title: 'The Simpsons',
season_number: 5,
episode_number: 12,
distributor: 'Warner Bros Discovery',
category: 'Kids',
genres: ['Comedy', 'Animation'],
original_language: 'en',
release_year: 2023,
duration_ms: 1420000,
},
},
})
That is the whole integration. The seven events flow from there.
Without a bundler
<script src="node_modules/@castlabs/prestoplay/cl.core.js"></script>
<script src="node_modules/@conetic/prestoplay-analytics/dist/index.global.js"></script>
<script>
VoyagerAnalytics.registerVoyagerAnalytics(clpp)
</script>
Three things that will bite
1. Register before constructing the player. PRESTOplay consumes plugin
factories inside the Player constructor. registerVoyagerAnalytics() after
new clpp.Player(...) has no effect on that player, and fails silently.
2. Per-playback keys belong in load() config, never the constructor config.
The SDK merges [constructorConfig, loadConfig, source] and nested objects merge
rather than replace. media, media_id and gui_path placed in the constructor
config would bleed into every subsequent playback. Static context
(endpoint, flight, session, system) belongs in the constructor config;
per-playback context belongs in load().
3. A custom transport must not be a class instance. The same merge copies
primitives, functions, Dates and plain objects, and silently drops anything
with a non-plain prototype. Pass a factory function or a plain object:
voyagerAnalytics: { transport: () => new MyTransport() } // works
voyagerAnalytics: { transport: new MyTransport() } // silently dropped
A transport's send(payload, { events, unloading }) receives one request's
worth at a time: payload is a single body or, when batching, an array of
them, and events lists the events inside in the same order.
Also note: the plugin does not run under clpp.PlaylistPlayer in
single-controller playlist mode, which disables plugins entirely.
Configuration
All keys live under voyagerAnalytics in the player config.
Static — constructor config
| Key | Type | Default | Description |
|---|---|---|---|
endpoint | string | — | Analytics endpoint URL. Absolute or same-origin. |
headers | Record<string,string> | {} | Extra request headers, e.g. {'x-api-key': '…'}. See the beacon caveat below. |
keepalive | boolean | true | Send every event with fetch(..., {keepalive: true}) so a request in flight at teardown still completes. Set false only when the endpoint is a same-origin Service Worker: Firefox routes keepalive requests around the worker. The unload path always uses keepalive after sendBeacon. |
envelope | 'mms' | 'raw' | 'mms' | mms posts {type, payload, occurredAt}; raw posts the bare event object. |
batchSize | number | 1 | Events per request. 1 posts every event on its own. Above 1, events queue and go out together as a JSON array — see Batching. |
batchFlushMs | number | 0 | Longest an incomplete batch may wait before being sent anyway. 0 = no time limit. Ignored when batchSize is 1. |
disabledEvents | EventName[] | [] | Event names never emitted. Checked before the event is built, so sequence has no hole. |
transport | 'http' | 'mock' | Transport | () => Transport | http when endpoint is set, else mock | Delivery mechanism. |
serverTime | RFC 3339 string | — | Server time at session start, for clock correction. Strongly recommended. |
flight | FlightContext | — | Spec 4.2. Passed through unchanged. |
session | SessionContext | — | Spec 4.3: session_id and device_id. |
system | SystemContext | — | Spec 4.5: oem and app_version. |
device | Partial<DeviceContext> | detected | Overrides for detected values. |
onEvent | (event, body) => void | — | Called for every event built, whatever the transport does. For inspectors and debugging — not a delivery guarantee. |
enabled | boolean | true | false loads the plugin but disables all reporting. |
trackChangeCoalesceMs | number | 50 | Window for merging a simultaneous audio + subtitle change into one event. |
trackDiscoveryMs | number | 1500 | How long LOADEDMETADATA waits for the track list when it still looks empty. Text tracks are registered after the content loads; reporting immediately yields an empty available_subtitle_languages on some runs and a full one on others. 0 disables the wait. Does not affect time_to_metadata_ms. |
seekSettleMs | number | 250 | How long the playhead must settle before a seek is reported, so a scrubber drag is one event and not one per intermediate position (spec 6.6). |
minSeekDeltaMs | number | 1000 | Smallest jump reported as a passenger seek. |
userGestureWindowMs | number | 2000 | How recently a user gesture must have occurred for a seek written to video.currentTime to count as a passenger action. See Seeks below. |
debug | boolean | false | Log plugin diagnostics. |
Per-playback — load() config
| Key | Type | Req | Description |
|---|---|---|---|
media_id | string | Yes | Catalogue identifier, at episode/track level. |
media | MediaMetadata | Yes | Spec 4.6 catalogue metadata, as amended below. Sent on PLAY only. |
gui_path | string | Yes | Navigation path taken to reach the asset. |
start_position_ms is read from the player's own startTime — do not duplicate it.
The media block
media_title is always the title of the thing being played — the movie, the
episode, the track, the chapter. There is no episode_title. The parent goes in
series_title, the position within it in episode_number:
media_type | media_subtype | media_title | series_title | season_number | episode_number | author_or_artist |
|---|---|---|---|---|---|---|
MOVIE | — | film | — | — | — | — |
SERIES_EPISODE | e.g. SITCOM | episode | show (req.) | req. | req. | — |
AUDIO | ALBUM | track | album (req.) | — | track no. if known | album artist |
AUDIO | AUDIOBOOK | chapter | book (req.) | — | chapter no. if known | author |
AUDIO | PODCAST | episode | podcast (req.) | — | req. | host |
TRAILER, LIVE | — | title | — | — | — | — |
distributor and duration_ms are always required; category, genres,
original_language and release_year are optional everywhere.
The plugin reduces whatever it is given to exactly these fields before the
event is built. A stray episode_title or an unrelated catalogue key is dropped
with a warning, and a legacy media_type: 'PODCAST' is mapped to AUDIO /
PODCAST so an integration built against 0.1.x keeps reporting.
If media_id or media is missing, the playback is not reported and a
clpp.Error(RECOVERABLE, PLUGIN, 7100) is raised on the player, matching how the
SDK's own plugins report a bad configuration.
Seeks
A passenger can move the playhead two ways and both are reported:
player.setPosition()— what a custom UI scrubber calls. Always reported.video.currentTime = x— what the browser's native<video controls>scrubber does, and any UI built on the media element. PRESTOplay'sUSER_SEEKING/USER_SEEKEDnever fire for these, only the raw media events.
The player also moves the playhead for itself — gap jumps, live-edge corrections
— through those same media events, and a start-up gap jump was measured at
1.9 s, so the size of the jump cannot separate them. Intent can: a passenger
seek always follows a user gesture, a player correction never does. So a seek
written to currentTime is reported only when a gesture occurred within
userGestureWindowMs. Seeks made through setPosition() bypass that check,
since calling the API is itself a statement of intent.
If your UI moves the playhead programmatically and you want it reported, call
player.setPosition() rather than writing currentTime.
Two behaviours worth knowing when reading the data
STATE_CHANGED chains. Each event's previous_state is the previous event's
current_state, so a playback's timeline can be reconstructed by ordering on
sequence within a playback_id. This is not simply PRESTOplay's own
previousState: the player has no SEEKING state — a seek is BUFFERING
carrying reason: SEEKING — so mapping entry and exit independently would emit
PLAYING → SEEKING followed by BUFFERING → PLAYING, which does not join up.
Language tags are canonicalised to BCP 47 casing: zh-HANT becomes
zh-Hant, pt-br becomes pt-BR. Manifests are inconsistent here and the
player passes the raw value through, so without this the same language arrives
under several spellings and any grouping on it splits. Only casing is changed —
an unfamiliar tag is passed through untouched, and und is omitted.
What the plugin detects for itself
device_type, os_name, browser_name and ui_language are derived from the
browser. Any of them can be overridden through config.device. Two worth
knowing:
LAPTOPis never detected. A browser cannot distinguish a laptop from a desktop; the plugin reportsDESKTOP. Override it if the host knows better.ui_languagedefaults tonavigator.language. The spec defines this as the interface language the passenger selected. If the app's language setting is independent of the browser's, override it.
device_id is never detected — it is the onboard router ID and must be supplied
by the host.
Runtime API
import { getVoyagerAnalytics } from '@conetic/prestoplay-analytics'
const analytics = getVoyagerAnalytics(player)!
| Method | Purpose |
|---|---|
setSessionContext({flight, session, system, device, serverTime}) | Supply or update context after the player was built. Session bootstrap usually finishes after player construction, so this is the normal route. Partial updates merge. |
setNavigationContext(guiPath) | Set gui_path for the next PLAY, as an alternative to the load() config key. |
reportError({error_id, error_category, error_location, error_description, is_fatal, media_id?}) | Report an application-level error. The player cannot produce the ENTITLEMENT / APPLICATION categories or the CATALOGUE_FETCH / SESSION_BOOTSTRAP / UI_NAVIGATION locations — they only arrive through here. |
endPlayback(exitReason) | Close the current playback explicitly, e.g. 'USER_STOP' when the app tears down its own player UI. |
flush() | Send whatever is queued now. A no-op unless batching, and safe to call any time. |
getPendingCount() | Events built but not yet sent. Zero unless batching. Diagnostics. |
getSequence() | Last sequence number issued. Diagnostics. |
Delivery
Fire-and-forget, per the spec's working assumption (open question 4):
- Normal events:
fetchwithkeepalive: true. - Unload-path events (
USER_LEAVINGwithTAB_CLOSED):navigator.sendBeacon, falling back tokeepalivefetch. Sent frompagehideonly. A page that is merely hidden — screen lock, app switch, another tab in front — does not end the playback; the passenger usually comes back, and closing it there would drop every later event until the nextload(). - Failures are logged and dropped. Nothing is queued or retried — a gap in
sequenceis the spec's loss signal, and a silent retry would hide it. With batching, a failed request loses the whole batch, and the gap shows that too.
Batching
batchSize: 1 (the default) posts every event as it happens. Above 1, events
queue and are posted together — a STATE_CHANGED no longer costs a request of
its own. Nothing about the events changes: event_id, event_time and
sequence are stamped when the event happens, not when it is sent.
A batch is sent when it is full, and early whenever holding it would risk
it: on USER_LEAVING (the playback is over), on pagehide (the page is over,
via sendBeacon), on player destroy, when the transport changes, when the app
calls flush(), and after batchFlushMs if set. Between those, an incomplete
batch waits — a passenger who pauses once in a two-hour film produces two
events that travel with USER_LEAVING.
Two things to size it by. sendBeacon has a payload budget of roughly 64 KB and
a context-laden event is about 1 KB, so a batch that must survive unload should
stay well under that. And a lost request loses the whole batch: a batchSize
of 5–10 halves request volume many times over without making any one loss
large.
onEvent still fires once per event, with that event's own body; the transport
sees the batch.
Beacon caveat. sendBeacon cannot set request headers. If headers carries
an API key, unload-path events will arrive without it. If the endpoint requires
authentication, either accept the loss of TAB_CLOSED events or move the
credential into the URL — a decision that belongs with spec open question 3.
Wire format
With envelope: 'mms' (the default), matching POST /api/v1/analytics/events:
{
"type": "PLAY",
"payload": { "event_id": "…", "event_name": "PLAY", "…": "…" },
"occurredAt": "2026-01-08T16:23:06.412Z"
}
With envelope: 'raw', the payload object is posted on its own.
When batchSize bundles several events, the body is a JSON array of the above,
in sequence order:
[
{ "type": "PLAY", "payload": { "…": "…" }, "occurredAt": "2026-01-08T16:23:06.412Z" },
{ "type": "STATE_CHANGED", "payload": { "…": "…" }, "occurredAt": "2026-01-08T16:23:06.418Z" }
]
A batch that happens to hold one event is posted as the single object, never a
one-element array — so an endpoint that accepts only the single form keeps
working as long as batchSize stays at 1.
Spec open questions, as implemented
| # | Question | What the plugin does today |
|---|---|---|
| 1 | Default dub/sub when AUDIO_TRACK_CHANGED never fires | Nothing is emitted if nothing changed. The current selection is seeded at LOADEDMETADATA so the first real change reports correct previous_* values. Emitting a synthetic default is deliberately not done — it would fabricate an event. |
| 3 | Endpoint specification | endpoint, headers and envelope are all configurable; nothing is hardcoded. |
| 4 | Retry / queueing | Fire-and-forget. A Transport interface is the extension point if this changes. |
| 5 | device_id stability | Treated as host-supplied and stable across sessions within a flight. The plugin never invents or persists it. |
TypeScript
clpp.PlayerConfiguration is a type alias with no index signature, so it cannot
be extended by declaration merging and a custom key is an excess-property error.
Use the exported intersection:
import type { WithVoyagerAnalytics } from '@conetic/prestoplay-analytics'
const config: WithVoyagerAnalytics<clpp.PlayerConfiguration> = {
source: { url },
voyagerAnalytics: { media_id: '9090929', media },
}
await player.load(config)
JSON Schema
A draft 2020-12 schema for the event payload ships with the package:
@conetic/prestoplay-analytics/schema
Every event the plugin can emit is validated against it in the test suite.
Development
npm install
npm run build # ESM + CJS + IIFE + .d.ts
npm test # unit suite (vitest, no browser)
npm run test:browser # browser suite (Playwright, real player, real streams)
npm run typecheck
The browser suite drives a real PRESTOplay player against public test streams,
loading the built dist/. It covers what a fake player cannot: the events the
SDK genuinely emits, in the order it emits them, with the values it reports —
which is how the seek, track-discovery and error-location behaviours were
established. It needs network access and uses the locally installed Chrome; in
CI run npx playwright install chromium and set PW_CHANNEL=.
The test suite includes a guard that reads the installed
@castlabs/prestoplay/typings.d.ts and asserts every PRESTOplay constant the
plugin mirrors still matches — so an SDK upgrade that renumbers a player state
fails the build rather than silently corrupting the analytics.