@newrelic/video-core 4.1.8-beta → 5.0.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +65 -15
  2. package/README.md +628 -45
  3. package/dist/cjs/browser/index.js +3 -0
  4. package/dist/cjs/browser/index.js.LICENSE.txt +6 -0
  5. package/dist/cjs/browser/index.js.map +1 -0
  6. package/dist/cjs/index.js +1 -1
  7. package/dist/cjs/index.js.LICENSE.txt +1 -1
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/vega/index.js +3 -0
  10. package/dist/cjs/vega/index.js.LICENSE.txt +6 -0
  11. package/dist/cjs/vega/index.js.map +1 -0
  12. package/dist/esm/browser/index.js +3 -0
  13. package/dist/esm/browser/index.js.LICENSE.txt +6 -0
  14. package/dist/esm/browser/index.js.map +1 -0
  15. package/dist/esm/index.js +1 -1
  16. package/dist/esm/index.js.LICENSE.txt +1 -1
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/vega/index.js +3 -0
  19. package/dist/esm/vega/index.js.LICENSE.txt +6 -0
  20. package/dist/esm/vega/index.js.map +1 -0
  21. package/dist/umd/nrvideo.min.js +1 -1
  22. package/dist/umd/nrvideo.min.js.LICENSE.txt +1 -1
  23. package/dist/umd/nrvideo.min.js.map +1 -1
  24. package/package.json +31 -6
  25. package/src/{agent.js → browser/agent.js} +43 -8
  26. package/src/{harvestScheduler.js → browser/harvestScheduler.js} +87 -86
  27. package/src/browser/index.js +58 -0
  28. package/src/connectedDevice/connectedDeviceAgent.js +116 -0
  29. package/src/connectedDevice/connectedDeviceConstants.js +121 -0
  30. package/src/connectedDevice/connectedDeviceHarvester.js +522 -0
  31. package/src/connectedDevice/index.js +49 -0
  32. package/src/constants.js +8 -0
  33. package/src/core.js +4 -18
  34. package/src/eventAggregator.js +48 -0
  35. package/src/index.js +9 -10
  36. package/src/obfuscate.js +33 -0
  37. package/src/optimizedHttpClient.js +6 -2
  38. package/src/recordEvent.js +56 -49
  39. package/src/tracker.js +14 -4
  40. package/src/utils/eventBuilder.js +126 -0
  41. package/src/utils/harvestTimer.js +109 -0
  42. package/src/{utils.js → utils/index.js} +2 -2
  43. package/src/utils/qoeFilters.js +149 -0
  44. package/src/videoConfiguration.js +90 -7
  45. package/src/videotracker.js +100 -32
  46. package/src/videotrackerstate.js +156 -31
@@ -0,0 +1,149 @@
1
+ /**
2
+ * QoE-related buffer + cycle helpers shared by both pipelines.
3
+ *
4
+ * All six helpers were previously duplicated between
5
+ * - `browser/agent.js` (`addEvent`, `refreshQoeKpis`)
6
+ * - `browser/harvestScheduler.js` (cycle filter, dirty splice, _qoeKpisUnchanged, _saveQoeKpis)
7
+ * - `connectedDevice/connectedDeviceHarvester.js` (same six)
8
+ *
9
+ * They're pure helpers — no class state. Callers pass in the buffer / snapshot
10
+ * map / event array. Keeping them shared makes the QoE behavior one source of
11
+ * truth across pipelines, so a fix landed once applies to both Browser and
12
+ * Vega (and any future CAF device pipeline).
13
+ *
14
+ * @module utils/qoeFilters
15
+ */
16
+
17
+ import Constants from "../constants";
18
+ import Tracker from "../tracker";
19
+
20
+ /**
21
+ * Add an event to the buffer with QOE_AGGREGATE dedup. QOE_AGGREGATE events
22
+ * are deduplicated by `(actionName, viewId)` so multiple players sharing one
23
+ * harvester each get exactly one buffered QoE event per view. Non-QoE events
24
+ * are appended as-is, preserving the emit-time `timestamp` already set by
25
+ * `recordEvent.js`.
26
+ *
27
+ * @param {NrVideoEventAggregator} buffer
28
+ * @param {object} eventObject
29
+ * @returns {boolean} True if added/replaced.
30
+ */
31
+ export function bufferEventWithQoeDedup(buffer, eventObject) {
32
+ if (!eventObject) return false;
33
+ if (eventObject.actionName === Tracker.Events.QOE_AGGREGATE) {
34
+ if (eventObject.viewId) {
35
+ return buffer.addOrReplaceByActionNameAndViewId(
36
+ Tracker.Events.QOE_AGGREGATE,
37
+ eventObject.viewId,
38
+ eventObject
39
+ );
40
+ }
41
+ return buffer.addOrReplaceByActionName(Tracker.Events.QOE_AGGREGATE, eventObject);
42
+ }
43
+ return buffer.add(eventObject);
44
+ }
45
+
46
+ /**
47
+ * Update QoE KPI fields on the buffered QOE_AGGREGATE event for a given viewId.
48
+ * Looks up the existing buffered event, merges the fresh KPI values for keys
49
+ * listed in `Constants.QOE_KPI_KEYS`, and replaces it in the buffer. No-op if
50
+ * no QOE_AGGREGATE event is currently buffered.
51
+ *
52
+ * @param {NrVideoEventAggregator} buffer
53
+ * @param {object} freshKpis
54
+ * @param {string} [viewId]
55
+ */
56
+ export function refreshQoeKpisInBuffer(buffer, freshKpis, viewId) {
57
+ if (!buffer || !freshKpis) return;
58
+ const existing = viewId
59
+ ? buffer.findByActionNameAndViewId(Tracker.Events.QOE_AGGREGATE, viewId)
60
+ : buffer.findByActionName(Tracker.Events.QOE_AGGREGATE);
61
+ if (!existing) return;
62
+ const updated = { ...existing };
63
+ for (const key of Constants.QOE_KPI_KEYS) {
64
+ if (key in freshKpis) updated[key] = freshKpis[key];
65
+ }
66
+ if (viewId) {
67
+ buffer.addOrReplaceByActionNameAndViewId(Tracker.Events.QOE_AGGREGATE, viewId, updated);
68
+ } else {
69
+ buffer.addOrReplaceByActionName(Tracker.Events.QOE_AGGREGATE, updated);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Cross-cycle dirty check — true iff every KPI field on `event` equals the
75
+ * snapshot saved for that event's viewId.
76
+ *
77
+ * @param {Object<string, object>} snapshots - viewId → KPI snapshot
78
+ * @param {object} event
79
+ * @returns {boolean}
80
+ */
81
+ export function qoeKpisUnchanged(snapshots, event) {
82
+ const snapshot = snapshots[event.viewId];
83
+ if (!snapshot) return false;
84
+ for (const key of Constants.QOE_KPI_KEYS) {
85
+ if (event[key] !== snapshot[key]) return false;
86
+ }
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Save a snapshot of an event's QoE KPI fields, keyed by viewId, for the next
92
+ * cross-cycle dirty check.
93
+ *
94
+ * @param {Object<string, object>} snapshots - viewId → KPI snapshot (mutated in place)
95
+ * @param {object} event
96
+ */
97
+ export function saveQoeKpiSnapshot(snapshots, event) {
98
+ const snapshot = {};
99
+ for (const key of Constants.QOE_KPI_KEYS) {
100
+ snapshot[key] = event[key];
101
+ }
102
+ snapshots[event.viewId] = snapshot;
103
+ }
104
+
105
+ /**
106
+ * Partition drained events by the QoE-cycle filter. On a QoE cycle, all events
107
+ * pass through. On a non-QoE cycle, QOE_AGGREGATE events are re-buffered (so
108
+ * they ship on the next QoE cycle) and only non-QoE events are returned.
109
+ *
110
+ * @param {object[]} drained
111
+ * @param {boolean} isQoeCycle
112
+ * @param {NrVideoEventAggregator} buffer
113
+ * @returns {object[]}
114
+ */
115
+ export function partitionByQoeCycle(drained, isQoeCycle, buffer) {
116
+ if (isQoeCycle) return drained;
117
+ const filtered = [];
118
+ for (const e of drained) {
119
+ if (e.actionName === Tracker.Events.QOE_AGGREGATE) {
120
+ buffer.add(e);
121
+ } else {
122
+ filtered.push(e);
123
+ }
124
+ }
125
+ return filtered;
126
+ }
127
+
128
+ /**
129
+ * Cross-cycle dirty splice. Walks the array in reverse and drops any
130
+ * QOE_AGGREGATE event whose KPI fields are unchanged since the last send (so
131
+ * we don't waste bytes shipping identical aggregates). Saves a fresh snapshot
132
+ * for any QOE_AGGREGATE that does ship. Mutates `filtered` in place.
133
+ *
134
+ * @param {object[]} filtered
135
+ * @param {Object<string, object>} snapshots - viewId → KPI snapshot (read+written)
136
+ * @param {boolean} isForced - When true, skip the dirty check and ship every
137
+ * QOE_AGGREGATE regardless of KPI sameness (final flush, CONTENT_END, etc.)
138
+ */
139
+ export function applyQoeDirtyFilter(filtered, snapshots, isForced) {
140
+ for (let i = filtered.length - 1; i >= 0; i--) {
141
+ const e = filtered[i];
142
+ if (e.actionName !== Tracker.Events.QOE_AGGREGATE) continue;
143
+ if (!isForced && qoeKpisUnchanged(snapshots, e)) {
144
+ filtered.splice(i, 1);
145
+ } else {
146
+ saveQoeKpiSnapshot(snapshots, e);
147
+ }
148
+ }
149
+ }
@@ -15,18 +15,42 @@ class VideoConfiguration {
15
15
  * @returns {boolean} True if configuration is valid and set
16
16
  */
17
17
 
18
- setConfiguration(userInfo, config) {
19
- if (!this.validateRequiredFields(userInfo)) {
18
+ setConfiguration(userInfo, config, src) {
19
+ const validated = src === "Vega"
20
+ ? this.validateVegaFields(userInfo)
21
+ : this.validateRequiredFields(userInfo);
22
+ if (!validated) {
20
23
  return false;
21
24
  }
22
25
  if (!this.validateConfigFields(config)) {
23
26
  return false;
24
27
  }
25
- this.initializeGlobalConfig(userInfo, config);
28
+ this.initializeGlobalConfig(userInfo, config, src);
26
29
  Log.notice("Video analytics configuration initialized successfully");
27
30
  return true;
28
31
  }
29
32
 
33
+ /**
34
+ * Validates required Vega configuration fields.
35
+ * @param {object} info
36
+ * @returns {boolean} True if valid
37
+ */
38
+ validateVegaFields(info) {
39
+ if (!info || typeof info !== "object") {
40
+ Log.error("Configuration must be an object");
41
+ return false;
42
+ }
43
+ if (!info.applicationToken) {
44
+ Log.error("applicationToken is required");
45
+ return false;
46
+ }
47
+ if (!["US", "EU", "staging", "GOV"].includes(info.endpoint)) {
48
+ Log.error("Invalid endpoint (must be US, EU, staging, or GOV)");
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+
30
54
  /**
31
55
  * Validates required configuration fields.
32
56
  * @param {object} config - Configuration to validate
@@ -92,22 +116,79 @@ class VideoConfiguration {
92
116
  return false;
93
117
  }
94
118
 
95
- const { qoeAggregate } = config;
119
+ const { qoeAggregate, obfuscate } = config;
96
120
 
97
121
  if (qoeAggregate !== undefined && typeof qoeAggregate !== "boolean") {
98
122
  Log.error("qoeAggregate must be a boolean");
99
123
  return false;
100
124
  }
101
125
 
126
+ if (obfuscate !== undefined && !Array.isArray(obfuscate)) {
127
+ Log.error("obfuscate must be an array");
128
+ return false;
129
+ }
130
+
102
131
  return true;
103
132
  }
104
133
 
134
+ /**
135
+ * Filters obfuscation rules, warning about and removing invalid ones.
136
+ * @param {Array} rules - Raw obfuscation rules
137
+ * @returns {Array} Valid rules only
138
+ */
139
+ filterObfuscateRules(rules) {
140
+ if (!rules) return [];
141
+ return rules.filter((rule) => {
142
+ const hasRegex = rule.regex !== undefined && (typeof rule.regex === "string" || rule.regex instanceof RegExp);
143
+ const hasReplacement = rule.replacement !== undefined && typeof rule.replacement === "string";
144
+ if (!hasRegex || !hasReplacement) {
145
+ Log.warn("obfuscate rule missing required 'regex' (string|RegExp) and/or 'replacement' (string), skipping:", rule);
146
+ return false;
147
+ }
148
+ return true;
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Sanitizes qoeIntervalFactor, defaulting to Constants.DEFAULT_QOE_INTERVAL_FACTOR
154
+ * if the value is not a positive integer.
155
+ * @param {*} value
156
+ * @returns {number}
157
+ */
158
+ sanitizeQoeIntervalFactor(value) {
159
+ if (value === undefined || value === null) return Constants.DEFAULT_QOE_INTERVAL_FACTOR;
160
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) return value;
161
+ Log.warn(`Invalid qoeIntervalFactor "${value}" — must be a positive integer. Defaulting to ${Constants.DEFAULT_QOE_INTERVAL_FACTOR}.`);
162
+ return Constants.DEFAULT_QOE_INTERVAL_FACTOR;
163
+ }
164
+
105
165
  /**
106
166
  * Initializes the global NRVIDEO configuration object.
107
167
  * @param {object} userInfo - User provided configuration
108
168
  * @param {object} [config] - Optional configuration object
109
169
  */
110
- initializeGlobalConfig(userInfo, config) {
170
+ initializeGlobalConfig(userInfo, config, src) {
171
+ // Vega path: write `globalThis.__NRVIDEO_CD__` with info+config only.
172
+ // The harvester is owned by `connectedDeviceAgent.js` as a module singleton — no
173
+ // harvester field on this global.
174
+ if (src === "Vega") {
175
+ globalThis.__NRVIDEO_CD__ = {
176
+ info: {
177
+ accountId: userInfo.accountId,
178
+ applicationToken: userInfo.applicationToken,
179
+ endpoint: userInfo.endpoint,
180
+ ...(userInfo.appName ? { appName: userInfo.appName } : {}),
181
+ ...(userInfo.applicationID ? { applicationID: userInfo.applicationID } : {}),
182
+ ...(userInfo.deviceInfo ? { deviceInfo: userInfo.deviceInfo } : {}),
183
+ },
184
+ config: {
185
+ qoeAggregate: config?.qoeAggregate ?? true,
186
+ qoeIntervalFactor: this.sanitizeQoeIntervalFactor(config?.qoeIntervalFactor),
187
+ obfuscate: this.filterObfuscateRules(config?.obfuscate),
188
+ },
189
+ };
190
+ return;
191
+ }
111
192
 
112
193
  let { licenseKey, appName, region, beacon, applicationID } = userInfo;
113
194
 
@@ -128,6 +209,8 @@ class VideoConfiguration {
128
209
  },
129
210
  config: {
130
211
  qoeAggregate: config?.qoeAggregate ?? true,
212
+ qoeIntervalFactor: this.sanitizeQoeIntervalFactor(config?.qoeIntervalFactor),
213
+ obfuscate: this.filterObfuscateRules(config?.obfuscate),
131
214
  }
132
215
  };
133
216
  }
@@ -142,8 +225,8 @@ const videoConfiguration = new VideoConfiguration();
142
225
  * @param {object} [config] - Optional configuration object
143
226
  * @returns {boolean} True if configuration was set successfully
144
227
  */
145
- export function setVideoConfig(info, config) {
146
- return videoConfiguration.setConfiguration(info, config);
228
+ export function setVideoConfig(info, config, src) {
229
+ return videoConfiguration.setConfiguration(info, config, src);
147
230
  }
148
231
 
149
232
  export { videoConfiguration };
@@ -44,6 +44,7 @@ class VideoTracker extends Tracker {
44
44
  */
45
45
  this._lastBufferType = null;
46
46
  this._userId = null;
47
+ this._src = null;
47
48
 
48
49
  options = options || {};
49
50
  this.setOptions(options);
@@ -83,6 +84,16 @@ class VideoTracker extends Tracker {
83
84
  if (typeof options.isAd === "boolean") {
84
85
  this.setIsAd(options.isAd);
85
86
  }
87
+ if (options.src !== undefined) {
88
+ if (this._src !== null && this._src !== options.src) {
89
+ Log.warn(`setOptions: src is locked to '${this._src}'. Ignoring override '${options.src}'.`);
90
+ } else {
91
+ this._src = options.src;
92
+ if (this.adsTracker && this.adsTracker._src == null) {
93
+ this.adsTracker._src = this._src;
94
+ }
95
+ }
96
+ }
86
97
  Tracker.prototype.setOptions.apply(this, arguments);
87
98
  }
88
99
  }
@@ -134,6 +145,12 @@ class VideoTracker extends Tracker {
134
145
  this.adsTracker = tracker;
135
146
  this.adsTracker.setIsAd(true);
136
147
  this.adsTracker.parentTracker = this;
148
+ // Propagate src so ads events route to the same pipeline as content events.
149
+ // Covers the case where setAdsTracker is called after the content tracker
150
+ // is fully initialised and this._src is already set.
151
+ if (this._src != null && tracker._src == null) {
152
+ tracker._src = this._src;
153
+ }
137
154
  this.adsTracker.on("*", funnelAdEvents.bind(this));
138
155
  }
139
156
  }
@@ -239,6 +256,21 @@ class VideoTracker extends Tracker {
239
256
  return null;
240
257
  }
241
258
 
259
+ /** Override to return the manifest-declared bitrate in bps (Indicated Bitrate). */
260
+ getManifestBitrate() {
261
+ return null;
262
+ }
263
+
264
+ /** Override to return the measured network bitrate in bps (Observed Bitrate). */
265
+ getSegmentDownloadBitrate() {
266
+ return null;
267
+ }
268
+
269
+ /** Override to return the download throughput in bps. */
270
+ getNetworkDownloadBitrate() {
271
+ return null;
272
+ }
273
+
242
274
  /** Calculates consumed bitrate using webkitVideoDecodedByteCount. */
243
275
  getWebkitBitrate() {
244
276
  if (this.tag && this.tag.webkitVideoDecodedByteCount) {
@@ -273,7 +305,7 @@ class VideoTracker extends Tracker {
273
305
  * saving the current rendition and thus preventing interferences with RENDITION_CHANGE events.
274
306
  */
275
307
  getRenditionShift(saveNewRendition) {
276
- let current = this.getRenditionBitrate();
308
+ let current = this.getManifestBitrate();
277
309
  let last;
278
310
  if (this.isAd()) {
279
311
  last = this._lastAdRendition;
@@ -449,7 +481,7 @@ class VideoTracker extends Tracker {
449
481
  att["instrumentation.name"] = this.getInstrumentationName();
450
482
  att["instrumentation.version"] = this.getInstrumentationVersion();
451
483
  att["enduser.id"] = this._userId;
452
- att["src"] = "Browser";
484
+ att["src"] = this._src || "Browser";
453
485
 
454
486
  if (type === "customAction") return att;
455
487
 
@@ -465,12 +497,14 @@ class VideoTracker extends Tracker {
465
497
  att.adTitle = this.getTitle();
466
498
  att.adSrc = this.getSrc();
467
499
  att.adCdn = this.getCdn();
468
- att.adBitrate =
469
- this.getBitrate() ||
470
- this.getWebkitBitrate() ||
471
- this.getRenditionBitrate();
500
+
501
+ // Only add bitrate attributes after ad has started
502
+ if (this.state.isStarted) {
503
+ att.adBitrate =
504
+ this.getBitrate() || 0;
505
+ }
506
+
472
507
  att.adRenditionName = this.getRenditionName();
473
- att.adRenditionBitrate = this.getRenditionBitrate();
474
508
  att.adRenditionHeight = this.getRenditionHeight();
475
509
  att.adRenditionWidth = this.getRenditionWidth();
476
510
  att.adDuration = this.getDuration();
@@ -492,13 +526,16 @@ class VideoTracker extends Tracker {
492
526
  att.contentPlayhead = this.getPlayhead();
493
527
 
494
528
  att.contentIsLive = this.isLive();
495
- att.contentBitrate =
496
- this.getBitrate() ||
497
- this.getWebkitBitrate() ||
498
- this.getRenditionBitrate();
529
+
530
+ // Only add bitrate attributes after content has started
531
+ if (this.state.isStarted) {
532
+ att.contentBitrate = this.getBitrate()|| 0;
533
+ att.contentManifestBitrate = this.getManifestBitrate() || 0;
534
+ att.contentSegmentDownloadBitrate = this.getSegmentDownloadBitrate() || 0;
535
+ att.contentNetworkDownloadBitrate = this.getNetworkDownloadBitrate() || 0;
536
+ }
499
537
 
500
538
  att.contentRenditionName = this.getRenditionName();
501
- att.contentRenditionBitrate = this.getRenditionBitrate();
502
539
  att.contentRenditionHeight = this.getRenditionHeight();
503
540
  att.contentRenditionWidth = this.getRenditionWidth();
504
541
  att.contentDuration = this.getDuration();
@@ -523,26 +560,24 @@ class VideoTracker extends Tracker {
523
560
 
524
561
  if(this.state.isStarted && !this.isAd()) {
525
562
  this.state.trackContentBitrateState(att.contentBitrate);
563
+ this.state.trackDownloadRate(att.contentNetworkDownloadBitrate);
564
+ this.state.addPlayedRendition(att.contentRenditionHeight, att.contentRenditionWidth);
526
565
  }
527
566
 
528
567
  for (let key in this.customData) {
529
568
  att[key] = this.customData[key];
530
569
  }
531
570
 
532
- /**
533
- * Adds all the attributes and custom attributes for qoe event
534
- */
535
- this.addQoeAttributes(att);
536
-
537
- return att;
538
- }
539
-
540
- addQoeAttributes(att) {
541
- att = this.state.getQoeAttributes(att);
571
+ // Add QOE v1.1 attributes (for content only, not ads)
572
+ if (!this.isAd()) {
573
+ this.state.getQoeAttributes(att);
542
574
  const qoe = att.qoe;
543
575
  for (let key in this.customData) {
544
- qoe[key] = this.customData[key];
576
+ qoe[key] = this.customData[key];
545
577
  }
578
+ }
579
+
580
+ return att;
546
581
  }
547
582
 
548
583
  /**
@@ -610,15 +645,23 @@ class VideoTracker extends Tracker {
610
645
  if(this.adsTracker) {
611
646
  // If ads state is set to playing (ad error) after content start, reset the ad state.
612
647
  if(this.adsTracker.state.isPlaying || this.adsTracker.state.isBuffering) {
613
- totalAdsTime = this.adsTracker.state.stopAdsTime();
648
+ this.adsTracker.state.stopAdsTime();
614
649
  this.adsTracker.state.isPlaying = false;
615
650
  this.adsTracker.state.isBuffering = false;
616
- } else {
617
- totalAdsTime = this.adsTracker.state.totalAdTime() ?? 0;
618
651
  }
652
+ // Always use totalAdTime() which includes accumulator across all ads
653
+ totalAdsTime = this.adsTracker.state.totalAdTime() ?? 0;
619
654
  }
620
655
  this.state.setStartupTime(totalAdsTime)
621
656
  this.sendVideoAction(ev, att);
657
+
658
+ const harvester = this.getHarvester?.();
659
+ harvester?.setBeforeDrainCallback(() => {
660
+ if (this.state) {
661
+ const freshKpis = this.state.getQoeAttributes({}).qoe;
662
+ harvester.refreshQoeKpis(freshKpis, this.getViewId());
663
+ }
664
+ });
622
665
  }
623
666
  //this.send(ev, att);
624
667
  this.startHeartbeat();
@@ -640,7 +683,10 @@ class VideoTracker extends Tracker {
640
683
  ev = VideoTracker.Events.AD_END;
641
684
  att.timeSinceAdRequested = this.state.timeSinceRequested.getDeltaTime();
642
685
  att.timeSinceAdStarted = this.state.timeSinceStarted.getDeltaTime();
643
- if (this.parentTracker) this.parentTracker.state.isPlaying = true;
686
+ if (this.parentTracker) {
687
+ this.parentTracker.state.isPlaying = true;
688
+ this.parentTracker.state.timeSincePaused.reset();
689
+ }
644
690
  this.state.stopAdsTime();
645
691
  } else {
646
692
  ev = VideoTracker.Events.CONTENT_END;
@@ -658,9 +704,15 @@ class VideoTracker extends Tracker {
658
704
  this.state.goViewCountUp();
659
705
  this.state.totalPlaytime = 0;
660
706
  if(!this.isAd()) {
707
+ const harvester = this.getHarvester?.();
708
+ // Force QoE to be included in the next harvest cycle at content end
709
+ harvester?.forceNextQoeCycle?.();
710
+ // Clear the before-drain callback so the next harvest doesn't overwrite
711
+ // the final QoE (already in buffer) with zeroed-out state values
712
+ harvester?.setBeforeDrainCallback?.(null);
661
713
  // reset the states after the view count is up
662
- if(this.adsTracker) this.adsTracker.state.clearTotalAdsTime();
663
- this.state.resetViewIdTrackedState();
714
+ if(this.adsTracker) this.adsTracker.state.clearTotalAdsTime();
715
+ this.state.resetViewIdTrackedState();
664
716
  }
665
717
  }
666
718
  }
@@ -679,8 +731,6 @@ class VideoTracker extends Tracker {
679
731
  this.isAd()
680
732
  ? this.sendVideoAdAction(ev, att)
681
733
  : this.sendVideoAction(ev, att);
682
-
683
- //this.send(ev, att);
684
734
  }
685
735
  }
686
736
 
@@ -865,7 +915,25 @@ class VideoTracker extends Tracker {
865
915
  att = att || {};
866
916
  att.timeSinceLastRenditionChange =
867
917
  this.state.timeSinceLastRenditionChange.getDeltaTime();
868
- att.shift = this.getRenditionShift(true);
918
+
919
+ const {oldBitrate, newBitrate} = att;
920
+
921
+ if (!this.isAd() && oldBitrate !== undefined && newBitrate !== undefined) {
922
+ if (newBitrate > oldBitrate) {
923
+ att.shift = "up";
924
+ this.state.totalSwitchUps += 1;
925
+ } else if (newBitrate < oldBitrate) {
926
+ att.shift = "down";
927
+ this.state.totalSwitchDowns += 1;
928
+ } else {
929
+ att.shift = null;
930
+ }
931
+ }
932
+
933
+ // Remove internal tracking fields before sending to analytics
934
+ delete att.oldBitrate;
935
+ delete att.newBitrate;
936
+
869
937
  let ev;
870
938
  if (this.isAd()) {
871
939
  ev = VideoTracker.Events.AD_RENDITION_CHANGE;