Skip to content
1 change: 1 addition & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,7 @@ export type CMCDControllerConfig = {
useHeaders?: boolean;
includeKeys?: CmcdKey[];
version?: CmcdVersion;
rtpSafetyFactor?: number;
eventTargets?: (Omit<CmcdEventReportConfig, 'enabledKeys'> & {
includeKeys?: CmcdKey[];
})[];
Expand Down
1 change: 1 addition & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type CMCDControllerConfig = {
useHeaders?: boolean;
includeKeys?: CmcdKey[];
version?: CmcdVersion;
rtpSafetyFactor?: number;
eventTargets?: (Omit<CmcdEventReportConfig, 'enabledKeys'> & {
includeKeys?: CmcdKey[];
})[];
Expand Down
127 changes: 110 additions & 17 deletions src/controller/cmcd-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
BufferAppendedData,
BufferFlushedData,
ErrorData,
LevelSwitchedData,
LevelSwitchingData,
ManifestLoadingData,
MediaAttachedData,
Expand All @@ -44,6 +45,8 @@ import type {
LoaderCallbacks,
LoaderConfiguration,
LoaderContext,
LoaderResponse,
LoaderStats,
PlaylistLoaderContext,
} from '../types/loader';
import type { Cmcd } from '@svta/cml-cmcd';
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];
}
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -683,7 +769,14 @@ export default class CMCDController implements ComponentAPI {
callbacks: LoaderCallbacks<FragmentLoaderContext>,
) {
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);
},
});
Comment on lines +772 to +779

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't the response mode need to be enabled? Can we not do this when the response event is not registered to be reported?

}
};
}
Expand Down
Loading
Loading