@newrelic/video-core 4.1.5-beta → 4.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/video-core",
3
- "version": "4.1.5-beta",
3
+ "version": "4.1.5",
4
4
  "description": "New Relic video tracking core library",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "module": "./dist/esm/index.js",
package/src/agent.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { HarvestScheduler } from "./harvestScheduler.js";
2
2
  import { NrVideoEventAggregator } from "./eventAggregator.js";
3
+ import Constants from "./constants.js";
3
4
  import Log from "./log.js";
4
5
  import Tracker from "./tracker";
5
6
 
@@ -49,7 +50,16 @@ class VideoAnalyticsAgent {
49
50
 
50
51
  try {
51
52
  if(eventObject.actionName && eventObject.actionName === Tracker.Events.QOE_AGGREGATE) {
52
- // This makes sure that there is only one QOE aggregate event for a harvest cycle
53
+ // Ensure only one QOE_AGGREGATE event per (actionName + viewId) in buffer per harvest cycle.
54
+ // Each player has a unique viewId, so multi-player scenarios are handled correctly.
55
+ // Dirty-check dedup happens at drain time in HarvestScheduler._qoeKpisUnchanged().
56
+ if (eventObject.viewId) {
57
+ return this.eventBuffer.addOrReplaceByActionNameAndViewId(
58
+ Tracker.Events.QOE_AGGREGATE,
59
+ eventObject.viewId,
60
+ eventObject
61
+ );
62
+ }
53
63
  return this.eventBuffer.addOrReplaceByActionName(Tracker.Events.QOE_AGGREGATE, eventObject);
54
64
  }
55
65
  return this.eventBuffer.add(eventObject);
@@ -71,6 +81,52 @@ class VideoAnalyticsAgent {
71
81
 
72
82
  this.harvestScheduler.updateHarvestInterval(interval);
73
83
  }
84
+
85
+ /**
86
+ * Forces the next harvest cycle to include QOE_AGGREGATE events.
87
+ * Called at CONTENT_END to ensure final QoE is sent.
88
+ */
89
+ forceNextQoeCycle() {
90
+ if (this.harvestScheduler) {
91
+ this.harvestScheduler.forceNextQoeCycle = true;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Sets a callback to be called before each drain to refresh QoE KPIs.
97
+ * @param {Function|null} callback - Function that refreshes QoE data in the buffer, or null to clear
98
+ */
99
+ setBeforeDrainCallback(callback) {
100
+ if (this.harvestScheduler) {
101
+ this.harvestScheduler.beforeDrainCallback = callback;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Updates QoE KPI fields on the existing QOE_AGGREGATE event in the buffer.
107
+ * Scoped to a specific viewId to support multiple players on the same page.
108
+ * @param {object} freshKpis - Object with latest KPI values
109
+ * @param {string} [viewId] - The viewId of the player whose QoE event to update
110
+ */
111
+ refreshQoeKpis(freshKpis, viewId) {
112
+ if (!this.eventBuffer || !freshKpis) return;
113
+ const existing = viewId
114
+ ? this.eventBuffer.findByActionNameAndViewId(Tracker.Events.QOE_AGGREGATE, viewId)
115
+ : this.eventBuffer.findByActionName(Tracker.Events.QOE_AGGREGATE);
116
+ if (existing) {
117
+ const updated = { ...existing };
118
+ for (const key of Constants.QOE_KPI_KEYS) {
119
+ if (key in freshKpis) {
120
+ updated[key] = freshKpis[key];
121
+ }
122
+ }
123
+ if (viewId) {
124
+ this.eventBuffer.addOrReplaceByActionNameAndViewId(Tracker.Events.QOE_AGGREGATE, viewId, updated);
125
+ } else {
126
+ this.eventBuffer.addOrReplaceByActionName(Tracker.Events.QOE_AGGREGATE, updated);
127
+ }
128
+ }
129
+ }
74
130
  }
75
131
 
76
132
  // Create singleton instance
package/src/constants.js CHANGED
@@ -42,10 +42,20 @@ Constants.MAX_BEACON_SIZE = 61440; // 60KB = 60 × 1024 bytes
42
42
  Constants.MAX_EVENTS_PER_BATCH = 1000;
43
43
  Constants.INTERVAL = 10000; //10 seconds
44
44
 
45
+ Constants.QOE_KPI_KEYS = [
46
+ "startupTime", "peakBitrate", "averageBitrate", "totalPlaytime",
47
+ "totalRebufferingTime", "rebufferingRatio", "hadStartupError",
48
+ "hadPlaybackError", "numberOfErrors"
49
+ ];
50
+
45
51
  Constants.QOE_AGGREGATE_KEYS = [
46
52
  "coreVersion", "instrumentation.name",
47
53
  "instrumentation.provider", "instrumentation.version", "isBackgroundEvent", "playerName", "playerVersion",
48
- "src", "viewId", "viewSession", "contentIsAutoplayed"
54
+ "src", "viewId", "viewSession", "contentIsAutoplayed", "contentIsMuted", "contentRenditionHeight", "contentRenditionWidth",
55
+ "contentSrc", "numberOfVideos", "pageUrl", "trackerName", "trackerVersion", "contentDuration", "contentPlayrate", "contentPlayhead",
56
+ "contentPreload", "elapsedTime", "contentTitle", "contentId", "contentIsLive", "deviceType", "deviceGroup", "deviceManufacturer",
57
+ "deviceModel", "deviceName", "deviceSize", "deviceUuid", "contentRenditionName", "contentIsFullscreen", "contentCdn",
58
+ "contentFps", "asnOrganization", "asnLongitude", "asnLatitude", "asn", "timeSinceRequested", "timeSinceStarted"
49
59
  ]
50
60
 
51
61
  export default Constants;
package/src/core.js CHANGED
@@ -16,7 +16,7 @@ class Core {
16
16
  static addTracker(tracker, options) {
17
17
  // Set video analytics configuration
18
18
  if (options?.info) {
19
- setVideoConfig(options.info);
19
+ setVideoConfig(options.info, options?.config);
20
20
  }
21
21
 
22
22
  if (tracker.on && tracker.emit) {
@@ -55,6 +55,54 @@ export class NrVideoEventAggregator {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * If an event with the specified actionName and viewId already exists in the buffer, it will be replaced.
60
+ * Otherwise, the event will be added as a new entry.
61
+ * @param {string} actionName - The actionName to search for in the buffer
62
+ * @param {string} viewId - The viewId to scope the lookup to
63
+ * @param {object} eventObject - The event object to add or use as replacement.
64
+ * @returns {boolean} True if the operation succeeded, false if an error occurred
65
+ */
66
+ addOrReplaceByActionNameAndViewId(actionName, viewId, eventObject) {
67
+ const i = this.buffer.findIndex(
68
+ e => e.actionName === actionName && e.viewId === viewId
69
+ );
70
+ try {
71
+ if (i === -1) {
72
+ this.add(eventObject);
73
+ } else {
74
+ this.add(eventObject, i);
75
+ }
76
+ return true;
77
+ } catch (error) {
78
+ Log.error("Failed to set or replace the event to buffer:", error.message);
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Returns the existing event in buffer matching the given actionName and viewId, or null.
85
+ * @param {string} actionName
86
+ * @param {string} viewId
87
+ * @returns {object|null}
88
+ */
89
+ findByActionNameAndViewId(actionName, viewId) {
90
+ const event = this.buffer.find(
91
+ e => e.actionName === actionName && e.viewId === viewId
92
+ );
93
+ return event || null;
94
+ }
95
+
96
+ /**
97
+ * Returns the existing event in buffer matching the given actionName, or null.
98
+ * @param {string} actionName
99
+ * @returns {object|null}
100
+ */
101
+ findByActionName(actionName) {
102
+ const event = this.buffer.find(e => e.actionName === actionName);
103
+ return event || null;
104
+ }
105
+
58
106
  /**
59
107
  * Adds an event to the unified buffer.
60
108
  * All events are treated equally in FIFO order.
@@ -3,6 +3,7 @@ import { RetryQueueHandler } from "./retryQueueHandler";
3
3
  import { OptimizedHttpClient } from "./optimizedHttpClient";
4
4
  import { buildUrl, dataSize } from "./utils";
5
5
  import Constants from "./constants";
6
+ import Tracker from "./tracker";
6
7
  import Log from "./log";
7
8
 
8
9
  /**
@@ -31,6 +32,10 @@ export class HarvestScheduler {
31
32
  this.currentTimerId = null;
32
33
  this.harvestCycle = Constants.INTERVAL;
33
34
  this.isHarvesting = false;
35
+ this.qoeCycleCount = 1;
36
+ this.forceNextQoeCycle = false;
37
+ this.beforeDrainCallback = null;
38
+ this._lastSentQoeKpis = {};
34
39
 
35
40
  // Page lifecycle handling
36
41
  this.setupPageLifecycleHandlers();
@@ -237,15 +242,65 @@ export class HarvestScheduler {
237
242
  /**
238
243
  * Drains events from the event buffer and optionally includes retry queue data.
239
244
  * Uses fresh-events-first approach with payload limits.
245
+ * Filters out QOE_AGGREGATE events based on the harvest interval multiplier,
246
+ * always including them on the first and final harvest cycles.
240
247
  * @param {object} options - Harvest options
241
248
  * @returns {Array} Drained events
242
249
  * @private
243
250
  */
244
- drainEvents() {
251
+ drainEvents(options = {}) {
252
+ // Determine if this cycle should include the QOE_AGGREGATE event
253
+ const multiplier = window.NRVIDEO?.config?.qoeIntervalFactor ?? 1;
254
+ const isForced = !!options.isFinalHarvest || this.forceNextQoeCycle;
255
+ const isQoeCycle =
256
+ (this.qoeCycleCount - 1) % multiplier === 0 || isForced;
257
+
258
+ // Reset force flag after use
259
+ if (this.forceNextQoeCycle) this.forceNextQoeCycle = false;
260
+
261
+ // Refresh QoE KPIs with latest tracker state before draining
262
+ if (this.beforeDrainCallback && typeof this.beforeDrainCallback === 'function') {
263
+ try {
264
+ this.beforeDrainCallback();
265
+ } catch (e) {
266
+ Log.error("Before drain callback failed:", e.message);
267
+ }
268
+ }
269
+
245
270
  // Always drain fresh events first (priority approach)
246
271
  const freshEvents = this.eventBuffer.drain();
247
- let events = [...freshEvents];
248
- let currentPayloadSize = dataSize(freshEvents);
272
+ let filteredFreshEvents;
273
+ if (isQoeCycle) {
274
+ filteredFreshEvents = freshEvents;
275
+ } else {
276
+ // On non-QoE cycles, put QoE events back into buffer instead of losing them
277
+ filteredFreshEvents = [];
278
+ for (const e of freshEvents) {
279
+ if (e.actionName === Tracker.Events.QOE_AGGREGATE) {
280
+ this.eventBuffer.add(e);
281
+ } else {
282
+ filteredFreshEvents.push(e);
283
+ }
284
+ }
285
+ }
286
+
287
+ this.qoeCycleCount++;
288
+
289
+ // Cross-cycle dirty check: skip QoE if KPIs unchanged since last send
290
+ // Forced cycles (CONTENT_END, page unload) always send QoE
291
+ for (let i = filteredFreshEvents.length - 1; i >= 0; i--) {
292
+ const e = filteredFreshEvents[i];
293
+ if (e.actionName === Tracker.Events.QOE_AGGREGATE) {
294
+ if (!isForced && this._qoeKpisUnchanged(e)) {
295
+ filteredFreshEvents.splice(i, 1);
296
+ } else {
297
+ this._saveQoeKpis(e);
298
+ }
299
+ }
300
+ }
301
+
302
+ let events = [...filteredFreshEvents];
303
+ let currentPayloadSize = dataSize(filteredFreshEvents);
249
304
 
250
305
  // Always check retry queue if it has data - no flags needed
251
306
  if (this.retryQueueHandler && this.retryQueueHandler.getQueueSize() > 0) {
@@ -413,4 +468,32 @@ export class HarvestScheduler {
413
468
  triggerFinalHarvest();
414
469
  });
415
470
  }
471
+
472
+ /**
473
+ * Checks if QoE KPIs are unchanged since last send.
474
+ * @param {object} event - QoE event to compare
475
+ * @returns {boolean} True if KPIs are identical to last sent
476
+ * @private
477
+ */
478
+ _qoeKpisUnchanged(event) {
479
+ const snapshot = this._lastSentQoeKpis[event.viewId];
480
+ if (!snapshot) return false;
481
+ for (const key of Constants.QOE_KPI_KEYS) {
482
+ if (event[key] !== snapshot[key]) return false;
483
+ }
484
+ return true;
485
+ }
486
+
487
+ /**
488
+ * Saves QoE KPI values after sending, keyed by viewId to support multiple players.
489
+ * @param {object} event - QoE event that was sent
490
+ * @private
491
+ */
492
+ _saveQoeKpis(event) {
493
+ const snapshot = {};
494
+ for (const key of Constants.QOE_KPI_KEYS) {
495
+ snapshot[key] = event[key];
496
+ }
497
+ this._lastSentQoeKpis[event.viewId] = snapshot;
498
+ }
416
499
  }
package/src/index.js CHANGED
@@ -26,7 +26,7 @@ const nrvideo = {
26
26
  version,
27
27
 
28
28
  // Enhanced video analytics components (new)
29
-
29
+
30
30
  NrVideoEventAggregator,
31
31
  RetryQueueHandler,
32
32
  OptimizedHttpClient,
@@ -40,22 +40,4 @@ const nrvideo = {
40
40
 
41
41
  };
42
42
 
43
- // Named exports for better tree-shaking and interop
44
- export {
45
- Core,
46
- Constants,
47
- Chrono,
48
- Log,
49
- Emitter,
50
- Tracker,
51
- VideoTracker,
52
- VideoTrackerState,
53
- NrVideoEventAggregator,
54
- RetryQueueHandler,
55
- OptimizedHttpClient,
56
- HarvestScheduler,
57
- recordEvent,
58
- version
59
- };
60
-
61
43
  export default nrvideo;
@@ -0,0 +1,33 @@
1
+ import Log from "./log";
2
+
3
+ /**
4
+ * Applies obfuscation rules to a JSON string before sending to the collector.
5
+ * Each rule replaces matches of `regex` with `replacement` in the string.
6
+ *
7
+ * @param {string} jsonString - Serialized JSON payload
8
+ * @param {Array<{regex: string|RegExp, replacement: string}>} rules - Obfuscation rules
9
+ * @returns {string} Obfuscated string
10
+ */
11
+ export function applyObfuscationRules(jsonString, rules) {
12
+ if (!rules || rules.length === 0) return jsonString;
13
+
14
+ return rules.reduce((str, rule) => {
15
+ let pattern;
16
+ try {
17
+ if (rule.regex instanceof RegExp) {
18
+ // Ensure global flag so all occurrences are replaced
19
+ const flags = rule.regex.flags.includes("g")
20
+ ? rule.regex.flags
21
+ : rule.regex.flags + "g";
22
+ pattern = new RegExp(rule.regex.source, flags);
23
+ } else {
24
+ pattern = new RegExp(rule.regex, "g");
25
+ }
26
+ } catch (e) {
27
+ Log.warn("applyObfuscationRules: invalid regex, skipping rule:", rule.regex, e.message);
28
+ return str;
29
+ }
30
+ let s = str.replace(pattern, rule.replacement);
31
+ return s;
32
+ }, jsonString);
33
+ }
@@ -1,5 +1,6 @@
1
- import { dataSize, shouldRetry } from "./utils";
1
+ import { shouldRetry } from "./utils";
2
2
  import Log from "./log";
3
+ import { applyObfuscationRules } from "./obfuscate";
3
4
 
4
5
  /**
5
6
  * Optimized HTTP client for video analytics data transmission with
@@ -50,7 +51,10 @@ export class OptimizedHttpClient {
50
51
  const startTime = Date.now();
51
52
 
52
53
  try {
53
- const requestBody = JSON.stringify(payload.body);
54
+ const requestBody = applyObfuscationRules(
55
+ JSON.stringify(payload.body),
56
+ window.NRVIDEO?.config?.obfuscate
57
+ );
54
58
 
55
59
  // Handle final harvest with sendBeacon
56
60
  if (options.isFinalHarvest && navigator.sendBeacon) {
@@ -57,7 +57,7 @@ export function recordEvent(eventType, attributes = {}) {
57
57
  // Send to video analytics harvester
58
58
  const success = videoAnalyticsHarvester.addEvent(eventObject);
59
59
 
60
- if(qoeEventObject) {
60
+ if(qoeEventObject && window?.NRVIDEO?.config?.qoeAggregate) {
61
61
  const successQoe = videoAnalyticsHarvester.addEvent(qoeEventObject);
62
62
  return success && successQoe;
63
63
  }
@@ -10,15 +10,19 @@ const { COLLECTOR } = Constants;
10
10
  class VideoConfiguration {
11
11
  /**
12
12
  * Validates and sets the video analytics configuration.
13
- * @param {object} userConfig - User provided configuration
13
+ * @param {object} userInfo - User provided configuration
14
+ * @param {object} [config] - Optional configuration object
14
15
  * @returns {boolean} True if configuration is valid and set
15
16
  */
16
17
 
17
- setConfiguration(userInfo) {
18
+ setConfiguration(userInfo, config) {
18
19
  if (!this.validateRequiredFields(userInfo)) {
19
20
  return false;
20
21
  }
21
- this.initializeGlobalConfig(userInfo);
22
+ if (!this.validateConfigFields(config)) {
23
+ return false;
24
+ }
25
+ this.initializeGlobalConfig(userInfo, config);
22
26
  Log.notice("Video analytics configuration initialized successfully");
23
27
  return true;
24
28
  }
@@ -73,10 +77,72 @@ class VideoConfiguration {
73
77
  return true;
74
78
  }
75
79
 
80
+ /**
81
+ * Validates optional config fields.
82
+ * @param {object} config - Config to validate
83
+ * @returns {boolean} True if valid
84
+ */
85
+ validateConfigFields(config) {
86
+ if (config === null || config === undefined) {
87
+ return true;
88
+ }
89
+
90
+ if (typeof config !== "object" || Array.isArray(config)) {
91
+ Log.error("config must be an object");
92
+ return false;
93
+ }
94
+
95
+ const { qoeAggregate, obfuscate } = config;
96
+
97
+ if (qoeAggregate !== undefined && typeof qoeAggregate !== "boolean") {
98
+ Log.error("qoeAggregate must be a boolean");
99
+ return false;
100
+ }
101
+
102
+ if (obfuscate !== undefined && !Array.isArray(obfuscate)) {
103
+ Log.error("obfuscate must be an array");
104
+ return false;
105
+ }
106
+
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * Filters obfuscation rules, warning about and removing invalid ones.
112
+ * @param {Array} rules - Raw obfuscation rules
113
+ * @returns {Array} Valid rules only
114
+ */
115
+ filterObfuscateRules(rules) {
116
+ if (!rules) return [];
117
+ return rules.filter((rule) => {
118
+ const hasRegex = rule.regex !== undefined && (typeof rule.regex === "string" || rule.regex instanceof RegExp);
119
+ const hasReplacement = rule.replacement !== undefined && typeof rule.replacement === "string";
120
+ if (!hasRegex || !hasReplacement) {
121
+ Log.warn("obfuscate rule missing required 'regex' (string|RegExp) and/or 'replacement' (string), skipping:", rule);
122
+ return false;
123
+ }
124
+ return true;
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Sanitizes qoeIntervalFactor, defaulting to 1 if the value is not a positive integer.
130
+ * @param {*} value
131
+ * @returns {number}
132
+ */
133
+ sanitizeQoeIntervalFactor(value) {
134
+ if (value === undefined || value === null) return 1;
135
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) return value;
136
+ console.warn(`[nrvideo] Invalid qoeIntervalFactor "${value}" — must be a positive integer. Defaulting to 1.`);
137
+ return 1;
138
+ }
139
+
76
140
  /**
77
141
  * Initializes the global NRVIDEO configuration object.
142
+ * @param {object} userInfo - User provided configuration
143
+ * @param {object} [config] - Optional configuration object
78
144
  */
79
- initializeGlobalConfig(userInfo) {
145
+ initializeGlobalConfig(userInfo, config) {
80
146
 
81
147
  let { licenseKey, appName, region, beacon, applicationID } = userInfo;
82
148
 
@@ -95,6 +161,11 @@ class VideoConfiguration {
95
161
  applicationID,
96
162
  ...(applicationID ? {} : { appName }), // Only include appName when no applicationID
97
163
  },
164
+ config: {
165
+ qoeAggregate: config?.qoeAggregate ?? false,
166
+ qoeIntervalFactor: this.sanitizeQoeIntervalFactor(config?.qoeIntervalFactor),
167
+ obfuscate: this.filterObfuscateRules(config?.obfuscate),
168
+ }
98
169
  };
99
170
  }
100
171
  }
@@ -104,11 +175,12 @@ const videoConfiguration = new VideoConfiguration();
104
175
 
105
176
  /**
106
177
  * Sets the video analytics configuration.
107
- * @param {object} config - Configuration object
178
+ * @param {object} info - Info configuration object
179
+ * @param {object} [config] - Optional configuration object
108
180
  * @returns {boolean} True if configuration was set successfully
109
181
  */
110
- export function setVideoConfig(info) {
111
- return videoConfiguration.setConfiguration(info);
182
+ export function setVideoConfig(info, config) {
183
+ return videoConfiguration.setConfiguration(info, config);
112
184
  }
113
185
 
114
186
  export { videoConfiguration };
@@ -1,6 +1,7 @@
1
1
  import Log from "./log";
2
2
  import Tracker from "./tracker";
3
3
  import TrackerState from "./videotrackerstate";
4
+ import { videoAnalyticsHarvester } from "./agent";
4
5
  import pkg from "../package.json";
5
6
 
6
7
  /**
@@ -239,6 +240,21 @@ class VideoTracker extends Tracker {
239
240
  return null;
240
241
  }
241
242
 
243
+ /** Override to return the manifest-declared bitrate in bps (Indicated Bitrate). */
244
+ getManifestBitrate() {
245
+ return null;
246
+ }
247
+
248
+ /** Override to return the measured network bitrate in bps (Observed Bitrate). */
249
+ getSegmentDownloadBitrate() {
250
+ return null;
251
+ }
252
+
253
+ /** Override to return the download throughput in bps. */
254
+ getNetworkDownloadBitrate() {
255
+ return null;
256
+ }
257
+
242
258
  /** Calculates consumed bitrate using webkitVideoDecodedByteCount. */
243
259
  getWebkitBitrate() {
244
260
  if (this.tag && this.tag.webkitVideoDecodedByteCount) {
@@ -465,12 +481,14 @@ class VideoTracker extends Tracker {
465
481
  att.adTitle = this.getTitle();
466
482
  att.adSrc = this.getSrc();
467
483
  att.adCdn = this.getCdn();
468
- att.adBitrate =
469
- this.getBitrate() ||
470
- this.getWebkitBitrate() ||
471
- this.getRenditionBitrate();
484
+
485
+ // Only add bitrate attributes after ad has started
486
+ if (this.state.isStarted) {
487
+ att.adBitrate =
488
+ this.getBitrate() || 0;
489
+ }
490
+
472
491
  att.adRenditionName = this.getRenditionName();
473
- att.adRenditionBitrate = this.getRenditionBitrate();
474
492
  att.adRenditionHeight = this.getRenditionHeight();
475
493
  att.adRenditionWidth = this.getRenditionWidth();
476
494
  att.adDuration = this.getDuration();
@@ -492,13 +510,16 @@ class VideoTracker extends Tracker {
492
510
  att.contentPlayhead = this.getPlayhead();
493
511
 
494
512
  att.contentIsLive = this.isLive();
495
- att.contentBitrate =
496
- this.getBitrate() ||
497
- this.getWebkitBitrate() ||
498
- this.getRenditionBitrate();
513
+
514
+ // Only add bitrate attributes after content has started
515
+ if (this.state.isStarted) {
516
+ att.contentBitrate = this.getBitrate()|| 0;
517
+ att.contentManifestBitrate = this.getManifestBitrate() || 0;
518
+ att.contentSegmentDownloadBitrate = this.getSegmentDownloadBitrate() || 0;
519
+ att.contentNetworkDownloadBitrate = this.getNetworkDownloadBitrate() || 0;
520
+ }
499
521
 
500
522
  att.contentRenditionName = this.getRenditionName();
501
- att.contentRenditionBitrate = this.getRenditionBitrate();
502
523
  att.contentRenditionHeight = this.getRenditionHeight();
503
524
  att.contentRenditionWidth = this.getRenditionWidth();
504
525
  att.contentDuration = this.getDuration();
@@ -610,15 +631,23 @@ class VideoTracker extends Tracker {
610
631
  if(this.adsTracker) {
611
632
  // If ads state is set to playing (ad error) after content start, reset the ad state.
612
633
  if(this.adsTracker.state.isPlaying || this.adsTracker.state.isBuffering) {
613
- totalAdsTime = this.adsTracker.state.stopAdsTime();
634
+ this.adsTracker.state.stopAdsTime();
614
635
  this.adsTracker.state.isPlaying = false;
615
636
  this.adsTracker.state.isBuffering = false;
616
- } else {
617
- totalAdsTime = this.adsTracker.state.totalAdTime() ?? 0;
618
637
  }
638
+ // Always use totalAdTime() which includes accumulator across all ads
639
+ totalAdsTime = this.adsTracker.state.totalAdTime() ?? 0;
619
640
  }
620
641
  this.state.setStartupTime(totalAdsTime)
621
642
  this.sendVideoAction(ev, att);
643
+
644
+ // Register callback to refresh QoE KPIs with latest state before each drain
645
+ videoAnalyticsHarvester.setBeforeDrainCallback(() => {
646
+ if (this.state) {
647
+ const freshKpis = this.state.getQoeAttributes({}).qoe;
648
+ videoAnalyticsHarvester.refreshQoeKpis(freshKpis, this.getViewId());
649
+ }
650
+ });
622
651
  }
623
652
  //this.send(ev, att);
624
653
  this.startHeartbeat();
@@ -658,6 +687,11 @@ class VideoTracker extends Tracker {
658
687
  this.state.goViewCountUp();
659
688
  this.state.totalPlaytime = 0;
660
689
  if(!this.isAd()) {
690
+ // Force QoE to be included in the next harvest cycle at content end
691
+ videoAnalyticsHarvester.forceNextQoeCycle();
692
+ // Clear the before-drain callback so the next harvest doesn't overwrite
693
+ // the final QoE (already in buffer) with zeroed-out state values
694
+ videoAnalyticsHarvester.setBeforeDrainCallback(null);
661
695
  // reset the states after the view count is up
662
696
  if(this.adsTracker) this.adsTracker.state.clearTotalAdsTime();
663
697
  this.state.resetViewIdTrackedState();