@newrelic/video-core 4.0.1 → 4.1.0-beta

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.0.1",
3
+ "version": "4.1.0-beta",
4
4
  "description": "New Relic video tracking core library",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -51,6 +51,7 @@
51
51
  "LICENSE",
52
52
  "README.md",
53
53
  "src",
54
+ "__mock__.js",
54
55
  "!test"
55
56
  ],
56
57
  "publishConfig": {
package/src/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { HarvestScheduler } from "./harvestScheduler.js";
2
2
  import { NrVideoEventAggregator } from "./eventAggregator.js";
3
3
  import Log from "./log.js";
4
+ import Tracker from "./tracker";
4
5
 
5
6
  /**
6
7
  * Enhanced video analytics agent with HarvestScheduler only.
@@ -47,6 +48,10 @@ class VideoAnalyticsAgent {
47
48
  }
48
49
 
49
50
  try {
51
+ 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
+ return this.eventBuffer.addOrReplaceByActionName(Tracker.Events.QOE_AGGREGATE, eventObject);
54
+ }
50
55
  return this.eventBuffer.add(eventObject);
51
56
  } catch (error) {
52
57
  Log.error("Failed to add event to harvesting system:", error.message);
package/src/chrono.js CHANGED
@@ -17,6 +17,9 @@ class Chrono {
17
17
  /** Stop time */
18
18
  this.stopTime = 0;
19
19
 
20
+ /** accumulation of all the start and stop intervals */
21
+ this.accumulator = 0;
22
+
20
23
  /**
21
24
  * If you set an offset in a chrono, its value will be added getDeltaTime and stop.
22
25
  *
@@ -59,9 +62,20 @@ class Chrono {
59
62
  */
60
63
  stop() {
61
64
  this.stopTime = new Date().getTime();
65
+ if(this.startTime < this.stopTime) {
66
+ this.accumulator += (this.stopTime - this.startTime);
67
+ }
62
68
  return this.getDeltaTime();
63
69
  }
64
70
 
71
+ getDuration() {
72
+ if(this.stopTime) {
73
+ return this.accumulator + this.offset;
74
+ } else {
75
+ return this.accumulator + (this.getDeltaTime() ?? 0)
76
+ }
77
+ }
78
+
65
79
  /**
66
80
  * Creates a copy of the chrono.
67
81
  * @returns {Chrono} Cloned chrono
@@ -71,6 +85,7 @@ class Chrono {
71
85
  chrono.startTime = this.startTime;
72
86
  chrono.stopTime = this.stopTime;
73
87
  chrono.offset = this.offset;
88
+ chrono.accumulator = this.accumulator;
74
89
  return chrono;
75
90
  }
76
91
  }
package/src/constants.js CHANGED
@@ -42,4 +42,10 @@ 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_AGGREGATE_KEYS = [
46
+ "coreVersion", "instrumentation.name",
47
+ "instrumentation.provider", "instrumentation.version", "isBackgroundEvent", "playerName", "playerVersion",
48
+ "src", "viewId", "viewSession", "contentIsAutoplayed"
49
+ ]
50
+
45
51
  export default Constants;
@@ -32,12 +32,37 @@ export class NrVideoEventAggregator {
32
32
  this.onSmartHarvestTrigger = null;
33
33
  }
34
34
 
35
+ /**
36
+ * If an event with the specified actionName already exists in the buffer, it will be replaced.
37
+ * Otherwise, the event will be added as a new entry.
38
+ * @param {string} actionName - The actionName to search for in the buffer
39
+ * @param {object} eventObject - The event object to add or use as replacement. Should contain an actionName property.
40
+ * @returns {boolean} True if the operation succeeded, false if an error occurred
41
+ */
42
+ addOrReplaceByActionName(actionName, eventObject) {
43
+ const i = this.buffer.findIndex(e => e.actionName === actionName);
44
+
45
+ try {
46
+ if(i === -1) {
47
+ this.add(eventObject);
48
+ } else {
49
+ this.add(eventObject, i);
50
+ }
51
+ return true;
52
+ } catch (error) {
53
+ Log.error("Failed to set or replace the event to buffer:", error.message);
54
+ return false;
55
+ }
56
+ return false;
57
+ }
58
+
35
59
  /**
36
60
  * Adds an event to the unified buffer.
37
61
  * All events are treated equally in FIFO order.
38
62
  * @param {object} eventObject - The event to add
63
+ * @param {number} index - index at which the event should be replaced with
39
64
  */
40
- add(eventObject) {
65
+ add(eventObject, index) {
41
66
  try {
42
67
  // Calculate event payload size
43
68
  const eventSize = dataSize(eventObject);
@@ -52,10 +77,18 @@ export class NrVideoEventAggregator {
52
77
  this.makeRoom(eventSize);
53
78
  }
54
79
 
55
- // Add to unified buffer
56
- this.buffer.push(eventObject);
57
- this.totalEvents++;
58
- this.currentPayloadSize += eventSize;
80
+ if(index !== undefined && index !== null && index > -1) {
81
+ // replace in unified buffer
82
+ const previousPayloadSize = dataSize(this.buffer[index]);
83
+ this.buffer[index] = eventObject;
84
+ // Updating the payload size for the replaced event
85
+ this.currentPayloadSize += eventSize - previousPayloadSize;
86
+ } else {
87
+ // Add to unified buffer
88
+ this.buffer.push(eventObject);
89
+ this.totalEvents++;
90
+ this.currentPayloadSize += eventSize;
91
+ }
59
92
 
60
93
  // Check if smart harvest should be triggered
61
94
  this.checkSmartHarvestTrigger();
@@ -1,6 +1,8 @@
1
1
  import { videoAnalyticsHarvester } from "./agent.js";
2
2
  import Constants from "./constants.js";
3
3
  import Log from "./log.js";
4
+ import Tracker from "./tracker";
5
+ import {getObjectEntriesForKeys} from "./utils";
4
6
 
5
7
  /**
6
8
  * Enhanced record event function with validation, enrichment, and unified handling.
@@ -21,18 +23,44 @@ export function recordEvent(eventType, attributes = {}) {
21
23
 
22
24
  const { appName, applicationID } = window.NRVIDEO.info;
23
25
 
26
+ const { qoe, ...eventAttributes } = attributes;
27
+ const qoeAttrs = qoe ? { ...qoe } : {};
28
+
29
+ const otherAttrs = {
30
+ ...(applicationID ? {} : { appName }), // Only include appName when no applicationID
31
+ timestamp: Date.now(),
32
+ timeSinceLoad: window.performance
33
+ ? window.performance.now() / 1000
34
+ : null,
35
+ }
36
+
24
37
  const eventObject = {
25
- ...attributes,
38
+ ...eventAttributes,
26
39
  eventType,
27
- ...(applicationID ? {} : { appName }), // Only include appName when no applicationID
28
- timestamp: Date.now(),
29
- timeSinceLoad: window.performance
30
- ? window.performance.now() / 1000
31
- : null,
40
+ ...otherAttrs,
32
41
  };
33
42
 
43
+ const metadataAttributes = getObjectEntriesForKeys(Constants.QOE_AGGREGATE_KEYS, attributes)
44
+
45
+ let qoeEventObject = null;
46
+ if(eventType === "VideoAction") {
47
+ qoeEventObject = {
48
+ eventType: "VideoAction",
49
+ actionName: Tracker.Events.QOE_AGGREGATE,
50
+ ...qoeAttrs,
51
+ ...metadataAttributes,
52
+ ...otherAttrs,
53
+ }
54
+ }
55
+
34
56
  // Send to video analytics harvester
35
57
  const success = videoAnalyticsHarvester.addEvent(eventObject);
58
+
59
+ if(qoeEventObject) {
60
+ const successQoe = videoAnalyticsHarvester.addEvent(qoeEventObject);
61
+ return success && successQoe;
62
+ }
63
+
36
64
  return success;
37
65
  } catch (error) {
38
66
  Log.error("Failed to record event:", error.message);
package/src/tracker.js CHANGED
@@ -293,6 +293,7 @@ class Tracker extends Emitter {
293
293
  Tracker.Events = {
294
294
  /** The heartbeat event is sent once every 30 seconds while the video is playing. */
295
295
  HEARTBEAT: "HEARTBEAT",
296
+ QOE_AGGREGATE: "QOE_AGGREGATE",
296
297
  };
297
298
 
298
299
  export default Tracker;
package/src/utils.js CHANGED
@@ -160,3 +160,27 @@ export async function decompressPayload(compressedData) {
160
160
  throw new Error(`Failed to decompress payload: ${error.message}`);
161
161
  }
162
162
  }
163
+
164
+ /**
165
+ * Filters an object to include only the specified keys.
166
+ * Creates a new object containing only the key-value pairs from the source object
167
+ * that match the provided keys array.
168
+ * @param {string[]} keys - Array of keys to extract from the object. If empty, null, or not an array, returns the original object.
169
+ * @param {object} obj - The source object to extract entries from.
170
+ * @returns {object} A new object containing only the entries that match the specified keys. Returns an empty object if obj is invalid.
171
+ * @example
172
+ * const data = { name: 'John', age: 30, city: 'NYC', country: 'USA' };
173
+ * const filtered = getObjectEntriesForKeys(['name', 'city'], data);
174
+ * // Returns: { name: 'John', city: 'NYC' }
175
+ */
176
+ export function getObjectEntriesForKeys(keys, obj) {
177
+ if(!keys || !Array.isArray(keys) || keys.length === 0) return obj;
178
+ if(!obj || typeof obj !== 'object') return {};
179
+
180
+ return keys.reduce((result, key) => {
181
+ if(key in obj) {
182
+ result[key] = obj[key];
183
+ }
184
+ return result;
185
+ }, {});
186
+ }
@@ -496,6 +496,7 @@ class VideoTracker extends Tracker {
496
496
  this.getBitrate() ||
497
497
  this.getWebkitBitrate() ||
498
498
  this.getRenditionBitrate();
499
+
499
500
  att.contentRenditionName = this.getRenditionName();
500
501
  att.contentRenditionBitrate = this.getRenditionBitrate();
501
502
  att.contentRenditionHeight = this.getRenditionHeight();
@@ -520,13 +521,30 @@ class VideoTracker extends Tracker {
520
521
 
521
522
  this.state.getStateAttributes(att);
522
523
 
524
+ if(this.state.isStarted && !this.isAd()) {
525
+ this.state.trackContentBitrateState(att.contentBitrate);
526
+ }
527
+
523
528
  for (let key in this.customData) {
524
529
  att[key] = this.customData[key];
525
530
  }
526
531
 
532
+ /**
533
+ * Adds all the attributes and custom attributes for qoe event
534
+ */
535
+ this.addQoeAttributes(att);
536
+
527
537
  return att;
528
538
  }
529
539
 
540
+ addQoeAttributes(att) {
541
+ att = this.state.getQoeAttributes(att);
542
+ const qoe = att.qoe;
543
+ for (let key in this.customData) {
544
+ qoe[key] = this.customData[key];
545
+ }
546
+ }
547
+
530
548
  /**
531
549
  * Sends custom event and registers a timeSince attribute.
532
550
  * @param {Object} [actionName] Custom action name.
@@ -585,8 +603,21 @@ class VideoTracker extends Tracker {
585
603
  ev = VideoTracker.Events.AD_START;
586
604
  if (this.parentTracker) this.parentTracker.state.isPlaying = false;
587
605
  this.sendVideoAdAction(ev, att);
606
+ this.state.startAdsTime();
588
607
  } else {
589
608
  ev = VideoTracker.Events.CONTENT_START;
609
+ let totalAdsTime = 0;
610
+ if(this.adsTracker) {
611
+ // If ads state is set to playing (ad error) after content start, reset the ad state.
612
+ if(this.adsTracker.state.isPlaying || this.adsTracker.state.isBuffering) {
613
+ totalAdsTime = this.adsTracker.state.stopAdsTime();
614
+ this.adsTracker.state.isPlaying = false;
615
+ this.adsTracker.state.isBuffering = false;
616
+ } else {
617
+ totalAdsTime = this.adsTracker.state.totalAdTime() ?? 0;
618
+ }
619
+ }
620
+ this.state.setStartupTime(totalAdsTime)
590
621
  this.sendVideoAction(ev, att);
591
622
  }
592
623
  //this.send(ev, att);
@@ -610,6 +641,7 @@ class VideoTracker extends Tracker {
610
641
  att.timeSinceAdRequested = this.state.timeSinceRequested.getDeltaTime();
611
642
  att.timeSinceAdStarted = this.state.timeSinceStarted.getDeltaTime();
612
643
  if (this.parentTracker) this.parentTracker.state.isPlaying = true;
644
+ this.state.stopAdsTime();
613
645
  } else {
614
646
  ev = VideoTracker.Events.CONTENT_END;
615
647
  att.timeSinceRequested = this.state.timeSinceRequested.getDeltaTime();
@@ -625,6 +657,11 @@ class VideoTracker extends Tracker {
625
657
  this.parentTracker.state.goLastAd();
626
658
  this.state.goViewCountUp();
627
659
  this.state.totalPlaytime = 0;
660
+ if(!this.isAd()) {
661
+ // reset the states after the view count is up
662
+ if(this.adsTracker) this.adsTracker.state.clearTotalAdsTime();
663
+ this.state.resetViewIdTrackedState();
664
+ }
628
665
  }
629
666
  }
630
667
 
@@ -61,6 +61,8 @@ class VideoTrackerState {
61
61
  */
62
62
  this.totalPlaytime = 0;
63
63
 
64
+ this.weightedAverageBitrate = 0;
65
+
64
66
  /**
65
67
  * The amount of ms the user has been watching ads during an ad break.
66
68
  */
@@ -72,6 +74,45 @@ class VideoTrackerState {
72
74
  /** True if initial buffering event already happened. */
73
75
  this.initialBufferingHappened = false;
74
76
 
77
+ /**
78
+ * New QoE KPIs - Content only
79
+ */
80
+
81
+ /**
82
+ * Startup Time: Time from CONTENT_REQUEST to CONTENT_START in milliseconds.
83
+ */
84
+ this.startupTime = null;
85
+
86
+ /**
87
+ * Peak Bitrate: Maximum contentBitrate observed across all content playback.
88
+ */
89
+ this.peakBitrate = 0;
90
+
91
+ /**
92
+ * Last tracked bitrate
93
+ */
94
+ this._lastBitrate = null;
95
+
96
+ /**
97
+ * total bitrate partial value for average weighted average bitrate
98
+ */
99
+ this.partialAverageBitrate = 0;
100
+
101
+ /**
102
+ * Had Startup Failure: TRUE if CONTENT_ERROR occurs before CONTENT_START.
103
+ */
104
+ this.hadStartupFailure = false;
105
+
106
+ /**
107
+ * Had Playback Failure: TRUE if CONTENT_ERROR occurs during content playback.
108
+ */
109
+ this.hadPlaybackFailure = false;
110
+
111
+ /**
112
+ * The amount of ms the user has been rebuffering during content playback.
113
+ */
114
+ this.totalRebufferingTime = 0;
115
+
75
116
  this.resetFlags();
76
117
  this.resetChronos();
77
118
  }
@@ -153,9 +194,12 @@ class VideoTrackerState {
153
194
  /** A dictionary containing the custom timeSince attributes. */
154
195
  this.customTimeSinceAttributes = {};
155
196
 
156
- /** This are used to collect the time of buffred and pause resume between two heartbeats */
197
+ /** This are used to collect the time of buffered and pause resume between two heartbeats */
157
198
  this.elapsedTime = new Chrono();
158
199
  this.bufferElapsedTime = new Chrono();
200
+
201
+ /** tracks total ad play time */
202
+ this._totalAdPlaytime = new Chrono();
159
203
  }
160
204
 
161
205
  /** Returns true if the tracker is currently on ads. */
@@ -293,6 +337,35 @@ class VideoTrackerState {
293
337
  return att;
294
338
  }
295
339
 
340
+ getQoeAttributes(att) {
341
+ att = att || {};
342
+ const kpi = {};
343
+
344
+ try {
345
+ // QoE KPIs - Content only
346
+ if (this.startupTime !== null) {
347
+ kpi["startupTime"] = this.startupTime;
348
+ }
349
+ if (this.peakBitrate > 0) {
350
+ kpi["peakBitrate"] = this.peakBitrate;
351
+ }
352
+ kpi["hadStartupFailure"] = this.hadStartupFailure;
353
+ kpi["hadPlaybackFailure"] = this.hadPlaybackFailure;
354
+ kpi["totalRebufferingTime"] = this.totalRebufferingTime;
355
+ // Calculate rebuffering ratio as percentage (avoid division by zero)
356
+ kpi["rebufferingRatio"] = this.totalPlaytime > 0
357
+ ? (this.totalRebufferingTime / this.totalPlaytime) * 100
358
+ : 0;
359
+ kpi["totalPlaytime"] = this.totalPlaytime;
360
+ kpi["averageBitrate"] = this.weightedBitrate;
361
+ } catch (error) {
362
+ Log.error("Failed to add attributes for QOE KPIs", error.message);
363
+ }
364
+
365
+ att.qoe = kpi;
366
+ return att;
367
+ }
368
+
296
369
  /**
297
370
  * Calculate the bufferType attribute.
298
371
  *
@@ -383,6 +456,7 @@ class VideoTrackerState {
383
456
  this.timeSinceRequested.stop();
384
457
  this.timeSinceStarted.stop();
385
458
  this.playtimeSinceLastEvent.stop();
459
+ this.isPlaying = false;
386
460
  return true;
387
461
  } else {
388
462
  return false;
@@ -468,6 +542,11 @@ class VideoTrackerState {
468
542
  this._bufferAcc += this.bufferElapsedTime.getDeltaTime();
469
543
  }
470
544
 
545
+ // Accumulate total rebuffering time for content only
546
+ if (!this.isAd() && this.initialBufferingHappened) {
547
+ this.totalRebufferingTime += this.timeSinceBufferBegin.getDeltaTime();
548
+ }
549
+
471
550
  return true;
472
551
  } else {
473
552
  return false;
@@ -584,6 +663,15 @@ class VideoTrackerState {
584
663
  this.timeSinceLastAdError.start();
585
664
  } else {
586
665
  this.timeSinceLastError.start();
666
+
667
+ // Track failure flags for content errors only
668
+ // Had Startup Failure: error before content started
669
+ if (!this.isStarted) {
670
+ this.hadStartupFailure = true;
671
+ } else {
672
+ // Had Playback Failure: any content error
673
+ this.hadPlaybackFailure = true;
674
+ }
587
675
  }
588
676
  }
589
677
 
@@ -593,6 +681,61 @@ class VideoTrackerState {
593
681
  goLastAd() {
594
682
  this.timeSinceLastAd.start();
595
683
  }
684
+
685
+ /**
686
+ * Updates peak bitrate with current bitrate value (content only).
687
+ * @param {number} bitrate Current content bitrate in bps.
688
+ */
689
+ trackContentBitrateState(bitrate) {
690
+ if (bitrate && typeof bitrate === "number") {
691
+ this.peakBitrate = Math.max(this.peakBitrate, bitrate);
692
+
693
+ if(this._lastBitrate === null || this._lastBitrate !== bitrate) {
694
+ const totalPlaytime = this.timeSinceLastRenditionChange.getDeltaTime() || this.totalPlaytime;
695
+ const currentWeightedBitrate = (bitrate * totalPlaytime);
696
+ this.partialAverageBitrate += currentWeightedBitrate;
697
+ this.weightedBitrate = currentWeightedBitrate / totalPlaytime;
698
+ this._lastBitrate = bitrate;
699
+ }
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Resets tracked variable for view id change
705
+ * */
706
+ resetViewIdTrackedState() {
707
+ this.peakBitrate = 0;
708
+ this.partialAverageBitrate = 0;
709
+ this.startupTime = null;
710
+ this._lastBitrate = null;
711
+ }
712
+
713
+ /** Methods to manage total ads time chrono */
714
+ clearTotalAdsTime() {
715
+ console.log("clear total ads time", this.totalAdTime);
716
+ this._totalAdPlaytime.reset();
717
+ }
718
+
719
+ totalAdTime() {
720
+ return this._totalAdPlaytime.getDuration();
721
+ }
722
+
723
+ startAdsTime() {
724
+ console.log("startAdsTime");
725
+ return this._totalAdPlaytime.start();
726
+ }
727
+
728
+ stopAdsTime() {
729
+ console.log("stopAdsTime");
730
+ return this._totalAdPlaytime.stop();
731
+ }
732
+
733
+ setStartupTime(totalAdTime) {
734
+ if (this.startupTime === null) {
735
+ this.startupTime = Math.max(this.timeSinceRequested.getDeltaTime() - totalAdTime, 0)
736
+ }
737
+ }
738
+
596
739
  }
597
740
 
598
741
  export default VideoTrackerState;