@conetic/prestoplay-analytics

中文文档
0.2.1发布于 2026/10/02SEE LICENSE IN LICENSETypeScriptESM + CJS零依赖近 30 天下载 3772

PRESTOplay plugin that reports Voyager IFE analytics events to an analytics endpoint

原始使用文档
文档目录18 节
  1. Install
  2. Quick start
  3. Without a bundler
  4. Three things that will bite
  5. Configuration
  6. Static — constructor config
  7. Per-playback — load() config
  8. Seeks
  9. Two behaviours worth knowing when reading the data
  10. What the plugin detects for itself
  11. Runtime API
  12. Delivery
  13. Batching
  14. Wire format
  15. Spec open questions, as implemented
  16. TypeScript
  17. JSON Schema
  18. Development

@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

KeyTypeDefaultDescription
endpointstring—Analytics endpoint URL. Absolute or same-origin.
headersRecord<string,string>{}Extra request headers, e.g. {'x-api-key': '…'}. See the beacon caveat below.
keepalivebooleantrueSend 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.
batchSizenumber1Events per request. 1 posts every event on its own. Above 1, events queue and go out together as a JSON array — see Batching.
batchFlushMsnumber0Longest an incomplete batch may wait before being sent anyway. 0 = no time limit. Ignored when batchSize is 1.
disabledEventsEventName[][]Event names never emitted. Checked before the event is built, so sequence has no hole.
transport'http' | 'mock' | Transport | () => Transporthttp when endpoint is set, else mockDelivery mechanism.
serverTimeRFC 3339 string—Server time at session start, for clock correction. Strongly recommended.
flightFlightContext—Spec 4.2. Passed through unchanged.
sessionSessionContext—Spec 4.3: session_id and device_id.
systemSystemContext—Spec 4.5: oem and app_version.
devicePartial<DeviceContext>detectedOverrides for detected values.
onEvent(event, body) => void—Called for every event built, whatever the transport does. For inspectors and debugging — not a delivery guarantee.
enabledbooleantruefalse loads the plugin but disables all reporting.
trackChangeCoalesceMsnumber50Window for merging a simultaneous audio + subtitle change into one event.
trackDiscoveryMsnumber1500How 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.
seekSettleMsnumber250How 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).
minSeekDeltaMsnumber1000Smallest jump reported as a passenger seek.
userGestureWindowMsnumber2000How recently a user gesture must have occurred for a seek written to video.currentTime to count as a passenger action. See Seeks below.
debugbooleanfalseLog plugin diagnostics.

Per-playback — load() config

KeyTypeReqDescription
media_idstringYesCatalogue identifier, at episode/track level.
mediaMediaMetadataYesSpec 4.6 catalogue metadata, as amended below. Sent on PLAY only.
gui_pathstringYesNavigation 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_typemedia_subtypemedia_titleseries_titleseason_numberepisode_numberauthor_or_artist
MOVIE—film————
SERIES_EPISODEe.g. SITCOMepisodeshow (req.)req.req.—
AUDIOALBUMtrackalbum (req.)—track no. if knownalbum artist
AUDIOAUDIOBOOKchapterbook (req.)—chapter no. if knownauthor
AUDIOPODCASTepisodepodcast (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's USER_SEEKING/USER_SEEKED never 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:

  • LAPTOP is never detected. A browser cannot distinguish a laptop from a desktop; the plugin reports DESKTOP. Override it if the host knows better.
  • ui_language defaults to navigator.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)!
MethodPurpose
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: fetch with keepalive: true.
  • Unload-path events (USER_LEAVING with TAB_CLOSED): navigator.sendBeacon, falling back to keepalive fetch. Sent from pagehide only. 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 next load().
  • Failures are logged and dropped. Nothing is queued or retried — a gap in sequence is 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

#QuestionWhat the plugin does today
1Default dub/sub when AUDIO_TRACK_CHANGED never firesNothing 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.
3Endpoint specificationendpoint, headers and envelope are all configurable; nothing is hardcoded.
4Retry / queueingFire-and-forget. A Transport interface is the extension point if this changes.
5device_id stabilityTreated 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.

版本与发布记录

@conetic/prestoplay-analytics 共发布 1 个版本,首次发布于 2026/10/02;过去 12 个月发布了 1 个版本。

版本发布日期距今
0.2.1latest2026/10/021 个月前

关于 @conetic/prestoplay-analytics 的常见问题

@conetic/prestoplay-analytics 是什么?

PRESTOplay plugin that reports Voyager IFE analytics events to an analytics endpoint

如何安装 @conetic/prestoplay-analytics?

在项目目录运行 npm install @conetic/prestoplay-analytics,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@castlabs/prestoplay(^6.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@conetic/prestoplay-analytics 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@conetic/prestoplay-analytics 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@conetic/prestoplay-analytics 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@conetic/prestoplay-analytics 可以商用吗?

@conetic/prestoplay-analytics 使用 SEE LICENSE IN LICENSE 许可。许可条款写在包内的独立文件中,请阅读该文件确认可否商用。以上为概括,不构成法律意见,请以许可原文为准。

@conetic/prestoplay-analytics 的下载量有多少?

根据 npm 官方下载统计,@conetic/prestoplay-analytics 在 2026/10/11 – 2026/11/09 这 30 天内被下载 3,772 次,日均约 126 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@conetic/prestoplay-analytics 还在维护吗?

最新版本 0.2.1 发布于 2026/10/02(1 个月前);至今共发布 1 个版本,其中过去 12 个月发布了 1 个;首次发布于 2026/10/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。