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
| 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
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.