@newrelic/video-core 3.2.0-beta-1 → 4.0.1

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.
@@ -0,0 +1,124 @@
1
+ import Log from "./log";
2
+ import { dataSize } from "./utils";
3
+ import Constants from "./constants";
4
+
5
+ const { MAX_PAYLOAD_SIZE, MAX_EVENTS_PER_BATCH } = Constants;
6
+
7
+ /**
8
+ * Retry Queue Handler for managing failed events with retry logic,
9
+ * backoff strategies, and persistent storage capabilities.
10
+ */
11
+ export class RetryQueueHandler {
12
+ constructor() {
13
+ this.retryQueue = [];
14
+ this.maxQueueSize = MAX_EVENTS_PER_BATCH; // Max 1000 events
15
+ this.maxQueueSizeBytes = MAX_PAYLOAD_SIZE; // Max 1MB
16
+ }
17
+
18
+ /**
19
+ * Adds failed events to the retry queue for retry processing.
20
+ * @param {Array|object} events - Failed event(s) to add to retry queue
21
+ */
22
+ addFailedEvents(events) {
23
+ try {
24
+ const eventsArray = Array.isArray(events) ? events : [events];
25
+
26
+ Log.notice(`Adding ${eventsArray.length} failed events to retry queue`, {
27
+ queueSizeBefore: this.retryQueue.length,
28
+ });
29
+
30
+ for (const event of eventsArray) {
31
+ // Check queue size and make room if necessary
32
+ if (this.retryQueue.length >= this.maxQueueSize) {
33
+ this.evictOldestEvent();
34
+ }
35
+
36
+ // Check queue memory size and make room if necessary
37
+ const eventSize = dataSize(event);
38
+ while (dataSize(this.retryQueue) + eventSize > this.maxQueueSizeBytes) {
39
+ this.evictOldestEvent();
40
+ }
41
+
42
+ // Store event directly - no wrapper needed
43
+ this.retryQueue.push({ ...event });
44
+ }
45
+ } catch (err) {
46
+ Log.error("Failed to add events to retry queue:", err.message);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Discards an event that cannot be retried.
52
+ * @param {object} event - Event to discard
53
+ * @param {string} reason - Reason for discarding
54
+ * @private
55
+ */
56
+ discardEvent(event, reason) {
57
+ Log.warn(`Discarded event`, {
58
+ reason,
59
+ eventType: event.eventType,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Evicts the oldest event from the queue to make room.
65
+ * @private
66
+ */
67
+ evictOldestEvent() {
68
+ if (this.retryQueue.length > 0) {
69
+ const oldest = this.retryQueue.shift();
70
+ this.discardEvent(oldest, "Queue full - evicted oldest");
71
+ }
72
+ }
73
+
74
+ /**
75
+ * For unified harvesting - get retry events that fit within payload limits
76
+ * Removes the selected events from the retry queue since they're being retried
77
+ * @param {number} availableSpace - Available payload space in bytes
78
+ * @param {number} availableEventCount - Available event count
79
+ * @returns {Array} Array of events that fit within limits
80
+ */
81
+ getRetryEventsToFit(availableSpace, availableEventCount) {
82
+ const retryEvents = [];
83
+ let usedSpace = 0;
84
+ let eventCount = 0;
85
+
86
+ // Process retry queue in chronological order (oldest first) by iterating backwards
87
+ // This allows us to remove elements immediately without index shifting issues
88
+ for (let i = this.retryQueue.length - 1; i >= 0; i--) {
89
+ const event = this.retryQueue[i]; // 1000
90
+
91
+ if (eventCount >= availableEventCount) break;
92
+
93
+ const eventSize = dataSize(event);
94
+ if (usedSpace + eventSize > availableSpace) break;
95
+
96
+ // Add to beginning of retryEvents to maintain chronological order (oldest first)
97
+ retryEvents.unshift(event);
98
+ usedSpace += eventSize;
99
+ eventCount++;
100
+
101
+ // Remove immediately - safe because we're iterating backwards
102
+ this.retryQueue.splice(i, 1);
103
+ }
104
+
105
+ return retryEvents;
106
+ }
107
+
108
+ /**
109
+ * Gets the current retry queue size.
110
+ * @returns {number} Queue size
111
+ */
112
+ getQueueSize() {
113
+ return this.retryQueue.length;
114
+ }
115
+
116
+ /**
117
+ * Clears the retry queue.
118
+ */
119
+ clear() {
120
+ this.retryQueue = [];
121
+ }
122
+ }
123
+
124
+ export default RetryQueueHandler;
package/src/tracker.js CHANGED
@@ -2,6 +2,8 @@ import pkg from "../package.json";
2
2
  import Emitter from "./emitter";
3
3
  import Chrono from "./chrono";
4
4
  import Constants from "./constants";
5
+ import { videoAnalyticsHarvester } from "./agent";
6
+ import Log from "./log";
5
7
 
6
8
  /**
7
9
  * Tracker class provides the basic logic to extend Newrelic's Browser Agent capabilities.
@@ -233,17 +235,12 @@ class Tracker extends Emitter {
233
235
  * Internally, this will call {@see Emitter#emit}, so you could listen any event fired.
234
236
  *
235
237
  * @example
236
- * tracker.send('BANNER_CLICK', { url: 'http....' })
238
+ * tracker.sendVideoAction('BANNER_CLICK', { url: 'http....' })
237
239
  *
238
240
  * @param {string} event Event name
239
241
  * @param {object} [att] Key:value dictionary filled with attributes.
240
242
  */
241
243
 
242
- /**
243
- * getElapsedTime: Calculate the time elapsed between two same actions
244
- *
245
- */
246
-
247
244
  sendVideoAction(event, att) {
248
245
  this.emit("VideoAction", event, this.getAttributes(att));
249
246
  }
@@ -264,6 +261,26 @@ class Tracker extends Emitter {
264
261
  this.getAttributes(att, "customAction")
265
262
  );
266
263
  }
264
+
265
+ /**
266
+ * Sets the harvest interval for video tracking.
267
+ * @param {*} interval - The interval in milliseconds.
268
+ * @returns {void}
269
+ */
270
+
271
+ setHarvestInterval(interval) {
272
+ if (!videoAnalyticsHarvester) {
273
+ Log.error("VideoAnalyticsHarvester is not available");
274
+ return;
275
+ }
276
+
277
+ try {
278
+ videoAnalyticsHarvester.setHarvestInterval(interval);
279
+ } catch (error) {
280
+ Log.error("Failed to set harvest interval:", error.message);
281
+ return;
282
+ }
283
+ }
267
284
  }
268
285
 
269
286
  /**
package/src/utils.js CHANGED
@@ -1,64 +1,94 @@
1
+ import pkg from "../package.json";
2
+ import Log from "./log";
3
+
1
4
  /**
2
- * Makes an API call with retry logic and fallback to sendBeacon for final harvests
3
- * @param {Object} params - Request parameters
4
- * @param {string} params.url - The URL to send the request to
5
- * @param {Object} params.payload - The payload object containing body data
6
- * @param {Object} params.options - Request options
7
- * @param {boolean} params.options.isFinalHarvest - Whether this is a final harvest on page unload
8
- * @param {Function} callback - Callback function to handle the response
5
+ * Builds the harvest URL with proper query parameters.
6
+ * @returns {string} Harvest URL
9
7
  */
10
- export function callApi({ url, payload, options = {} }, callback) {
11
- // Input validation
12
- if (!url || !payload || !callback) {
13
- console.error("callApi: Missing required parameters");
14
- if (callback) callback({ retry: false, status: 0 });
15
- return;
16
- }
17
8
 
18
- // The Browser Agent sends the 'body' part of the payload object as the actual request body.
19
- let body;
9
+ export function buildUrl(fallbackUrl) {
20
10
  try {
21
- body = JSON.stringify(payload.body);
11
+ if (!window.NRVIDEO || !window.NRVIDEO.info) {
12
+ throw new Error("NRVIDEO info is not available.");
13
+ }
14
+
15
+ let { beacon, licenseKey, applicationID } = window.NRVIDEO.info;
16
+
17
+ if (!beacon || !licenseKey)
18
+ throw new Error(
19
+ "Options object provided by New Relic is not correctly initialized"
20
+ );
21
+
22
+ if (applicationID) {
23
+ return `https://${
24
+ fallbackUrl ? fallbackUrl : beacon
25
+ }/ins/1/${licenseKey}?a=${applicationID}&v=${pkg.version}&ref=${
26
+ window.location.href
27
+ }&ca=VA`;
28
+ }
29
+
30
+ return `https://${
31
+ fallbackUrl ? fallbackUrl : beacon
32
+ }/ins/1/${licenseKey}?&v=${pkg.version}&ref=${window.location.href}&ca=VA`;
22
33
  } catch (error) {
23
- console.error("callApi: Error serializing payload", error);
24
- callback({ retry: false, status: 0 });
25
- return;
34
+ console.error(error.message);
35
+ return null; // Return null instead of undefined
26
36
  }
37
+ }
27
38
 
28
- // For final harvests on page unload, use sendBeacon for reliability.
29
- if (options.isFinalHarvest && navigator.sendBeacon) {
30
- try {
31
- const success = navigator.sendBeacon(url, body);
32
- // sendBeacon returns true if the request was successfully queued
33
- callback({ retry: !success, status: success ? 200 : 0 });
34
- } catch (e) {
35
- // sendBeacon can fail if the payload is too large.
36
- callback({ retry: true, status: 0 });
39
+ /**
40
+ * Returns a function for use as a replacer parameter in JSON.stringify() to handle circular references.
41
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value MDN - Cyclical object value}
42
+ * @returns {Function} A function that filters out values it has seen before.
43
+ */
44
+ const getCircularReplacer = () => {
45
+ const seen = new WeakSet();
46
+ return (key, value) => {
47
+ if (typeof value === "object" && value !== null) {
48
+ if (seen.has(value)) {
49
+ return;
50
+ }
51
+ seen.add(value);
37
52
  }
38
- return;
53
+ return value;
54
+ };
55
+ };
56
+
57
+ /**
58
+ * The native JSON.stringify method augmented with a replacer function to handle circular references.
59
+ * Circular references will be excluded from the JSON output rather than triggering errors.
60
+ * @param {*} val - A value to be converted to a JSON string.
61
+ * @returns {string} A JSON string representation of the value, with circular references handled.
62
+ */
63
+ function stringify(val) {
64
+ try {
65
+ return JSON.stringify(val, getCircularReplacer()) ?? "";
66
+ } catch (e) {
67
+ Log.error("Error stringifying value:", e.message);
68
+ return "";
39
69
  }
70
+ }
71
+
72
+ export function dataSize(data) {
73
+ if (typeof data === "string" && data.length) return data.length;
74
+ if (typeof data !== "object") return undefined;
75
+ // eslint-disable-next-line
76
+ if (
77
+ typeof ArrayBuffer !== "undefined" &&
78
+ data instanceof ArrayBuffer &&
79
+ data.byteLength
80
+ )
81
+ return data.byteLength;
82
+ if (typeof Blob !== "undefined" && data instanceof Blob && data.size)
83
+ return data.size;
84
+ if (typeof FormData !== "undefined" && data instanceof FormData)
85
+ return undefined;
40
86
 
41
- fetch(url, {
42
- method: "POST",
43
- body: body,
44
- headers: {
45
- "Content-Type": "application/json", // More accurate content type
46
- },
47
- keepalive: options.isFinalHarvest, // Important for final harvest fallback
48
- })
49
- .then((response) => {
50
- // Check for statuses that indicate a retry is needed.
51
- const isRetry = shouldRetry(response.status);
52
- callback({
53
- retry: isRetry,
54
- status: response.status,
55
- ok: response.ok,
56
- });
57
- })
58
- .catch(() => {
59
- // Any network failure (e.g., no internet) should also trigger a retry.
60
- callback({ retry: true, status: 0 });
61
- });
87
+ try {
88
+ return stringify(data).length;
89
+ } catch (e) {
90
+ return undefined;
91
+ }
62
92
  }
63
93
 
64
94
  /**
@@ -66,7 +96,7 @@ export function callApi({ url, payload, options = {} }, callback) {
66
96
  * @param {number} status - HTTP status code
67
97
  * @returns {boolean} - True if request should be retried
68
98
  */
69
- function shouldRetry(status) {
99
+ export function shouldRetry(status) {
70
100
  switch (status) {
71
101
  case 408: // Request Timeout
72
102
  case 429: // Too Many Requests
@@ -82,20 +112,51 @@ function shouldRetry(status) {
82
112
  }
83
113
 
84
114
  /**
85
- * Calculates the size of a payload object in megabytes
86
- * @param {Object} obj - The object to calculate size for
87
- * @returns {number} - Size in megabytes, or 0 if calculation fails
115
+ * Compresses a JSON payload using the Compression Streams API with Gzip.
116
+ * see @description(https://developer.mozilla.org/en-US/docs/Web/API/Compression_Streams_API)
117
+ * @param {object} payload - The JSON object to compress.
118
+ * @returns {Promise<Blob>} A Promise that resolves with a Blob of the Gzipped data.
88
119
  */
89
- export function getPayloadSize(obj) {
90
- if (!obj || typeof obj !== "object") {
91
- return 0;
92
- }
93
120
 
121
+ export function compressPayload(payload) {
122
+ const stringifiedPayload = JSON.stringify(payload);
123
+ const stream = new Blob([stringifiedPayload]).stream();
124
+ const compressionStream = new CompressionStream("gzip");
125
+ const compressedStream = stream.pipeThrough(compressionStream);
126
+
127
+ return new Response(compressedStream).blob();
128
+ }
129
+
130
+ /**
131
+ * Decompresses a gzipped Blob back to a JSON object using the Compression Streams API.
132
+ * @param {Blob|ArrayBuffer|Uint8Array} compressedData - The gzipped data to decompress.
133
+ * @returns {Promise<object>} A Promise that resolves with the decompressed JSON object.
134
+ */
135
+ export async function decompressPayload(compressedData) {
94
136
  try {
95
- const json = JSON.stringify(obj);
96
- return new TextEncoder().encode(json).length / (1024 * 1024);
137
+ // Convert different input types to a stream
138
+ let stream;
139
+ if (compressedData instanceof Blob) {
140
+ stream = compressedData.stream();
141
+ } else if (compressedData instanceof ArrayBuffer) {
142
+ stream = new Blob([compressedData]).stream();
143
+ } else if (compressedData instanceof Uint8Array) {
144
+ stream = new Blob([compressedData]).stream();
145
+ } else {
146
+ throw new Error("Unsupported compressed data type");
147
+ }
148
+
149
+ // Decompress using DecompressionStream
150
+ const decompressionStream = new DecompressionStream("gzip");
151
+ const decompressedStream = stream.pipeThrough(decompressionStream);
152
+
153
+ // Convert back to text
154
+ const response = new Response(decompressedStream);
155
+ const decompressedText = await response.text();
156
+
157
+ // Parse JSON
158
+ return JSON.parse(decompressedText);
97
159
  } catch (error) {
98
- console.error("getPayloadSize: Error calculating payload size", error);
99
- return 0;
160
+ throw new Error(`Failed to decompress payload: ${error.message}`);
100
161
  }
101
162
  }
@@ -0,0 +1,113 @@
1
+ import Log from "./log";
2
+ import Constants from "./constants";
3
+
4
+ const { COLLECTOR } = Constants;
5
+
6
+ /**
7
+ * Enhanced video analytics configuration system that extends the existing auth configuration.
8
+ * Provides feature flags, retry policies, and advanced harvesting options.
9
+ */
10
+ class VideoConfiguration {
11
+ /**
12
+ * Validates and sets the video analytics configuration.
13
+ * @param {object} userConfig - User provided configuration
14
+ * @returns {boolean} True if configuration is valid and set
15
+ */
16
+
17
+ setConfiguration(userInfo) {
18
+ this.initializeGlobalConfig(userInfo);
19
+ Log.notice("Video analytics configuration initialized successfully");
20
+ return true;
21
+ }
22
+
23
+ /**
24
+ * Validates required configuration fields.
25
+ * @param {object} config - Configuration to validate
26
+ * @returns {boolean} True if valid
27
+ */
28
+ validateRequiredFields(info) {
29
+ if (!info || typeof info !== "object") {
30
+ Log.error("Configuration must be an object");
31
+ return false;
32
+ }
33
+
34
+ const { licenseKey, appName, region, applicationID, beacon } = info;
35
+
36
+ if (!licenseKey) {
37
+ Log.error("licenseKey is required");
38
+ return false;
39
+ }
40
+
41
+ if (applicationID) {
42
+ if (!beacon) {
43
+ Log.error("beacon is required when applicationID is provided");
44
+ return false;
45
+ } else {
46
+ const validBeacons = Object.values(COLLECTOR).flatMap((el) => el);
47
+ if (!validBeacons.includes(beacon)) {
48
+ Log.error(`Invalid beacon: ${beacon}`);
49
+ return false;
50
+ }
51
+ }
52
+ } else {
53
+ if (!appName || !region) {
54
+ Log.error(
55
+ "appName and region are required when applicationID is not provided"
56
+ );
57
+ return false;
58
+ }
59
+
60
+ if (!COLLECTOR[region]) {
61
+ Log.error(
62
+ `Invalid region: ${region}. Valid regions are: ${Object.keys(
63
+ COLLECTOR
64
+ ).join(", ")}`
65
+ );
66
+ return false;
67
+ }
68
+ }
69
+
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Initializes the global NRVIDEO configuration object.
75
+ */
76
+ initializeGlobalConfig(userInfo) {
77
+ if (!this.validateRequiredFields(userInfo)) return;
78
+
79
+ let { licenseKey, appName, region, beacon, applicationID } = userInfo;
80
+
81
+ if (region === "US") {
82
+ beacon = Constants.COLLECTOR["US"][0];
83
+ } else {
84
+ beacon = beacon || COLLECTOR[region];
85
+ }
86
+
87
+ window.NRVIDEO = {
88
+ // Existing format for backward compatibility
89
+ info: {
90
+ ...(region ? { region } : {}), // Only include region if available
91
+ beacon,
92
+ licenseKey,
93
+ applicationID,
94
+ ...(applicationID ? {} : { appName }), // Only include appName when no applicationID
95
+ },
96
+ };
97
+ }
98
+ }
99
+
100
+ // Create singleton instance
101
+ const videoConfiguration = new VideoConfiguration();
102
+
103
+ /**
104
+ * Sets the video analytics configuration.
105
+ * @param {object} config - Configuration object
106
+ * @returns {boolean} True if configuration was set successfully
107
+ */
108
+ export function setVideoConfig(info) {
109
+ return videoConfiguration.setConfiguration(info);
110
+ }
111
+
112
+ export { videoConfiguration };
113
+ export default VideoConfiguration;
@@ -135,6 +135,12 @@ class VideoTrackerState {
135
135
  /** Content only. Chrono that counts time since last AD_END. */
136
136
  this.timeSinceLastAd = new Chrono();
137
137
 
138
+ /** Chrono that counts time since last error event. */
139
+ this.timeSinceLastError = new Chrono();
140
+
141
+ /** Chrono that counts time since last ad error event. */
142
+ this.timeSinceLastAdError = new Chrono();
143
+
138
144
  /** Chrono that counts time since last *_RESUME. Only for buffering events. */
139
145
  this.timeSinceResumed = new Chrono();
140
146
 
@@ -231,6 +237,12 @@ class VideoTrackerState {
231
237
  att.timeSinceAdSeekBegin = this.timeSinceSeekBegin.getDeltaTime();
232
238
  if (this.isAdBreak)
233
239
  att.timeSinceAdBreakBegin = this.timeSinceAdBreakStart.getDeltaTime();
240
+
241
+ // Only include timeSinceLastAdError if an ad error has occurred
242
+ if (this.numberOfErrors > 0 && this.timeSinceLastAdError.startTime > 0) {
243
+ att.timeSinceLastAdError = this.timeSinceLastAdError.getDeltaTime();
244
+ }
245
+
234
246
  att.numberOfAds = this.numberOfAds;
235
247
  } else {
236
248
  // Content only
@@ -247,6 +259,12 @@ class VideoTrackerState {
247
259
  if (this.isSeeking)
248
260
  att.timeSinceSeekBegin = this.timeSinceSeekBegin.getDeltaTime();
249
261
  att.timeSinceLastAd = this.timeSinceLastAd.getDeltaTime();
262
+
263
+ // Only include timeSinceLastError if a content error has occurred
264
+ if (this.numberOfErrors > 0 && this.timeSinceLastError.startTime > 0) {
265
+ att.timeSinceLastError = this.timeSinceLastError.getDeltaTime();
266
+ }
267
+
250
268
  att.numberOfVideos = this.numberOfVideos;
251
269
  }
252
270
  att.numberOfErrors = this.numberOfErrors;
@@ -556,11 +574,17 @@ class VideoTrackerState {
556
574
  }
557
575
 
558
576
  /**
559
- * Increments error counter.
577
+ * Increments error counter and starts appropriate error timer.
560
578
  */
561
579
  goError() {
562
580
  this.isError = true;
563
581
  this.numberOfErrors++;
582
+
583
+ if (this.isAd()) {
584
+ this.timeSinceLastAdError.start();
585
+ } else {
586
+ this.timeSinceLastError.start();
587
+ }
564
588
  }
565
589
 
566
590
  /**