@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/CHANGELOG.md +53 -10
- package/README.md +80 -0
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.LICENSE.txt +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.LICENSE.txt +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/umd/nrvideo.min.js +1 -1
- package/dist/umd/nrvideo.min.js.LICENSE.txt +1 -1
- package/dist/umd/nrvideo.min.js.map +1 -1
- package/package.json +1 -1
- package/src/agent.js +57 -1
- package/src/constants.js +11 -1
- package/src/core.js +1 -1
- package/src/eventAggregator.js +48 -0
- package/src/harvestScheduler.js +86 -3
- package/src/index.js +1 -19
- package/src/obfuscate.js +33 -0
- package/src/optimizedHttpClient.js +6 -2
- package/src/recordEvent.js +1 -1
- package/src/videoConfiguration.js +79 -7
- package/src/videotracker.js +47 -13
- package/src/videotrackerstate.js +72 -21
package/package.json
CHANGED
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
|
-
//
|
|
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
package/src/eventAggregator.js
CHANGED
|
@@ -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.
|
package/src/harvestScheduler.js
CHANGED
|
@@ -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
|
|
248
|
-
|
|
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;
|
package/src/obfuscate.js
ADDED
|
@@ -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 {
|
|
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 =
|
|
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) {
|
package/src/recordEvent.js
CHANGED
|
@@ -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}
|
|
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.
|
|
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}
|
|
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 };
|
package/src/videotracker.js
CHANGED
|
@@ -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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
this.
|
|
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
|
-
|
|
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();
|