diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 0928a77e7bc..a8420fb4edd 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -1027,6 +1027,7 @@ export type CMCDControllerConfig = { useHeaders?: boolean; includeKeys?: CmcdKey[]; version?: CmcdVersion; + rtpSafetyFactor?: number; eventTargets?: (Omit & { includeKeys?: CmcdKey[]; })[]; diff --git a/docs/API.md b/docs/API.md index be92360d095..f953348fde9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1903,6 +1903,7 @@ data will be passed on all media requests (manifests, playlists, a/v segments, t - `interval`: For the time-interval event, the reporting cadence in seconds. Defaults to `30`. - `batchSize`: The number of events to batch before sending a report. Defaults to `1` (send each event immediately). - `includeKeys`: An optional array of CMCD keys that overrides the top-level `includeKeys` for this target. +- `rtpSafetyFactor`: A multiplier applied to the segment bitrate to compute the `rtp` (requested throughput) field. The spec defines `rtp` as the maximum throughput the client considers sufficient, which should include headroom above the encoded bitrate to absorb network jitter and avoid rebuffering. Defaults to `5` (5× the segment bitrate). Only used when `version: 2`. - `loader`: An optional async function `(request) => Promise<{ status }>` used to deliver CMCD v2 event reports. When omitted, event reports are delivered via `fetch` (honoring the Hls `xhrSetup`/`fetchSetup` hooks). Only used when `eventTargets` is configured. - `reporterCallback`: An optional `(reporter: CmcdCustomReporter) => void` callback. Called once per `MANIFEST_LOADING`, before the reporter starts. Use it to seed custom CMCD keys or store the reference for firing custom events at runtime. Always use the most recently received reference, since a new source load yields a new instance. diff --git a/src/config.ts b/src/config.ts index 918154f28d5..ea376895ea5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -103,6 +103,7 @@ export type CMCDControllerConfig = { useHeaders?: boolean; includeKeys?: CmcdKey[]; version?: CmcdVersion; + rtpSafetyFactor?: number; eventTargets?: (Omit & { includeKeys?: CmcdKey[]; })[]; diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts index c01202af08a..d9d6613e8aa 100644 --- a/src/controller/cmcd-controller.ts +++ b/src/controller/cmcd-controller.ts @@ -32,6 +32,7 @@ import type { BufferAppendedData, BufferFlushedData, ErrorData, + LevelSwitchedData, LevelSwitchingData, ManifestLoadingData, MediaAttachedData, @@ -44,6 +45,8 @@ import type { LoaderCallbacks, LoaderConfiguration, LoaderContext, + LoaderResponse, + LoaderStats, PlaylistLoaderContext, } from '../types/loader'; import type { Cmcd } from '@svta/cml-cmcd'; @@ -65,6 +68,7 @@ export default class CMCDController implements ComponentAPI { private buffering: boolean = true; private playerState?: CmcdPlayerState; private reporter?: CmcdReporter; + private playheadLevel?: Level; constructor(hls: Hls) { this.hls = hls; @@ -142,6 +146,7 @@ export default class CMCDController implements ComponentAPI { hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.on(Events.ERROR, this.onError, this); hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); + hls.on(Events.LEVEL_SWITCHED, this.onLevelSwitched, this); hls.on(Events.BUFFER_APPENDED, this.onBufferInfoChange, this); hls.on(Events.BUFFER_FLUSHED, this.onBufferInfoChange, this); } @@ -154,6 +159,7 @@ export default class CMCDController implements ComponentAPI { hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.off(Events.ERROR, this.onError, this); hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this); + hls.off(Events.LEVEL_SWITCHED, this.onLevelSwitched, this); hls.off(Events.BUFFER_APPENDED, this.onBufferInfoChange, this); hls.off(Events.BUFFER_FLUSHED, this.onBufferInfoChange, this); } @@ -314,6 +320,16 @@ export default class CMCDController implements ComponentAPI { this.reporter.recordEvent(CmcdEventType.BITRATE_CHANGE, eventData); } + private onLevelSwitched( + _event: Events.LEVEL_SWITCHED, + data: LevelSwitchedData, + ) { + const level = this.hls.levels[data.level]; + if (level) { + this.playheadLevel = level; + } + } + private setPlayerState(state: CmcdPlayerState) { this.playerState = state; if (this.reporter) { @@ -373,8 +389,6 @@ export default class CMCDController implements ComponentAPI { data.su = this.buffering; } - // TODO: Implement rtp, dl - const report = this.reporter.createRequestReport( { url: context.url, headers: context.headers }, data, @@ -414,14 +428,44 @@ export default class CMCDController implements ComponentAPI { ot === CmcdObjectType.MUXED || (ot == null && (frag.type === 'main' || frag.type === 'audio')) ) { - data.br = [level.bitrate / 1000]; - const tb = this.getTopBandwidth(frag) / 1000; - if (Number.isFinite(tb)) { - data.tb = [tb]; + const bitrateKbps = level.bitrate / 1000; + data.br = [bitrateKbps]; + const { cmcd } = this.config; + const rtpSafetyFactor = cmcd?.rtpSafetyFactor ?? 5; + data.rtp = Math.round((bitrateKbps * rtpSafetyFactor) / 100) * 100; + + if ( + ot === CmcdObjectType.MUXED || + (ot == null && frag.type === 'main') + ) { + const tb = this.getTopBandwidth() / 1000; + if (Number.isFinite(tb)) { + data.tb = [tb]; + } + + const lb = this.getLowestBandwidth() / 1000; + if (Number.isFinite(lb)) { + data.lb = [lb]; + } } + const bl = this.getBufferLength(frag); if (Number.isFinite(bl)) { data.bl = [bl]; + const pr = this.media?.playbackRate || 1; + data.dl = Math.round(bl / pr / 100) * 100; + } + + if (this.playheadLevel) { + data.pb = [this.playheadLevel.bitrate / 1000]; + } + + const maxIdx = this.hls.maxAutoLevel; + if (maxIdx >= 0) { + const topLevel = this.hls.levels[maxIdx]; + if (topLevel) { + data.tpb = [topLevel.bitrate / 1000]; + } } } @@ -534,18 +578,12 @@ export default class CMCDController implements ComponentAPI { * Audio renditions live in hls.audioTracks; everything else (including * audio-only main playlists) draws from hls.levels. */ - private getTopBandwidth(fragment: Fragment | MediaFragment) { + private getTopBandwidth() { let bitrate: number = 0; - let levels; const hls = this.hls; - - if (fragment.type === 'audio') { - levels = hls.audioTracks; - } else { - const max = hls.maxAutoLevel; - const len = max > -1 ? max + 1 : hls.levels.length; - levels = hls.levels.slice(0, len); - } + const max = hls.maxAutoLevel; + const len = max > -1 ? max + 1 : hls.levels.length; + const levels = hls.levels.slice(0, len); levels.forEach((level) => { if (level.bitrate > bitrate) { @@ -556,6 +594,22 @@ export default class CMCDController implements ComponentAPI { return bitrate > 0 ? bitrate : NaN; } + private getLowestBandwidth() { + let bitrate: number = Infinity; + const hls = this.hls; + const max = hls.maxAutoLevel; + const len = max > -1 ? max + 1 : hls.levels.length; + const levels = hls.levels.slice(0, len); + + levels.forEach((level) => { + if (level.bitrate < bitrate) { + bitrate = level.bitrate; + } + }); + + return Number.isFinite(bitrate) ? bitrate : NaN; + } + /** * Get the buffer length in milliseconds for the source backing this fragment. */ @@ -604,6 +658,37 @@ export default class CMCDController implements ComponentAPI { this.reporter.update({ bl: [bl] }); } + private recordFragmentResponse = ( + url: string, + response: LoaderResponse, + stats: LoaderStats, + ) => { + const { cmcd } = this.config; + const hasResponseTarget = cmcd?.eventTargets?.some((t) => + t.events?.includes(CmcdEventType.RESPONSE_RECEIVED), + ); + if (!this.reporter || !(stats.loading.first > 0) || !hasResponseTarget) { + return; + } + try { + this.reporter.recordResponseReceived({ + request: { url }, + status: response.code, + resourceTiming: { + startTime: stats.loading.start, + responseStart: stats.loading.first, + duration: stats.loading.end - stats.loading.start, + encodedBodySize: stats.total, + }, + }); + } catch (error) { + this.hls.logger.warn( + 'Could not record fragment response CMCD data.', + error, + ); + } + }; + /** * Create a playlist loader */ @@ -652,6 +737,7 @@ export default class CMCDController implements ComponentAPI { private createFragmentLoader(): FragmentLoaderConstructor | undefined { const { fLoader } = this.config; const apply = this.applyFragmentData; + const recordResponse = this.recordFragmentResponse; const Ctor = fLoader || (this.config.loader as FragmentLoaderConstructor); return class CmcdFragmentLoader { @@ -683,7 +769,14 @@ export default class CMCDController implements ComponentAPI { callbacks: LoaderCallbacks, ) { apply(context); - this.loader.load(context, config, callbacks); + const { onSuccess } = callbacks; + this.loader.load(context, config, { + ...callbacks, + onSuccess: (response, stats, ctx, networkDetails) => { + onSuccess(response, stats, ctx, networkDetails); + recordResponse(context.url, response, stats); + }, + }); } }; } diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts index ae6fe7c5826..52fda783329 100644 --- a/tests/unit/controller/cmcd-controller.ts +++ b/tests/unit/controller/cmcd-controller.ts @@ -402,6 +402,246 @@ describe('CMCDController', function () { }); }); + describe('v2 fragment keys (rtp, dl, lb, pb, tpb)', function () { + const stubBufferInfo = ( + hls: any, + prop: 'mainForwardBufferInfo' | 'audioForwardBufferInfo', + info: { len: number } | null, + ) => { + Object.defineProperty(hls, prop, { + configurable: true, + get: () => info, + }); + }; + + it('includes rtp (rounded max throughput in kbps) in v2 fragment data', function () { + const details = setupEach({ version: 2 }); + cmcdController.hls.levelController.levels[0].bitrate = 500_000; + + const { url } = applyFragmentData(details.fragments[0]); + // 500 kbps * default rtpSafetyFactor(5) = 2500; Math.round(2500/100)*100 = 2500 + expectField(url, `rtp%3D2500`); + }); + + it('respects a custom rtpSafetyFactor config value', function () { + const details = setupEach({ version: 2, rtpSafetyFactor: 3 }); + cmcdController.hls.levelController.levels[0].bitrate = 500_000; + + const { url } = applyFragmentData(details.fragments[0]); + // 500 kbps * 3 = 1500; Math.round(1500/100)*100 = 1500 + expectField(url, `rtp%3D1500`); + }); + + it('includes lb (lowest bitrate in kbps as a list) in v2 fragment data for muxed variants', function () { + const details = setupEach({ version: 2 }); + + const { url } = applyFragmentData(details.fragments[0]); + // Single level at 1000 bps = 1 kbps → lb=(1) + expectField(url, `lb%3D%281%29`); + }); + + it('omits lb and tb when content has audio alternates (ot=VIDEO)', function () { + const details = setupEach({ version: 2 }); + const hls = cmcdController.hls as any; + Object.defineProperty(hls, 'audioTracks', { + configurable: true, + get: () => [{ id: 0, bitrate: 0, url: '', name: 'English' }], + }); + + const { url } = applyFragmentData(details.fragments[0]); + expect(url).to.not.include('lb%3D'); + expect(url).to.not.include('%2Ctb%3D'); + }); + + it('includes dl (deadline in ms, rounded to 100ms) when buffer info is known', function () { + const details = setupEach({ version: 2 }); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + playbackRate: 1, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10.0 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + + const { url } = applyFragmentData(details.fragments[0]); + // 10s buffer @ pr=1 = 10000ms; Math.round(10000/100)*100 = 10000 + expectField(url, `dl%3D10000`); + }); + + it('adjusts dl by playbackRate when pr != 1', function () { + const details = setupEach({ version: 2 }); + const hls = cmcdController.hls; + (cmcdController as any).media = { + removeEventListener: () => {}, + playbackRate: 2, + } as unknown as HTMLMediaElement; + stubBufferInfo(hls, 'mainForwardBufferInfo', { len: 10.0 }); + stubBufferInfo(hls, 'audioForwardBufferInfo', null); + + const { url } = applyFragmentData(details.fragments[0]); + // 10s buffer @ pr=2 → 5000ms deadline; Math.round(5000/100)*100 = 5000 + expectField(url, `dl%3D5000`); + }); + + it('omits dl when no media is attached', function () { + const details = setupEach({ version: 2 }); + + const { url } = applyFragmentData(details.fragments[0]); + expect(url).to.not.include('dl%3D'); + }); + + it('includes pb (playhead bitrate in kbps as a list) after FRAG_CHANGED', function () { + const details = setupEach({ version: 2 }); + const frag = details.fragments[0]; + cmcdController.hls.trigger(Events.FRAG_CHANGED, { + frag, + previousFrag: null, + }); + + const { url } = applyFragmentData(frag); + // playheadLevel.bitrate = 1000 bps = 1 kbps → pb=(1) + expectField(url, `pb%3D%281%29`); + }); + + it('omits pb before any FRAG_CHANGED event', function () { + const details = setupEach({ version: 2 }); + + const { url } = applyFragmentData(details.fragments[0]); + // Match standalone pb= key (preceded by comma or start of CMCD string), + // not the substring that appears inside tpb=. + expect(url).to.not.match(/(?:^|%2C)pb%3D/); + }); + + it('includes tpb (top playable bitrate in kbps as a list) in v2 fragment data', function () { + const details = setupEach({ version: 2 }); + const hls = cmcdController.hls as any; + // Precondition: mock exposes a valid top level so the tpb if-guard is entered. + // _autoLevelCapping defaults to -1 and levels has 1 entry → maxAutoLevel = 0. + expect(hls.maxAutoLevel).to.equal(0); + // Single level at 1000 bps = 1 kbps → tpb=(1) + const { url } = applyFragmentData(details.fragments[0]); + expectField(url, `tpb%3D%281%29`); + }); + + it('reflects autoLevelCapping in tpb', function () { + const details = setupEach({ version: 2 }); + const hls = cmcdController.hls as any; + // Add a second higher-bitrate level and cap auto-selection to level 0 (1 kbps) + hls.levelController.levels.push({ + ...hls.levelController.levels[0], + bitrate: 3_000, + }); + hls._autoLevelCapping = 0; + const { url } = applyFragmentData(details.fragments[0]); + // tpb must reflect the capped level (1 kbps), not the uncapped top (3 kbps) + expectField(url, `tpb%3D%281%29`); + expect(url).to.not.include('tpb%3D%283%29'); + }); + }); + + describe('recordFragmentResponse (ttfb/ttlb)', function () { + const eventTarget = { + url: 'https://collector.example.com/cmcd', + events: [CmcdEventType.RESPONSE_RECEIVED], + }; + + it('calls reporter.recordResponseReceived with correct resource timing', function () { + setupEach({ version: 2, eventTargets: [eventTarget] }); + const reporter = (cmcdController as any).reporter; + const calls: any[][] = []; + reporter.recordResponseReceived = (...args: any[]) => { + calls.push(args); + }; + + cmcdController.recordFragmentResponse( + 'https://example.com/seg.m4s', + { code: 200 }, + { loading: { start: 1000, first: 1050, end: 1200 }, total: 50000 }, + ); + + expect(calls).to.have.lengthOf(1); + const { request, status, resourceTiming } = calls[0][0]; + expect(request.url).to.equal('https://example.com/seg.m4s'); + expect(status).to.equal(200); + expect(resourceTiming.startTime).to.equal(1000); + expect(resourceTiming.responseStart).to.equal(1050); + expect(resourceTiming.duration).to.equal(200); + expect(resourceTiming.encodedBodySize).to.equal(50000); + }); + + it('does nothing when no eventTargets are configured', function () { + setupEach({ version: 2 }); + const reporter = (cmcdController as any).reporter; + let called = false; + reporter.recordResponseReceived = () => { + called = true; + }; + + cmcdController.recordFragmentResponse( + 'https://example.com/seg.m4s', + { code: 200 }, + { loading: { start: 1000, first: 1050, end: 1200 }, total: 50000 }, + ); + + expect(called).to.equal(false); + }); + + it('does nothing when no eventTarget has rr in its events list', function () { + setupEach({ + version: 2, + eventTargets: [ + { + url: 'https://collector.example.com/cmcd', + events: [CmcdEventType.PLAY_STATE], + }, + ], + }); + const reporter = (cmcdController as any).reporter; + let called = false; + reporter.recordResponseReceived = () => { + called = true; + }; + + cmcdController.recordFragmentResponse( + 'https://example.com/seg.m4s', + { code: 200 }, + { loading: { start: 1000, first: 1050, end: 1200 }, total: 50000 }, + ); + + expect(called).to.equal(false); + }); + + it('does nothing when stats.loading.first is 0', function () { + setupEach({ version: 2, eventTargets: [eventTarget] }); + const reporter = (cmcdController as any).reporter; + let called = false; + reporter.recordResponseReceived = () => { + called = true; + }; + + cmcdController.recordFragmentResponse( + 'https://example.com/seg.m4s', + { code: 200 }, + { loading: { start: 1000, first: 0, end: 1200 }, total: 50000 }, + ); + + expect(called).to.equal(false); + }); + + it('does nothing when reporter is not initialized', function () { + setupEach({ version: 2, eventTargets: [eventTarget] }); + (cmcdController as any).reporter = undefined; + + expect(() => { + cmcdController.recordFragmentResponse( + 'https://example.com/seg.m4s', + { code: 200 }, + { loading: { start: 1000, first: 1050, end: 1200 }, total: 50000 }, + ); + }).to.not.throw(); + }); + }); + describe('v2 event reporting', function () { it('creates reporter without eventTargets (no event reporting)', function () { setupEach({ version: 2 }); @@ -1151,11 +1391,12 @@ describe('CMCDController', function () { }); }; - it('uses hls.audioTracks for fragments with type=audio', function () { + it('uses hls.levels for fragments with type=audio (audioTracks not used)', function () { setupEach({}); const hls = cmcdController.hls; - stubAudioTracks(hls, [{ bitrate: 96000 }, { bitrate: 128000 }]); - // hls.levels has the test playlist's level at bitrate 1000; should NOT be used here. + hls.levelController.levels = [{ bitrate: 96000 }, { bitrate: 128000 }]; + stubAudioTracks(hls, [{ bitrate: 999999 }]); + // audioTracks should NOT be used; result comes from hls.levels. const result = (cmcdController as any).getTopBandwidth( fragWithType(PlaylistLevelType.AUDIO), );