diff --git a/package-lock.json b/package-lock.json index d0b9bf5de0..de69f1cb15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,11 @@ "license": "BSD-3-Clause", "dependencies": { "@svta/cml-608": "1.0.2", - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-cmsd": "1.0.6", "@svta/cml-dash": "1.0.6", "@svta/cml-id3": "1.0.6", - "@svta/cml-request": "1.0.12", + "@svta/cml-request": "1.0.13", "@svta/cml-xml": "1.1.4", "bcp-47-match": "^2.0.3", "codem-isoboxer": "0.3.10", @@ -2695,9 +2695,9 @@ } }, "node_modules/@svta/cml-cmcd": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.3.2.tgz", - "integrity": "sha512-SKBBjLmci0WK8HMjuv+36tVIMktonoOoxsXblOFZmB+ePPV2zjRMTD+2ZmE/1VEPJkKHENyhSjSHgJyeOlvZ1A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.4.0.tgz", + "integrity": "sha512-LEVH5t5igv1fN18huFK+9S+qQOej2jFU16Z6E1phlaL615uP/+G3JiVZrw5n/GPAME4XjWmcgzbd7rP8dNwzvQ==", "license": "Apache-2.0", "engines": { "node": ">=20" @@ -2760,15 +2760,15 @@ } }, "node_modules/@svta/cml-request": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.12.tgz", - "integrity": "sha512-4sJvnnoNpq58j2mCGP8k+MF6wVy/qa4gbt6kfT1dPIKmn3mPxw+JVfilhcWsUi+peK2yCZxOJJYyHj1cAcQE1w==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.13.tgz", + "integrity": "sha512-DK2Bx94JjTPJAxNlc18JVa5+v3tCLHMorpgTPdy0KqhzQ6S0KjK4kdX8u7ULu3oYgEnBuimjMPOnWBvEJws87A==", "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-utils": "1.5.0", "@svta/cml-xml": "1.1.4" } diff --git a/package.json b/package.json index 885d97e97a..a06b77958b 100644 --- a/package.json +++ b/package.json @@ -91,11 +91,11 @@ }, "dependencies": { "@svta/cml-608": "1.0.2", - "@svta/cml-cmcd": "2.3.2", + "@svta/cml-cmcd": "2.4.0", "@svta/cml-cmsd": "1.0.6", "@svta/cml-dash": "1.0.6", "@svta/cml-id3": "1.0.6", - "@svta/cml-request": "1.0.12", + "@svta/cml-request": "1.0.13", "@svta/cml-xml": "1.1.4", "bcp-47-match": "^2.0.3", "codem-isoboxer": "0.3.10", diff --git a/src/streaming/controllers/CmcdController.js b/src/streaming/controllers/CmcdController.js index 2d85c2c925..e5d1e3c507 100644 --- a/src/streaming/controllers/CmcdController.js +++ b/src/streaming/controllers/CmcdController.js @@ -58,12 +58,14 @@ function CmcdController() { logger, mediaPlayerModel, reporterNeedsRebuild, - urlLoader; + urlLoader, + _lastPtUpdateAt; let context = this.context; let eventBus = EventBus(context).getInstance(); let debug = Debug(context).getInstance(); + const PT_UPDATE_THROTTLE_MS = 250; const playbackStateMap = { [MediaPlayerEvents.PLAYBACK_INITIALIZED]: Constants.CMCD_PLAYER_STATES.STARTING, [MediaPlayerEvents.PLAYBACK_PAUSED]: Constants.CMCD_PLAYER_STATES.PAUSED, @@ -134,6 +136,7 @@ function CmcdController() { function _resetInitialSettings() { reporterNeedsRebuild = false; + _lastPtUpdateAt = 0; } function _initializeEventBus(autoPlay) { @@ -142,6 +145,7 @@ function CmcdController() { eventBus.on(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, _onBufferLevelStateChanged, instance); eventBus.on(MediaPlayerEvents.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); eventBus.on(MediaPlayerEvents.PERIOD_SWITCH_COMPLETED, _onPeriodSwitchComplete, instance); + eventBus.on(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onPlaybackTimeUpdated, instance); if (autoPlay) { eventBus.on(MediaPlayerEvents.MANIFEST_LOADING_STARTED, _onPlaybackStarted, instance); @@ -165,12 +169,44 @@ function CmcdController() { }); } + function _gatherContinuousMetrics() { + return { + ...cmcdModel.getEventModeData(), + ...cmcdModel.calculateMsd(), + }; + } + function _onPlaybackStateChange(state) { - // Update CmcdReporter with the new player state - if (cmcdReporter) { - cmcdReporter.update({ sta: state }); + if (!cmcdReporter) { + return; + } + _rebuildReporterIfNeeded(); + + // Single consolidated update() call: + // - Persists continuous metrics + sta into the reporter's data store + // - Auto-fires the PLAY_STATE event with the full enriched payload + // A separate recordEvent('ps', ...) would be dedup-suppressed under + // @svta/cml-cmcd 2.4.0 and silently drop its data argument. + cmcdReporter.update({ ..._gatherContinuousMetrics(), sta: state }); + } + + function _onPlaybackTimeUpdated(e) { + if (!cmcdReporter) { + return; + } + if (typeof e?.time !== 'number' || !isFinite(e.time) || e.time < 0) { + return; } - triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.PLAY_STATE); + const now = Date.now(); + if (now - _lastPtUpdateAt < PT_UPDATE_THROTTLE_MS) { + return; + } + // Honor any pending reporter rebuild before writing, otherwise pt + // lands on a reporter that's about to be discarded by the next + // state-change or response-received rebuild. + _rebuildReporterIfNeeded(); + _lastPtUpdateAt = now; + cmcdReporter.update({ pt: Math.round(e.time * 1000) }); } function _createCmcdReporter() { @@ -250,15 +286,13 @@ function CmcdController() { if (errorData.error?.data?.request?.type === HTTPRequest.CMCD_EVENT) { return; } - // Update CmcdReporter with the error code - if (cmcdReporter) { - const errorCode = errorData.error?.code || errorData.error?.data?.code; - if (errorCode) { - cmcdReporter.update({ ec: errorCode }); - } - } - triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.ERROR); + const code = errorData.error?.code || errorData.error?.data?.code; + const transientData = code !== undefined && code !== null + ? { ec: [String(code)] } + : {}; + + _recordNonStateEvent(Constants.CMCD_REPORTING_EVENTS.ERROR, transientData); } function _rebuildReporterIfNeeded() { @@ -268,7 +302,7 @@ function CmcdController() { // Only rebuild if manifest params are available and enabled. // Without manifest params, the reporter config hasn't changed - // and rebuilding would unnecessarily reset sid and sn. + // and rebuilding would unnecessarily reset sid. // IMPORTANT: Don't reset reporterNeedsRebuild until we actually rebuild, // otherwise a race condition can occur where params aren't available yet // and we never get another chance to rebuild. @@ -286,26 +320,28 @@ function CmcdController() { } /** - * The handler that is triggered for CMCD event mode events (e.g., play, pause, error). Note that response recevived (rr) events are handled by getCmcdResponseReceivedInterceptors. - * @param event + * Records a non-state-change event (e.g., ERROR, custom). Continuous + * metrics are persisted into the reporter's data store via update(); + * only ephemeral per-event payload (e.g., ec for ERROR) is passed + * through recordEvent()'s data argument. + * + * State-change events (ps, pr, c, b, bc) auto-fire from update() and + * MUST NOT be routed through this helper — a follow-up recordEvent() + * for the same event token is dedup-suppressed under + * @svta/cml-cmcd >= 2.4.0. + * + * @param event - Event token (e.g., Constants.CMCD_REPORTING_EVENTS.ERROR) + * @param transientData - Per-event payload that should NOT persist into + * the reporter's data store. */ - function triggerCmcdEventMode(event) { + function _recordNonStateEvent(event, transientData = {}) { if (!cmcdReporter) { return; } - _rebuildReporterIfNeeded(); - const cmcdData = cmcdModel.getEventModeData(); - - // Route media start delay (MSD) through update() for the reporter's internal send-once tracking - const msdData = cmcdModel.calculateMsd(); - if (msdData.msd !== undefined) { - cmcdReporter.update(msdData); - } - - // Pass event-mode data as transient per-event data (not persisted) - cmcdReporter.recordEvent(event, cmcdData); + cmcdReporter.update(_gatherContinuousMetrics()); + cmcdReporter.recordEvent(event, transientData); } /** @@ -547,16 +583,14 @@ function CmcdController() { _rebuildReporterIfNeeded(); - // Collect event-mode data from the model - const eventData = cmcdModel.getEventModeData(); - - // Route MSD through update() for the reporter's internal send-once tracking - const msdData = cmcdModel.calculateMsd(); - if (msdData.msd !== undefined) { - cmcdReporter.update(msdData); - } + // Persist continuous metrics into the reporter's data store via update(). + // recordResponseReceived() will derive per-response fields and merge with + // whatever's in the store, so only ephemeral CMSD-derived data needs to + // ride the data argument. + cmcdReporter.update(_gatherContinuousMetrics()); - // Collect dash.js-specific additional data + // Collect dash.js-specific additional data (CMSD headers are ephemeral + // to this response and must not leak into the persistent store). const additionalData = {}; if (response.headers) { @@ -576,7 +610,7 @@ function CmcdController() { } try { - cmcdReporter.recordResponseReceived(response, { ...eventData, ...additionalData }); + cmcdReporter.recordResponseReceived(response, additionalData); } catch (e) { logger.error(e); } @@ -596,6 +630,7 @@ function CmcdController() { eventBus.off(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, _onBufferLevelStateChanged, instance); eventBus.off(MediaPlayerEvents.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); eventBus.off(MediaPlayerEvents.PERIOD_SWITCH_COMPLETED, _onPeriodSwitchComplete, instance); + eventBus.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onPlaybackTimeUpdated, instance); eventBus.off(MediaPlayerEvents.PLAYBACK_STARTED, _onPlaybackStarted, instance); eventBus.off(MediaPlayerEvents.MANIFEST_LOADING_STARTED, _onPlaybackStarted, instance); eventBus.off(MediaPlayerEvents.ERROR, _onPlayerError, instance); diff --git a/src/streaming/models/CmcdModel.js b/src/streaming/models/CmcdModel.js index 8c4bd6ee95..79e169ea9b 100644 --- a/src/streaming/models/CmcdModel.js +++ b/src/streaming/models/CmcdModel.js @@ -567,8 +567,15 @@ function CmcdModel() { data.ltc = ltc; } - if (typeof document !== 'undefined' && document.hidden) { - data.bg = true; + // Always report the current backgrounded state so transitions + // round-trip through the reporter's persistent store. Sending only + // bg:true (and omitting on visible) leaves a stale bg:true in the + // store after a hidden->visible transition, which then leaks into + // unrelated event-mode payloads. The library handles emission + // semantics: bg:false is stripped on non-`e=b` events and emitted + // as `?0` on `e=b` per the 2.4.0 carve-out. + if (typeof document !== 'undefined') { + data.bg = !!document.hidden; } if (mediaType && _shouldIncludeDroppedFrames(mediaType)) { diff --git a/test/functional/helpers/CmcdRequestCollector.js b/test/functional/helpers/CmcdRequestCollector.js deleted file mode 100644 index ee08b0ee18..0000000000 --- a/test/functional/helpers/CmcdRequestCollector.js +++ /dev/null @@ -1,222 +0,0 @@ -const CMCD_HEADER_NAMES = [ - 'cmcd-object', - 'cmcd-request', - 'cmcd-session', - 'cmcd-status', -]; - -function classifyUrl(url, method) { - if (method === 'POST') { - return 'event'; - } - if (/\.mpd/i.test(url)) { - return 'manifest'; - } - if (/\.(m4s|m4v|m4a|mp4)/i.test(url)) { - return 'segment'; - } - return 'unknown'; -} - -function detectReportingMode(url, headers) { - const hasCmcdHeaders = CMCD_HEADER_NAMES.some((name) => headers[name]); - if (hasCmcdHeaders) { - return 'header'; - } - if (url.includes('CMCD=')) { - return 'query'; - } - return null; -} - -/** - * Collects CMCD request data from outgoing XHR requests by monkey-patching - * XMLHttpRequest prototype methods. Stores full httpRequest objects compatible - * with CML's validateCmcdRequest(). - * - * For event target URLs, intercepts POST requests and simulates a 200 - * response to prevent actual network calls. - */ -class CmcdRequestCollector { - - constructor() { - this.requests = []; - this._resolvers = []; - this._eventTargetUrls = []; - this._origOpen = null; - this._origSetRequestHeader = null; - this._origSend = null; - } - - /** - * Install XHR patches to start collecting CMCD data. - * @param {object} [options] - * @param {string[]} [options.eventTargetUrls] - URLs to intercept as event POSTs - */ - attach(options = {}) { - this._eventTargetUrls = options.eventTargetUrls || []; - - const self = this; - - this._origOpen = XMLHttpRequest.prototype.open; - this._origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; - this._origSend = XMLHttpRequest.prototype.send; - - XMLHttpRequest.prototype.open = function (method, url) { - this._cmcd_method = method; - this._cmcd_url = typeof url === 'string' ? url : String(url); - this._cmcd_headers = {}; - return self._origOpen.apply(this, arguments); - }; - - XMLHttpRequest.prototype.setRequestHeader = function (name, value) { - if (this._cmcd_headers) { - this._cmcd_headers[name.toLowerCase()] = value; - } - return self._origSetRequestHeader.apply(this, arguments); - }; - - XMLHttpRequest.prototype.send = function (body) { - const url = this._cmcd_url || ''; - const method = (this._cmcd_method || 'GET').toUpperCase(); - const headers = this._cmcd_headers || {}; - - // Event target POST interception - const isEventTarget = self._eventTargetUrls.some( - (target) => url.startsWith(target) - ); - - if (isEventTarget && method === 'POST') { - self.requests.push({ - httpRequest: { url, method, headers, body }, - type: 'event', - reportingMode: 'event', - timestamp: Date.now(), - }); - self._notifyResolvers('event'); - - // Simulate 200 response matching XHRLoader expectations - const xhr = this; - setTimeout(() => { - try { - Object.defineProperty(xhr, 'status', { value: 200, configurable: true }); - Object.defineProperty(xhr, 'statusText', { value: 'OK', configurable: true }); - Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true }); - Object.defineProperty(xhr, 'responseURL', { value: url, configurable: true }); - Object.defineProperty(xhr, 'response', { value: '', configurable: true }); - Object.defineProperty(xhr, 'responseText', { value: '', configurable: true }); - xhr.getAllResponseHeaders = () => ''; - - if (typeof xhr.onload === 'function') { - xhr.onload.call(xhr); - } - if (typeof xhr.onloadend === 'function') { - xhr.onloadend.call(xhr); - } - } catch (e) { - console.error('Failed to simulate XHR response:', e); - } - }, 0); - return; - } - - // Passive collection for media requests - const type = classifyUrl(url, method); - if (type === 'manifest' || type === 'segment') { - const reportingMode = detectReportingMode(url, headers); - if (reportingMode) { - self.requests.push({ - httpRequest: { url, method, headers }, - type, - reportingMode, - timestamp: Date.now(), - }); - self._notifyResolvers(type); - } - } - - return self._origSend.apply(this, arguments); - }; - } - - /** - * Remove XHR patches and stop collecting. - */ - detach() { - if (this._origOpen) { - XMLHttpRequest.prototype.open = this._origOpen; - } - if (this._origSetRequestHeader) { - XMLHttpRequest.prototype.setRequestHeader = this._origSetRequestHeader; - } - if (this._origSend) { - XMLHttpRequest.prototype.send = this._origSend; - } - this._origOpen = null; - this._origSetRequestHeader = null; - this._origSend = null; - } - - /** - * Get collected requests, optionally filtered by type. - * @param {'manifest'|'segment'|'event'|'unknown'} [type] - * @returns {Array} - */ - getRequests(type) { - if (!type) { - return this.requests; - } - return this.requests.filter((r) => r.type === type); - } - - /** - * Wait until at least `count` requests of the given type have been collected. - * @param {'manifest'|'segment'|'event'|'unknown'} type - * @param {number} count - * @param {number} [timeout=15000] - * @returns {Promise} - */ - waitForRequests(type, count, timeout = 15000) { - const current = this.getRequests(type); - if (current.length >= count) { - return Promise.resolve(current); - } - - return new Promise((resolve) => { - const timer = setTimeout(() => { - this._resolvers = this._resolvers.filter((r) => r !== entry); - resolve(this.getRequests(type)); - }, timeout); - - const entry = { - type, - count, - resolve: (result) => { - clearTimeout(timer); - resolve(result); - }, - }; - this._resolvers.push(entry); - }); - } - - clear() { - this.requests = []; - } - - _notifyResolvers(type) { - this._resolvers = this._resolvers.filter((r) => { - if (r.type !== type) { - return true; - } - const requests = this.getRequests(r.type); - if (requests.length >= r.count) { - r.resolve(requests); - return false; - } - return true; - }); - } -} - -export default CmcdRequestCollector; diff --git a/test/functional/test/feature-support/cmcd-v2.js b/test/functional/test/feature-support/cmcd-v2.js index b5e8b86b3d..4a73a17727 100644 --- a/test/functional/test/feature-support/cmcd-v2.js +++ b/test/functional/test/feature-support/cmcd-v2.js @@ -7,8 +7,13 @@ import { initializeDashJsAdapter, } from '../common/common.js'; import { expect } from 'chai'; -import CmcdRequestCollector from '../../helpers/CmcdRequestCollector.js'; -import { validateCmcdRequest, validateCmcdEvents } from '@svta/cml-cmcd'; +import { + CmcdRecordedReportMode, + CmcdRecordedRequestType, + CmcdReportRecorder, + validateCmcdEvents, + validateCmcdRequest, +} from '@svta/cml-cmcd'; const TESTCASE = Constants.TESTCASES.FEATURE_SUPPORT.CMCD_V2; @@ -42,11 +47,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe(`CMCD V2 Query Mode - ${item.name} - ${mpd}`, () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'query' }, @@ -56,7 +61,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -71,12 +76,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('Manifest requests carry CMCD query params with v=2, ot, sid, cid', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.MANIFEST); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); expect(result.data.ot).to.not.be.undefined; @@ -85,14 +90,14 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('Init segment requests carry CMCD query params with ot, sid, cid, v=2', async function () { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const initSegments = collector.getRequests('segment').filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.httpRequest.url)); + const initSegments = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.SEGMENT).filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.request.url)); if (initSegments.length === 0) { this.skip(); } - const result = validateCmcdRequest(initSegments[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(initSegments[0].request, { version: 2 }); expect(result.valid, `CMCD validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.ot).to.not.be.undefined; expect(result.data.sid).to.equal('test-session-id'); @@ -100,10 +105,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('sn increments across successive requests', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const allRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); - const parsed = allRequests.map((r) => validateCmcdRequest(r.httpRequest, { version: 2 }).data); + const allRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); + const parsed = allRequests.map((r) => validateCmcdRequest(r.request, { version: 2 }).data); const withSn = parsed.filter((d) => d.sn !== undefined); expect(withSn.length).to.be.at.least(2); @@ -113,10 +118,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('sf is d (DASH)', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const allRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); - const parsed = allRequests.map((r) => validateCmcdRequest(r.httpRequest, { version: 2 }).data); + const allRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); + const parsed = allRequests.map((r) => validateCmcdRequest(r.request, { version: 2 }).data); const withSf = parsed.filter((d) => d.sf !== undefined); expect(withSf.length).to.be.greaterThan(0); @@ -126,10 +131,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('st is v for VOD', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const allRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); - const parsed = allRequests.map((r) => validateCmcdRequest(r.httpRequest, { version: 2 }).data); + const allRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); + const parsed = allRequests.map((r) => validateCmcdRequest(r.request, { version: 2 }).data); const withSt = parsed.filter((d) => d.st !== undefined); expect(withSt.length).to.be.greaterThan(0); @@ -139,14 +144,14 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('CMCD query payloads pass spec validation', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); expect(queryRequests.length).to.be.greaterThan(0); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); - expect(result.valid, `CMCD validation failed for ${req.httpRequest.url}:\n${formatIssues(result)}`).to.be.true; + const result = validateCmcdRequest(req.request, { version: 2 }); + expect(result.valid, `CMCD validation failed for ${req.request.url}:\n${formatIssues(result)}`).to.be.true; } }); @@ -159,11 +164,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe(`CMCD V2 Header Mode - ${item.name} - ${mpd}`, () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'headers' }, @@ -173,7 +178,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -184,47 +189,47 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('CMCD headers present on manifest requests with v=2', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.MANIFEST); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD header validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); }); it('CMCD headers present on init segment requests', async function () { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const initSegments = collector.getRequests('segment').filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.httpRequest.url)); + const initSegments = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.SEGMENT).filter((r) => /_0\.(m4s|m4v|m4a|mp4)/i.test(r.request.url)); if (initSegments.length === 0) { this.skip(); } - const result = validateCmcdRequest(initSegments[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(initSegments[0].request, { version: 2 }); expect(result.valid, `CMCD header validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.ot).to.not.be.undefined; expect(result.data.sid).to.equal('test-session-id'); }); it('Keys distributed across correct header shards (validateCmcdRequest)', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const headerRequests = collector.getRequests().filter((r) => r.reportingMode === 'header'); + const headerRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.HEADER); expect(headerRequests.length).to.be.greaterThan(0); for (const req of headerRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); - expect(result.valid, `CMCD header validation failed for ${req.httpRequest.url}:\n${formatIssues(result)}`).to.be.true; + const result = validateCmcdRequest(req.request, { version: 2 }); + expect(result.valid, `CMCD header validation failed for ${req.request.url}:\n${formatIssues(result)}`).to.be.true; } }); it('No CMCD= query params when in header mode', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const headerReqs = collector.getRequests().filter((r) => r.reportingMode === 'header'); - const queryReqs = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const headerReqs = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.HEADER); + const queryReqs = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); expect(queryReqs.length).to.equal(0); expect(headerReqs.length).to.be.greaterThan(0); }); @@ -240,13 +245,16 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Response-received events (e=rr)', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(60000); const targetUrl = `${EVENT_TARGET_BASE}/rr`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ + eventTargetUrls: [targetUrl], + waitTimeout: TIMEOUTS.REQUEST_COLLECTION, + }); const settings = { streaming: { cmcd: { @@ -268,7 +276,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -280,12 +288,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('POST to configured target with e=rr', async function () { this.timeout(30000); - await collector.waitForRequests('event', 2, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 2 }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.EVENT); expect(events.length).to.be.at.least(1); - const allEvents = events.flatMap((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 }).data || []); + const allEvents = events.flatMap((r) => validateCmcdEvents(r.request.body, { version: 2 }).data || []); const rrEvents = allEvents.filter((d) => d.e === 'rr'); expect(rrEvents.length).to.be.at.least(1); }); @@ -293,13 +301,16 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Play-state events (e=ps)', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(60000); const targetUrl = `${EVENT_TARGET_BASE}/ps`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ + eventTargetUrls: [targetUrl], + waitTimeout: TIMEOUTS.REQUEST_COLLECTION, + }); const settings = { streaming: { cmcd: { @@ -309,7 +320,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { { enabled: true, url: targetUrl, - enabledKeys: ['e', 'sta', 'msd', 'ts', 'sn'], + enabledKeys: ['e', 'sta', 'msd', 'ts', 'sn', 'pt', 'bl', 'mtp'], events: ['ps'], }, ], @@ -320,7 +331,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -332,12 +343,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('POST on state changes with sta defined', async function () { this.timeout(30000); - await collector.waitForRequests('event', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 1 }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.EVENT); expect(events.length).to.be.at.least(1); - const results = events.map((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 })); + const results = events.map((r) => validateCmcdEvents(r.request.body, { version: 2 })); for (const result of results) { expect(result.valid, `Play-state event validation failed:\n${formatIssues(result)}`).to.be.true; @@ -350,17 +361,49 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { expect(d.sta).to.not.be.undefined; } }); + + it('PLAY_STATE event reports carry enrichment fields (pt, ltc, bl, mtp)', async function () { + this.timeout(30000); + await recorder.waitForEvents({ count: 1 }); + + const events = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.EVENT); + expect(events.length).to.be.greaterThan(0); + + const psEvents = events + .flatMap((r) => validateCmcdEvents(r.request.body, { version: 2 }).data || []) + .filter((d) => d.e === 'ps'); + + expect(psEvents.length, 'expected at least one PLAY_STATE event report').to.be.greaterThan(0); + + const sample = psEvents[0]; + expect(sample, 'PLAY_STATE report should carry sta').to.have.property('sta'); + + // Multiple continuous enrichment fields should be present in real + // playback — pt (from the PLAYBACK_TIME_UPDATED listener), bl + // (buffer level), and mtp (measured throughput) all flow through + // the consolidated update() call. ltc is omitted from the assertion + // because PlaybackController.getCurrentLiveLatency() returns NaN + // on VOD streams. Requiring >=2 distinct enrichment fields ensures + // the test catches a regression that drops most of the payload. + const enrichmentKeys = ['pt', 'bl', 'mtp']; + const present = enrichmentKeys.filter((k) => sample[k] !== undefined); + expect(present.length, `expected >=2 enrichment fields in {${enrichmentKeys.join(',')}}; got: ${JSON.stringify(sample)}`) + .to.be.at.least(2); + }); }); describe('Time interval events', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(90000); const targetUrl = `${EVENT_TARGET_BASE}/ti`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ + eventTargetUrls: [targetUrl], + waitTimeout: TIMEOUTS.REQUEST_COLLECTION, + }); const settings = { streaming: { cmcd: { @@ -382,7 +425,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -396,10 +439,10 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { this.timeout(60000); // Wait long enough for at least 2 time interval events (3s interval) await playerAdapter.sleep(8000); - await collector.waitForRequests('event', 2, 20000); + await recorder.waitForEvents({ count: 2 }); - const allEvents = collector.getRequests('event') - .flatMap((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 }).data || []); + const allEvents = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.EVENT) + .flatMap((r) => validateCmcdEvents(r.request.body, { version: 2 }).data || []); const tiEvents = allEvents.filter((d) => d.e === 't'); expect(tiEvents.length).to.be.at.least(2); }); @@ -407,13 +450,16 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('POST format and validation', () => { let playerAdapter; - let collector; + let recorder; before(function () { this.timeout(60000); const targetUrl = `${EVENT_TARGET_BASE}/all`; - collector = new CmcdRequestCollector(); - collector.attach({ eventTargetUrls: [targetUrl] }); + recorder = new CmcdReportRecorder(); + recorder.attach({ + eventTargetUrls: [targetUrl], + waitTimeout: TIMEOUTS.REQUEST_COLLECTION, + }); const settings = { streaming: { cmcd: { @@ -435,7 +481,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -447,36 +493,36 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('POST content type is application/cmcd', async function () { this.timeout(30000); - await collector.waitForRequests('event', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 1 }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.EVENT); expect(events.length).to.be.at.least(1); for (const evt of events) { - const contentType = evt.httpRequest.headers['content-type'] || ''; + const contentType = evt.request.headers['content-type'] || ''; expect(contentType).to.include('application/cmcd'); } }); it('POST bodies pass CMCD event spec validation', async function () { this.timeout(30000); - await collector.waitForRequests('event', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 1 }); - const events = collector.getRequests('event'); + const events = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.EVENT); expect(events.length).to.be.at.least(1); for (const evt of events) { - const result = validateCmcdEvents(evt.httpRequest.body, { version: 2 }); - expect(result.valid, `CMCD event validation failed for POST to ${evt.httpRequest.url}:\n${formatIssues(result)}`).to.be.true; + const result = validateCmcdEvents(evt.request.body, { version: 2 }); + expect(result.valid, `CMCD event validation failed for POST to ${evt.request.url}:\n${formatIssues(result)}`).to.be.true; } }); it('sn increments across event POSTs', async function () { this.timeout(30000); - await collector.waitForRequests('event', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForEvents({ count: 3 }); - const withSn = collector.getRequests('event') - .flatMap((r) => validateCmcdEvents(r.httpRequest.body, { version: 2 }).data || []) + const withSn = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.EVENT) + .flatMap((r) => validateCmcdEvents(r.request.body, { version: 2 }).data || []) .filter((d) => d.sn !== undefined); expect(withSn.length).to.be.at.least(2); @@ -493,12 +539,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Only configured enabledKeys appear', () => { let playerAdapter; - let collector; + let recorder; const enabledKeys = ['ot', 'sid', 'v', 'sn']; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { @@ -512,7 +558,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -523,13 +569,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('Only configured keys appear in query mode', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); expect(queryRequests.length).to.be.greaterThan(0); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); + const result = validateCmcdRequest(req.request, { version: 2 }); const keys = Object.keys(result.data); for (const key of keys) { expect( @@ -543,11 +589,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('Empty enabledKeys produces no CMCD data', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { @@ -561,7 +607,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -573,18 +619,18 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { it('No CMCD data appended to requests', async () => { await playerAdapter.sleep(5000); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); expect(queryRequests.length).to.equal(0); }); }); describe('All default keys appear when no filter is set', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { @@ -597,7 +643,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -608,12 +654,12 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('Core keys appear across requests', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); const allSeenKeys = new Set(); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 2 }); + const result = validateCmcdRequest(req.request, { version: 2 }); for (const key of Object.keys(result.data)) { allSeenKeys.add(key); } @@ -637,11 +683,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('v=2 in query mode', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'query' }, @@ -651,7 +697,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -662,15 +708,15 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('v=2 present in query mode payloads', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); expect(queryRequests.length).to.be.greaterThan(0); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.MANIFEST); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD v2 query validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); }); @@ -678,11 +724,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('v=2 in header mode', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { ...DEFAULT_CMCD_V2_CONFIG, mode: 'headers' }, @@ -692,7 +738,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -703,15 +749,15 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('v=2 present in header mode payloads', async () => { - await collector.waitForRequests('segment', 3, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 3 }); - const headerRequests = collector.getRequests().filter((r) => r.reportingMode === 'header'); + const headerRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.HEADER); expect(headerRequests.length).to.be.greaterThan(0); - const manifests = collector.getRequests('manifest'); + const manifests = recorder.getReports().filter((r) => r.type === CmcdRecordedRequestType.MANIFEST); expect(manifests.length).to.be.greaterThan(0); - const result = validateCmcdRequest(manifests[0].httpRequest, { version: 2 }); + const result = validateCmcdRequest(manifests[0].request, { version: 2 }); expect(result.valid, `CMCD v2 header validation failed:\n${formatIssues(result)}`).to.be.true; expect(result.data.v).to.equal(2); }); @@ -719,11 +765,11 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { describe('v1 regression', () => { let playerAdapter; - let collector; + let recorder; before(() => { - collector = new CmcdRequestCollector(); - collector.attach(); + recorder = new CmcdReportRecorder(); + recorder.attach({ waitTimeout: TIMEOUTS.REQUEST_COLLECTION }); const settings = { streaming: { cmcd: { @@ -743,7 +789,7 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); after(() => { - collector.detach(); + recorder.detach(); if (playerAdapter) { playerAdapter.destroy(); } @@ -754,13 +800,13 @@ Utils.getTestvectorsForTestcase(TESTCASE).forEach((item) => { }); it('v absent or 1 in v1 mode', async () => { - await collector.waitForRequests('segment', 1, TIMEOUTS.REQUEST_COLLECTION); + await recorder.waitForSegments({ count: 1 }); - const queryRequests = collector.getRequests().filter((r) => r.reportingMode === 'query'); + const queryRequests = recorder.getReports().filter((r) => r.reportingMode === CmcdRecordedReportMode.QUERY); expect(queryRequests.length).to.be.greaterThan(0); for (const req of queryRequests) { - const result = validateCmcdRequest(req.httpRequest, { version: 1 }); + const result = validateCmcdRequest(req.request, { version: 1 }); expect( result.data.v === undefined || result.data.v === 1, `Expected v to be undefined or 1, got ${result.data.v}` diff --git a/test/unit/test/streaming/streaming.controllers.CmcdController.js b/test/unit/test/streaming/streaming.controllers.CmcdController.js index 942bfe9c5b..3d5ca5e498 100644 --- a/test/unit/test/streaming/streaming.controllers.CmcdController.js +++ b/test/unit/test/streaming/streaming.controllers.CmcdController.js @@ -354,6 +354,168 @@ describe('CmcdController', function () { const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); expect(metrics).to.have.property('e', 'ps'); }); + + it('should include pt in the event report after PLAYBACK_TIME_UPDATED fires', () => { + settings.update({ + streaming: { + cmcd: { + version: 2, + eventTargets: [{ + url: 'https://cmcd.event.collector/api', + enabled: true, + enabledKeys: ['e', 'sta', 'pt'], + events: ['ps'], + interval: 0 + }] + } + } + }); + cmcdController.initialize(); + + // Populate pt in the reporter's persistent store via the listener. + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, { time: 5.5 }); + + // Trigger a state change to fire the PLAY_STATE event. + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + expect(urlLoaderMock.load.calledOnce).to.be.true; + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); + expect(metrics).to.have.property('e', 'ps'); + expect(metrics).to.have.property('pt', 5500); + }); + + it('should throttle PLAYBACK_TIME_UPDATED to PT_UPDATE_THROTTLE_MS', () => { + settings.update({ + streaming: { + cmcd: { + version: 2, + eventTargets: [{ + url: 'https://cmcd.event.collector/api', + enabled: true, + enabledKeys: ['e', 'sta', 'pt'], + events: ['ps'], + interval: 0 + }] + } + } + }); + cmcdController.initialize(); + + // First fire: lands in the store (no throttle yet). + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, { time: 1.0 }); + + // Install fake timers AFTER the first update so the reporter is running. + // Stub Date.now to simulate only 100ms having passed since the first update. + const realNow = Date.now(); + const stub = sinon.stub(Date, 'now').returns(realNow + 100); + try { + // Second fire within the throttle window: should be skipped. + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, { time: 99.0 }); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); + // pt reflects the FIRST fire (1.0s → 1000ms), not the throttled second (99.0s). + expect(metrics).to.have.property('pt', 1000); + } finally { + stub.restore(); + } + }); + + it('should ignore PLAYBACK_TIME_UPDATED with invalid time values', () => { + settings.update({ + streaming: { + cmcd: { + version: 2, + eventTargets: [{ + url: 'https://cmcd.event.collector/api', + enabled: true, + enabledKeys: ['e', 'sta', 'pt'], + events: ['ps'], + interval: 0 + }] + } + } + }); + cmcdController.initialize(); + + // Each of these should be a no-op (no update() call to the reporter). + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, { time: undefined }); + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, { time: NaN }); + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, { time: -1 }); + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, {}); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); + // pt should not be in the payload because no valid update happened. + expect(metrics).to.not.have.property('pt'); + }); + + it('should include continuous-metric enrichment (ltc, pt) in the PLAY_STATE event payload', () => { + settings.update({ + streaming: { + cmcd: { + version: 2, + eventTargets: [{ + url: 'https://cmcd.event.collector/api', + enabled: true, + enabledKeys: ['e', 'sta', 'ltc', 'pt'], + events: ['ps'], + interval: 0 + }] + } + } + }); + cmcdController.initialize(); + + // Prime pt in the reporter's persistent store. + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, { time: 7.5 }); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + expect(urlLoaderMock.load.calledOnce).to.be.true; + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); + expect(metrics).to.have.property('e', 'ps'); + expect(metrics).to.have.property('sta', 'p'); + expect(metrics).to.have.property('ltc', 15000); // PlaybackControllerMock returns 15 (seconds) + expect(metrics).to.have.property('pt', 7500); + }); + + it('should emit ERROR event with ec as a string array', () => { + settings.update({ + streaming: { + cmcd: { + version: 2, + eventTargets: [{ + url: 'https://cmcd.event.collector/api', + enabled: true, + enabledKeys: ['e', 'ec'], + events: ['e'], + interval: 0 + }] + } + } + }); + cmcdController.initialize(); + + eventBus.trigger(MediaPlayerEvents.ERROR, { + error: { + code: 'PLAYER-FATAL-42', + data: { request: { type: 'someOtherRequestType' } } + } + }); + + expect(urlLoaderMock.load.calledOnce).to.be.true; + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); + expect(metrics).to.have.property('e', 'e'); + expect(metrics).to.have.property('ec').that.deep.equals(['PLAYER-FATAL-42']); + }); }); describe('Event Mode player state events', () => {