diff --git a/src/dash/constants/DashConstants.js b/src/dash/constants/DashConstants.js index e2901ee84e..e4ac0cda3c 100644 --- a/src/dash/constants/DashConstants.js +++ b/src/dash/constants/DashConstants.js @@ -87,6 +87,7 @@ export default { END_NUMBER: 'endNumber', ESSENTIAL_PROPERTY: 'EssentialProperty', EVENT: 'Event', + EVENT_TARGET: 'EventTarget', EVENT_STREAM: 'EventStream', FORCED_SUBTITLE: 'forced-subtitle', FRAMERATE: 'frameRate', @@ -153,6 +154,7 @@ export default { REMOVE: 'remove', REPLACE: 'replace', REPORTING: 'Reporting', + REPORTING_TARGETS: 'ReportingTargets', REPRESENTATION: 'Representation', REPRESENTATION_INDEX: 'RepresentationIndex', ROBUSTNESS: 'robustness', diff --git a/src/dash/parser/DashParser.js b/src/dash/parser/DashParser.js index 1748bb64b3..439732614f 100644 --- a/src/dash/parser/DashParser.js +++ b/src/dash/parser/DashParser.js @@ -51,6 +51,7 @@ const arrayNodes = [ DashConstants.CONTENT_STEERING, DashConstants.ESSENTIAL_PROPERTY, DashConstants.EVENT, + DashConstants.EVENT_TARGET, DashConstants.EVENT_STREAM, DashConstants.INBAND_EVENT_STREAM, DashConstants.LABEL, diff --git a/src/dash/vo/CMCDParameters.js b/src/dash/vo/CMCDParameters.js index 9ec31e461e..6422644cc4 100644 --- a/src/dash/vo/CMCDParameters.js +++ b/src/dash/vo/CMCDParameters.js @@ -30,6 +30,7 @@ */ import DescriptorType from './DescriptorType.js'; +import EventTarget from './EventTarget.js'; import Constants from '../../streaming/constants/Constants.js'; /** @@ -45,6 +46,7 @@ class CMCDParameters extends DescriptorType { this.mode = null; this.keys = null; this.includeInRequests = null; + this.reportingTargets = null; } init(data) { @@ -60,6 +62,20 @@ class CMCDParameters extends DescriptorType { ? data.includeInRequests.split(' ') : [Constants.CMCD_DEFAULT_INCLUDE_IN_REQUESTS]; this.schemeIdUri = data.schemeIdUri; + + // Version 2: Parse ReportingTargets with EventTargets + if (data.ReportingTargets && data.ReportingTargets.EventTarget) { + this.reportingTargets = []; + const eventTargets = Array.isArray(data.ReportingTargets.EventTarget) + ? data.ReportingTargets.EventTarget + : [data.ReportingTargets.EventTarget]; + + eventTargets.forEach(targetData => { + const eventTarget = new EventTarget(); + eventTarget.init(targetData); + this.reportingTargets.push(eventTarget); + }); + } } } } diff --git a/src/dash/vo/EventTarget.js b/src/dash/vo/EventTarget.js new file mode 100644 index 0000000000..a30254eca2 --- /dev/null +++ b/src/dash/vo/EventTarget.js @@ -0,0 +1,62 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2024, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * @class + * @ignore + */ +class EventTarget { + constructor() { + this.url = null; + this.timeInterval = 0; + this.mode = null; + this.keys = null; + this.events = null; + this.enabled = true; + this.batchSize = 0; + this.batchTimer = 0; + } + + init(data) { + if (data) { + this.url = data.url; + this.timeInterval = data.timeInterval ? parseInt(data.timeInterval, 10) : 0; + this.mode = data.mode ?? 'query'; + this.keys = data.keys ? data.keys.split(' ') : null; + this.events = data.events ? data.events.split(' ') : null; + this.enabled = data.enabled ?? true; + this.batchSize = data.batchSize ? parseInt(data.batchSize, 10) : 0; + this.batchTimer = data.batchTimer ? parseInt(data.batchTimer, 10) : 0; + } + } +} + +export default EventTarget; diff --git a/src/streaming/cmcd/config/CmcdConfigAccessor.js b/src/streaming/cmcd/config/CmcdConfigAccessor.js index 78b27b2053..7c26c578ff 100644 --- a/src/streaming/cmcd/config/CmcdConfigAccessor.js +++ b/src/streaming/cmcd/config/CmcdConfigAccessor.js @@ -199,7 +199,7 @@ function CmcdConfigAccessor() { return undefined; } - // Handle array notation: targets[0] + // Handle array notation: targets[0] or reportingTargets[1] const arrayMatch = part.match(/^([^\[]+)\[(\d+)\]$/); if (arrayMatch) { diff --git a/src/streaming/cmcd/config/CmcdPropertyMap.js b/src/streaming/cmcd/config/CmcdPropertyMap.js index ce6615b42c..d0e401c4c6 100644 --- a/src/streaming/cmcd/config/CmcdPropertyMap.js +++ b/src/streaming/cmcd/config/CmcdPropertyMap.js @@ -273,14 +273,19 @@ const CmcdPropertyMap = { /** * V2: Reporting targets array - * Priority: settings > default ([]) + * Priority: manifest > settings > default ([]) */ targets: { version: [2], sources: [ { - path: 'settings.streaming.cmcd.targets', + path: 'manifestParams.reportingTargets', priority: 1, + type: 'array' + }, + { + path: 'settings.streaming.cmcd.targets', + priority: 2, type: 'array', default: [] } @@ -295,8 +300,13 @@ const CmcdPropertyMap = { version: [2], sources: [ { - path: 'settings.streaming.cmcd.targets[{targetIndex}].enabled', + path: 'manifestParams.reportingTargets[{targetIndex}].enabled', priority: 1, + type: 'boolean' + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].enabled', + priority: 2, type: 'boolean', default: true } @@ -311,8 +321,13 @@ const CmcdPropertyMap = { version: [2], sources: [ { - path: 'settings.streaming.cmcd.targets[{targetIndex}].url', + path: 'manifestParams.reportingTargets[{targetIndex}].url', priority: 1, + type: 'string' + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].url', + priority: 2, type: 'string', default: null } @@ -327,9 +342,20 @@ const CmcdPropertyMap = { version: [2], sources: [ { - path: 'settings.streaming.cmcd.targets[{targetIndex}].enabledKeys', + path: 'manifestParams.reportingTargets[{targetIndex}].keys', priority: 1, type: 'array', + transform: (val) => { + if (typeof val === 'string') { + return val.split(' '); + } + return val; + } + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].enabledKeys', + priority: 2, + type: 'array', default: [] } ] @@ -343,9 +369,20 @@ const CmcdPropertyMap = { version: [2], sources: [ { - path: 'settings.streaming.cmcd.targets[{targetIndex}].events', + path: 'manifestParams.reportingTargets[{targetIndex}].events', priority: 1, type: 'array', + transform: (val) => { + if (typeof val === 'string') { + return val.split(' '); + } + return val; + } + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].events', + priority: 2, + type: 'array', default: [] } ] @@ -359,8 +396,13 @@ const CmcdPropertyMap = { version: [2], sources: [ { - path: 'settings.streaming.cmcd.targets[{targetIndex}].timeInterval', + path: 'manifestParams.reportingTargets[{targetIndex}].timeInterval', priority: 1, + type: 'number' + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].timeInterval', + priority: 2, type: 'number', default: Constants.CMCD_DEFAULT_TIME_INTERVAL } @@ -375,10 +417,16 @@ const CmcdPropertyMap = { version: [2], sources: [ { - path: 'settings.streaming.cmcd.targets[{targetIndex}].batchSize', + path: 'manifestParams.reportingTargets[{targetIndex}].batchSize', priority: 1, type: 'number', default: 0 + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].batchSize', + priority: 2, + type: 'number', + default: 0 } ] }, diff --git a/src/streaming/controllers/CmcdController.js b/src/streaming/controllers/CmcdController.js index 5b0ae25d9f..fd0150a103 100644 --- a/src/streaming/controllers/CmcdController.js +++ b/src/streaming/controllers/CmcdController.js @@ -425,7 +425,7 @@ function CmcdController() { function _checkTargetIncludeInRequests(targetIndex) { const targetAccessor = cmcdConfig.getTarget(targetIndex); - let enabledRequests = targetAccessor.get('targetIncludeOnRequests'); + let enabledRequests = targetAccessor.get('targetIncludeInRequests'); if (!enabledRequests) { return true; diff --git a/test/unit/test/streaming/streaming.controllers.CmcdController.js b/test/unit/test/streaming/streaming.controllers.CmcdController.js index c03ae76ae9..21d8cc0833 100644 --- a/test/unit/test/streaming/streaming.controllers.CmcdController.js +++ b/test/unit/test/streaming/streaming.controllers.CmcdController.js @@ -1436,4 +1436,220 @@ describe('CmcdController', function () { expect(result.url).to.not.include('CMCD='); }); }); + + describe('CMCD v2: Manifest-based ReportingTargets Configuration', function () { + let urlLoaderMock; + + beforeEach(function () { + urlLoaderMock = { + load: sinon.spy() + }; + + cmcdController.setConfig({ + abrController: abrControllerMock, + dashMetrics: dashMetricsMock, + playbackController: playbackControllerMock, + throughputController: throughputControllerMock, + serviceDescriptionController: serviceDescriptionControllerMock, + urlLoader: urlLoaderMock + }); + }); + + it('should use ReportingTargets from manifest when configured', () => { + serviceDescriptionControllerMock.applyServiceDescription({ + clientDataReporting: { + cmcdParameters: { + version: 2, + sessionID: 'manifest-session-123', + contentID: 'manifest-content-456', + reportingTargets: [{ + url: 'http://manifest.analytics.com/cmcd-collector', + mode: 'query', + keys: ['sid', 'cid', 'e', 'sta'], + events: ['ps'], + enabled: true, + timeInterval: 0 + }] + } + } + }); + + // Trigger manifest loaded to update CMCD with manifest params + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADED, {}); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + expect(urlLoaderMock.load.calledOnce).to.be.true; + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + // CMCD v2 Event Mode uses body transmission + expect(requestSent.url).to.equal('http://manifest.analytics.com/cmcd-collector'); + expect(requestSent.body).to.exist; + + const metrics = decodeCmcd(decodeURIComponent(requestSent.body)); + + expect(metrics).to.have.property('e', 'ps'); + expect(metrics).to.have.property('sid', 'manifest-session-123'); + expect(metrics).to.have.property('cid', 'manifest-content-456'); + }); + + it('should use manifest mode configuration (header) for event reporting', () => { + serviceDescriptionControllerMock.applyServiceDescription({ + clientDataReporting: { + cmcdParameters: { + version: 2, + sessionID: 'session-header-test', + reportingTargets: [{ + url: 'http://manifest.analytics.com/cmcd-collector', + mode: 'header', + keys: ['sid', 'e'], + events: ['ps'], + enabled: true, + timeInterval: 0 + }] + } + } + }); + + // Trigger manifest loaded to update CMCD with manifest params + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADED, {}); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + expect(urlLoaderMock.load.calledOnce).to.be.true; + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + + // In header mode, CMCD should be in headers, not query params + expect(requestSent.url).to.equal('http://manifest.analytics.com/cmcd-collector'); + expect(requestSent.headers).to.exist; + }); + + it('should filter events based on manifest configuration', () => { + serviceDescriptionControllerMock.applyServiceDescription({ + clientDataReporting: { + cmcdParameters: { + version: 2, + sessionID: 'session-filter-test', + reportingTargets: [{ + url: 'http://manifest.analytics.com/cmcd-collector', + mode: 'query', + events: ['e'], // Only error events + enabled: true, + timeInterval: 0 + }] + } + } + }); + + // Trigger manifest loaded to update CMCD with manifest params + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADED, {}); + + // Trigger a playback playing event (not in the events list) + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + expect(urlLoaderMock.load.called).to.be.false; + + // Trigger an error event (in the events list) + const errorPayload = { + error: { + code: 1, + message: 'Test Error', + data: { + request: { + type: 'segment' + } + } + } + }; + eventBus.trigger(MediaPlayerEvents.ERROR, errorPayload); + expect(urlLoaderMock.load.calledOnce).to.be.true; + }); + + it('should support multiple ReportingTargets from manifest', () => { + serviceDescriptionControllerMock.applyServiceDescription({ + clientDataReporting: { + cmcdParameters: { + version: 2, + sessionID: 'multi-target-session', + reportingTargets: [ + { + url: 'http://target1.analytics.com/api', + mode: 'query', + events: ['ps'], + enabled: true, + timeInterval: 0 + }, + { + url: 'http://target2.analytics.com/api', + mode: 'query', + events: ['ps'], + enabled: true, + timeInterval: 0 + } + ] + } + } + }); + + // Trigger manifest loaded to update CMCD with manifest params + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADED, {}); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + expect(urlLoaderMock.load.calledTwice).to.be.true; + + const request1 = urlLoaderMock.load.firstCall.args[0].request; + expect(request1.url).to.equal('http://target1.analytics.com/api'); + expect(request1.body).to.exist; + + const request2 = urlLoaderMock.load.secondCall.args[0].request; + expect(request2.url).to.equal('http://target2.analytics.com/api'); + expect(request2.body).to.exist; + }); + + it('should prioritize manifest targets over settings targets when both are configured', () => { + // Settings provide one target (lower priority - priority 2) + settings.update({ + streaming: { + cmcd: { + version: 2, + targets: [{ + url: 'http://settings.analytics.com/api', + enabled: true, + mode: 'query', + events: ['ps'], + timeInterval: 0 + }] + } + } + }); + + serviceDescriptionControllerMock.applyServiceDescription({ + clientDataReporting: { + cmcdParameters: { + version: 2, + sessionID: 'manifest-session', + reportingTargets: [{ + url: 'http://manifest.analytics.com/api', + mode: 'query', + events: ['ps'], + enabled: true, + timeInterval: 0 + }] + } + } + }); + + // Trigger manifest loaded to update CMCD with manifest params + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADED, {}); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PLAYING); + + expect(urlLoaderMock.load.calledOnce).to.be.true; + const requestSent = urlLoaderMock.load.firstCall.args[0].request; + + expect(requestSent.url).to.equal('http://manifest.analytics.com/api'); + expect(requestSent.url).to.not.include('http://settings.analytics.com/api'); + expect(requestSent.body).to.exist; + }); + + }); });