UNPKG

shaka-player

Version:
834 lines (735 loc) 26.1 kB
/*! @license * Copyright 2024 Streaming Video Technology Alliance * SPDX-License-Identifier: Apache-2.0 */ goog.provide('cml.cmcd.CmcdReporter'); goog.require('cml.cmcd.CMCD_DEFAULT_TIME_INTERVAL'); goog.require('cml.cmcd.CMCD_EVENT_MODE'); goog.require('cml.cmcd.CMCD_EVENT_RESPONSE_RECEIVED'); goog.require('cml.cmcd.CMCD_EVENT_TIME_INTERVAL'); goog.require('cml.cmcd.CMCD_HEADERS'); goog.require('cml.cmcd.CMCD_MIME_TYPE'); goog.require('cml.cmcd.CMCD_PARAM'); goog.require('cml.cmcd.CMCD_QUERY'); goog.require('cml.cmcd.CMCD_REQUEST_MODE'); goog.require('cml.cmcd.CMCD_STATE_EVENT_FIELDS'); goog.require('cml.cmcd.CMCD_V2'); goog.require('cml.cmcd.CmcdEventType'); goog.require('cml.cmcd.encodeCmcd'); goog.require('cml.cmcd.encodePreparedCmcd'); goog.require('cml.cmcd.prepareCmcdData'); goog.require('cml.cmcd.toPreparedCmcdHeaders'); goog.require('cml.cmcd.uuid'); goog.requireType('cml.cmcd.Cmcd'); goog.requireType('cml.cmcd.CmcdEncodeOptions'); goog.requireType('cml.cmcd.CmcdEventReportConfig'); goog.requireType('cml.cmcd.CmcdObjectTypeList'); goog.requireType('cml.cmcd.CmcdReportConfig'); goog.requireType('cml.cmcd.CmcdReporterConfig'); goog.requireType('cml.cmcd.CmcdRequestReport'); /** * @typedef {{ * sn: number, * msdSent: boolean * }} * @private */ cml.cmcd.CmcdReporter_CmcdTarget_; /** * @typedef {{ * sn: number, * msdSent: boolean, * intervalId: (number|undefined), * queue: !Array<!cml.cmcd.Cmcd> * }} * @private */ cml.cmcd.CmcdReporter_CmcdEventTarget_; /** * Build the encoder options for a given reporting mode + report config. * * @param {string} reportingMode * @param {!cml.cmcd.CmcdReportConfig} config * @param {string=} baseUrl * @return {!cml.cmcd.CmcdEncodeOptions} * @private */ cml.cmcd.CmcdReporter_createEncodingOptions_ = function( reportingMode, config, baseUrl) { const enabledKeySet = new Set(config.enabledKeys || []); return { version: config.version || cml.cmcd.CMCD_V2, reportingMode: reportingMode, filter: (key) => enabledKeySet.has(key), baseUrl: baseUrl, }; }; /** * Apply config defaults to produce a fully-normalized * `CmcdReporterConfigNormalized`-equivalent shape. * * Upstream returns a TS intersection type * (`CmcdReporterConfig & {sid, eventTargets, version, transmissionMode}`); * we return a plain object literal with the same keys. * * @param {!cml.cmcd.CmcdReporterConfig} config * @return {!cml.cmcd.CmcdReporterConfig} * @private */ cml.cmcd.CmcdReporter_createCmcdReporterConfig_ = function(config) { // Apply top-level config defaults const version = config.version || cml.cmcd.CMCD_V2; const eventTargets = config.eventTargets || []; const sid = config.sid || cml.cmcd.uuid(); const transmissionMode = config.transmissionMode || cml.cmcd.CMCD_QUERY; /** @type {!Array<!cml.cmcd.CmcdEventReportConfig>} */ const normalizedEventTargets = []; for (const target of eventTargets) { if (target && target.url && target.events && target.events.length) { /** @type {!Array<string>} */ const enabledKeysCopy = target.enabledKeys ? /** @type {!Array<string>} */ ([]).concat(target.enabledKeys) : []; /** @type {!Array<string>} */ const eventsCopy = /** @type {!Array<string>} */ ([]).concat(target.events); normalizedEventTargets.push({ version: target.version || cml.cmcd.CMCD_V2, enabledKeys: enabledKeysCopy, url: target.url, events: eventsCopy, interval: target.interval != null ? target.interval : cml.cmcd.CMCD_DEFAULT_TIME_INTERVAL, batchSize: target.batchSize || 1, }); } } /** @type {!cml.cmcd.CmcdReporterConfig} */ const normalized = /** @type {!cml.cmcd.CmcdReporterConfig} */ ( Object.assign({}, config, { version: version, transmissionMode: transmissionMode, sid: sid, cid: config.cid, enabledKeys: config.enabledKeys, eventTargets: normalizedEventTargets, })); return normalized; }; /** * Deep equality for CmcdObjectTypeList (used for `br` dedup). * * Order-sensitive: arrays with the same elements in different positions * are treated as different. Players that construct `br` consistently * get correct dedup; shuffling produces spurious emits, which is the * safer failure mode. * * @param {!cml.cmcd.CmcdObjectTypeList} a * @param {!cml.cmcd.CmcdObjectTypeList} b * @return {boolean} * @private */ cml.cmcd.CmcdReporter_cmcdObjectTypeListEqual_ = function(a, b) { if (a === b) return true; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { const ai = a[i]; const bi = b[i]; if (ai === bi) continue; if (typeof ai === 'number' || typeof bi === 'number') return false; // Both are SfItem<number, ExclusiveRecord<CmcdObjectType, boolean>> const ais = /** @type {!Object<string, *>} */ (ai); const bis = /** @type {!Object<string, *>} */ (bi); if (ais['value'] !== bis['value']) return false; const ap = /** @type {(Object<string, *>|null|undefined)} */ ( ais['params']); const bp = /** @type {(Object<string, *>|null|undefined)} */ ( bis['params']); const ak = ap && Object.keys(ap)[0]; const bk = bp && Object.keys(bp)[0]; if (ak !== bk) return false; if (ak && bk && ap && bp && ap[/** @type {string} */ (ak)] !== bp[/** @type {string} */ (bk)]) { return false; } } return true; }; /** * One row in the STATE_FIELDS_ dispatch table. * * `snapshot` captures the value stored in `lastEmitted_` for dedup * comparisons. Reference types must clone so the baseline doesn't * share a reference with the caller's input, which would let in-place * mutation silently poison the dedup state. * * @typedef {{ * field: string, * event: string, * equal: function(*, *): boolean, * snapshot: function(*): * * }} * @private */ cml.cmcd.CmcdReporter_StateFieldEntry_; /** * Identity snapshot for primitive fields. * * @param {*} v * @return {*} * @private */ cml.cmcd.CmcdReporter_identity_ = function(v) { return v; }; /** * Maps each tracked state field to its event type and equality * function. Order matters: `update()` fires events in this order for * multi-field updates. * * @const {!Array<!cml.cmcd.CmcdReporter_StateFieldEntry_>} * @private */ cml.cmcd.CmcdReporter_STATE_FIELDS_ = (function() { /** @type {!Array<!cml.cmcd.CmcdReporter_StateFieldEntry_>} */ const entries = []; cml.cmcd.CMCD_STATE_EVENT_FIELDS.forEach((field, event) => { if (field === 'br') { entries.push({ event: event, field: field, equal: (a, b) => (a === undefined || b === undefined) ? a === b : cml.cmcd.CmcdReporter_cmcdObjectTypeListEqual_( /** @type {!cml.cmcd.CmcdObjectTypeList} */ (a), /** @type {!cml.cmcd.CmcdObjectTypeList} */ (b)), snapshot: (v) => /** @type {!cml.cmcd.CmcdObjectTypeList} */ (v).slice(), }); } else { entries.push({ event: event, field: field, equal: Object.is, snapshot: cml.cmcd.CmcdReporter_identity_, }); } }); return entries; })(); /** * @const {!Map<string, !cml.cmcd.CmcdReporter_StateFieldEntry_>} * @private */ cml.cmcd.CmcdReporter_STATE_FIELDS_BY_EVENT_ = (function() { /** @type {!Map<string, !cml.cmcd.CmcdReporter_StateFieldEntry_>} */ const map = new Map(); for (const entry of cml.cmcd.CmcdReporter_STATE_FIELDS_) { map.set(entry.event, entry); } return map; })(); /** * The CMCD reporter. * * @see {@link https://cta-wave.github.io/Resources/common-media-client-data--cta-5004-b.html#reporting-modes-when-we-send-data} */ cml.cmcd.CmcdReporter = class { /** * Creates a new CMCD reporter. * * @param {!cml.cmcd.CmcdReporterConfig} config The configuration for * the CMCD reporter. * @param {function(!Object): !Promise<{status: number}>} requester * The function to use to send the request. Required: shaka's * adapter routes requests through NetworkingEngine. Diverges from * upstream's optional `requester = defaultRequester` since * `defaultRequester` was removed in `1fab16caa`. */ constructor(config, requester) { /** @private {number} */ this.timeOrigin_ = performance.timeOrigin || (performance.timing && performance.timing.fetchStart) || (Date.now() - performance.now()); /** @private {!cml.cmcd.Cmcd} */ this.data_ = /** @type {!cml.cmcd.Cmcd} */ ({}); /** @private {!cml.cmcd.CmcdReporterConfig} */ this.config_ = cml.cmcd.CmcdReporter_createCmcdReporterConfig_(config); /** @private {number} */ this.msd_ = NaN; /** * @private {!Map<!cml.cmcd.CmcdEventReportConfig, * !cml.cmcd.CmcdReporter_CmcdEventTarget_>} */ this.eventTargets_ = new Map(); /** @private {!cml.cmcd.CmcdReporter_CmcdTarget_} */ this.requestTarget_ = { sn: 0, msdSent: false, }; /** @private {function(!Object): !Promise<{status: number}>} */ this.requester_ = requester; /** * Last value emitted on the wire for each tracked state field. * Used by update() to dedup state-change events and by * recordEvent() to suppress no-transition events. * * @private {!Object<string, *>} */ this.lastEmitted_ = {}; this.data_ = /** @type {!cml.cmcd.Cmcd} */ ({ cid: this.config_.cid, sid: this.config_.sid, v: this.config_.version, }); for (const target of this.config_.eventTargets || []) { this.eventTargets_.set(target, { intervalId: undefined, sn: 0, msdSent: false, queue: [], }); } } /** * Starts the CMCD reporter. Called by the player when the reporter * is enabled. * * Note: This fires an initial time-interval event immediately * (synchronously) before the first interval elapses. Ensure CMCD * data (sid, cid, etc.) is populated before calling start(). */ start() { this.eventTargets_.forEach((target, config) => { // Disarm any existing timer so repeated start() calls do not leak // intervals. this.disarmInterval_(target); // If the interval is 0 or the TIME_INTERVAL event is not enabled, // do not start the interval. if (config.interval === 0 || !config.events || !config.events.includes(cml.cmcd.CmcdEventType.TIME_INTERVAL)) { return; } const timeIntervalEvent = () => { this.recordTargetEvent_( target, config, cml.cmcd.CMCD_EVENT_TIME_INTERVAL); this.processEventTargets_(); }; target.intervalId = setInterval( timeIntervalEvent, /** @type {number} */ (config.interval) * 1000); timeIntervalEvent(); }); } /** * Stops the CMCD reporter. Called by the player when the reporter is * disabled. * * @param {boolean=} flush Whether to flush the event targets. */ stop(flush = false) { if (flush) { this.flush(); } this.eventTargets_.forEach((target) => { this.disarmInterval_(target); }); } /** * Forces the sending of all event reports, regardless of the batch * size or interval. Useful for sending outstanding reports when the * player is destroyed or a playback session ends. */ flush() { this.processEventTargets_(true); } /** * Updates the CMCD data. * * Called by the player when data changes. For tracked state fields * (`sta`, `pr`, `cid`, `bg`, `br`), if the new value differs from * the last-reported value (the value most recently emitted on the * wire for that field), the corresponding state-change event is * automatically fired. Comparing against the last-reported value * (rather than the previous persisted value) ensures the first * state-change event in a new session always emits, even when the * persisted value didn't change across the `sid` boundary. * * Multi-field updates fire multiple events in the order: * `sta` → `pr` → `cid` → `bg` → `br`. The order of keys in the * input object does not affect the firing order. * * A `sid` change resets the dedup baseline. * * @param {!cml.cmcd.Cmcd} data The data to update. */ update(data) { if (data.sid && data.sid !== this.data_.sid) { this.resetSession_(); } if (data.msd && !isNaN(data.msd)) { this.msd_ = data.msd; } // msd is tracked separately via this.msd_ and sent once per target, // so it is stripped from the persistent data store after each // update. this.data_ = /** @type {!cml.cmcd.Cmcd} */ ( Object.assign({}, this.data_, data, {msd: undefined})); // Auto-trigger state-change events for any tracked field whose // value differs from the last wire-emitted value. Comparing // against lastEmitted_ (not the pre-merge value) ensures // correctness after a session reset, and unifies the comparison // basis with recordEvent's internal dedup. for (const entry of cml.cmcd.CmcdReporter_STATE_FIELDS_) { if (entry.field in data && !entry.equal(this.data_[entry.field], this.lastEmitted_[entry.field])) { this.recordEvent(entry.event); } } } /** * Records an event. Called by the player when an event occurs. * * For state-change events (`PLAY_STATE`, `PLAYBACK_RATE`, * `CONTENT_ID`, `BACKGROUNDED_MODE`, `BITRATE_CHANGE`), this method: * 1. Persists the dedup field from `data` (if present) into the * reporter's persistent data store — equivalent to a * write-through `update()`. * 2. Drops the event entirely if the dedup field has no value * after the write-through. State-change events without their * required field would violate CTA-5004-B. * 3. Suppresses the event if the field's current value matches * the last-emitted value (no state transition). * * For all other event types, the event is always emitted. * * Most callers can rely on {@link CmcdReporter#update} to auto-fire * state-change events. Use `recordEvent` directly when you need to * attach extra context to the event report (e.g., `bl`, `pt` at the * moment of a play-state transition). * * @param {string} type The type of event to record. * @param {!cml.cmcd.Cmcd=} data Additional data to record with the * event. This data only applies to this event report, except for * the dedup field of a state-change event, which is also * persisted into the reporter's data store. */ recordEvent(type, data) { const eventData = data || /** @type {!cml.cmcd.Cmcd} */ ({}); const entry = cml.cmcd.CmcdReporter_STATE_FIELDS_BY_EVENT_.get( /** @type {string} */ (type)); if (entry) { const field = entry.field; const incoming = eventData[field]; if (incoming !== undefined) { this.data_[field] = incoming; } const current = this.data_[field]; // Never emit a state-change event with a missing required // field — per CTA-5004-B these events must carry their dedup // field. Catches both "no value ever set" and "previous value // was cleared to undefined". if (current === undefined) { return; } if (entry.equal(current, this.lastEmitted_[field])) { return; } this.lastEmitted_[field] = entry.snapshot(current); } this.eventTargets_.forEach((target, config) => { this.recordTargetEvent_(target, config, type, eventData); }); this.processEventTargets_(); } /** * Records an event for a target. Called by the reporter when an * event occurs. * * @param {!cml.cmcd.CmcdReporter_CmcdEventTarget_} target The target * to record the event for. * @param {!cml.cmcd.CmcdEventReportConfig} config The configuration * for the target. * @param {string} type The type of event to record. * @param {!cml.cmcd.Cmcd=} data Additional data to record with the * event. This data only applies to this event report. Persistent * data should be updated using `update()`. * @private */ recordTargetEvent_(target, config, type, data) { const eventData = data || /** @type {!cml.cmcd.Cmcd} */ ({}); if (!config.events || !config.events.includes(type)) { return; } /** @type {!cml.cmcd.Cmcd} */ const item = /** @type {!cml.cmcd.Cmcd} */ ( Object.assign({}, this.data_, eventData, { e: type, ts: eventData.ts != null ? eventData.ts : Date.now(), sn: target.sn++, })); if (!isNaN(this.msd_) && !target.msdSent) { item.msd = this.msd_; target.msdSent = true; } target.queue.push(item); } /** * Records a response-received event. Called by the player when a * media request response has been fully received. * * This method automatically derives the `rr` event keys from the * * - `url` - the original requested URL (before any redirects) * - `rc` - the HTTP response status code * - `ts` - the request initiation time (from `resourceTiming.startTime`) * - `ttfb` - time to first byte (from `resourceTiming.responseStart`) * - `ttlb` - time to last byte (from `resourceTiming.duration`) * * Additional keys like `ttfbb`, `cmsdd`, `cmsds`, and `smrt` can be * supplied via the `data` parameter if the player has access to * them. * * @param {!Object} response The HTTP response received. Upstream * shape: `HttpResponse<HttpRequest<{cmcd?: Cmcd}>>` — * `{request: {url, customData?: {cmcd?: Cmcd}}, status?, * resourceTiming?: {startTime, responseStart, duration}}`. * @param {!cml.cmcd.Cmcd=} data Additional CMCD data to include with * the event. Values provided here override any auto-derived * values. */ recordResponseReceived(response, data) { const responseData = data || /** @type {!cml.cmcd.Cmcd} */ ({}); const r = /** @type {!Object<string, *>} */ (response); const request = /** @type {!Object<string, *>} */ (r['request'] || {}); const status = /** @type {(number|undefined)} */ (r['status']); const timing = /** @type {?{startTime: (number|undefined), responseStart: (number|undefined), duration: (number|undefined)}} */ ( r['resourceTiming'] || null); const url = responseData.url != null ? responseData.url : /** @type {(string|undefined)} */ (request['url']); if (!url) { return; } const urlObj = new URL(/** @type {string} */ (url)); urlObj.searchParams.delete(cml.cmcd.CMCD_PARAM); /** @type {!cml.cmcd.Cmcd} */ const derived = /** @type {!cml.cmcd.Cmcd} */ ({ url: urlObj.toString(), rc: status, }); if (timing) { if (timing.startTime != null) { derived.ts = Math.round(this.timeOrigin_ + timing.startTime); if (timing.responseStart != null) { derived.ttfb = Math.round(timing.responseStart - timing.startTime); } } if (timing.duration != null) { derived.ttlb = Math.round(timing.duration); } } const reqCustomData = /** @type {!Object<string, *>} */ (request['customData'] || {}); const cmcd = /** @type {!cml.cmcd.Cmcd} */ ( reqCustomData['cmcd'] != null ? reqCustomData['cmcd'] : /** @type {!cml.cmcd.Cmcd} */ ({})); this.recordEvent( cml.cmcd.CMCD_EVENT_RESPONSE_RECEIVED, /** @type {!cml.cmcd.Cmcd} */ ( Object.assign({}, cmcd, derived, responseData))); } /** * Applies the CMCD request report data to the request. Called by the * player before sending the request. * * @param {!Object} req The request to apply the CMCD request report * to. * @return {!Object} The request with the CMCD request report * applied. * * @deprecated Use `createRequestReport` instead. */ applyRequestReport(req) { const result = this.createRequestReport(req); return result != null ? result : req; } /** * Checks if the request reporting is enabled. * * @return {boolean} `true` if the request reporting is enabled, * `false` otherwise. */ isRequestReportingEnabled() { return !!(this.config_.enabledKeys && this.config_.enabledKeys.length); } /** * Creates a new request with the CMCD request report data applied. * Called by the player before sending the request. * * Upstream signature is generic * (`<R extends HttpRequest>(request: R, data?: Partial<Cmcd>): R & * CmcdRequestReport<R['customData']>`); the generic erases in * Closure. * * @param {!Object} request The request to apply the CMCD request * report to. * @param {!cml.cmcd.Cmcd=} data The data to apply to the request. * This data only applies to this request report. Persistent data * should be updated using `update()`. * @return {!cml.cmcd.CmcdRequestReport} The request with the CMCD * request report applied. */ createRequestReport(request, data) { const r = /** @type {!Object<string, *>} */ (request); const customData = /** @type {!Object<string, *>} */ (r['customData'] || {}); const headers = /** @type {!Object<string, string>} */ (r['headers'] || {}); /** @type {!cml.cmcd.CmcdRequestReport} */ const report = /** @type {!cml.cmcd.CmcdRequestReport} */ ( Object.assign({}, request, { headers: Object.assign({}, headers), customData: Object.assign({}, customData, { cmcd: /** @type {!cml.cmcd.Cmcd} */ ({}), }), })); if (!this.config_.enabledKeys || !this.config_.enabledKeys.length || !report.url) { return report; } const url = new URL(report.url); /** @type {!cml.cmcd.Cmcd} */ const cmcdData = /** @type {!cml.cmcd.Cmcd} */ ( Object.assign({}, this.data_, data || {}, { sn: this.requestTarget_.sn++, })); const options = cml.cmcd.CmcdReporter_createEncodingOptions_( cml.cmcd.CMCD_REQUEST_MODE, this.config_, report.url); if (!isNaN(this.msd_) && !this.requestTarget_.msdSent) { cmcdData.msd = this.msd_; this.requestTarget_.msdSent = true; } const cmcd = report.customData.cmcd = cml.cmcd.prepareCmcdData(cmcdData, options); switch (this.config_.transmissionMode) { case cml.cmcd.CMCD_QUERY: { const param = cml.cmcd.encodePreparedCmcd(cmcd); if (param) { url.searchParams.set(cml.cmcd.CMCD_PARAM, param); report.url = url.toString(); } break; } case cml.cmcd.CMCD_HEADERS: Object.assign( report.headers, cml.cmcd.toPreparedCmcdHeaders(cmcd, options.customHeaderMap)); break; } return report; } /** * Processes the event targets. Called by the reporter when an event * occurs. * * @param {boolean=} flush Whether to flush the event targets. * @private */ processEventTargets_(flush = false) { let reprocess = false; this.eventTargets_.forEach((target, config) => { const queue = target.queue; if (!queue.length) { return; } if (queue.length < /** @type {number} */ (config.batchSize) && !flush) { return; } const deleteCount = flush ? queue.length : /** @type {number} */ (config.batchSize); const events = queue.splice(0, deleteCount); this.sendEventReport_(config, events).catch(() => { // Re-queue events that failed to send target.queue.unshift(...events); }); reprocess = reprocess || queue.length > 0; }); if (reprocess) { this.processEventTargets_(); } } /** * Sends an event report. Called by the reporter when a batch is * ready to be sent. * * @param {!cml.cmcd.CmcdEventReportConfig} config The target config * to send the event report to. * @param {!Array<!cml.cmcd.Cmcd>} data The data to send in the event * report. * @return {!Promise<void>} * @private */ async sendEventReport_(config, data) { const options = cml.cmcd.CmcdReporter_createEncodingOptions_( cml.cmcd.CMCD_EVENT_MODE, config); const response = await this.requester_({ url: config.url, method: 'POST', headers: { 'Content-Type': cml.cmcd.CMCD_MIME_TYPE, }, body: data.map((item) => cml.cmcd.encodeCmcd(item, options)).join('\n') + '\n', }); const status = response.status; if (status === 410) { this.disposeEventTarget_(config); } else if (status === 429 || (status > 499 && status < 600)) { throw new Error(`Event report failed with status ${status}`); } } /** * Cancels the time-interval timer for an event target and clears the * stored id. Safe to call when no timer is armed * (clearInterval(undefined) is a no-op). * * @param {!cml.cmcd.CmcdReporter_CmcdEventTarget_} target * @private */ disarmInterval_(target) { clearInterval(target.intervalId); target.intervalId = undefined; } /** * Permanently removes an event target: cancels its timer and removes * it from the eventTargets map. Used when the collector signals the * target is gone (HTTP 410). * * @param {!cml.cmcd.CmcdEventReportConfig} config * @private */ disposeEventTarget_(config) { const target = this.eventTargets_.get(config); if (!target) { return; } this.disarmInterval_(target); this.eventTargets_.delete(config); } /** * Resets the session related data. Called when the session ID * changes. * * @private */ resetSession_() { this.eventTargets_.forEach((target) => { target.sn = 0; }); this.requestTarget_.sn = 0; this.lastEmitted_ = {}; } };