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 ahidden/posterstate 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
hlsasundefineduntil playback is ready to attach, then set it once in state. The identity change re-runs the effect, which readsref.currentfresh.
What an integration adds
| Event | hls.js | Shaka |
|---|---|---|
source_change — manifest URL, protocol, live flag | ✓ | ✓ |
bitrate_change — bitrate, resolution, abr/manual | ✓ (also the level index) | ✓ |
drm — key system | Widevine/PlayReady/FairPlay from drmSystems, AES-128 from the loaded key, or none for a clear manifest | drmInfo()/keySystem(), none for clear content, plus license time when Shaka’s stats report one |
startup breakdown | manifest + first-fragment timing, on top of the element’s own time-to-first-frame | manifest + 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 flag | non-fatal errors capped at 10/min per player; fatal always passes | same 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.