Skip to content
TraceItX Docs
Documentation

Playback tracking

One call per player. The Sessions tab then shows what played, on which stack, and how it went.

Updated

Session Vitals already tracks every <video> and <audio> it finds: play, pause, buffering, seeks, startup time, dropped frames. What a bare media element cannot tell us is which manifest is playing, whether it is DRM-protected, the bitrate ladder, or why a fragment failed — only the player library knows. Attach it:

Attach a player

import Hls from 'hls.js';

const video = document.querySelector('video')!;
const hls = new Hls();

// Attach BEFORE loadSource(). hls.js fires its manifest-loading event
// synchronously from loadSource() — trackPlayer establishes the timing
// baseline when it attaches, so calling it any later misses that event and
// the startup breakdown below loses manifestMs/firstFragmentMs for this
// initial load.
const player = traceitx.trackPlayer({ element: video, hls, name: 'main' });

hls.loadSource('https://cdn.example.com/live/main.m3u8');
hls.attachMedia(video);

Shaka is the same shape: traceitx.trackPlayer({ element: video, shaka: shakaPlayer }).

trackPlayer never throws — a bad element or a broken integration hands back an inert handle instead of taking down your page. name is optional but worth setting; it is what the session detail page shows. Call player.detach() when you tear the player down.

Elements you never attach explicitly are still tracked automatically, as native players — attaching one later (once you know it needs hls.js or Shaka) upgrades it in place rather than starting over, so it keeps its identity on the session timeline.

What an integration adds

Eventhls.jsShaka
source_change — manifest URL, protocol, live flag
bitrate_change — bitrate, resolution, abr/manual✓ (also the level index)
drm — key systemWidevine/PlayReady/FairPlay from drmSystems, AES-128 from the loaded key, or none for a clear manifestdrmInfo()/keySystem(), none for clear content, plus license time when Shaka’s stats report one
startup breakdownmanifest + first-fragment timing, on top of the element’s own time-to-first-framemanifest + first-fragment + license timing, on top of time-to-first-frame
stats every 20 s — buffer ahead, dropped-frame delta, plus the integration’s own bandwidth/bitrate snapshot
error — message, code, fatal flagnon-fatal errors capped at 10/min per player; fatal always passessame cap, same rule

Transport state — play, pause, buffering — always comes from the media element itself, never from the integration; see Other players below for exactly what a library needs to supply.

Log your own events

traceitx.trackVitals('cdn.switch', { from: 'edge-a', to: 'edge-b' });
player.track('ad.break', { position: 'midroll', durationMs: 30000 });

data is any JSON value up to 2 KB serialised. Larger payloads are kept but truncated to a preview and flagged — never silently dropped. These entries show up as markers on the session timeline; click one to see the payload.

Source URLs and tokens

Query strings are stripped from source URLs before they leave the browser, because signed CDN and license URLs carry tokens. The fragment is always dropped, with no option to keep it. To keep the query string:

const traceitx = init({ apiKey: 'txx_live_…', vitals: { captureSourceQuery: true } });

Other players

Anything with an event bus fits the same seam. Implement PlayerIntegration and pass it as integration:

Supplying an integration takes over source reporting entirely. The moment you attach one — yours or a built-in — the SDK stops deriving source_change from the element itself, because the integration is expected to own that fact (a blob: element URL would otherwise mask the real manifest URL). If your attach() never emits source_change, that player’s card shows no source at all, for the whole session. Emit it for whatever is already loaded when attach() runs, and again on every later change — see myPlayer below.

import type { PlayerIntegration } from '@traceitx/web';

// Keep the exact (event, handler) pairs `attach()` registered so `detach()`
// can unsubscribe precisely. A bare `engine.off()` with no arguments is
// unsafe on either kind of emitter: one that requires exact unsubscription
// throws (and leaks the listener on every rebind), and one that treats a
// bare `off()` as "remove everything" takes your OWN application's
// playback listeners down with it.
let listeners: Array<[string, (...args: never[]) => void]> = [];

const myPlayer: PlayerIntegration = {
  library: 'my-player',
  version: '2.0.0',
  attach({ emit, now }) {
    // Return `false` if this object doesn't actually look like your player
    // (wrong instance, mismatched version) — the SDK then clears the
    // integration and falls back to native, element-derived tracking
    // instead of leaving the player labelled with your `library` but
    // silent forever.
    if (typeof engine.on !== 'function') return false;

    // Describe whatever is already loaded right away — attach() can run
    // well after the engine already started playing.
    if (engine.currentSrc) emit('source_change', { src: engine.currentSrc, protocol: 'hls' });

    const onSourceChange = (src: string) => emit('source_change', { src, protocol: 'hls' });
    const onLevelSwitch = (l: { bitrate: number; width: number; height: number }) =>
      emit('bitrate_change', { bitrate: l.bitrate, width: l.width, height: l.height, reason: 'abr' });
    const onError = (e: { message: string; fatal: boolean }) => emit('error', { message: e.message, fatal: e.fatal });

    engine.on('sourceChange', onSourceChange);
    engine.on('levelSwitch', onLevelSwitch);
    engine.on('error', onError);
    listeners = [
      ['sourceChange', onSourceChange],
      ['levelSwitch', onLevelSwitch],
      ['error', onError],
    ];
    // No return needed here — anything other than `false` (including
    // nothing) tells the SDK the subscription succeeded.
  },
  snapshot: () => ({ bandwidthEstimate: engine.bandwidth }),
  detach() {
    for (const [name, handler] of listeners) engine.off(name, handler);
    listeners = [];
  },
};

traceitx.trackPlayer({ element: video, integration: myPlayer });

attach() returns false for exactly one reason: it could not subscribe to the object it was given. Anything else — including returning nothing, the normal case — means it subscribed successfully.

Player events use a closed vocabulary (play, pause, seek, buffer_start, buffer_end, bitrate_change, rate_change, error, startup, source_change, player_attach, player_detach, drm, quality_change, stats). Transport state is always read from the element, so an integration only needs to add what the element cannot see: source, DRM, bitrate ladder, startup timing and library errors.