@newrelic/video-core 4.1.8 → 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 (41) hide show
  1. package/README.md +140 -27
  2. package/dist/cjs/browser/index.js +3 -0
  3. package/dist/cjs/browser/index.js.LICENSE.txt +6 -0
  4. package/dist/cjs/browser/index.js.map +1 -0
  5. package/dist/cjs/index.js +1 -1
  6. package/dist/cjs/index.js.LICENSE.txt +1 -1
  7. package/dist/cjs/index.js.map +1 -1
  8. package/dist/cjs/vega/index.js +3 -0
  9. package/dist/cjs/vega/index.js.LICENSE.txt +6 -0
  10. package/dist/cjs/vega/index.js.map +1 -0
  11. package/dist/esm/browser/index.js +3 -0
  12. package/dist/esm/browser/index.js.LICENSE.txt +6 -0
  13. package/dist/esm/browser/index.js.map +1 -0
  14. package/dist/esm/index.js +1 -1
  15. package/dist/esm/index.js.LICENSE.txt +1 -1
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/vega/index.js +3 -0
  18. package/dist/esm/vega/index.js.LICENSE.txt +6 -0
  19. package/dist/esm/vega/index.js.map +1 -0
  20. package/dist/umd/nrvideo.min.js +1 -1
  21. package/dist/umd/nrvideo.min.js.LICENSE.txt +1 -1
  22. package/dist/umd/nrvideo.min.js.map +1 -1
  23. package/package.json +25 -7
  24. package/src/{agent.js → browser/agent.js} +14 -35
  25. package/src/{harvestScheduler.js → browser/harvestScheduler.js} +55 -137
  26. package/src/browser/index.js +58 -0
  27. package/src/connectedDevice/connectedDeviceAgent.js +116 -0
  28. package/src/connectedDevice/connectedDeviceConstants.js +121 -0
  29. package/src/connectedDevice/connectedDeviceHarvester.js +522 -0
  30. package/src/connectedDevice/index.js +49 -0
  31. package/src/constants.js +2 -0
  32. package/src/core.js +4 -18
  33. package/src/index.js +9 -10
  34. package/src/recordEvent.js +56 -49
  35. package/src/tracker.js +14 -4
  36. package/src/utils/eventBuilder.js +126 -0
  37. package/src/utils/harvestTimer.js +109 -0
  38. package/src/{utils.js → utils/index.js} +2 -2
  39. package/src/utils/qoeFilters.js +149 -0
  40. package/src/videoConfiguration.js +56 -10
  41. package/src/videotracker.js +26 -9
@@ -0,0 +1,121 @@
1
+ // =====================================================================
2
+ // Connected-device pipeline constants (Vega / mobile collector)
3
+ // =====================================================================
4
+ // Endpoint URLs, request payload templates, and harvest defaults for the
5
+ // connected-device pipeline (`/v5/connect` + `/v3/data`).
6
+ // Only `connectedDeviceHarvester.js` imports these — browser builds
7
+ // never reference this file, keeping the browser bundle lean.
8
+
9
+ /** US production mobile collector base URL. */
10
+ export const MOBILE_ENDPOINT_US = "https://mobile-collector.newrelic.com/mobile";
11
+
12
+ /** EU production mobile collector base URL. Routes EU-account events to the EU datacenter. */
13
+ export const MOBILE_ENDPOINT_EU = "https://mobile-collector.eu01.nr-data.net/mobile";
14
+
15
+ /** Staging mobile collector base URL. */
16
+ export const STAGING_MOBILE_ENDPOINT =
17
+ "https://staging-mobile-collector.newrelic.com/mobile";
18
+
19
+ /** FedRAMP/GOV mobile collector base URL. Required for US government accounts. */
20
+ export const MOBILE_ENDPOINT_GOV = "https://gov-mobile-collector.newrelic.com/mobile";
21
+
22
+ /** Endpoint region selector. Each value routes to its regional collector. */
23
+ export const NR_ENDPOINT = {
24
+ US: "US",
25
+ EU: "EU",
26
+ STAGING: "staging",
27
+ GOV: "GOV",
28
+ };
29
+
30
+ /** Default harvest cadence in ms. */
31
+ export const DEFAULT_HARVEST_TIME = 60000;
32
+
33
+ /** Default in-memory event-buffer cap (informational; aggregator enforces
34
+ * MAX_EVENTS_PER_BATCH / MAX_PAYLOAD_SIZE). */
35
+ export const DEFAULT_BUFFER_SIZE = 100;
36
+
37
+ /** Maximum number of /v5/connect attempts before giving up. */
38
+ export const CD_CONNECT_MAX_ATTEMPTS = 3;
39
+
40
+ /** Fixed delay between /v5/connect retry attempts in ms.
41
+ * 10s gives the device network stack enough time to recover after
42
+ * wake-from-sleep before the next attempt fires. */
43
+ export const CD_CONNECT_RETRY_DELAY_MS = 10000;
44
+
45
+ /** Hard timeout for POST /v5/connect. A hung connect holds _isFetchingToken=true
46
+ * forever, blocking all future reconnect attempts. */
47
+ export const CD_CONNECT_TIMEOUT_MS = 10000;
48
+
49
+ /** Hard timeout for POST /v3/data. A hung data send holds isHarvesting=true,
50
+ * freezing the chained harvest timer until the fetch resolves. */
51
+ export const CD_DATA_TIMEOUT_MS = 30000;
52
+
53
+ /**
54
+ * Positional 2-tuple sent as the body of `POST /v5/connect`.
55
+ * [
56
+ * appInfo[3] = [appName, appVersion, bundleId],
57
+ * deviceInfo[10] = [osName, osVersion, deviceModel, agentName,
58
+ * agentVersion, deviceUuid, "", "",
59
+ * manufacturer, sizeMeta]
60
+ * ]
61
+ *
62
+ * DATA_TOKENS_PAYLOAD shape — empty strings are rejected with a 401,
63
+ * every slot must carry a non-empty token. Uses `osName='Android'` +
64
+ * `agentName='AndroidAgent'` for collector auth; the real device identity
65
+ * is recorded in slot [1] / slot [8] of `/v3/data`.
66
+ */
67
+ export const CD_DATA_TOKENS_PAYLOAD = [
68
+ // appInfo
69
+ [
70
+ "newrelic_mobile_example", // appName
71
+ "1.1", // appVersion
72
+ "com.newrelic.newrelic_mobile_example", // bundleId
73
+ ],
74
+ // deviceInfo
75
+ [
76
+ "Android", // osName (collector expects a recognized osName)
77
+ "14", // osVersion
78
+ "sdk_gphone64_arm64", // deviceModel
79
+ "AndroidAgent", // agentName (collector expects a recognized agentName)
80
+ "7.4.0-alpha01", // agentVersion
81
+ "b797aee6-aa69-4879-9ba3-1f4aed1a7777", // deviceUuid
82
+ "",
83
+ "",
84
+ "Google", // manufacturer
85
+ {
86
+ size: "normal",
87
+ platform: "Flutter",
88
+ platformVersion: "1.0.8",
89
+ },
90
+ ],
91
+ ];
92
+
93
+ /**
94
+ * Slot [1] of the `POST /v3/data` 10-tuple. Real device identity tuple
95
+ * (Vega + Amazon by default). Customer-supplied `info.deviceInfo` overrides
96
+ * the runtime fields; static slots (osName, agentName, etc.) stay fixed.
97
+ */
98
+ export const CD_DEVICE_INFO = [
99
+ "Vega", // osName
100
+ "1.0", // osVersion
101
+ "VegaDevice", // deviceModel
102
+ "VegaAgent", // agentName
103
+ "1.0.0", // agentVersion
104
+ "00000000-0000-0000-0000-000000000000", // deviceUuid
105
+ "",
106
+ "",
107
+ "Amazon", // manufacturer
108
+ {
109
+ size: "normal",
110
+ platform: "Native",
111
+ platformVersion: "1.0.0",
112
+ },
113
+ ];
114
+
115
+ export const CD_METADATA = {
116
+ osBuild: "1",
117
+ osName: "Vega",
118
+ platform: "Native",
119
+ appBuild: "1",
120
+ architecture: "aarch64",
121
+ };
@@ -0,0 +1,522 @@
1
+ import { NrVideoEventAggregator } from "../eventAggregator";
2
+ import {
3
+ MOBILE_ENDPOINT_US,
4
+ MOBILE_ENDPOINT_EU,
5
+ MOBILE_ENDPOINT_GOV,
6
+ STAGING_MOBILE_ENDPOINT,
7
+ NR_ENDPOINT,
8
+ DEFAULT_HARVEST_TIME,
9
+ DEFAULT_BUFFER_SIZE,
10
+ CD_DATA_TOKENS_PAYLOAD,
11
+ CD_DEVICE_INFO,
12
+ CD_METADATA,
13
+ CD_CONNECT_MAX_ATTEMPTS,
14
+ CD_CONNECT_RETRY_DELAY_MS,
15
+ CD_CONNECT_TIMEOUT_MS,
16
+ CD_DATA_TIMEOUT_MS,
17
+ } from "./connectedDeviceConstants";
18
+ import {
19
+ bufferEventWithQoeDedup,
20
+ refreshQoeKpisInBuffer,
21
+ partitionByQoeCycle,
22
+ applyQoeDirtyFilter,
23
+ } from "../utils/qoeFilters";
24
+ import { createHarvestTimer } from "../utils/harvestTimer";
25
+ import { applyObfuscationRules } from "../obfuscate";
26
+ import Log from "../log";
27
+
28
+ /**
29
+ * Generic NR mobile-collector harvester for the connected-device pipeline
30
+ * (`/v5/connect` + `/v3/data`).
31
+ *
32
+ * The class is **not Vega-specific** — it owns the HTTP client, dataToken,
33
+ * harvest setInterval, and event buffer for any consumer that needs to ship
34
+ * to NR's mobile collector. Vega-specific routing/lifecycle is provided by
35
+ * the wrapper `ConnectedDeviceAnalyticsAgent` in `connectedDeviceAgent.js`.
36
+ *
37
+ */
38
+ export default class ConnectedDeviceHarvester {
39
+ /**
40
+ * @param {object} opts
41
+ * @param {string} [opts.accountId] Captured for parity with CAF; not transmitted.
42
+ * @param {string} opts.applicationToken Sent as `X-App-License-Key` header.
43
+ * @param {string} opts.endpoint One of `US`, `EU`, `staging`.
44
+ * @param {object} [opts.deviceInfo] Customer-collected device identity. Any of:
45
+ * uuid, osVersion, deviceModel, deviceManufacturer, osBuild, appBuild, architecture.
46
+ * Each field optional — missing values fall back to placeholders from
47
+ * `connectedDeviceConstants.js`. Extra fields are ignored. The customer is
48
+ * expected to source these from a platform device-info library (e.g.
49
+ * `@amazon-devices/react-native-device-info` on Kepler) and pass the
50
+ * resulting object once at construction. `osBuild` is the OS image build
51
+ * (e.g., `getBuildIdSync()`); `appBuild` is the consumer app's build
52
+ * number (e.g., `getBuildNumber()`).
53
+ */
54
+ constructor({
55
+ accountId,
56
+ applicationToken,
57
+ endpoint,
58
+ deviceInfo,
59
+ } = {}) {
60
+ if (!applicationToken) {
61
+ throw new Error("applicationToken is required");
62
+ }
63
+ if (!Object.values(NR_ENDPOINT).includes(endpoint)) {
64
+ throw new Error("Invalid endpoint");
65
+ }
66
+
67
+ this.accountId = accountId;
68
+ this.applicationToken = applicationToken;
69
+ this.endpoint = endpoint;
70
+ this.harvestInterval = DEFAULT_HARVEST_TIME;
71
+ this.maxBufferSize = DEFAULT_BUFFER_SIZE;
72
+
73
+ // Destructure customer-supplied device identity.
74
+ if (!deviceInfo) {
75
+ Log.warn("ConnectedDeviceHarvester: no deviceInfo provided — using placeholder defaults. Pass info.deviceInfo for accurate device data.");
76
+ }
77
+ const {
78
+ uuid,
79
+ osVersion,
80
+ deviceModel,
81
+ deviceManufacturer,
82
+ osBuild,
83
+ appBuild,
84
+ architecture,
85
+ } = deviceInfo ?? {};
86
+ this.deviceInfo = {
87
+ uuid: uuid || CD_DEVICE_INFO[5], // slot [5] = deviceUuid placeholder
88
+ osVersion: osVersion || CD_DEVICE_INFO[1],
89
+ deviceModel: deviceModel || CD_DEVICE_INFO[2],
90
+ deviceManufacturer: deviceManufacturer || CD_DEVICE_INFO[8],
91
+ osBuild: osBuild || CD_METADATA.osBuild,
92
+ appBuild: appBuild || CD_METADATA.appBuild,
93
+ architecture: architecture || CD_METADATA.architecture,
94
+ };
95
+
96
+ this.eventBuffer = new NrVideoEventAggregator();
97
+
98
+ // Smart-harvest wiring: buffer triggers an early drain at 60% (smart) and
99
+ // 90% (overflow) capacity, before makeRoom()'s drop-oldest FIFO eviction
100
+ // would silently lose events.
101
+ this.eventBuffer.setSmartHarvestCallback((type, threshold) =>
102
+ this.triggerSmartHarvest(type, threshold)
103
+ );
104
+
105
+ this.dataToken = null;
106
+ this.isHarvesting = false;
107
+ this.isDisposed = false;
108
+ this._isFetchingToken = false; // guard: prevents parallel /v5/connect sequences
109
+
110
+ // Periodic harvest timer. Chained-setTimeout under the hood guarantees no overlapping ticks even if the
111
+ // drain takes longer than the interval.
112
+ this.timer = createHarvestTimer({
113
+ interval: this.harvestInterval,
114
+ onTick: () => this.sendBufferedEvents(),
115
+ errorLabel: "ConnectedDeviceHarvester",
116
+ });
117
+
118
+ // QoE state
119
+ this.qoeCycleCount = 1;
120
+ this.forceQoeNextCycle = false;
121
+ this.beforeDrainCallback = null;
122
+ this._lastSentQoeKpis = {};
123
+
124
+ // Connect retry state
125
+ this._connectAttempt = 0;
126
+
127
+ // fire-and-forget initialise.
128
+ this.initialise();
129
+ }
130
+
131
+ /**
132
+ * Returns the regional mobile collector base URL for the configured endpoint.
133
+ * Each region routes to its own datacenter — EU events terminate on EU
134
+ * infrastructure rather than transiting through the US host.
135
+ *
136
+ * @returns {string}
137
+ */
138
+ getEndpointBaseUrl() {
139
+ switch (this.endpoint) {
140
+ case NR_ENDPOINT.EU: return MOBILE_ENDPOINT_EU;
141
+ case NR_ENDPOINT.STAGING: return STAGING_MOBILE_ENDPOINT;
142
+ case NR_ENDPOINT.GOV: return MOBILE_ENDPOINT_GOV;
143
+ default: return MOBILE_ENDPOINT_US;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Two-phase init: fetch dataToken, then start harvest interval.
149
+ * @returns {Promise<void>}
150
+ */
151
+ async initialise() {
152
+ await this.fetchDataTokens();
153
+ // Defensive guard: if dispose() ran while fetchDataTokens was awaiting,
154
+ // do not start an interval that will outlive the harvester.
155
+ if (this.isDisposed) return;
156
+ if (this.dataToken) {
157
+ this.startHarvestInterval();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * POST `/v5/connect` to obtain a dataToken. Retries up to
163
+ * CD_CONNECT_MAX_ATTEMPTS times with a fixed CD_CONNECT_RETRY_DELAY_MS
164
+ * wait between attempts.
165
+ *
166
+ * Iterative loop keeps the call stack flat across all attempts.
167
+ * `_isFetchingToken` guard ensures only one connect sequence runs at a
168
+ * time — if `initialise()` (startup) and the 401-refresh path in
169
+ * `sendBufferedEvents` both call this concurrently, the second call
170
+ * returns immediately without issuing a duplicate POST.
171
+ *
172
+ * @returns {Promise<void>}
173
+ */
174
+ async fetchDataTokens() {
175
+ if (this._isFetchingToken) return;
176
+ this._isFetchingToken = true;
177
+
178
+ try {
179
+ for (let attempt = 0; attempt < CD_CONNECT_MAX_ATTEMPTS; attempt++) {
180
+ if (this.isDisposed) return;
181
+
182
+ const url = `${this.getEndpointBaseUrl()}/v5/connect`;
183
+ try {
184
+ Log.notice(
185
+ `ConnectedDeviceHarvester: POST ${url} (attempt ${attempt + 1}/${CD_CONNECT_MAX_ATTEMPTS})`
186
+ );
187
+
188
+ const res = await this._fetchWithTimeout(
189
+ url,
190
+ {
191
+ method: "POST",
192
+ headers: {
193
+ "Content-Type": "application/json",
194
+ "X-App-License-Key": this.applicationToken,
195
+ },
196
+ body: JSON.stringify(CD_DATA_TOKENS_PAYLOAD),
197
+ },
198
+ CD_CONNECT_TIMEOUT_MS
199
+ );
200
+
201
+ if (!res.ok) {
202
+ let errBody = "";
203
+ try { errBody = await res.text(); } catch (_) { /* ignore */ }
204
+ throw new Error(`HTTP ${res.status} body=${errBody}`);
205
+ }
206
+
207
+ const body = await res.json();
208
+ if (!body || !body.data_token) {
209
+ throw new Error("Missing data_token in connect response");
210
+ }
211
+
212
+ this.dataToken = body.data_token;
213
+ this._connectAttempt = attempt + 1;
214
+ Log.notice("ConnectedDeviceHarvester: dataToken acquired");
215
+ return;
216
+ } catch (err) {
217
+ this._connectAttempt = attempt + 1;
218
+ Log.error(
219
+ `ConnectedDeviceHarvester: /v5/connect failed (attempt ${this._connectAttempt}/${CD_CONNECT_MAX_ATTEMPTS}):`,
220
+ err.message
221
+ );
222
+
223
+ if (attempt >= CD_CONNECT_MAX_ATTEMPTS - 1) {
224
+ Log.error("ConnectedDeviceHarvester: Max retries reached — dataToken stays null");
225
+ return;
226
+ }
227
+
228
+ // Fixed delay gives the device network stack time to recover
229
+ // (e.g. after wake-from-sleep) before the next attempt.
230
+ await new Promise((resolve) => setTimeout(resolve, CD_CONNECT_RETRY_DELAY_MS));
231
+ }
232
+ }
233
+ } finally {
234
+ this._isFetchingToken = false;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Starts the periodic harvest timer.
240
+ * Idempotent — safe to call repeatedly.
241
+ */
242
+ startHarvestInterval() {
243
+ if (this.isDisposed) return;
244
+ this.timer.start();
245
+ }
246
+
247
+ /**
248
+ * Smart-harvest handler. Invoked by `NrVideoEventAggregator` when the buffer
249
+ * crosses 60% (`type='smart'`) or 90% (`type='overflow'`) of capacity, before
250
+ * `makeRoom()` would start FIFO-evicting events. Drains the buffer immediately
251
+ * and resets the periodic clock so the next scheduled tick fires
252
+ * `harvestInterval` after this drain completes.
253
+ *
254
+ * @param {'smart'|'overflow'} type
255
+ * @param {number} threshold - The threshold percentage that triggered the harvest (60 or 90).
256
+ * @returns {Promise<void>}
257
+ */
258
+ async triggerSmartHarvest(type, threshold) {
259
+ Log.notice(
260
+ `ConnectedDeviceHarvester: smart-harvest trigger (${type} at ${threshold}%)`
261
+ );
262
+ if (!this.eventBuffer || this.eventBuffer.isEmpty()) return;
263
+ try {
264
+ await this.sendBufferedEvents();
265
+ } catch (error) {
266
+ Log.error(`${type} smart-harvest failed:`, error.message);
267
+ } finally {
268
+ // Reset the periodic clock — next periodic tick happens `harvestInterval`
269
+ // after this smart drain, not from the originally-scheduled time.
270
+ this.timer.cancelAndReschedule();
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Buffers an event for the next harvest cycle.
276
+ *
277
+ * @param {object} eventObject
278
+ * @returns {boolean}
279
+ */
280
+ addEvent(eventObject) {
281
+ try {
282
+ // Shared QOE_AGGREGATE dedup + non-QoE append. Timestamp
283
+ // is preserved from `recordEvent.js` (emit-time), matching the Browser
284
+ // pipeline. Cross-pipeline analytics produce consistent timestamps.
285
+ return bufferEventWithQoeDedup(this.eventBuffer, eventObject);
286
+ } catch (err) {
287
+ Log.error("ConnectedDeviceHarvester.addEvent failed:", err.message);
288
+ return false;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Forces the next harvest cycle to ship QOE_AGGREGATE regardless of cycle
294
+ * multiplier or dirty check. Used at CONTENT_END for final QoE flush.
295
+ */
296
+ forceQoeNextHarvest() {
297
+ this.forceQoeNextCycle = true;
298
+ }
299
+
300
+ /**
301
+ * Registers a callback invoked at the start of every send cycle, before
302
+ * the buffer is drained. Lets the tracker refresh QoE KPIs in the buffer
303
+ * before they ship.
304
+ * @param {Function|null} cb
305
+ */
306
+ setBeforeDrainCallback(cb) {
307
+ if (typeof cb === "function" || cb === null) {
308
+ this.beforeDrainCallback = cb;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Updates QoE KPI fields on the existing QOE_AGGREGATE event in the buffer.
314
+ * Mirror of `VideoAnalyticsAgent#refreshQoeKpis` (`agent.js`) so VegaTracker's
315
+ * QoE drain wiring can route here through `getHarvester()`.
316
+ *
317
+ * @param {object} freshKpis - Object with latest KPI values
318
+ * @param {string} [viewId] - The viewId of the player whose QoE event to update
319
+ */
320
+ refreshQoeKpis(freshKpis, viewId) {
321
+ refreshQoeKpisInBuffer(this.eventBuffer, freshKpis, viewId);
322
+ }
323
+
324
+ /**
325
+ * Updates the harvest cadence at runtime. If the periodic timer is already
326
+ * running, it is cleared and restarted with the new interval. Mirror of
327
+ * `VideoAnalyticsAgent#setHarvestInterval` (`agent.js`).
328
+ *
329
+ * @param {number} interval - New cadence in ms.
330
+ */
331
+ setHarvestInterval(interval) {
332
+ if (typeof interval !== "number" || interval <= 0) return;
333
+ this.harvestInterval = interval;
334
+ this.timer.updateInterval(interval);
335
+ }
336
+
337
+ /**
338
+ * Drains the buffer and POSTs to `/v3/data`. Applies the QoE cycle filter
339
+ * and cross-cycle dirty check before send.
340
+ *
341
+ * Mirror of `harvestScheduler.js` drain + send logic, adapted for the CAF
342
+ * 10-tuple wire format and plain `fetch` transport.
343
+ *
344
+ *
345
+ * @returns {Promise<void>}
346
+ */
347
+ async sendBufferedEvents() {
348
+ if (this.isHarvesting) return;
349
+ if (!this.dataToken) return;
350
+ if (this.eventBuffer.isEmpty()) return;
351
+
352
+ // Refresh QoE KPIs before drain.
353
+ if (typeof this.beforeDrainCallback === "function") {
354
+ try {
355
+ this.beforeDrainCallback();
356
+ } catch (e) {
357
+ Log.error("ConnectedDeviceHarvester beforeDrainCallback failed:", e.message);
358
+ }
359
+ }
360
+
361
+ this.isHarvesting = true;
362
+ const drained = this.eventBuffer.drain();
363
+
364
+ // QoE cycle filter
365
+ const multiplier =
366
+ globalThis.__NRVIDEO_CD__?.config?.qoeIntervalFactor ?? 1;
367
+ const isForced = this.forceQoeNextCycle;
368
+ const isQoeCycle =
369
+ (this.qoeCycleCount - 1) % multiplier === 0 || isForced;
370
+ if (this.forceQoeNextCycle) this.forceQoeNextCycle = false;
371
+
372
+ const filtered = partitionByQoeCycle(drained, isQoeCycle, this.eventBuffer);
373
+ this.qoeCycleCount++;
374
+
375
+ // Cross-cycle dirty check
376
+ applyQoeDirtyFilter(filtered, this._lastSentQoeKpis, isForced);
377
+
378
+ if (filtered.length === 0) {
379
+ this.isHarvesting = false;
380
+ return;
381
+ }
382
+
383
+ const url = `${this.getEndpointBaseUrl()}/v3/data`;
384
+ const headers = {
385
+ "Content-Type": "application/json",
386
+ "X-App-License-Key": this.applicationToken,
387
+ };
388
+ // Build 10-tuple body. Retry can
389
+ // rebuild with a refreshed dataToken in slot [0]. Slots [1] and [8] are
390
+ // built from this.deviceInfo (customer-supplied where present, defaults
391
+ // from CD_DEVICE_INFO / CD_METADATA otherwise). Obfuscation rules (if any)
392
+ // are applied to the serialised payload before sending — same behaviour as
393
+ // the Browser pipeline's OptimizedHttpClient.
394
+ const buildBody = () => {
395
+ const raw = JSON.stringify([
396
+ this.dataToken, // [0] from /v5/connect
397
+ this._buildDeviceInfo(), // [1] device tuple
398
+ 0, // [2] reserved
399
+ [], // [3] reserved
400
+ [], // [4] reserved
401
+ [], // [5] reserved
402
+ [], // [6] reserved
403
+ [], // [7] reserved
404
+ this._buildMetadata(), // [8] session metadata
405
+ filtered, // [9] events
406
+ ]);
407
+ return applyObfuscationRules(raw, globalThis.__NRVIDEO_CD__?.config?.obfuscate);
408
+ };
409
+
410
+ try {
411
+ let res = await this._fetchWithTimeout(
412
+ url,
413
+ { method: "POST", headers, body: buildBody() },
414
+ CD_DATA_TIMEOUT_MS
415
+ );
416
+
417
+ // DataToken expiry. Re-queue the drained events and kick
418
+ // off a background reconnect rather than awaiting fetchDataTokens()
419
+ // inline. Awaiting inline would hold isHarvesting = true for up to
420
+ // CD_CONNECT_MAX_ATTEMPTS × CD_CONNECT_RETRY_DELAY_MS (20s), freezing
421
+ // all subsequent harvest ticks. Fire-and-forget releases the lock
422
+ // immediately; next ticks short-circuit on !dataToken until the new
423
+ // token arrives.
424
+ if (res.status === 401) {
425
+ Log.notice(
426
+ "ConnectedDeviceHarvester: /v3/data returned 401 — re-queuing events and reconnecting"
427
+ );
428
+ this.dataToken = null;
429
+ this._connectAttempt = 0;
430
+ for (const e of filtered) this.eventBuffer.add(e);
431
+ this.initialise(); // fire-and-forget background reconnect
432
+ return; // isHarvesting released in finally block
433
+ }
434
+
435
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
436
+ Log.notice(`ConnectedDeviceHarvester: sent ${filtered.length} events`);
437
+ } catch (err) {
438
+ Log.error("ConnectedDeviceHarvester: /v3/data send failed:", err.message);
439
+ // Re-queue drained events into buffer; next tick retries.
440
+ for (const e of filtered) this.eventBuffer.add(e);
441
+ } finally {
442
+ this.isHarvesting = false;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Build slot [1] of /v3/data — the 10-element device-info tuple. Customer-
448
+ * supplied fields override the static defaults from CD_DEVICE_INFO; missing
449
+ * or empty fields fall through to the placeholders that ship in
450
+ * connectedDeviceConstants.js.
451
+ * @returns {Array}
452
+ * @private
453
+ */
454
+ _buildDeviceInfo() {
455
+ const d = this.deviceInfo;
456
+ return [
457
+ CD_DEVICE_INFO[0], // osName — fixed ("Vega")
458
+ d.osVersion, // [1] osVersion
459
+ d.deviceModel, // [2] deviceModel
460
+ CD_DEVICE_INFO[3], // agentName — fixed ("VegaAgent")
461
+ CD_DEVICE_INFO[4], // agentVersion — fixed
462
+ d.uuid, // [5] deviceUuid
463
+ "", // [6] reserved
464
+ "", // [7] reserved
465
+ d.deviceManufacturer, // [8] manufacturer
466
+ CD_DEVICE_INFO[9], // [9] sizeMeta — fixed
467
+ ];
468
+ }
469
+
470
+ /**
471
+ * Build slot [8] of /v3/data — the session-metadata object. Spreads the
472
+ * static `CD_METADATA` defaults (`osName`, `platform`, `appBuild`) and
473
+ * overrides only `osBuild` and `architecture` from the customer-supplied
474
+ * deviceInfo. Device-identity fields (osVersion, deviceModel,
475
+ * deviceManufacturer, osMajorVersion, platformVersion) live in slot [1]
476
+ * (`CD_DEVICE_INFO`) and are intentionally not duplicated here.
477
+ * @returns {object}
478
+ * @private
479
+ */
480
+ _buildMetadata() {
481
+ const d = this.deviceInfo;
482
+ return {
483
+ ...CD_METADATA,
484
+ osBuild: d.osBuild,
485
+ appBuild: d.appBuild,
486
+ architecture: d.architecture,
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Wraps `fetch` with an AbortController deadline. If no response arrives
492
+ * within `timeoutMs`, the controller aborts the request and the returned
493
+ * Promise rejects with an AbortError. The caller's existing `catch` block
494
+ * handles it identically to any other network failure — connect retries for
495
+ * /v5/connect, event re-queue for /v3/data.
496
+ *
497
+ * `clearTimeout` in `.finally()` cancels the pending abort when the fetch
498
+ * completes before the deadline, so the abort never fires spuriously.
499
+ *
500
+ * @param {string} url
501
+ * @param {RequestInit} options
502
+ * @param {number} timeoutMs
503
+ * @returns {Promise<Response>}
504
+ * @private
505
+ */
506
+ _fetchWithTimeout(url, options, timeoutMs) {
507
+ const controller = new AbortController();
508
+ const timerId = setTimeout(() => controller.abort(), timeoutMs);
509
+ return fetch(url, { ...options, signal: controller.signal })
510
+ .finally(() => clearTimeout(timerId));
511
+ }
512
+
513
+ /**
514
+ * Stops the harvest interval and attempts one final best-effort send.
515
+ * @returns {Promise<void>}
516
+ */
517
+ async dispose() {
518
+ this.isDisposed = true;
519
+ this.timer.stop();
520
+ return this.sendBufferedEvents();
521
+ }
522
+ }
@@ -0,0 +1,49 @@
1
+ import Core from "../core";
2
+ import Constants from "../constants";
3
+ import Chrono from "../chrono";
4
+ import Log from "../log";
5
+ import Emitter from "../emitter";
6
+ import Tracker from "../tracker";
7
+ import VideoTracker from "../videotracker";
8
+ import VideoTrackerState from "../videotrackerstate";
9
+ import { NrVideoEventAggregator } from "../eventAggregator";
10
+ import ConnectedDeviceHarvester from "./connectedDeviceHarvester";
11
+ import { connectedDeviceAnalyticsHarvester } from "./connectedDeviceAgent";
12
+ import { getRegisteredHarvester } from "../recordEvent";
13
+ import { recordEvent } from "../recordEvent";
14
+ import { version } from "../../package.json";
15
+
16
+ const nrvideo = {
17
+ Constants,
18
+ Chrono,
19
+ Log,
20
+ Emitter,
21
+ Tracker,
22
+ VideoTracker,
23
+ VideoTrackerState,
24
+ Core,
25
+ version,
26
+
27
+ NrVideoEventAggregator,
28
+ ConnectedDeviceHarvester,
29
+ recordEvent,
30
+ };
31
+
32
+ export {
33
+ Core,
34
+ Constants,
35
+ Chrono,
36
+ Log,
37
+ Emitter,
38
+ Tracker,
39
+ VideoTracker,
40
+ VideoTrackerState,
41
+ NrVideoEventAggregator,
42
+ ConnectedDeviceHarvester,
43
+ connectedDeviceAnalyticsHarvester,
44
+ getRegisteredHarvester,
45
+ recordEvent,
46
+ version,
47
+ };
48
+
49
+ export default nrvideo;