Skip to content
TraceItX Docs
Documentation

Playback tracking

One hook 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 with useTrackPlayer:

Attach a player

import { useRef } from 'react';
import { useTrackPlayer, useTraceItX } from '@traceitx/react';

function Player({ hls }: { hls: Hls }) {
  const ref = useRef<HTMLVideoElement>(null);
  useTrackPlayer(ref, { hls, name: 'main' });
  const { trackVitals } = useTraceItX();
  return <video ref={ref} onClick={() => trackVitals('ui.tap', { target: 'video' })} />;
}

Shaka is the same shape — useTrackPlayer(ref, { shaka: shakaPlayer }). The hook attaches once ref resolves to an element, detaches on unmount, and re-attaches whenever hls, shaka, integration or name changes identity.

The hls/shaka instance must be stable across renders — held in state or a ref, not constructed inline in JSX or on every render. A new instance every render re-attaches every render, tearing down and rebuilding the integration each time.

The hook needs the element to exist when it runs

useTrackPlayer takes a ref object, and a ref object’s identity never changes. If the <video> mounts conditionally after the first render — the common

{ready && <video ref={ref} />}

shape — the hook’s effect ran once already, before the element existed, saw ref.current === null, and did nothing. Because none of its dependencies (ref, hls, shaka, integration, name) ever change identity afterwards, the effect never runs again, and the element is never attached by the hook — even after it mounts.

This is a known limitation in this release, not a bug you can configure around, and it fails silently: nothing throws, nothing warns.

What you still get: the SDK’s own automatic scan finds that <video> regardless and tracks it as an anonymous native player — you don’t lose the session’s playback vitals. What you lose is your chosen name and your hls.js/Shaka integration: no source, DRM, bitrate ladder, or startup-timing detail for that player, because nothing ever handed it one.

Workaround — pick one:

  • Render the element unconditionally (e.g. keep the <video> in the tree and toggle a hidden/poster state instead of the element itself), or
  • Make one of the hook’s other options change identity exactly when the player becomes ready — e.g. keep hls as undefined until playback is ready to attach, then set it once in state. The identity change re-runs the effect, which reads ref.current fresh.

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

useTraceItX() returns trackVitals for session-scoped events — not tied to any one player:

const { trackVitals } = useTraceItX();
trackVitals('cdn.switch', { from: 'edge-a', to: 'edge-b' });

That is the whole hook-based flow: useTrackPlayer returns void, not a PlayerHandle — it builds one internally to attach and detach the element, but never gives it back to you. If you only need session-scoped events, the snippet above is all you want.

A player-scoped event — one that shows up grouped under that specific player on the session timeline — needs a PlayerHandle, which only trackPlayer() itself returns. So for that, skip the hook and call trackPlayer directly, keeping the handle yourself:

import { useEffect, useRef } from 'react';
import { trackPlayer, type PlayerHandle } from '@traceitx/react';

function Player({ hls }: { hls: Hls }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const playerRef = useRef<PlayerHandle | null>(null);

  useEffect(() => {
    const element = videoRef.current;
    if (!element) return undefined;
    const handle = trackPlayer({ element, hls, name: 'main' });
    playerRef.current = handle;
    return () => {
      handle.detach();
      playerRef.current = null;
    };
  }, [hls]);

  return (
    <video
      ref={videoRef}
      onClick={() => playerRef.current?.track('ui.tap', { target: 'video' })}
    />
  );
}

That is the trade-off: useTrackPlayer is the ergonomic path and detaches for you on unmount; calling trackPlayer yourself is the path when you need the handle for player-scoped events, and in exchange detaching is now your job — the effect’s cleanup above, not the hook, is what calls .detach().

Outside a component entirely — a player-library callback, a module-level event listener — trackPlayer and trackVitals are also exported top-level from @traceitx/react, the same way:

import { trackVitals } from '@traceitx/react';

trackVitals('cdn.switch', { from: 'edge-a', to: 'edge-b' });

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:

<TraceItXProvider config={{ 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/react';

// 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 = [];
  },
};

useTrackPlayer(ref, { 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.

Same stability rule applies: hold myPlayer in state or a ref so its identity does not change every render.

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.