mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
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:
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1415,6 +1415,8 @@ export type {
|
||||
BufferControllerConfig,
|
||||
CapLevelControllerConfig,
|
||||
CMCDControllerConfig,
|
||||
CmcdCustomReporter,
|
||||
CmcdCustomData,
|
||||
EMEControllerConfig,
|
||||
DRMSystemConfiguration,
|
||||
DRMSystemsConfiguration,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user