feat(cmcd): custom events, custom keys and ec (#7887)

Adds `cmcd.reporterCallback` config option, enabling custom key and custom event reporting for CMCD v2.
This commit is contained in:
Constanza Dibueno
2026-06-05 09:33:49 -07:00
committed by GitHub
parent ed533a9b00
commit d9dedb412e
6 changed files with 349 additions and 2 deletions
+20
View File
@@ -4,6 +4,8 @@
```ts
import type { CmcdCustomKey } from '@svta/cml-cmcd';
import type { CmcdCustomValue } from '@svta/cml-cmcd';
import type { CmcdEventReportConfig } from '@svta/cml-cmcd';
import type { CmcdKey } from '@svta/cml-cmcd';
import type { CmcdVersion } from '@svta/cml-cmcd';
@@ -1036,8 +1038,26 @@ export type CMCDControllerConfig = {
}) => Promise<{
status: number;
}>;
reporterCallback?: (reporter: CmcdCustomReporter) => void;
};
// Warning: (ae-missing-release-tag) "CmcdCustomData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type CmcdCustomData = {
[index: CmcdCustomKey]: CmcdCustomValue | undefined;
};
// Warning: (ae-missing-release-tag) "CmcdCustomReporter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface CmcdCustomReporter {
// (undocumented)
recordCustomEvent(eventName: string, data?: CmcdCustomData): void;
// (undocumented)
updateCustomData(data: CmcdCustomData): void;
}
// Warning: (ae-missing-release-tag) "CodecsParsed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+67
View File
@@ -1904,6 +1904,73 @@ data will be passed on all media requests (manifests, playlists, a/v segments, t
- `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.
- `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.
The `CmcdCustomReporter` exposes two methods:
- `updateCustomData(data)` — sets persistent custom key/value pairs included in every subsequent report. Keys must follow the CMCD custom key convention (`<reverse-dns>-<label>`, e.g. `com.myco-chapter`). Invalid keys are silently dropped.
- `recordCustomEvent(eventName, data?)` — fires a one-off CMCD custom event (`ce`) with the given name and optional custom data. Requires `cmcd.eventTargets` to be configured with a target whose `events` array includes `'ce'`.
```js
let cmcdReporter = null;
const hls = new Hls({
cmcd: {
version: 2,
contentId: 'my-content',
includeKeys: [
'sid',
'cid',
'sf',
'st',
'su',
'bl',
'br',
'mtp',
'com.myco-adBreak',
],
eventTargets: [
{
url: 'https://analytics.example.com/cmcd',
events: ['ce'],
includeKeys: ['sid', 'cid', 'cen', 'com.myco-chapter'],
},
],
reporterCallback: (reporter) => {
cmcdReporter = reporter;
// Seed persistent custom key state (included in all subsequent reports)
reporter.updateCustomData({
'com.myco-adBreak': 'false',
'com.myco-chapter': 'intro',
});
},
},
});
// Update custom key state at runtime (takes effect on the next report)
cmcdReporter?.updateCustomData({
'com.myco-adBreak': adManager.isInAdBreak() ? 'true' : 'false',
});
// Fire a one-off CMCD custom event
cmcdReporter?.recordCustomEvent('chapter-change');
```
`reporterCallback` is called on every `MANIFEST_LOADING` (new source = new reporter). Always store the latest reference.
**Validating custom keys:** If you want to verify your keys follow the CMCD custom key convention before use, you can use `validateCmcdKeys` from `@svta/cml-cmcd`:
```js
import { validateCmcdKeys } from '@svta/cml-cmcd';
const myKeys = { 'com.myco-chapter': 'intro' };
const { valid, issues } = validateCmcdKeys(myKeys);
if (!valid) {
console.warn(
'Invalid CMCD keys:',
issues.map((i) => i.message),
);
}
```
### `enableInterstitialPlayback`
+12
View File
@@ -46,6 +46,8 @@ import type { CuesInterface } from './utils/cues';
import type { ILogger } from './utils/logger';
import type { KeySystems, MediaKeyFunc } from './utils/mediakeys-helper';
import type {
CmcdCustomKey,
CmcdCustomValue,
CmcdEventReportConfig,
CmcdKey,
CmcdVersion,
@@ -86,6 +88,15 @@ export type CapLevelControllerConfig = {
capLevelToPlayerSize: boolean;
};
export type CmcdCustomData = {
[index: CmcdCustomKey]: CmcdCustomValue | undefined;
};
export interface CmcdCustomReporter {
updateCustomData(data: CmcdCustomData): void;
recordCustomEvent(eventName: string, data?: CmcdCustomData): void;
}
export type CMCDControllerConfig = {
sessionId?: string;
contentId?: string;
@@ -101,6 +112,7 @@ export type CMCDControllerConfig = {
headers?: Record<string, string>;
body?: BodyInit;
}) => Promise<{ status: number }>;
reporterCallback?: (reporter: CmcdCustomReporter) => void;
};
export type DRMSystemOptions = {
+28 -1
View File
@@ -11,6 +11,7 @@ import {
CmcdReporter,
CmcdStreamingFormat,
CmcdStreamType,
isCmcdCustomKey,
toCmcdValue,
} from '@svta/cml-cmcd';
import { Events } from '../events';
@@ -19,6 +20,7 @@ import {
removeEventListener,
} from '../utils/event-listener-helper';
import type {
CmcdCustomReporter,
FragmentLoaderConstructor,
HlsConfig,
PlaylistLoaderConstructor,
@@ -46,6 +48,10 @@ import type {
} from '../types/loader';
import type { Cmcd } from '@svta/cml-cmcd';
function validateCmcdCustomData(data: Record<string, unknown>): boolean {
return Object.keys(data).every(isCmcdCustomKey);
}
/**
* Controller to deal with Common Media Client Data (CMCD)
* @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf
@@ -104,6 +110,27 @@ export default class CMCDController implements ComponentAPI {
sf: CmcdStreamingFormat.HLS,
sta: this.playerState,
});
if (cmcd.reporterCallback) {
const reporter = this.reporter;
const customKeyAndEventReport: CmcdCustomReporter = {
updateCustomData: (data) => {
if (validateCmcdCustomData(data)) {
reporter.update(data);
}
},
recordCustomEvent: (eventName, data = {}) => {
if (validateCmcdCustomData(data)) {
reporter.recordEvent(CmcdEventType.CUSTOM_EVENT, {
cen: eventName,
...data,
});
}
},
};
cmcd.reporterCallback(customKeyAndEventReport);
}
this.reporter.start();
}
@@ -266,7 +293,7 @@ export default class CMCDController implements ComponentAPI {
if (data.fatal) {
this.setPlayerState(CmcdPlayerState.FATAL_ERROR);
if (this.reporter) {
this.reporter.recordEvent(CmcdEventType.ERROR);
this.reporter.recordEvent(CmcdEventType.ERROR, { ec: [data.details] });
}
}
}
+2
View File
@@ -1415,6 +1415,8 @@ export type {
BufferControllerConfig,
CapLevelControllerConfig,
CMCDControllerConfig,
CmcdCustomReporter,
CmcdCustomData,
EMEControllerConfig,
DRMSystemConfiguration,
DRMSystemsConfiguration,
+220 -1
View File
@@ -542,9 +542,20 @@ describe('CMCDController', function () {
});
it('records error events on fatal errors', function () {
const requests: any[] = [];
const captureLoader = (req: any) => {
requests.push(req);
return Promise.resolve({ status: 204 });
};
setupEach({
version: 2,
eventTargets: [{ url: 'https://analytics.example.com/cmcd' }],
loader: captureLoader as any,
eventTargets: [
{
url: 'https://analytics.example.com/cmcd',
events: [CmcdEventType.ERROR],
},
],
});
// Trigger fatal error via hls event
@@ -558,6 +569,15 @@ describe('CMCDController', function () {
// Player state should transition to FATAL_ERROR
expect((cmcdController as any).playerState).to.equal('f');
const eventRequests = requests.filter(
(r) => r.url === 'https://analytics.example.com/cmcd',
);
const body = String(eventRequests[0].body || '');
expect(eventRequests.length).to.be.greaterThan(0);
expect(body).to.include('e=e');
expect(body).to.include('ec=("fragLoadError")');
expect(body).to.include('ts=');
cmcdController.destroy();
});
@@ -1159,4 +1179,203 @@ describe('CMCDController', function () {
});
});
});
describe('reporterCallback', function () {
it('reporterCallback is called with a CmcdCustomReporter facade on MANIFEST_LOADING', function () {
const reporters: any[] = [];
setupEach({ version: 2, reporterCallback: (r) => reporters.push(r) });
expect(reporters).to.have.lengthOf(1);
expect(reporters[0])
.to.have.property('updateCustomData')
.that.is.a('function');
expect(reporters[0])
.to.have.property('recordCustomEvent')
.that.is.a('function');
});
it('reporterCallback is called again on each MANIFEST_LOADING (new facade per session)', function () {
const reporters: any[] = [];
setupEach({ version: 2, reporterCallback: (r) => reporters.push(r) });
cmcdController.hls.trigger(Events.MANIFEST_LOADING, { url });
expect(reporters).to.have.lengthOf(2);
expect(reporters[0]).to.not.equal(reporters[1]);
});
it('reporterCallback is not called before MANIFEST_LOADING', function () {
const reporters: any[] = [];
const hls = new Hls({
cmcd: { version: 2, reporterCallback: (r) => reporters.push(r) },
}) as any;
hls.networkControllers.forEach((c) => c.destroy());
hls.networkControllers.length = 0;
hls.coreComponents.forEach((c) => c.destroy());
hls.coreComponents.length = 0;
new CMCDController(hls);
expect(reporters).to.have.lengthOf(0);
});
it('recordCustomEvent fires CUSTOM_EVENT to the event target', function () {
const requests: any[] = [];
let reporter: any;
setupEach({
version: 2,
loader: ((req: any) => {
requests.push(req);
return Promise.resolve({ status: 204 });
}) as any,
eventTargets: [
{
url: 'https://analytics.example.com/cmcd',
events: [CmcdEventType.CUSTOM_EVENT],
},
],
reporterCallback: (r) => {
reporter = r;
},
});
reporter.recordCustomEvent('test-event');
const eventRequests = requests.filter(
(r) => r.url === 'https://analytics.example.com/cmcd',
);
expect(eventRequests.length).to.be.greaterThan(0);
expect(String(eventRequests[0].body || '')).to.include(
'cen="test-event"',
);
});
it('updateCustomData() in reporterCallback seeds custom keys in request reports', function () {
setupEach({
includeKeys: ['sid', 'sf', 'com.acme-label'] as any,
reporterCallback: (r) => {
r.updateCustomData({ 'com.acme-label': 'hello' } as any);
},
});
expectField(applyPlaylistData().url, 'com.acme-label%3D%22hello%22');
});
it('updateCustomData() at runtime takes effect on subsequent request reports', function () {
let reporter: any;
setupEach({
includeKeys: ['sid', 'sf', 'com.acme-label'] as any,
reporterCallback: (r) => {
reporter = r;
r.updateCustomData({ 'com.acme-label': 'cold' } as any);
},
});
expectField(applyPlaylistData().url, 'com.acme-label%3D%22cold%22');
reporter.updateCustomData({ 'com.acme-label': 'warm' } as any);
expectField(applyPlaylistData().url, 'com.acme-label%3D%22warm%22');
});
it('updateCustomData() in reporterCallback seeds custom keys in event reports', function () {
const requests: any[] = [];
let reporter: any;
setupEach({
version: 2,
loader: ((req: any) => {
requests.push(req);
return Promise.resolve({ status: 204 });
}) as any,
eventTargets: [
{
url: 'https://analytics.example.com/cmcd',
events: [CmcdEventType.CUSTOM_EVENT],
includeKeys: ['cen', 'com.myco-chapter'] as any,
},
],
reporterCallback: (r) => {
reporter = r;
r.updateCustomData({ 'com.myco-chapter': 'intro' } as any);
},
});
reporter.recordCustomEvent('test');
const body = String(
requests.filter(
(r) => r.url === 'https://analytics.example.com/cmcd',
)[0]?.body || '',
);
expect(body).to.include('com.myco-chapter="intro"');
});
it('updateCustomData() at runtime takes effect on subsequent event reports', function () {
const requests: any[] = [];
let reporter: any;
setupEach({
version: 2,
loader: ((req: any) => {
requests.push(req);
return Promise.resolve({ status: 204 });
}) as any,
eventTargets: [
{
url: 'https://analytics.example.com/cmcd',
events: [CmcdEventType.CUSTOM_EVENT],
includeKeys: ['cen', 'com.myco-chapter'] as any,
},
],
reporterCallback: (r) => {
reporter = r;
r.updateCustomData({ 'com.myco-chapter': 'intro' } as any);
},
});
reporter.recordCustomEvent('test');
const firstBody = String(
requests.filter(
(r) => r.url === 'https://analytics.example.com/cmcd',
)[0]?.body || '',
);
expect(firstBody).to.include('com.myco-chapter="intro"');
reporter.updateCustomData({ 'com.myco-chapter': 'chapter-2' } as any);
requests.length = 0;
reporter.recordCustomEvent('test');
const secondBody = String(
requests.filter(
(r) => r.url === 'https://analytics.example.com/cmcd',
)[0]?.body || '',
);
expect(secondBody).to.include('com.myco-chapter="chapter-2"');
});
it('recordCustomEvent routes by events array: fires to matching target, not to non-matching target', function () {
const reqA: any[] = [];
const reqB: any[] = [];
let reporter: any;
setupEach({
version: 2,
loader: ((req: any) => {
if (req.url === 'https://custom-events.example.com') reqA.push(req);
if (req.url === 'https://bitrate-events.example.com') reqB.push(req);
return Promise.resolve({ status: 204 });
}) as any,
eventTargets: [
{
url: 'https://custom-events.example.com',
events: [CmcdEventType.CUSTOM_EVENT],
includeKeys: ['cen'] as any,
},
{
url: 'https://bitrate-events.example.com',
events: [CmcdEventType.BITRATE_CHANGE],
},
],
reporterCallback: (r) => {
reporter = r;
},
});
reporter.recordCustomEvent('chapter-change');
expect(reqA.length).to.be.greaterThan(0);
expect(String(reqA[0].body || '')).to.include('cen="chapter-change"');
expect(reqB.length).to.equal(0);
});
});
});