@reproapp/node-sdk 0.0.41 → 0.0.44
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/README.md +4 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +47 -2
- package/dist/ingest/client.d.ts +1 -1
- package/dist/ingest/client.js +155 -8
- package/dist/privacy.d.ts +1 -0
- package/dist/privacy.js +7 -1
- package/package.json +2 -2
- package/src/index.ts +55 -2
- package/src/ingest/client.ts +186 -10
- package/src/privacy.ts +8 -1
- package/test/client-performance-regression.test.js +5 -0
- package/test/ingest-batching.test.js +67 -0
- package/test/privacy-service-scope.test.js +56 -0
- package/test/request-capture-background.test.js +6 -1
- package/test/runtime-service-registration.test.js +80 -0
package/README.md
CHANGED
|
@@ -194,10 +194,11 @@ type ReproInitConfig = {
|
|
|
194
194
|
```
|
|
195
195
|
|
|
196
196
|
Important options:
|
|
197
|
-
- `tenantId`, `appId`, `appSecret`: Repro
|
|
198
|
-
- `appName`:
|
|
197
|
+
- `tenantId`, `appId`, `appSecret`: Repro application credentials.
|
|
198
|
+
- `appName`: Application label sent as `X-App-Name`.
|
|
199
199
|
- `serviceName`: Backend service label stored on request, trace, DB, queue, Redis,
|
|
200
|
-
and email events.
|
|
200
|
+
and email events. The SDK also registers this service with the application in
|
|
201
|
+
the background so admins can see its environments and privacy policy status.
|
|
201
202
|
- `apiBase`: Repro API base URL. Used by backend live sessions and privacy policy fetches.
|
|
202
203
|
- `captureHeaders`: Enables header capture. Sensitive headers are masked unless
|
|
203
204
|
explicitly unmasked.
|
package/dist/index.d.ts
CHANGED
|
@@ -292,6 +292,7 @@ type BackendLiveTimingConfig = {
|
|
|
292
292
|
segmentMaxDurationMs: number;
|
|
293
293
|
segmentIdleMs: number;
|
|
294
294
|
};
|
|
295
|
+
declare function registerRuntimeService(cfg: ReproMiddlewareConfig): Promise<void>;
|
|
295
296
|
declare function sanitizeTraceValueForPrivacy(value: any): any;
|
|
296
297
|
declare function sanitizeMaterializedTraceValue(value: any): any;
|
|
297
298
|
export declare function __materializePendingTraceEventsForWorker(payload: TraceMaterializationWorkerPayload): Promise<PendingTraceEventRecord[]>;
|
|
@@ -458,6 +459,7 @@ export declare const __reproTestHooks: {
|
|
|
458
459
|
}): void;
|
|
459
460
|
drainSdkBackgroundQueueForTest(): Promise<void>;
|
|
460
461
|
resetSdkBackgroundQueuesForTest(): void;
|
|
462
|
+
registerRuntimeServiceForTest: typeof registerRuntimeService;
|
|
461
463
|
setBackendLiveTimingForTest(override: Partial<BackendLiveTimingConfig> | null): void;
|
|
462
464
|
runWithRequestContextForTest<T>(ctx: Partial<Ctx>, fn: () => T): T;
|
|
463
465
|
getNativeMongoSuppressionCountForTest(): number;
|
package/dist/index.js
CHANGED
|
@@ -841,7 +841,7 @@ function emitRequestCaptureStart(cfg, sessionId, actionId, scope, openedAtMs) {
|
|
|
841
841
|
},
|
|
842
842
|
t: openedAt,
|
|
843
843
|
}],
|
|
844
|
-
}, { priority: '
|
|
844
|
+
}, { priority: 'critical' });
|
|
845
845
|
}
|
|
846
846
|
const als = new async_hooks_1.AsyncLocalStorage();
|
|
847
847
|
const getCtx = () => als.getStore() || {};
|
|
@@ -985,7 +985,7 @@ const SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = (() => {
|
|
|
985
985
|
const SDK_BACKGROUND_MAX_DEFER_MS = (() => {
|
|
986
986
|
const raw = String(process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS ?? '').trim().toLowerCase();
|
|
987
987
|
if (!raw) {
|
|
988
|
-
return
|
|
988
|
+
return 30000;
|
|
989
989
|
}
|
|
990
990
|
if (raw === 'off' || raw === 'never' || raw === 'infinite' || raw === 'infinity' || raw === '-1') {
|
|
991
991
|
return Number.POSITIVE_INFINITY;
|
|
@@ -1813,6 +1813,47 @@ async function postLiveApiJson(cfg, liveMode, path, body) {
|
|
|
1813
1813
|
return null;
|
|
1814
1814
|
}
|
|
1815
1815
|
}
|
|
1816
|
+
async function registerRuntimeService(cfg) {
|
|
1817
|
+
const serviceName = typeof cfg.serviceName === 'string' ? cfg.serviceName.trim() : '';
|
|
1818
|
+
const apiBase = resolveLiveApiBase(cfg, normalizeLiveModeConfig(cfg));
|
|
1819
|
+
const fetchImpl = globalThis.fetch;
|
|
1820
|
+
if (!serviceName ||
|
|
1821
|
+
!apiBase ||
|
|
1822
|
+
!cfg.appId ||
|
|
1823
|
+
!cfg.appSecret ||
|
|
1824
|
+
typeof fetchImpl !== 'function') {
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1828
|
+
const timeout = setTimeout(() => controller?.abort(), 2500);
|
|
1829
|
+
try {
|
|
1830
|
+
timeout.unref?.();
|
|
1831
|
+
}
|
|
1832
|
+
catch { }
|
|
1833
|
+
try {
|
|
1834
|
+
await fetchImpl(`${apiBase}/v1/sdk/runtime-services`, {
|
|
1835
|
+
method: 'POST',
|
|
1836
|
+
headers: liveApiHeaders(cfg),
|
|
1837
|
+
body: JSON.stringify({
|
|
1838
|
+
serviceName,
|
|
1839
|
+
environment: (0, privacy_1.normalizePrivacyEnvironment)(cfg.privacy?.environment),
|
|
1840
|
+
}),
|
|
1841
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
catch {
|
|
1845
|
+
// Service discovery is best-effort and must never affect host startup.
|
|
1846
|
+
}
|
|
1847
|
+
finally {
|
|
1848
|
+
try {
|
|
1849
|
+
clearTimeout(timeout);
|
|
1850
|
+
}
|
|
1851
|
+
catch { }
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
function enqueueRuntimeServiceRegistration(cfg) {
|
|
1855
|
+
scheduleSdkBackgroundWork(() => registerRuntimeService(cfg));
|
|
1856
|
+
}
|
|
1816
1857
|
function enqueueLiveSessionStart(cfg, liveMode, payload) {
|
|
1817
1858
|
scheduleSdkBackgroundWork(() => postLiveApi(cfg, liveMode, '/v1/live-sessions', payload), { priority: 'high' });
|
|
1818
1859
|
}
|
|
@@ -3190,6 +3231,7 @@ async function ensureRuntimePrivacyPolicyLoaded(cfg) {
|
|
|
3190
3231
|
appId: cfg.appId,
|
|
3191
3232
|
appSecret: cfg.appSecret,
|
|
3192
3233
|
appName: cfg.appName,
|
|
3234
|
+
serviceName: cfg.serviceName,
|
|
3193
3235
|
apiBase,
|
|
3194
3236
|
environment: state.environment,
|
|
3195
3237
|
fetchTimeoutMs: privacy?.fetchTimeoutMs,
|
|
@@ -3231,6 +3273,7 @@ async function initializeRuntimePrivacyPolicyAtStartup(cfg) {
|
|
|
3231
3273
|
appId: cfg.appId,
|
|
3232
3274
|
appSecret: cfg.appSecret,
|
|
3233
3275
|
appName: cfg.appName,
|
|
3276
|
+
serviceName: cfg.serviceName,
|
|
3234
3277
|
apiBase,
|
|
3235
3278
|
environment: state.environment,
|
|
3236
3279
|
fetchTimeoutMs: privacy?.fetchTimeoutMs,
|
|
@@ -9835,6 +9878,7 @@ async function initRepro(cfg) {
|
|
|
9835
9878
|
await installMongoDbImportHook();
|
|
9836
9879
|
patchSendgridMail(integrationCfg);
|
|
9837
9880
|
patchMongooseIntegration(reproAutoMiddlewareConfig);
|
|
9881
|
+
enqueueRuntimeServiceRegistration(reproAutoMiddlewareConfig);
|
|
9838
9882
|
await initializeRuntimePrivacyPolicyAtStartup(reproAutoMiddlewareConfig);
|
|
9839
9883
|
}
|
|
9840
9884
|
exports.initRepro = initRepro;
|
|
@@ -9866,6 +9910,7 @@ exports.__reproTestHooks = {
|
|
|
9866
9910
|
lastClientActivityAt = 0;
|
|
9867
9911
|
sdkBackgroundQuietUntil = 0;
|
|
9868
9912
|
},
|
|
9913
|
+
registerRuntimeServiceForTest: registerRuntimeService,
|
|
9869
9914
|
setBackendLiveTimingForTest(override) {
|
|
9870
9915
|
backendLiveTimingOverrideForTest = override;
|
|
9871
9916
|
},
|
package/dist/ingest/client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IngestClientConfig, LegacyEntry } from './types';
|
|
2
2
|
export type EnqueueIngestOptions = {
|
|
3
|
-
priority?: 'normal' | 'high';
|
|
3
|
+
priority?: 'normal' | 'high' | 'critical';
|
|
4
4
|
};
|
|
5
5
|
export declare const configureIngestQueueDrain: (options: {
|
|
6
6
|
shouldDefer?: (() => boolean) | null;
|
package/dist/ingest/client.js
CHANGED
|
@@ -14,6 +14,22 @@ const DEFAULT_QUEUE_MAX_ITEMS = (() => {
|
|
|
14
14
|
const DEFAULT_QUEUE_MAX_ATTEMPTS = 5;
|
|
15
15
|
const DEFAULT_QUEUE_BACKOFF_MAX_MS = 4000;
|
|
16
16
|
const DEFAULT_DEFER_DRAIN_MS = 25;
|
|
17
|
+
const DEFAULT_BATCH_MAX_ENTRIES = (() => {
|
|
18
|
+
const env = Number(typeof process !== 'undefined' ? process?.env?.REPRO_INGEST_BATCH_MAX_ENTRIES : undefined);
|
|
19
|
+
if (Number.isFinite(env) && env > 0) {
|
|
20
|
+
return Math.trunc(env);
|
|
21
|
+
}
|
|
22
|
+
return 100;
|
|
23
|
+
})();
|
|
24
|
+
const DEFAULT_BATCH_MAX_SERIALIZED_BYTES = (() => {
|
|
25
|
+
const env = Number(typeof process !== 'undefined'
|
|
26
|
+
? process?.env?.REPRO_INGEST_BATCH_MAX_SERIALIZED_BYTES
|
|
27
|
+
: undefined);
|
|
28
|
+
if (Number.isFinite(env) && env > 0) {
|
|
29
|
+
return Math.trunc(env);
|
|
30
|
+
}
|
|
31
|
+
return 1024 * 1024;
|
|
32
|
+
})();
|
|
17
33
|
const DEFAULT_INGEST_REQUEST_TIMEOUT_MS = (() => {
|
|
18
34
|
const env = Number(typeof process !== 'undefined' ? process?.env?.REPRO_INGEST_REQUEST_TIMEOUT_MS : undefined);
|
|
19
35
|
if (Number.isFinite(env) && env >= 0) {
|
|
@@ -22,9 +38,13 @@ const DEFAULT_INGEST_REQUEST_TIMEOUT_MS = (() => {
|
|
|
22
38
|
return 120000;
|
|
23
39
|
})();
|
|
24
40
|
const ingestQueue = [];
|
|
41
|
+
const criticalIngestQueue = [];
|
|
25
42
|
let drainTimer = null;
|
|
43
|
+
let criticalDrainTimer = null;
|
|
26
44
|
let draining = false;
|
|
45
|
+
let criticalDraining = false;
|
|
27
46
|
let backoffMs = 0;
|
|
47
|
+
let criticalBackoffMs = 0;
|
|
28
48
|
let shouldDeferDrain = null;
|
|
29
49
|
let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
|
|
30
50
|
const configureIngestQueueDrain = (options) => {
|
|
@@ -35,12 +55,15 @@ const configureIngestQueueDrain = (options) => {
|
|
|
35
55
|
};
|
|
36
56
|
exports.configureIngestQueueDrain = configureIngestQueueDrain;
|
|
37
57
|
const kickIngestQueueDrain = () => {
|
|
58
|
+
if (criticalIngestQueue.length > 0 && !criticalDrainTimer) {
|
|
59
|
+
scheduleCriticalDrain();
|
|
60
|
+
}
|
|
38
61
|
if (ingestQueue.length > 0 && !drainTimer) {
|
|
39
62
|
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
40
63
|
}
|
|
41
64
|
};
|
|
42
65
|
exports.kickIngestQueueDrain = kickIngestQueueDrain;
|
|
43
|
-
const
|
|
66
|
+
const isHighPriorityIngestEntry = (entry) => (entry.requestCaptureStart !== undefined ||
|
|
44
67
|
entry.requestCaptureManifest !== undefined ||
|
|
45
68
|
entry.sessionCaptureManifest !== undefined ||
|
|
46
69
|
entry.request !== undefined ||
|
|
@@ -48,10 +71,72 @@ const isCriticalIngestEntry = (entry) => (entry.requestCaptureStart !== undefine
|
|
|
48
71
|
entry.trace !== undefined ||
|
|
49
72
|
entry.email !== undefined);
|
|
50
73
|
const resolveBatchPriority = (entries, options) => {
|
|
51
|
-
if (options.priority === 'high') {
|
|
52
|
-
return
|
|
74
|
+
if (options.priority === 'high' || options.priority === 'critical') {
|
|
75
|
+
return options.priority;
|
|
76
|
+
}
|
|
77
|
+
return entries.some(isHighPriorityIngestEntry) ? 'high' : 'normal';
|
|
78
|
+
};
|
|
79
|
+
const sameIngestDestination = (left, right) => (left.sessionId === right.sessionId &&
|
|
80
|
+
left.priority === right.priority &&
|
|
81
|
+
left.config.appId === right.config.appId &&
|
|
82
|
+
left.config.tenantId === right.config.tenantId &&
|
|
83
|
+
left.config.appSecret === right.config.appSecret &&
|
|
84
|
+
left.config.appName === right.config.appName &&
|
|
85
|
+
left.config.serviceName === right.config.serviceName &&
|
|
86
|
+
left.config.ingestBase === right.config.ingestBase &&
|
|
87
|
+
left.config.ingestPath === right.config.ingestPath &&
|
|
88
|
+
left.config.actorId === right.config.actorId &&
|
|
89
|
+
left.config.actorType === right.config.actorType &&
|
|
90
|
+
JSON.stringify(left.config.actorLabels ?? null) ===
|
|
91
|
+
JSON.stringify(right.config.actorLabels ?? null) &&
|
|
92
|
+
left.config.source === right.config.source &&
|
|
93
|
+
left.config.schemaVersion === right.config.schemaVersion);
|
|
94
|
+
const serializedBytes = (value) => {
|
|
95
|
+
try {
|
|
96
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return DEFAULT_BATCH_MAX_SERIALIZED_BYTES + 1;
|
|
53
100
|
}
|
|
54
|
-
|
|
101
|
+
};
|
|
102
|
+
const selectSendBatch = (queue) => {
|
|
103
|
+
const first = queue[0];
|
|
104
|
+
if (!first) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (first.priority === 'normal') {
|
|
108
|
+
return { batch: first, queueItems: 1 };
|
|
109
|
+
}
|
|
110
|
+
const entries = first.entries.slice();
|
|
111
|
+
let bytes = serializedBytes(entries);
|
|
112
|
+
let queueItems = 1;
|
|
113
|
+
for (let index = 1; index < queue.length && entries.length < DEFAULT_BATCH_MAX_ENTRIES; index += 1) {
|
|
114
|
+
const candidate = queue[index];
|
|
115
|
+
if (!candidate || !sameIngestDestination(first, candidate)) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (entries.length + candidate.entries.length > DEFAULT_BATCH_MAX_ENTRIES) {
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
const candidateBytes = serializedBytes(candidate.entries);
|
|
122
|
+
if (bytes + candidateBytes > DEFAULT_BATCH_MAX_SERIALIZED_BYTES) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
entries.push(...candidate.entries);
|
|
126
|
+
bytes += candidateBytes;
|
|
127
|
+
queueItems += 1;
|
|
128
|
+
}
|
|
129
|
+
if (queueItems === 1) {
|
|
130
|
+
return { batch: first, queueItems };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
batch: {
|
|
134
|
+
...first,
|
|
135
|
+
entries,
|
|
136
|
+
attempts: Math.max(...queue.slice(0, queueItems).map((item) => item.attempts)),
|
|
137
|
+
},
|
|
138
|
+
queueItems,
|
|
139
|
+
};
|
|
55
140
|
};
|
|
56
141
|
const resolveIngestUrl = (config) => {
|
|
57
142
|
const envBase = typeof process !== 'undefined' ? process?.env?.REPRO_INGEST_BASE : undefined;
|
|
@@ -144,6 +229,51 @@ const scheduleDrain = (delayMs = 0) => {
|
|
|
144
229
|
}
|
|
145
230
|
catch { }
|
|
146
231
|
};
|
|
232
|
+
const scheduleCriticalDrain = (delayMs = 0) => {
|
|
233
|
+
if (criticalDrainTimer) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
criticalDrainTimer = setTimeout(() => {
|
|
237
|
+
criticalDrainTimer = null;
|
|
238
|
+
void drainCriticalIngestQueue();
|
|
239
|
+
}, Math.max(0, delayMs));
|
|
240
|
+
try {
|
|
241
|
+
criticalDrainTimer.unref?.();
|
|
242
|
+
}
|
|
243
|
+
catch { }
|
|
244
|
+
};
|
|
245
|
+
const drainCriticalIngestQueue = async () => {
|
|
246
|
+
if (criticalDraining) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
criticalDraining = true;
|
|
250
|
+
try {
|
|
251
|
+
while (criticalIngestQueue.length > 0) {
|
|
252
|
+
const selected = selectSendBatch(criticalIngestQueue);
|
|
253
|
+
if (!selected) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const ok = await sendIngestEntries(selected.batch.config, selected.batch.sessionId, selected.batch.entries);
|
|
257
|
+
if (ok) {
|
|
258
|
+
criticalIngestQueue.splice(0, selected.queueItems);
|
|
259
|
+
criticalBackoffMs = 0;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
for (let index = 0; index < selected.queueItems; index += 1) {
|
|
263
|
+
criticalIngestQueue[index].attempts += 1;
|
|
264
|
+
}
|
|
265
|
+
criticalBackoffMs = Math.min(DEFAULT_QUEUE_BACKOFF_MAX_MS, criticalBackoffMs ? criticalBackoffMs * 2 : 250);
|
|
266
|
+
scheduleCriticalDrain(criticalBackoffMs);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
criticalDraining = false;
|
|
272
|
+
if (criticalIngestQueue.length > 0 && !criticalDrainTimer) {
|
|
273
|
+
scheduleCriticalDrain(criticalBackoffMs);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
147
277
|
const drainIngestQueue = async () => {
|
|
148
278
|
if (draining) {
|
|
149
279
|
return;
|
|
@@ -159,15 +289,22 @@ const drainIngestQueue = async () => {
|
|
|
159
289
|
scheduleDrain(deferDrainMs);
|
|
160
290
|
return;
|
|
161
291
|
}
|
|
162
|
-
const
|
|
292
|
+
const selected = selectSendBatch(ingestQueue);
|
|
293
|
+
if (!selected) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const batch = selected.batch;
|
|
163
297
|
const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
|
|
164
298
|
if (ok) {
|
|
165
|
-
ingestQueue.
|
|
299
|
+
ingestQueue.splice(0, selected.queueItems);
|
|
166
300
|
backoffMs = 0;
|
|
167
301
|
continue;
|
|
168
302
|
}
|
|
169
|
-
|
|
170
|
-
|
|
303
|
+
for (let index = 0; index < selected.queueItems; index += 1) {
|
|
304
|
+
ingestQueue[index].attempts += 1;
|
|
305
|
+
}
|
|
306
|
+
if (ingestQueue[0].attempts >= DEFAULT_QUEUE_MAX_ATTEMPTS &&
|
|
307
|
+
ingestQueue[0].priority === 'normal') {
|
|
171
308
|
ingestQueue.shift();
|
|
172
309
|
backoffMs = 0;
|
|
173
310
|
continue;
|
|
@@ -196,6 +333,11 @@ const enqueueIngestEntries = (config, sessionId, entries, options = {}) => {
|
|
|
196
333
|
attempts: 0,
|
|
197
334
|
priority,
|
|
198
335
|
};
|
|
336
|
+
if (priority === 'critical') {
|
|
337
|
+
criticalIngestQueue.push(batch);
|
|
338
|
+
scheduleCriticalDrain();
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
199
341
|
if (priority === 'high') {
|
|
200
342
|
const firstNormalIndex = ingestQueue.findIndex((queued) => queued.priority !== 'high');
|
|
201
343
|
if (firstNormalIndex === -1) {
|
|
@@ -228,10 +370,15 @@ const enqueueIngestEntries = (config, sessionId, entries, options = {}) => {
|
|
|
228
370
|
};
|
|
229
371
|
exports.enqueueIngestEntries = enqueueIngestEntries;
|
|
230
372
|
const flushIngestQueue = async () => {
|
|
373
|
+
if (criticalDrainTimer) {
|
|
374
|
+
clearTimeout(criticalDrainTimer);
|
|
375
|
+
criticalDrainTimer = null;
|
|
376
|
+
}
|
|
231
377
|
if (drainTimer) {
|
|
232
378
|
clearTimeout(drainTimer);
|
|
233
379
|
drainTimer = null;
|
|
234
380
|
}
|
|
381
|
+
await drainCriticalIngestQueue();
|
|
235
382
|
await drainIngestQueue();
|
|
236
383
|
};
|
|
237
384
|
exports.flushIngestQueue = flushIngestQueue;
|
package/dist/privacy.d.ts
CHANGED
package/dist/privacy.js
CHANGED
|
@@ -104,7 +104,13 @@ async function fetchApprovedRuntimePrivacyPolicy(params) {
|
|
|
104
104
|
reason: !params.apiBase ? 'missing_config' : 'missing_credentials',
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
const
|
|
107
|
+
const query = new URLSearchParams({
|
|
108
|
+
environment: params.environment,
|
|
109
|
+
});
|
|
110
|
+
if (typeof params.serviceName === 'string' && params.serviceName.trim()) {
|
|
111
|
+
query.set('serviceName', params.serviceName.trim());
|
|
112
|
+
}
|
|
113
|
+
const url = `${params.apiBase}/v1/sdk/privacy-policy?${query.toString()}`;
|
|
108
114
|
const headers = {
|
|
109
115
|
accept: 'application/json',
|
|
110
116
|
'x-app-id': params.appId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reproapp/node-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.44",
|
|
4
4
|
"description": "Repro Nest SDK",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"build": "tsc -p tsconfig.json",
|
|
13
13
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
14
14
|
"prepublishOnly": "npm run build",
|
|
15
|
-
"test": "npm run build && node test/unawaited.test.js && node test/integration-unawaited.js && node test/request-flush-timing.test.js && node test/request-capture-background.test.js && node test/init-auto-http-server.test.js && node test/backend-live-mode.test.js && node test/client-performance-regression.test.js && node test/request-manifest-priority.test.js && node test/runtime-no-double-invoke.test.js && node test/express-trace-http-args.test.js && node test/trace-batch-size.test.js && node test/sdk-background-priority.test.js && node test/ingest-queue-retention.test.js && node test/ingest-high-priority-retention.test.js && node test/ingest-critical-retention.test.js && node test/ingest-critical-retry-retention.test.js && node test/ingest-timeout.test.js && node -r ./tracer/register test/promise-map.test.js && node test/disable-subtree.test.js && node test/circular-capture.test.js && node test/wrap-plugin-arrow-args.test.js && node test/wrap-plugin-super-call.test.js && node test/function-arg-identity.test.js && node test/privacy-runtime-policy.test.js && node test/runtime-privacy-materialization.test.js && node test/kafka-runtime-privacy-policy.test.js && node test/amqplib-integration.test.js && node test/mongodb-integration.test.js && node test/mongodb-esm-import-hook.test.js && node test/mongodb-suppression-cleanup.test.js && node test/postgresql-integration.test.js",
|
|
15
|
+
"test": "npm run build && node test/unawaited.test.js && node test/integration-unawaited.js && node test/request-flush-timing.test.js && node test/request-capture-background.test.js && node test/init-auto-http-server.test.js && node test/backend-live-mode.test.js && node test/client-performance-regression.test.js && node test/request-manifest-priority.test.js && node test/runtime-no-double-invoke.test.js && node test/express-trace-http-args.test.js && node test/trace-batch-size.test.js && node test/sdk-background-priority.test.js && node test/runtime-service-registration.test.js && node test/ingest-queue-retention.test.js && node test/ingest-high-priority-retention.test.js && node test/ingest-critical-retention.test.js && node test/ingest-critical-retry-retention.test.js && node test/ingest-batching.test.js && node test/ingest-timeout.test.js && node -r ./tracer/register test/promise-map.test.js && node test/disable-subtree.test.js && node test/circular-capture.test.js && node test/wrap-plugin-arrow-args.test.js && node test/wrap-plugin-super-call.test.js && node test/function-arg-identity.test.js && node test/privacy-service-scope.test.js && node test/privacy-runtime-policy.test.js && node test/runtime-privacy-materialization.test.js && node test/kafka-runtime-privacy-policy.test.js && node test/amqplib-integration.test.js && node test/mongodb-integration.test.js && node test/mongodb-esm-import-hook.test.js && node test/mongodb-suppression-cleanup.test.js && node test/postgresql-integration.test.js",
|
|
16
16
|
"test:containers": "npm run build && node test/runtime-no-double-invoke.testcontainers.test.js && node test/wrap-plugin-super-call.testcontainers.test.js && node test/function-arg-identity.testcontainers.test.js && node test/mongodb-mongoose-native-suppression.testcontainers.test.js && node test/postgresql-integrations.testcontainers.test.js"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -1249,7 +1249,7 @@ function emitRequestCaptureStart(
|
|
|
1249
1249
|
},
|
|
1250
1250
|
t: openedAt,
|
|
1251
1251
|
}],
|
|
1252
|
-
}, { priority: '
|
|
1252
|
+
}, { priority: 'critical' });
|
|
1253
1253
|
}
|
|
1254
1254
|
|
|
1255
1255
|
const als = new AsyncLocalStorage<Ctx>();
|
|
@@ -1393,7 +1393,7 @@ const SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = (() => {
|
|
|
1393
1393
|
const SDK_BACKGROUND_MAX_DEFER_MS = (() => {
|
|
1394
1394
|
const raw = String(process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS ?? '').trim().toLowerCase();
|
|
1395
1395
|
if (!raw) {
|
|
1396
|
-
return
|
|
1396
|
+
return 30000;
|
|
1397
1397
|
}
|
|
1398
1398
|
if (raw === 'off' || raw === 'never' || raw === 'infinite' || raw === 'infinity' || raw === '-1') {
|
|
1399
1399
|
return Number.POSITIVE_INFINITY;
|
|
@@ -2250,6 +2250,55 @@ async function postLiveApiJson<T = Record<string, any>>(
|
|
|
2250
2250
|
}
|
|
2251
2251
|
}
|
|
2252
2252
|
|
|
2253
|
+
async function registerRuntimeService(
|
|
2254
|
+
cfg: ReproMiddlewareConfig,
|
|
2255
|
+
): Promise<void> {
|
|
2256
|
+
const serviceName =
|
|
2257
|
+
typeof cfg.serviceName === 'string' ? cfg.serviceName.trim() : '';
|
|
2258
|
+
const apiBase = resolveLiveApiBase(cfg, normalizeLiveModeConfig(cfg));
|
|
2259
|
+
const fetchImpl = (globalThis as any).fetch;
|
|
2260
|
+
if (
|
|
2261
|
+
!serviceName ||
|
|
2262
|
+
!apiBase ||
|
|
2263
|
+
!cfg.appId ||
|
|
2264
|
+
!cfg.appSecret ||
|
|
2265
|
+
typeof fetchImpl !== 'function'
|
|
2266
|
+
) {
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
const controller =
|
|
2271
|
+
typeof AbortController === 'function' ? new AbortController() : null;
|
|
2272
|
+
const timeout = setTimeout(() => controller?.abort(), 2_500);
|
|
2273
|
+
try {
|
|
2274
|
+
timeout.unref?.();
|
|
2275
|
+
} catch {}
|
|
2276
|
+
|
|
2277
|
+
try {
|
|
2278
|
+
await fetchImpl(`${apiBase}/v1/sdk/runtime-services`, {
|
|
2279
|
+
method: 'POST',
|
|
2280
|
+
headers: liveApiHeaders(cfg),
|
|
2281
|
+
body: JSON.stringify({
|
|
2282
|
+
serviceName,
|
|
2283
|
+
environment: normalizePrivacyEnvironment(
|
|
2284
|
+
cfg.privacy?.environment,
|
|
2285
|
+
),
|
|
2286
|
+
}),
|
|
2287
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
2288
|
+
});
|
|
2289
|
+
} catch {
|
|
2290
|
+
// Service discovery is best-effort and must never affect host startup.
|
|
2291
|
+
} finally {
|
|
2292
|
+
try {
|
|
2293
|
+
clearTimeout(timeout);
|
|
2294
|
+
} catch {}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function enqueueRuntimeServiceRegistration(cfg: ReproMiddlewareConfig): void {
|
|
2299
|
+
scheduleSdkBackgroundWork(() => registerRuntimeService(cfg));
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2253
2302
|
function enqueueLiveSessionStart(
|
|
2254
2303
|
cfg: ReproMiddlewareConfig,
|
|
2255
2304
|
liveMode: ReproLiveModeConfig | null,
|
|
@@ -3844,6 +3893,7 @@ async function ensureRuntimePrivacyPolicyLoaded(cfg: ReproMiddlewareConfig): Pro
|
|
|
3844
3893
|
appId: cfg.appId,
|
|
3845
3894
|
appSecret: cfg.appSecret,
|
|
3846
3895
|
appName: cfg.appName,
|
|
3896
|
+
serviceName: cfg.serviceName,
|
|
3847
3897
|
apiBase,
|
|
3848
3898
|
environment: state.environment,
|
|
3849
3899
|
fetchTimeoutMs: privacy?.fetchTimeoutMs,
|
|
@@ -3894,6 +3944,7 @@ async function initializeRuntimePrivacyPolicyAtStartup(cfg: ReproMiddlewareConfi
|
|
|
3894
3944
|
appId: cfg.appId,
|
|
3895
3945
|
appSecret: cfg.appSecret,
|
|
3896
3946
|
appName: cfg.appName,
|
|
3947
|
+
serviceName: cfg.serviceName,
|
|
3897
3948
|
apiBase,
|
|
3898
3949
|
environment: state.environment,
|
|
3899
3950
|
fetchTimeoutMs: privacy?.fetchTimeoutMs,
|
|
@@ -11365,6 +11416,7 @@ export async function initRepro(cfg: ReproInitConfig): Promise<void> {
|
|
|
11365
11416
|
patchSendgridMail(integrationCfg);
|
|
11366
11417
|
patchMongooseIntegration(reproAutoMiddlewareConfig);
|
|
11367
11418
|
|
|
11419
|
+
enqueueRuntimeServiceRegistration(reproAutoMiddlewareConfig);
|
|
11368
11420
|
await initializeRuntimePrivacyPolicyAtStartup(reproAutoMiddlewareConfig);
|
|
11369
11421
|
}
|
|
11370
11422
|
|
|
@@ -11396,6 +11448,7 @@ export const __reproTestHooks = {
|
|
|
11396
11448
|
lastClientActivityAt = 0;
|
|
11397
11449
|
sdkBackgroundQuietUntil = 0;
|
|
11398
11450
|
},
|
|
11451
|
+
registerRuntimeServiceForTest: registerRuntimeService,
|
|
11399
11452
|
setBackendLiveTimingForTest(override: Partial<BackendLiveTimingConfig> | null): void {
|
|
11400
11453
|
backendLiveTimingOverrideForTest = override;
|
|
11401
11454
|
},
|
package/src/ingest/client.ts
CHANGED
|
@@ -13,6 +13,24 @@ const DEFAULT_QUEUE_MAX_ITEMS = (() => {
|
|
|
13
13
|
const DEFAULT_QUEUE_MAX_ATTEMPTS = 5;
|
|
14
14
|
const DEFAULT_QUEUE_BACKOFF_MAX_MS = 4000;
|
|
15
15
|
const DEFAULT_DEFER_DRAIN_MS = 25;
|
|
16
|
+
const DEFAULT_BATCH_MAX_ENTRIES = (() => {
|
|
17
|
+
const env = Number(typeof process !== 'undefined' ? (process as any)?.env?.REPRO_INGEST_BATCH_MAX_ENTRIES : undefined);
|
|
18
|
+
if (Number.isFinite(env) && env > 0) {
|
|
19
|
+
return Math.trunc(env);
|
|
20
|
+
}
|
|
21
|
+
return 100;
|
|
22
|
+
})();
|
|
23
|
+
const DEFAULT_BATCH_MAX_SERIALIZED_BYTES = (() => {
|
|
24
|
+
const env = Number(
|
|
25
|
+
typeof process !== 'undefined'
|
|
26
|
+
? (process as any)?.env?.REPRO_INGEST_BATCH_MAX_SERIALIZED_BYTES
|
|
27
|
+
: undefined,
|
|
28
|
+
);
|
|
29
|
+
if (Number.isFinite(env) && env > 0) {
|
|
30
|
+
return Math.trunc(env);
|
|
31
|
+
}
|
|
32
|
+
return 1024 * 1024;
|
|
33
|
+
})();
|
|
16
34
|
const DEFAULT_INGEST_REQUEST_TIMEOUT_MS = (() => {
|
|
17
35
|
const env = Number(typeof process !== 'undefined' ? (process as any)?.env?.REPRO_INGEST_REQUEST_TIMEOUT_MS : undefined);
|
|
18
36
|
if (Number.isFinite(env) && env >= 0) {
|
|
@@ -26,17 +44,21 @@ type QueuedIngestBatch = {
|
|
|
26
44
|
sessionId: string;
|
|
27
45
|
entries: LegacyEntry[];
|
|
28
46
|
attempts: number;
|
|
29
|
-
priority: 'normal' | 'high';
|
|
47
|
+
priority: 'normal' | 'high' | 'critical';
|
|
30
48
|
};
|
|
31
49
|
|
|
32
50
|
export type EnqueueIngestOptions = {
|
|
33
|
-
priority?: 'normal' | 'high';
|
|
51
|
+
priority?: 'normal' | 'high' | 'critical';
|
|
34
52
|
};
|
|
35
53
|
|
|
36
54
|
const ingestQueue: QueuedIngestBatch[] = [];
|
|
55
|
+
const criticalIngestQueue: QueuedIngestBatch[] = [];
|
|
37
56
|
let drainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
let criticalDrainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
58
|
let draining = false;
|
|
59
|
+
let criticalDraining = false;
|
|
39
60
|
let backoffMs = 0;
|
|
61
|
+
let criticalBackoffMs = 0;
|
|
40
62
|
let shouldDeferDrain: (() => boolean) | null = null;
|
|
41
63
|
let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
|
|
42
64
|
|
|
@@ -51,12 +73,15 @@ export const configureIngestQueueDrain = (options: {
|
|
|
51
73
|
};
|
|
52
74
|
|
|
53
75
|
export const kickIngestQueueDrain = (): void => {
|
|
76
|
+
if (criticalIngestQueue.length > 0 && !criticalDrainTimer) {
|
|
77
|
+
scheduleCriticalDrain();
|
|
78
|
+
}
|
|
54
79
|
if (ingestQueue.length > 0 && !drainTimer) {
|
|
55
80
|
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
56
81
|
}
|
|
57
82
|
};
|
|
58
83
|
|
|
59
|
-
const
|
|
84
|
+
const isHighPriorityIngestEntry = (entry: LegacyEntry): boolean => (
|
|
60
85
|
entry.requestCaptureStart !== undefined ||
|
|
61
86
|
entry.requestCaptureManifest !== undefined ||
|
|
62
87
|
entry.sessionCaptureManifest !== undefined ||
|
|
@@ -70,10 +95,89 @@ const resolveBatchPriority = (
|
|
|
70
95
|
entries: LegacyEntry[],
|
|
71
96
|
options: EnqueueIngestOptions,
|
|
72
97
|
): QueuedIngestBatch['priority'] => {
|
|
73
|
-
if (options.priority === 'high') {
|
|
74
|
-
return
|
|
98
|
+
if (options.priority === 'high' || options.priority === 'critical') {
|
|
99
|
+
return options.priority;
|
|
100
|
+
}
|
|
101
|
+
return entries.some(isHighPriorityIngestEntry) ? 'high' : 'normal';
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const sameIngestDestination = (
|
|
105
|
+
left: QueuedIngestBatch,
|
|
106
|
+
right: QueuedIngestBatch,
|
|
107
|
+
): boolean => (
|
|
108
|
+
left.sessionId === right.sessionId &&
|
|
109
|
+
left.priority === right.priority &&
|
|
110
|
+
left.config.appId === right.config.appId &&
|
|
111
|
+
left.config.tenantId === right.config.tenantId &&
|
|
112
|
+
left.config.appSecret === right.config.appSecret &&
|
|
113
|
+
left.config.appName === right.config.appName &&
|
|
114
|
+
left.config.serviceName === right.config.serviceName &&
|
|
115
|
+
left.config.ingestBase === right.config.ingestBase &&
|
|
116
|
+
left.config.ingestPath === right.config.ingestPath &&
|
|
117
|
+
left.config.actorId === right.config.actorId &&
|
|
118
|
+
left.config.actorType === right.config.actorType &&
|
|
119
|
+
JSON.stringify(left.config.actorLabels ?? null) ===
|
|
120
|
+
JSON.stringify(right.config.actorLabels ?? null) &&
|
|
121
|
+
left.config.source === right.config.source &&
|
|
122
|
+
left.config.schemaVersion === right.config.schemaVersion
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const serializedBytes = (value: unknown): number => {
|
|
126
|
+
try {
|
|
127
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
128
|
+
} catch {
|
|
129
|
+
return DEFAULT_BATCH_MAX_SERIALIZED_BYTES + 1;
|
|
75
130
|
}
|
|
76
|
-
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const selectSendBatch = (
|
|
134
|
+
queue: QueuedIngestBatch[],
|
|
135
|
+
): { batch: QueuedIngestBatch; queueItems: number } | null => {
|
|
136
|
+
const first = queue[0];
|
|
137
|
+
if (!first) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (first.priority === 'normal') {
|
|
141
|
+
return { batch: first, queueItems: 1 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const entries = first.entries.slice();
|
|
145
|
+
let bytes = serializedBytes(entries);
|
|
146
|
+
let queueItems = 1;
|
|
147
|
+
for (
|
|
148
|
+
let index = 1;
|
|
149
|
+
index < queue.length && entries.length < DEFAULT_BATCH_MAX_ENTRIES;
|
|
150
|
+
index += 1
|
|
151
|
+
) {
|
|
152
|
+
const candidate = queue[index];
|
|
153
|
+
if (!candidate || !sameIngestDestination(first, candidate)) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (entries.length + candidate.entries.length > DEFAULT_BATCH_MAX_ENTRIES) {
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
const candidateBytes = serializedBytes(candidate.entries);
|
|
160
|
+
if (bytes + candidateBytes > DEFAULT_BATCH_MAX_SERIALIZED_BYTES) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
entries.push(...candidate.entries);
|
|
164
|
+
bytes += candidateBytes;
|
|
165
|
+
queueItems += 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (queueItems === 1) {
|
|
169
|
+
return { batch: first, queueItems };
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
batch: {
|
|
173
|
+
...first,
|
|
174
|
+
entries,
|
|
175
|
+
attempts: Math.max(
|
|
176
|
+
...queue.slice(0, queueItems).map((item) => item.attempts),
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
queueItems,
|
|
180
|
+
};
|
|
77
181
|
};
|
|
78
182
|
|
|
79
183
|
const resolveIngestUrl = (config: IngestClientConfig): string => {
|
|
@@ -181,6 +285,59 @@ const scheduleDrain = (delayMs = 0): void => {
|
|
|
181
285
|
} catch {}
|
|
182
286
|
};
|
|
183
287
|
|
|
288
|
+
const scheduleCriticalDrain = (delayMs = 0): void => {
|
|
289
|
+
if (criticalDrainTimer) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
criticalDrainTimer = setTimeout(() => {
|
|
293
|
+
criticalDrainTimer = null;
|
|
294
|
+
void drainCriticalIngestQueue();
|
|
295
|
+
}, Math.max(0, delayMs));
|
|
296
|
+
try {
|
|
297
|
+
(criticalDrainTimer as any).unref?.();
|
|
298
|
+
} catch {}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const drainCriticalIngestQueue = async (): Promise<void> => {
|
|
302
|
+
if (criticalDraining) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
criticalDraining = true;
|
|
306
|
+
try {
|
|
307
|
+
while (criticalIngestQueue.length > 0) {
|
|
308
|
+
const selected = selectSendBatch(criticalIngestQueue);
|
|
309
|
+
if (!selected) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const ok = await sendIngestEntries(
|
|
313
|
+
selected.batch.config,
|
|
314
|
+
selected.batch.sessionId,
|
|
315
|
+
selected.batch.entries,
|
|
316
|
+
);
|
|
317
|
+
if (ok) {
|
|
318
|
+
criticalIngestQueue.splice(0, selected.queueItems);
|
|
319
|
+
criticalBackoffMs = 0;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (let index = 0; index < selected.queueItems; index += 1) {
|
|
324
|
+
criticalIngestQueue[index].attempts += 1;
|
|
325
|
+
}
|
|
326
|
+
criticalBackoffMs = Math.min(
|
|
327
|
+
DEFAULT_QUEUE_BACKOFF_MAX_MS,
|
|
328
|
+
criticalBackoffMs ? criticalBackoffMs * 2 : 250,
|
|
329
|
+
);
|
|
330
|
+
scheduleCriticalDrain(criticalBackoffMs);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
criticalDraining = false;
|
|
335
|
+
if (criticalIngestQueue.length > 0 && !criticalDrainTimer) {
|
|
336
|
+
scheduleCriticalDrain(criticalBackoffMs);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
184
341
|
const drainIngestQueue = async (): Promise<void> => {
|
|
185
342
|
if (draining) {
|
|
186
343
|
return;
|
|
@@ -196,16 +353,25 @@ const drainIngestQueue = async (): Promise<void> => {
|
|
|
196
353
|
scheduleDrain(deferDrainMs);
|
|
197
354
|
return;
|
|
198
355
|
}
|
|
199
|
-
const
|
|
356
|
+
const selected = selectSendBatch(ingestQueue);
|
|
357
|
+
if (!selected) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const batch = selected.batch;
|
|
200
361
|
const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
|
|
201
362
|
if (ok) {
|
|
202
|
-
ingestQueue.
|
|
363
|
+
ingestQueue.splice(0, selected.queueItems);
|
|
203
364
|
backoffMs = 0;
|
|
204
365
|
continue;
|
|
205
366
|
}
|
|
206
367
|
|
|
207
|
-
|
|
208
|
-
|
|
368
|
+
for (let index = 0; index < selected.queueItems; index += 1) {
|
|
369
|
+
ingestQueue[index].attempts += 1;
|
|
370
|
+
}
|
|
371
|
+
if (
|
|
372
|
+
ingestQueue[0].attempts >= DEFAULT_QUEUE_MAX_ATTEMPTS &&
|
|
373
|
+
ingestQueue[0].priority === 'normal'
|
|
374
|
+
) {
|
|
209
375
|
ingestQueue.shift();
|
|
210
376
|
backoffMs = 0;
|
|
211
377
|
continue;
|
|
@@ -243,6 +409,11 @@ export const enqueueIngestEntries = (
|
|
|
243
409
|
attempts: 0,
|
|
244
410
|
priority,
|
|
245
411
|
};
|
|
412
|
+
if (priority === 'critical') {
|
|
413
|
+
criticalIngestQueue.push(batch);
|
|
414
|
+
scheduleCriticalDrain();
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
246
417
|
if (priority === 'high') {
|
|
247
418
|
const firstNormalIndex = ingestQueue.findIndex((queued) => queued.priority !== 'high');
|
|
248
419
|
if (firstNormalIndex === -1) {
|
|
@@ -273,10 +444,15 @@ export const enqueueIngestEntries = (
|
|
|
273
444
|
};
|
|
274
445
|
|
|
275
446
|
export const flushIngestQueue = async (): Promise<void> => {
|
|
447
|
+
if (criticalDrainTimer) {
|
|
448
|
+
clearTimeout(criticalDrainTimer);
|
|
449
|
+
criticalDrainTimer = null;
|
|
450
|
+
}
|
|
276
451
|
if (drainTimer) {
|
|
277
452
|
clearTimeout(drainTimer);
|
|
278
453
|
drainTimer = null;
|
|
279
454
|
}
|
|
455
|
+
await drainCriticalIngestQueue();
|
|
280
456
|
await drainIngestQueue();
|
|
281
457
|
};
|
|
282
458
|
|
package/src/privacy.ts
CHANGED
|
@@ -230,6 +230,7 @@ export async function fetchApprovedRuntimePrivacyPolicy(params: {
|
|
|
230
230
|
appId: string;
|
|
231
231
|
appSecret?: string;
|
|
232
232
|
appName?: string;
|
|
233
|
+
serviceName?: string;
|
|
233
234
|
apiBase: string;
|
|
234
235
|
environment: string;
|
|
235
236
|
fetchTimeoutMs?: number;
|
|
@@ -240,7 +241,13 @@ export async function fetchApprovedRuntimePrivacyPolicy(params: {
|
|
|
240
241
|
reason: !params.apiBase ? 'missing_config' : 'missing_credentials',
|
|
241
242
|
};
|
|
242
243
|
}
|
|
243
|
-
const
|
|
244
|
+
const query = new URLSearchParams({
|
|
245
|
+
environment: params.environment,
|
|
246
|
+
});
|
|
247
|
+
if (typeof params.serviceName === 'string' && params.serviceName.trim()) {
|
|
248
|
+
query.set('serviceName', params.serviceName.trim());
|
|
249
|
+
}
|
|
250
|
+
const url = `${params.apiBase}/v1/sdk/privacy-policy?${query.toString()}`;
|
|
244
251
|
const headers: Record<string, string> = {
|
|
245
252
|
accept: 'application/json',
|
|
246
253
|
'x-app-id': params.appId,
|
|
@@ -119,6 +119,11 @@ async function main() {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
await sleep(2200);
|
|
122
|
+
assert.equal(
|
|
123
|
+
eventsOf(capturedBodies, 'request_capture_start').length,
|
|
124
|
+
1,
|
|
125
|
+
`request-open fence was deferred with background work: ${JSON.stringify(capturedBodies)}`,
|
|
126
|
+
);
|
|
122
127
|
assert.equal(
|
|
123
128
|
eventsOf(capturedBodies, 'backend_request').length,
|
|
124
129
|
0,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
process.env.REPRO_INGEST_BATCH_MAX_ENTRIES = '100';
|
|
2
|
+
process.env.REPRO_INGEST_BATCH_MAX_SERIALIZED_BYTES = String(1024 * 1024);
|
|
3
|
+
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const originalFetch = global.fetch;
|
|
7
|
+
const capturedBodies = [];
|
|
8
|
+
|
|
9
|
+
global.fetch = async (_url, init = {}) => {
|
|
10
|
+
capturedBodies.push(JSON.parse(String(init.body || '{}')));
|
|
11
|
+
return {
|
|
12
|
+
ok: true,
|
|
13
|
+
status: 202,
|
|
14
|
+
json: async () => ({ ok: true }),
|
|
15
|
+
text: async () => '{"ok":true}',
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const { enqueueIngestEntries, flushIngestQueue } = require('../dist/ingest/client');
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
const cfg = {
|
|
23
|
+
tenantId: 'TENANT_test',
|
|
24
|
+
appId: 'APP_test',
|
|
25
|
+
appSecret: 'secret',
|
|
26
|
+
serviceName: 'batching-test',
|
|
27
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
28
|
+
};
|
|
29
|
+
const sessionId = 'S_ingest_batching';
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < 209; index += 1) {
|
|
32
|
+
enqueueIngestEntries(cfg, sessionId, [{
|
|
33
|
+
actionId: 'A_ingest_batching',
|
|
34
|
+
requestRid: 'R_ingest_batching',
|
|
35
|
+
db: [{
|
|
36
|
+
collection: 'subjects',
|
|
37
|
+
op: 'findOne',
|
|
38
|
+
query: { index },
|
|
39
|
+
}],
|
|
40
|
+
t: Date.now() + index,
|
|
41
|
+
}], { priority: 'high' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await flushIngestQueue();
|
|
45
|
+
|
|
46
|
+
const events = capturedBodies.flatMap((body) =>
|
|
47
|
+
Array.isArray(body?.events) ? body.events : [],
|
|
48
|
+
);
|
|
49
|
+
assert.equal(events.length, 209);
|
|
50
|
+
assert(events.every((event) => event.event_type === 'db_change'));
|
|
51
|
+
assert.equal(
|
|
52
|
+
capturedBodies.length,
|
|
53
|
+
3,
|
|
54
|
+
`209 small evidence entries should use three bounded requests, got ${capturedBodies.length}`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
console.log('ingest batching OK');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
main()
|
|
61
|
+
.catch((error) => {
|
|
62
|
+
console.error(error);
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
global.fetch = originalFetch;
|
|
67
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const {
|
|
5
|
+
fetchApprovedRuntimePrivacyPolicy,
|
|
6
|
+
} = require('../dist/privacy.js');
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const originalFetch = global.fetch;
|
|
10
|
+
const calls = [];
|
|
11
|
+
global.fetch = async (url, init) => {
|
|
12
|
+
calls.push({ url: String(url), init });
|
|
13
|
+
return {
|
|
14
|
+
ok: true,
|
|
15
|
+
json: async () => ({ enabled: false }),
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
await fetchApprovedRuntimePrivacyPolicy({
|
|
21
|
+
appId: 'APP_shared',
|
|
22
|
+
appSecret: 'secret',
|
|
23
|
+
appName: 'Shared app',
|
|
24
|
+
serviceName: ' Orders API ',
|
|
25
|
+
apiBase: 'https://repro.example/api',
|
|
26
|
+
environment: 'prod',
|
|
27
|
+
});
|
|
28
|
+
await fetchApprovedRuntimePrivacyPolicy({
|
|
29
|
+
appId: 'APP_shared',
|
|
30
|
+
appSecret: 'secret',
|
|
31
|
+
apiBase: 'https://repro.example/api',
|
|
32
|
+
environment: 'dev',
|
|
33
|
+
});
|
|
34
|
+
} finally {
|
|
35
|
+
global.fetch = originalFetch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
assert.strictEqual(calls.length, 2);
|
|
39
|
+
const serviceUrl = new URL(calls[0].url);
|
|
40
|
+
assert.strictEqual(serviceUrl.pathname, '/api/v1/sdk/privacy-policy');
|
|
41
|
+
assert.strictEqual(serviceUrl.searchParams.get('environment'), 'prod');
|
|
42
|
+
assert.strictEqual(serviceUrl.searchParams.get('serviceName'), 'Orders API');
|
|
43
|
+
assert.strictEqual(calls[0].init.headers['x-app-id'], 'APP_shared');
|
|
44
|
+
assert.strictEqual(calls[0].init.headers['x-app-secret'], 'secret');
|
|
45
|
+
|
|
46
|
+
const appWideUrl = new URL(calls[1].url);
|
|
47
|
+
assert.strictEqual(appWideUrl.searchParams.get('environment'), 'dev');
|
|
48
|
+
assert.strictEqual(appWideUrl.searchParams.has('serviceName'), false);
|
|
49
|
+
|
|
50
|
+
console.log('runtime privacy service scope OK');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch((error) => {
|
|
54
|
+
console.error(error);
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
});
|
|
@@ -107,14 +107,19 @@ async function main() {
|
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
await sleep(100);
|
|
110
|
+
assert.equal(
|
|
111
|
+
countEvents(capturedBodies, 'request_capture_start'),
|
|
112
|
+
1,
|
|
113
|
+
`request-open fence must bypass client workload deferral: ${JSON.stringify(capturedBodies)}`,
|
|
114
|
+
);
|
|
110
115
|
assert.equal(
|
|
111
116
|
countEvents(capturedBodies, 'backend_request'),
|
|
112
117
|
0,
|
|
113
118
|
`request capture should defer while another client request is active: ${JSON.stringify(capturedBodies)}`,
|
|
114
119
|
);
|
|
115
120
|
|
|
116
|
-
held.res.emit('finish');
|
|
117
121
|
await waitFor(() => countEvents(capturedBodies, 'backend_request') === 1);
|
|
122
|
+
held.res.emit('finish');
|
|
118
123
|
await flushIngestQueue();
|
|
119
124
|
|
|
120
125
|
assert.equal(countEvents(capturedBodies, 'backend_request'), 1, JSON.stringify(capturedBodies));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
|
|
5
|
+
const { __reproTestHooks } = require('../dist');
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
const originalFetch = global.fetch;
|
|
9
|
+
const calls = [];
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
global.fetch = async (url, options) => {
|
|
13
|
+
calls.push({ url: String(url), options });
|
|
14
|
+
return { ok: true };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
await __reproTestHooks.registerRuntimeServiceForTest({
|
|
18
|
+
tenantId: 'TENANT_test',
|
|
19
|
+
appId: 'APP_test',
|
|
20
|
+
appSecret: 'secret',
|
|
21
|
+
appName: 'Acme',
|
|
22
|
+
serviceName: ' orders-api ',
|
|
23
|
+
apiBase: 'http://repro-api.test/api/',
|
|
24
|
+
privacy: {
|
|
25
|
+
environment: 'production',
|
|
26
|
+
captureRaw: true,
|
|
27
|
+
},
|
|
28
|
+
ingestBase: 'http://collector.test',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.equal(calls.length, 1);
|
|
32
|
+
assert.equal(
|
|
33
|
+
calls[0].url,
|
|
34
|
+
'http://repro-api.test/api/v1/sdk/runtime-services',
|
|
35
|
+
);
|
|
36
|
+
assert.equal(calls[0].options.method, 'POST');
|
|
37
|
+
assert.deepEqual(calls[0].options.headers, {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'X-App-Id': 'APP_test',
|
|
40
|
+
'X-App-Secret': 'secret',
|
|
41
|
+
'X-App-Name': 'Acme',
|
|
42
|
+
});
|
|
43
|
+
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
44
|
+
serviceName: 'orders-api',
|
|
45
|
+
environment: 'prod',
|
|
46
|
+
});
|
|
47
|
+
assert.ok(calls[0].options.signal);
|
|
48
|
+
|
|
49
|
+
await __reproTestHooks.registerRuntimeServiceForTest({
|
|
50
|
+
tenantId: 'TENANT_test',
|
|
51
|
+
appId: 'APP_test',
|
|
52
|
+
appSecret: 'secret',
|
|
53
|
+
appName: 'Acme',
|
|
54
|
+
apiBase: 'http://repro-api.test/api',
|
|
55
|
+
ingestBase: 'http://collector.test',
|
|
56
|
+
});
|
|
57
|
+
assert.equal(calls.length, 1);
|
|
58
|
+
|
|
59
|
+
global.fetch = async () => {
|
|
60
|
+
throw new Error('network down');
|
|
61
|
+
};
|
|
62
|
+
await __reproTestHooks.registerRuntimeServiceForTest({
|
|
63
|
+
tenantId: 'TENANT_test',
|
|
64
|
+
appId: 'APP_test',
|
|
65
|
+
appSecret: 'secret',
|
|
66
|
+
serviceName: 'billing-api',
|
|
67
|
+
apiBase: 'http://repro-api.test/api',
|
|
68
|
+
ingestBase: 'http://collector.test',
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
global.fetch = originalFetch;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log('runtime service registration OK');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((error) => {
|
|
78
|
+
console.error(error);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
});
|