@reproapp/node-sdk 0.0.50 → 0.0.52
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 +17 -2
- package/dist/capture-work-budget.d.ts +1 -0
- package/dist/capture-work-budget.js +25 -0
- package/dist/index.js +80 -38
- package/dist/ingest/client.d.ts +2 -2
- package/dist/ingest/client.js +6 -5
- package/dist/privacy.js +5 -1
- package/package.json +3 -2
- package/src/capture-work-budget.ts +16 -0
- package/src/index.ts +80 -35
- package/src/ingest/client.ts +9 -7
- package/src/privacy.ts +4 -1
- package/test/background-continuous-traffic.testcontainers.test.js +100 -0
- package/test/capture-latency.testcontainers.test.js +123 -0
- package/test/content-addressed-trace-values.test.js +12 -4
- package/test/fixtures/capture-latency-business.js +10 -0
- package/test/fixtures/capture-latency-server.js +54 -0
- package/test/fixtures/worker-value-failure.js +11 -0
- package/test/privacy-fetch-timeout.test.js +25 -0
package/README.md
CHANGED
|
@@ -304,8 +304,8 @@ await initRepro({
|
|
|
304
304
|
privacy: {
|
|
305
305
|
environment: process.env.REPRO_PRIVACY_ENV ?? "dev",
|
|
306
306
|
apiBase: process.env.REPRO_PRIVACY_API_BASE ?? process.env.REPRO_API_BASE,
|
|
307
|
-
fetchTimeoutMs:
|
|
308
|
-
startupMaxWaitMs:
|
|
307
|
+
fetchTimeoutMs: 10000,
|
|
308
|
+
startupMaxWaitMs: 15000,
|
|
309
309
|
startupRetryDelayMs: 500,
|
|
310
310
|
},
|
|
311
311
|
});
|
|
@@ -313,6 +313,21 @@ await initRepro({
|
|
|
313
313
|
|
|
314
314
|
Use `privacy.captureRaw: true` only in controlled local development.
|
|
315
315
|
|
|
316
|
+
The default policy fetch timeout is 10 seconds, including reading the response
|
|
317
|
+
body. The startup retry budget defaults to 15 seconds. Large approved policies
|
|
318
|
+
are loaded in full; a timeout does not permit uploading unredacted values.
|
|
319
|
+
|
|
320
|
+
Trace value materialization uses a restartable background worker in both live and
|
|
321
|
+
staging modes. Worker messages are processed in bounded batches without dropping
|
|
322
|
+
events. When worker execution is unavailable, asynchronous capture traversal
|
|
323
|
+
yields to the event loop. Observation snapshots still have a synchronous cost,
|
|
324
|
+
so measure overhead against your own payloads and traffic before production use.
|
|
325
|
+
|
|
326
|
+
Run `npm run test:latency` with Docker running to test real MongoDB queries and
|
|
327
|
+
HTTP traffic with recording disabled, raw capture, approved policies, worker
|
|
328
|
+
fallback, and a worker-side policy failure. The test checks unrelated HTTP
|
|
329
|
+
responsiveness, captured values and mutations, privacy, and request manifests.
|
|
330
|
+
|
|
316
331
|
## Verify
|
|
317
332
|
|
|
318
333
|
With the React SDK, record a browser session in `live` mode or start a manual QA
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function yieldCaptureWork(): Promise<void> | null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.yieldCaptureWork = void 0;
|
|
4
|
+
// Promise-only recursion never yields to HTTP sockets. Share a small time slice
|
|
5
|
+
// across asynchronous capture walkers, including the worker-unavailable fallback.
|
|
6
|
+
let deadline = 0;
|
|
7
|
+
let pendingYield = null;
|
|
8
|
+
function yieldCaptureWork() {
|
|
9
|
+
if (pendingYield)
|
|
10
|
+
return pendingYield;
|
|
11
|
+
const now = performance.now();
|
|
12
|
+
if (now < deadline)
|
|
13
|
+
return null;
|
|
14
|
+
if (deadline === 0) {
|
|
15
|
+
deadline = now + 4;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
pendingYield = new Promise(resolve => setImmediate(() => {
|
|
19
|
+
deadline = performance.now() + 4;
|
|
20
|
+
pendingYield = null;
|
|
21
|
+
resolve();
|
|
22
|
+
}));
|
|
23
|
+
return pendingYield;
|
|
24
|
+
}
|
|
25
|
+
exports.yieldCaptureWork = yieldCaptureWork;
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,11 @@ const crypto_1 = require("crypto");
|
|
|
7
7
|
const module_1 = require("module");
|
|
8
8
|
const path_1 = require("path");
|
|
9
9
|
const url_1 = require("url");
|
|
10
|
+
const node_v8_1 = require("node:v8");
|
|
10
11
|
const client_1 = require("./ingest/client");
|
|
11
12
|
const privacy_1 = require("./privacy");
|
|
12
13
|
const semantic_serialization_1 = require("./semantic-serialization");
|
|
14
|
+
const capture_work_budget_1 = require("./capture-work-budget");
|
|
13
15
|
const live_capture_limits_1 = require("./live-capture-limits");
|
|
14
16
|
/**
|
|
15
17
|
* ============================= IMPORTANT =============================
|
|
@@ -1121,22 +1123,20 @@ function hasActiveClientRequests() {
|
|
|
1121
1123
|
function noteClientActivity(now = Date.now()) {
|
|
1122
1124
|
lastClientActivityAt = now;
|
|
1123
1125
|
}
|
|
1124
|
-
function getSdkBackgroundDelayMs(now = Date.now()) {
|
|
1126
|
+
function getSdkBackgroundDelayMs(now = Date.now(), ingestEnqueuedAt) {
|
|
1127
|
+
// Bound deferral by work age, not just by a continuously active request.
|
|
1128
|
+
// Short non-overlapping requests reset oldestActiveClientRequestAt forever.
|
|
1129
|
+
const oldestWorkAt = Math.min(sdkBackgroundHighQueue[0]?.enqueuedAt ?? Infinity, sdkBackgroundQueue[0]?.enqueuedAt ?? Infinity, ingestEnqueuedAt ?? Infinity, oldestActiveClientRequestAt ?? Infinity);
|
|
1130
|
+
const remainingDeferMs = Number.isFinite(oldestWorkAt) && Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS)
|
|
1131
|
+
? Math.max(0, oldestWorkAt + SDK_BACKGROUND_MAX_DEFER_MS - now)
|
|
1132
|
+
: Infinity;
|
|
1125
1133
|
if (hasActiveClientRequests()) {
|
|
1126
|
-
|
|
1127
|
-
Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS) &&
|
|
1128
|
-
SDK_BACKGROUND_MAX_DEFER_MS >= 0) {
|
|
1129
|
-
const activeForMs = now - oldestActiveClientRequestAt;
|
|
1130
|
-
if (activeForMs >= SDK_BACKGROUND_MAX_DEFER_MS)
|
|
1131
|
-
return 0;
|
|
1132
|
-
return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, Math.max(0, SDK_BACKGROUND_MAX_DEFER_MS - activeForMs));
|
|
1133
|
-
}
|
|
1134
|
-
return SDK_BACKGROUND_IDLE_DELAY_MS;
|
|
1134
|
+
return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, remainingDeferMs);
|
|
1135
1135
|
}
|
|
1136
1136
|
const sinceLastActivity = now - lastClientActivityAt;
|
|
1137
1137
|
const idleDelayRemaining = Math.max(0, SDK_BACKGROUND_IDLE_DELAY_MS - sinceLastActivity);
|
|
1138
1138
|
const quietWindowRemaining = Math.max(0, sdkBackgroundQuietUntil - now);
|
|
1139
|
-
return Math.max(idleDelayRemaining, quietWindowRemaining);
|
|
1139
|
+
return Math.min(Math.max(idleDelayRemaining, quietWindowRemaining), remainingDeferMs);
|
|
1140
1140
|
}
|
|
1141
1141
|
function shouldDeferSdkBackgroundWork(now = Date.now()) {
|
|
1142
1142
|
return getSdkBackgroundDelayMs(now) > 0;
|
|
@@ -1172,6 +1172,7 @@ function scheduleTraceMaterializerIdleShutdown() {
|
|
|
1172
1172
|
const worker = traceMaterializerWorker;
|
|
1173
1173
|
traceMaterializerWorker = null;
|
|
1174
1174
|
if (worker) {
|
|
1175
|
+
traceMaterializerExpectedTerminations.add(worker);
|
|
1175
1176
|
try {
|
|
1176
1177
|
void worker.terminate?.();
|
|
1177
1178
|
}
|
|
@@ -1197,7 +1198,17 @@ function handleTraceMaterializerMessage(message) {
|
|
|
1197
1198
|
traceMaterializerPending.delete(message.taskId);
|
|
1198
1199
|
clearTraceMaterializerTaskTimeout(message.taskId);
|
|
1199
1200
|
if (message.ok) {
|
|
1200
|
-
|
|
1201
|
+
const events = Array.isArray(message.events) ? message.events : [];
|
|
1202
|
+
for (const event of events) {
|
|
1203
|
+
// Symbols do not cross worker messages. Restore internal attachments
|
|
1204
|
+
// before transport, otherwise each occurrence embeds its full value.
|
|
1205
|
+
const entries = event.__reproTraceValueEntries;
|
|
1206
|
+
if (entries)
|
|
1207
|
+
for (const entry of entries)
|
|
1208
|
+
attachTraceValueEntry(event, entry);
|
|
1209
|
+
delete event.__reproTraceValueEntries;
|
|
1210
|
+
}
|
|
1211
|
+
pending.resolve(events);
|
|
1201
1212
|
scheduleTraceMaterializerIdleShutdown();
|
|
1202
1213
|
return;
|
|
1203
1214
|
}
|
|
@@ -1258,9 +1269,15 @@ function getTraceMaterializerWorker() {
|
|
|
1258
1269
|
catch { }
|
|
1259
1270
|
worker.on('message', handleTraceMaterializerMessage);
|
|
1260
1271
|
worker.on('error', (err) => {
|
|
1272
|
+
if (worker !== traceMaterializerWorker || traceMaterializerExpectedTerminations.has(worker))
|
|
1273
|
+
return;
|
|
1261
1274
|
disableTraceMaterializerWorker(err instanceof Error ? err : new Error(String(err)));
|
|
1262
1275
|
});
|
|
1263
1276
|
worker.on('exit', (code) => {
|
|
1277
|
+
// Idle/timeout shutdown can finish after a replacement worker starts.
|
|
1278
|
+
// An old worker must never clear or disable its replacement.
|
|
1279
|
+
if (worker !== traceMaterializerWorker)
|
|
1280
|
+
return;
|
|
1264
1281
|
const hadPending = traceMaterializerPending.size > 0;
|
|
1265
1282
|
const expectedTermination = traceMaterializerExpectedTerminations.has(worker);
|
|
1266
1283
|
clearTraceMaterializerIdleTimer();
|
|
@@ -1342,18 +1359,12 @@ function shouldOffloadPendingTraceEvents(events) {
|
|
|
1342
1359
|
if (!pending)
|
|
1343
1360
|
continue;
|
|
1344
1361
|
if (pending.argsRaw !== undefined) {
|
|
1345
|
-
if (traceInlineValueExceedsLimit(pending.argsRaw))
|
|
1346
|
-
return false;
|
|
1347
1362
|
pendingValueCount += 1;
|
|
1348
1363
|
}
|
|
1349
1364
|
if (pending.returnValueRaw !== undefined) {
|
|
1350
|
-
if (traceInlineValueExceedsLimit(pending.returnValueRaw))
|
|
1351
|
-
return false;
|
|
1352
1365
|
pendingValueCount += 1;
|
|
1353
1366
|
}
|
|
1354
1367
|
if (pending.errorRaw !== undefined) {
|
|
1355
|
-
if (traceInlineValueExceedsLimit(pending.errorRaw))
|
|
1356
|
-
return false;
|
|
1357
1368
|
pendingValueCount += 1;
|
|
1358
1369
|
}
|
|
1359
1370
|
if (pendingValueCount >= TRACE_MATERIALIZE_OFFTHREAD_MIN_VALUES) {
|
|
@@ -1372,13 +1383,8 @@ function beginClientRequest() {
|
|
|
1372
1383
|
oldestActiveClientRequestAt = now;
|
|
1373
1384
|
}
|
|
1374
1385
|
activeClientRequestCount += 1;
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
clearTimeout(sdkBackgroundTimer);
|
|
1378
|
-
}
|
|
1379
|
-
catch { }
|
|
1380
|
-
sdkBackgroundTimer = null;
|
|
1381
|
-
}
|
|
1386
|
+
// The scheduled drain rechecks activity; cancelling it here loses the
|
|
1387
|
+
// queued work's deadline under sustained traffic.
|
|
1382
1388
|
}
|
|
1383
1389
|
function endClientRequest() {
|
|
1384
1390
|
activeClientRequestCount = Math.max(0, activeClientRequestCount - 1);
|
|
@@ -1397,15 +1403,18 @@ function endClientRequest() {
|
|
|
1397
1403
|
}
|
|
1398
1404
|
}
|
|
1399
1405
|
function scheduleSdkBackgroundWork(work, options = {}) {
|
|
1406
|
+
const item = { work, enqueuedAt: Date.now() };
|
|
1400
1407
|
if (options.priority === 'high') {
|
|
1401
|
-
sdkBackgroundHighQueue.push(
|
|
1408
|
+
sdkBackgroundHighQueue.push(item);
|
|
1402
1409
|
}
|
|
1403
1410
|
else {
|
|
1404
|
-
sdkBackgroundQueue.push(
|
|
1411
|
+
sdkBackgroundQueue.push(item);
|
|
1405
1412
|
}
|
|
1406
1413
|
scheduleSdkBackgroundDrain();
|
|
1407
1414
|
}
|
|
1408
1415
|
function scheduleSdkBackgroundDrain(delayMs = 0) {
|
|
1416
|
+
if (!sdkBackgroundHighQueue.length && !sdkBackgroundQueue.length)
|
|
1417
|
+
return;
|
|
1409
1418
|
if (sdkBackgroundTimer)
|
|
1410
1419
|
return;
|
|
1411
1420
|
const nextDelay = Math.max(delayMs, getSdkBackgroundDelayMs());
|
|
@@ -1441,12 +1450,12 @@ async function drainSdkBackgroundQueue() {
|
|
|
1441
1450
|
scheduleSdkBackgroundDrain();
|
|
1442
1451
|
return;
|
|
1443
1452
|
}
|
|
1444
|
-
const
|
|
1445
|
-
if (!
|
|
1453
|
+
const item = sdkBackgroundHighQueue.shift() ?? sdkBackgroundQueue.shift();
|
|
1454
|
+
if (!item)
|
|
1446
1455
|
continue;
|
|
1447
1456
|
try {
|
|
1448
1457
|
await drainQueuedIngestBeforeBackgroundWork();
|
|
1449
|
-
await runSdkBackgroundWork(work);
|
|
1458
|
+
await runSdkBackgroundWork(item.work);
|
|
1450
1459
|
}
|
|
1451
1460
|
catch {
|
|
1452
1461
|
// never break user code
|
|
@@ -1464,8 +1473,8 @@ async function drainSdkBackgroundQueue() {
|
|
|
1464
1473
|
}
|
|
1465
1474
|
}
|
|
1466
1475
|
(0, client_1.configureIngestQueueDrain)({
|
|
1467
|
-
shouldDefer:
|
|
1468
|
-
deferMs: SDK_BACKGROUND_IDLE_DELAY_MS,
|
|
1476
|
+
shouldDefer: (enqueuedAt) => getSdkBackgroundDelayMs(Date.now(), enqueuedAt) > 0,
|
|
1477
|
+
deferMs: Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, SDK_BACKGROUND_MAX_DEFER_MS),
|
|
1469
1478
|
});
|
|
1470
1479
|
// Track in-flight requests per session so we can delay flushing until a session is drained.
|
|
1471
1480
|
const sessionInFlight = new Map();
|
|
@@ -2824,9 +2833,16 @@ function internTraceObservationSnapshot(snapshot, captureScope) {
|
|
|
2824
2833
|
if (!captureScope || snapshot === null || typeof snapshot !== 'object') {
|
|
2825
2834
|
return snapshot;
|
|
2826
2835
|
}
|
|
2827
|
-
|
|
2828
|
-
|
|
2836
|
+
// These are already detached data snapshots. Binary serialization avoids
|
|
2837
|
+
// sorting and cloning the whole graph again on the application's call stack,
|
|
2838
|
+
// while preserving undefined, NaN, and other non-JSON primitive distinctions.
|
|
2839
|
+
let serialized;
|
|
2840
|
+
try {
|
|
2841
|
+
serialized = (0, node_v8_1.serialize)(snapshot);
|
|
2842
|
+
}
|
|
2843
|
+
catch {
|
|
2829
2844
|
return snapshot;
|
|
2845
|
+
} // Interning is optional; the snapshot is not.
|
|
2830
2846
|
const digest = (0, crypto_1.createHash)('sha256')
|
|
2831
2847
|
.update('repro-trace-observation-v1\0')
|
|
2832
2848
|
.update(serialized)
|
|
@@ -3006,6 +3022,16 @@ async function materializePendingTraceEventsAsync(events, params) {
|
|
|
3006
3022
|
if (!events.some((evt) => evt.__reproPending)) {
|
|
3007
3023
|
return events;
|
|
3008
3024
|
}
|
|
3025
|
+
// Bound work per worker message and timeout, not the captured data itself.
|
|
3026
|
+
// A long request must not become one enormous structured-clone operation.
|
|
3027
|
+
if (events.length > 64) {
|
|
3028
|
+
const materialized = [];
|
|
3029
|
+
for (let offset = 0; offset < events.length; offset += 64) {
|
|
3030
|
+
materialized.push(...await materializePendingTraceEventsAsync(events.slice(offset, offset + 64), params));
|
|
3031
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
3032
|
+
}
|
|
3033
|
+
return materialized;
|
|
3034
|
+
}
|
|
3009
3035
|
const privacy = params.resolvePrivacy();
|
|
3010
3036
|
if (!shouldOffloadPendingTraceEvents(events)) {
|
|
3011
3037
|
try {
|
|
@@ -3020,7 +3046,7 @@ async function materializePendingTraceEventsAsync(events, params) {
|
|
|
3020
3046
|
}
|
|
3021
3047
|
}
|
|
3022
3048
|
try {
|
|
3023
|
-
|
|
3049
|
+
const materialized = await materializePendingTraceEventsOffThread(events, {
|
|
3024
3050
|
events,
|
|
3025
3051
|
cfg: {
|
|
3026
3052
|
tenantId: params.cfg.tenantId,
|
|
@@ -3036,6 +3062,14 @@ async function materializePendingTraceEventsAsync(events, params) {
|
|
|
3036
3062
|
fullValueCaptureEnabled: __FULL_VALUE_CAPTURE_ENABLED,
|
|
3037
3063
|
valueNamespace: params.valueNamespace,
|
|
3038
3064
|
});
|
|
3065
|
+
for (const event of materialized) {
|
|
3066
|
+
for (const [field, captureField] of [['args', 'argsValueCapture'], ['returnValue', 'returnValueCapture'], ['error', 'errorValueCapture']]) {
|
|
3067
|
+
if (event[captureField]?.status === 'failed-policy') {
|
|
3068
|
+
recordRequestCaptureFailure(params.captureScope ?? getCtx().captureScope, 'privacy.full_value', `trace.${field}`, 'worker_value_materialization_failed');
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
return materialized;
|
|
3039
3073
|
}
|
|
3040
3074
|
catch (err) {
|
|
3041
3075
|
try {
|
|
@@ -3063,10 +3097,15 @@ async function __materializePendingTraceEventsForWorker(payload) {
|
|
|
3063
3097
|
resolvePrivacy: () => payload.privacy,
|
|
3064
3098
|
valueNamespace: payload.valueNamespace,
|
|
3065
3099
|
});
|
|
3100
|
+
const values = new Map();
|
|
3066
3101
|
return materialized.map((event) => {
|
|
3067
3102
|
const entries = getEventTraceValueEntries(event);
|
|
3068
3103
|
if (entries?.length) {
|
|
3069
|
-
event.__reproTraceValueEntries = entries.map((entry) =>
|
|
3104
|
+
event.__reproTraceValueEntries = entries.map((entry) => {
|
|
3105
|
+
if (!values.has(entry.id))
|
|
3106
|
+
values.set(entry.id, entry.value);
|
|
3107
|
+
return { ...entry, value: values.get(entry.id) };
|
|
3108
|
+
});
|
|
3070
3109
|
}
|
|
3071
3110
|
return event;
|
|
3072
3111
|
});
|
|
@@ -3192,7 +3231,7 @@ function sanitizeHeaders(headers, rawCfg) {
|
|
|
3192
3231
|
}
|
|
3193
3232
|
const RUNTIME_PRIVACY_STATE_SYMBOL = Symbol('repro.runtimePrivacyState');
|
|
3194
3233
|
const RUNTIME_PRIVACY_RETRY_COOLDOWN_MS = 5000;
|
|
3195
|
-
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS =
|
|
3234
|
+
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 15000;
|
|
3196
3235
|
const DEFAULT_RUNTIME_PRIVACY_STARTUP_RETRY_DELAY_MS = 150;
|
|
3197
3236
|
function normalizeMaskTargets(target) {
|
|
3198
3237
|
const out = Array.isArray(target) ? target : [target];
|
|
@@ -3338,7 +3377,7 @@ function handleRuntimePrivacyFetchResult(cfg, state, apiBase, result) {
|
|
|
3338
3377
|
state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
|
|
3339
3378
|
return null;
|
|
3340
3379
|
}
|
|
3341
|
-
reportRuntimePrivacyOutcome(cfg, state, `network:${result.error}:${apiBase ?? 'no-api-base'}`, `privacy policy fetch failed while contacting ${apiBase}`);
|
|
3380
|
+
reportRuntimePrivacyOutcome(cfg, state, `network:${result.error}:${apiBase ?? 'no-api-base'}`, `privacy policy fetch failed while contacting ${apiBase}: ${result.error}`);
|
|
3342
3381
|
state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
|
|
3343
3382
|
return null;
|
|
3344
3383
|
}
|
|
@@ -4200,6 +4239,9 @@ function cloneFullValueCandidate(value, ctx, limits, depth = 0, seen = new WeakM
|
|
|
4200
4239
|
return out;
|
|
4201
4240
|
}
|
|
4202
4241
|
async function cloneFullValueCandidateAsync(value, ctx, limits, depth = 0, seen = new WeakMap(), valuePath = '$') {
|
|
4242
|
+
const pause = (0, capture_work_budget_1.yieldCaptureWork)();
|
|
4243
|
+
if (pause)
|
|
4244
|
+
await pause;
|
|
4203
4245
|
if (value === undefined)
|
|
4204
4246
|
return undefined;
|
|
4205
4247
|
if (value === null || typeof value === 'number' || typeof value === 'boolean')
|
package/dist/ingest/client.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ export type EnqueueIngestOptions = {
|
|
|
3
3
|
priority?: 'normal' | 'high' | 'critical';
|
|
4
4
|
};
|
|
5
5
|
export declare const configureIngestQueueDrain: (options: {
|
|
6
|
-
shouldDefer?: (() => boolean) | null;
|
|
7
|
-
deferMs?: number;
|
|
6
|
+
shouldDefer?: ((enqueuedAt?: number) => boolean) | null | undefined;
|
|
7
|
+
deferMs?: number | undefined;
|
|
8
8
|
}) => void;
|
|
9
9
|
export declare const kickIngestQueueDrain: () => void;
|
|
10
10
|
export declare const postIngestEntries: (config: IngestClientConfig, sessionId: string, entries: LegacyEntry[]) => Promise<void>;
|
package/dist/ingest/client.js
CHANGED
|
@@ -61,7 +61,7 @@ const kickIngestQueueDrain = () => {
|
|
|
61
61
|
scheduleCriticalDrain();
|
|
62
62
|
}
|
|
63
63
|
if (ingestQueue.length > 0 && !drainTimer) {
|
|
64
|
-
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
64
|
+
scheduleDrain(shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt) ? deferDrainMs : 0);
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
exports.kickIngestQueueDrain = kickIngestQueueDrain;
|
|
@@ -233,12 +233,12 @@ const scheduleDrain = (delayMs = 0) => {
|
|
|
233
233
|
if (drainTimer) {
|
|
234
234
|
return;
|
|
235
235
|
}
|
|
236
|
-
const nextDelay = shouldDeferDrain?.()
|
|
236
|
+
const nextDelay = shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)
|
|
237
237
|
? Math.max(delayMs, deferDrainMs)
|
|
238
238
|
: delayMs;
|
|
239
239
|
drainTimer = setTimeout(() => {
|
|
240
240
|
drainTimer = null;
|
|
241
|
-
if (shouldDeferDrain?.()) {
|
|
241
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
242
242
|
scheduleDrain(deferDrainMs);
|
|
243
243
|
return;
|
|
244
244
|
}
|
|
@@ -302,14 +302,14 @@ const drainIngestQueue = async () => {
|
|
|
302
302
|
if (draining) {
|
|
303
303
|
return;
|
|
304
304
|
}
|
|
305
|
-
if (shouldDeferDrain?.()) {
|
|
305
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
306
306
|
scheduleDrain(deferDrainMs);
|
|
307
307
|
return;
|
|
308
308
|
}
|
|
309
309
|
draining = true;
|
|
310
310
|
try {
|
|
311
311
|
while (ingestQueue.length > 0) {
|
|
312
|
-
if (shouldDeferDrain?.()) {
|
|
312
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
313
313
|
scheduleDrain(deferDrainMs);
|
|
314
314
|
return;
|
|
315
315
|
}
|
|
@@ -367,6 +367,7 @@ const enqueueIngestEntries = (config, sessionId, entries, options = {}) => {
|
|
|
367
367
|
sessionId,
|
|
368
368
|
entries: entries.slice(),
|
|
369
369
|
attempts: 0,
|
|
370
|
+
enqueuedAt: Date.now(),
|
|
370
371
|
priority,
|
|
371
372
|
};
|
|
372
373
|
if (priority === 'critical') {
|
package/dist/privacy.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.applyRuntimePrivacyPolicyAsync = exports.applyRuntimePrivacyPolicy = exports.normalizeRuntimePrivacyPolicy = exports.fetchApprovedRuntimePrivacyPolicy = exports.resolvePrivacyApiBase = exports.normalizePrivacyEnvironment = exports.clearRuntimePrivacyProvenance = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
|
+
const capture_work_budget_1 = require("./capture-work-budget");
|
|
5
6
|
const privacy_fallback_1 = require("./privacy-fallback");
|
|
6
7
|
const privacy_redaction_1 = require("./privacy-redaction");
|
|
7
8
|
const EMPTY_RUNTIME_PRIVACY_POLICY = {
|
|
@@ -89,7 +90,7 @@ function resolvePrivacyApiBase(cfg) {
|
|
|
89
90
|
return String(value).replace(/\/+$/, '');
|
|
90
91
|
}
|
|
91
92
|
exports.resolvePrivacyApiBase = resolvePrivacyApiBase;
|
|
92
|
-
const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS =
|
|
93
|
+
const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS = 10000;
|
|
93
94
|
function resolveRuntimePrivacyFetchTimeoutMs(value) {
|
|
94
95
|
if (!Number.isFinite(value)) {
|
|
95
96
|
return DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS;
|
|
@@ -671,6 +672,9 @@ function transformValue(policy, surface, value, context, path, fallbackInput = n
|
|
|
671
672
|
return transformChildrenExact(policy, surface, currentValue, context, path, fallback);
|
|
672
673
|
}
|
|
673
674
|
async function transformValueAsync(policy, surface, value, context, path, fallbackInput = null) {
|
|
675
|
+
const pause = (0, capture_work_budget_1.yieldCaptureWork)();
|
|
676
|
+
if (pause)
|
|
677
|
+
await pause;
|
|
674
678
|
const fallback = resolveTransformFallback(value, fallbackInput);
|
|
675
679
|
const objectId = coercePrivacyObjectId(value);
|
|
676
680
|
if (objectId !== null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reproapp/node-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.52",
|
|
4
4
|
"description": "Repro Nest SDK",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
"node": ">=18"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
+
"test:latency": "npm run build && node test/privacy-fetch-timeout.test.js && node test/capture-latency.testcontainers.test.js",
|
|
12
13
|
"test:live-capture": "npm run build && node test/live-capture-limits.test.js && node test/ingest-throttle-retention.test.js && node test/live-capture-limits.testcontainers.test.js",
|
|
13
14
|
"test:live-brokers": "npm run build && node test/live-broker-limits.testcontainers.test.js",
|
|
14
15
|
"build": "tsc -p tsconfig.json",
|
|
15
16
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
16
17
|
"prepublishOnly": "npm run build",
|
|
17
18
|
"test": "npm run build && node test/semantic-serialization.test.js && node test/semantic-serialization-trace.test.js && node test/content-addressed-trace-values.test.js && node test/trace-observation-snapshots.test.js && node test/session-storage-budget.test.js && node test/trace-source-exclude.test.js && 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/sdk-background-timeout-isolation.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-high-priority-fairness.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-policy-index.test.js && node test/policy-capture-attestation.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",
|
|
18
|
-
"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/content-addressed-mongodb.testcontainers.test.js && node test/policy-session-completion.testcontainers.test.js && node test/postgresql-integrations.testcontainers.test.js"
|
|
19
|
+
"test:containers": "npm run build && node test/background-continuous-traffic.testcontainers.test.js && 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/content-addressed-mongodb.testcontainers.test.js && node test/policy-session-completion.testcontainers.test.js && node test/postgresql-integrations.testcontainers.test.js"
|
|
19
20
|
},
|
|
20
21
|
"peerDependencies": {
|
|
21
22
|
"express": "^5.1.0",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Promise-only recursion never yields to HTTP sockets. Share a small time slice
|
|
2
|
+
// across asynchronous capture walkers, including the worker-unavailable fallback.
|
|
3
|
+
let deadline = 0;
|
|
4
|
+
let pendingYield: Promise<void> | null = null;
|
|
5
|
+
export function yieldCaptureWork(): Promise<void> | null {
|
|
6
|
+
if (pendingYield) return pendingYield;
|
|
7
|
+
const now = performance.now();
|
|
8
|
+
if (now < deadline) return null;
|
|
9
|
+
if (deadline === 0) { deadline = now + 4; return null; }
|
|
10
|
+
pendingYield = new Promise<void>(resolve => setImmediate(() => {
|
|
11
|
+
deadline = performance.now() + 4;
|
|
12
|
+
pendingYield = null;
|
|
13
|
+
resolve();
|
|
14
|
+
}));
|
|
15
|
+
return pendingYield;
|
|
16
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createHash, createHmac, randomUUID } from 'crypto';
|
|
|
6
6
|
import { createRequire } from 'module';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { pathToFileURL } from 'url';
|
|
9
|
+
import { serialize as serializeSnapshot } from 'node:v8';
|
|
9
10
|
import {
|
|
10
11
|
configureIngestQueueDrain,
|
|
11
12
|
enqueueIngestEntries,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
type RuntimePrivacyTraceContext,
|
|
30
31
|
} from './privacy';
|
|
31
32
|
import { projectSemanticInfrastructureValue } from './semantic-serialization';
|
|
33
|
+
import { yieldCaptureWork } from './capture-work-budget';
|
|
32
34
|
import { limitLiveCapture, limitLiveArguments, collectCaptureOmissions, type CaptureOmission } from './live-capture-limits';
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -1552,8 +1554,9 @@ let activeClientRequestCount = 0;
|
|
|
1552
1554
|
let oldestActiveClientRequestAt: number | null = null;
|
|
1553
1555
|
let lastClientActivityAt = 0;
|
|
1554
1556
|
let sdkBackgroundQuietUntil = 0;
|
|
1555
|
-
|
|
1556
|
-
const
|
|
1557
|
+
type SdkBackgroundWork = { work: () => Promise<void> | void; enqueuedAt: number };
|
|
1558
|
+
const sdkBackgroundHighQueue: SdkBackgroundWork[] = [];
|
|
1559
|
+
const sdkBackgroundQueue: SdkBackgroundWork[] = [];
|
|
1557
1560
|
let sdkBackgroundTimer: NodeJS.Timeout | null = null;
|
|
1558
1561
|
let sdkBackgroundDraining = false;
|
|
1559
1562
|
let traceMaterializerWorker: any = null;
|
|
@@ -1575,23 +1578,25 @@ function noteClientActivity(now = Date.now()): void {
|
|
|
1575
1578
|
lastClientActivityAt = now;
|
|
1576
1579
|
}
|
|
1577
1580
|
|
|
1578
|
-
function getSdkBackgroundDelayMs(now = Date.now()): number {
|
|
1581
|
+
function getSdkBackgroundDelayMs(now = Date.now(), ingestEnqueuedAt?: number): number {
|
|
1582
|
+
// Bound deferral by work age, not just by a continuously active request.
|
|
1583
|
+
// Short non-overlapping requests reset oldestActiveClientRequestAt forever.
|
|
1584
|
+
const oldestWorkAt = Math.min(
|
|
1585
|
+
sdkBackgroundHighQueue[0]?.enqueuedAt ?? Infinity,
|
|
1586
|
+
sdkBackgroundQueue[0]?.enqueuedAt ?? Infinity,
|
|
1587
|
+
ingestEnqueuedAt ?? Infinity,
|
|
1588
|
+
oldestActiveClientRequestAt ?? Infinity,
|
|
1589
|
+
);
|
|
1590
|
+
const remainingDeferMs = Number.isFinite(oldestWorkAt) && Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS)
|
|
1591
|
+
? Math.max(0, oldestWorkAt + SDK_BACKGROUND_MAX_DEFER_MS - now)
|
|
1592
|
+
: Infinity;
|
|
1579
1593
|
if (hasActiveClientRequests()) {
|
|
1580
|
-
|
|
1581
|
-
oldestActiveClientRequestAt !== null &&
|
|
1582
|
-
Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS) &&
|
|
1583
|
-
SDK_BACKGROUND_MAX_DEFER_MS >= 0
|
|
1584
|
-
) {
|
|
1585
|
-
const activeForMs = now - oldestActiveClientRequestAt;
|
|
1586
|
-
if (activeForMs >= SDK_BACKGROUND_MAX_DEFER_MS) return 0;
|
|
1587
|
-
return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, Math.max(0, SDK_BACKGROUND_MAX_DEFER_MS - activeForMs));
|
|
1588
|
-
}
|
|
1589
|
-
return SDK_BACKGROUND_IDLE_DELAY_MS;
|
|
1594
|
+
return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, remainingDeferMs);
|
|
1590
1595
|
}
|
|
1591
1596
|
const sinceLastActivity = now - lastClientActivityAt;
|
|
1592
1597
|
const idleDelayRemaining = Math.max(0, SDK_BACKGROUND_IDLE_DELAY_MS - sinceLastActivity);
|
|
1593
1598
|
const quietWindowRemaining = Math.max(0, sdkBackgroundQuietUntil - now);
|
|
1594
|
-
return Math.max(idleDelayRemaining, quietWindowRemaining);
|
|
1599
|
+
return Math.min(Math.max(idleDelayRemaining, quietWindowRemaining), remainingDeferMs);
|
|
1595
1600
|
}
|
|
1596
1601
|
|
|
1597
1602
|
function shouldDeferSdkBackgroundWork(now = Date.now()): boolean {
|
|
@@ -1622,6 +1627,7 @@ function scheduleTraceMaterializerIdleShutdown(): void {
|
|
|
1622
1627
|
const worker = traceMaterializerWorker;
|
|
1623
1628
|
traceMaterializerWorker = null;
|
|
1624
1629
|
if (worker) {
|
|
1630
|
+
traceMaterializerExpectedTerminations.add(worker);
|
|
1625
1631
|
try { void worker.terminate?.(); } catch {}
|
|
1626
1632
|
}
|
|
1627
1633
|
}, TRACE_MATERIALIZE_WORKER_IDLE_SHUTDOWN_MS);
|
|
@@ -1646,7 +1652,15 @@ function handleTraceMaterializerMessage(message: TraceMaterializationWorkerRespo
|
|
|
1646
1652
|
traceMaterializerPending.delete(message.taskId);
|
|
1647
1653
|
clearTraceMaterializerTaskTimeout(message.taskId);
|
|
1648
1654
|
if (message.ok) {
|
|
1649
|
-
|
|
1655
|
+
const events = Array.isArray(message.events) ? message.events : [];
|
|
1656
|
+
for (const event of events) {
|
|
1657
|
+
// Symbols do not cross worker messages. Restore internal attachments
|
|
1658
|
+
// before transport, otherwise each occurrence embeds its full value.
|
|
1659
|
+
const entries = event.__reproTraceValueEntries;
|
|
1660
|
+
if (entries) for (const entry of entries) attachTraceValueEntry(event, entry);
|
|
1661
|
+
delete event.__reproTraceValueEntries;
|
|
1662
|
+
}
|
|
1663
|
+
pending.resolve(events);
|
|
1650
1664
|
scheduleTraceMaterializerIdleShutdown();
|
|
1651
1665
|
return;
|
|
1652
1666
|
}
|
|
@@ -1697,9 +1711,13 @@ function getTraceMaterializerWorker(): any | null {
|
|
|
1697
1711
|
try { worker.unref?.(); } catch {}
|
|
1698
1712
|
worker.on('message', handleTraceMaterializerMessage);
|
|
1699
1713
|
worker.on('error', (err: Error) => {
|
|
1714
|
+
if (worker !== traceMaterializerWorker || traceMaterializerExpectedTerminations.has(worker)) return;
|
|
1700
1715
|
disableTraceMaterializerWorker(err instanceof Error ? err : new Error(String(err)));
|
|
1701
1716
|
});
|
|
1702
1717
|
worker.on('exit', (code: number) => {
|
|
1718
|
+
// Idle/timeout shutdown can finish after a replacement worker starts.
|
|
1719
|
+
// An old worker must never clear or disable its replacement.
|
|
1720
|
+
if (worker !== traceMaterializerWorker) return;
|
|
1703
1721
|
const hadPending = traceMaterializerPending.size > 0;
|
|
1704
1722
|
const expectedTermination = traceMaterializerExpectedTerminations.has(worker);
|
|
1705
1723
|
clearTraceMaterializerIdleTimer();
|
|
@@ -1776,15 +1794,12 @@ function shouldOffloadPendingTraceEvents(events: PendingTraceEventRecord[]): boo
|
|
|
1776
1794
|
const pending = event.__reproPending;
|
|
1777
1795
|
if (!pending) continue;
|
|
1778
1796
|
if (pending.argsRaw !== undefined) {
|
|
1779
|
-
if (traceInlineValueExceedsLimit(pending.argsRaw)) return false;
|
|
1780
1797
|
pendingValueCount += 1;
|
|
1781
1798
|
}
|
|
1782
1799
|
if (pending.returnValueRaw !== undefined) {
|
|
1783
|
-
if (traceInlineValueExceedsLimit(pending.returnValueRaw)) return false;
|
|
1784
1800
|
pendingValueCount += 1;
|
|
1785
1801
|
}
|
|
1786
1802
|
if (pending.errorRaw !== undefined) {
|
|
1787
|
-
if (traceInlineValueExceedsLimit(pending.errorRaw)) return false;
|
|
1788
1803
|
pendingValueCount += 1;
|
|
1789
1804
|
}
|
|
1790
1805
|
if (pendingValueCount >= TRACE_MATERIALIZE_OFFTHREAD_MIN_VALUES) {
|
|
@@ -1804,10 +1819,8 @@ function beginClientRequest(): void {
|
|
|
1804
1819
|
oldestActiveClientRequestAt = now;
|
|
1805
1820
|
}
|
|
1806
1821
|
activeClientRequestCount += 1;
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
sdkBackgroundTimer = null;
|
|
1810
|
-
}
|
|
1822
|
+
// The scheduled drain rechecks activity; cancelling it here loses the
|
|
1823
|
+
// queued work's deadline under sustained traffic.
|
|
1811
1824
|
}
|
|
1812
1825
|
|
|
1813
1826
|
function endClientRequest(): void {
|
|
@@ -1828,15 +1841,17 @@ function scheduleSdkBackgroundWork(
|
|
|
1828
1841
|
work: () => Promise<void> | void,
|
|
1829
1842
|
options: { priority?: 'high' | 'normal' } = {},
|
|
1830
1843
|
): void {
|
|
1844
|
+
const item = { work, enqueuedAt: Date.now() };
|
|
1831
1845
|
if (options.priority === 'high') {
|
|
1832
|
-
sdkBackgroundHighQueue.push(
|
|
1846
|
+
sdkBackgroundHighQueue.push(item);
|
|
1833
1847
|
} else {
|
|
1834
|
-
sdkBackgroundQueue.push(
|
|
1848
|
+
sdkBackgroundQueue.push(item);
|
|
1835
1849
|
}
|
|
1836
1850
|
scheduleSdkBackgroundDrain();
|
|
1837
1851
|
}
|
|
1838
1852
|
|
|
1839
1853
|
function scheduleSdkBackgroundDrain(delayMs = 0): void {
|
|
1854
|
+
if (!sdkBackgroundHighQueue.length && !sdkBackgroundQueue.length) return;
|
|
1840
1855
|
if (sdkBackgroundTimer) return;
|
|
1841
1856
|
const nextDelay = Math.max(delayMs, getSdkBackgroundDelayMs());
|
|
1842
1857
|
sdkBackgroundTimer = setTimeout(() => {
|
|
@@ -1871,11 +1886,11 @@ async function drainSdkBackgroundQueue(): Promise<void> {
|
|
|
1871
1886
|
scheduleSdkBackgroundDrain();
|
|
1872
1887
|
return;
|
|
1873
1888
|
}
|
|
1874
|
-
const
|
|
1875
|
-
if (!
|
|
1889
|
+
const item = sdkBackgroundHighQueue.shift() ?? sdkBackgroundQueue.shift();
|
|
1890
|
+
if (!item) continue;
|
|
1876
1891
|
try {
|
|
1877
1892
|
await drainQueuedIngestBeforeBackgroundWork();
|
|
1878
|
-
await runSdkBackgroundWork(work);
|
|
1893
|
+
await runSdkBackgroundWork(item.work);
|
|
1879
1894
|
} catch {
|
|
1880
1895
|
// never break user code
|
|
1881
1896
|
}
|
|
@@ -1891,8 +1906,8 @@ async function drainSdkBackgroundQueue(): Promise<void> {
|
|
|
1891
1906
|
}
|
|
1892
1907
|
|
|
1893
1908
|
configureIngestQueueDrain({
|
|
1894
|
-
shouldDefer:
|
|
1895
|
-
deferMs: SDK_BACKGROUND_IDLE_DELAY_MS,
|
|
1909
|
+
shouldDefer: (enqueuedAt) => getSdkBackgroundDelayMs(Date.now(), enqueuedAt) > 0,
|
|
1910
|
+
deferMs: Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, SDK_BACKGROUND_MAX_DEFER_MS),
|
|
1896
1911
|
});
|
|
1897
1912
|
|
|
1898
1913
|
// Track in-flight requests per session so we can delay flushing until a session is drained.
|
|
@@ -3427,8 +3442,12 @@ function internTraceObservationSnapshot(
|
|
|
3427
3442
|
return snapshot;
|
|
3428
3443
|
}
|
|
3429
3444
|
|
|
3430
|
-
|
|
3431
|
-
|
|
3445
|
+
// These are already detached data snapshots. Binary serialization avoids
|
|
3446
|
+
// sorting and cloning the whole graph again on the application's call stack,
|
|
3447
|
+
// while preserving undefined, NaN, and other non-JSON primitive distinctions.
|
|
3448
|
+
let serialized: Buffer;
|
|
3449
|
+
try { serialized = serializeSnapshot(snapshot); }
|
|
3450
|
+
catch { return snapshot; } // Interning is optional; the snapshot is not.
|
|
3432
3451
|
|
|
3433
3452
|
const digest = createHash('sha256')
|
|
3434
3453
|
.update('repro-trace-observation-v1\0')
|
|
@@ -3702,6 +3721,17 @@ async function materializePendingTraceEventsAsync(
|
|
|
3702
3721
|
return events;
|
|
3703
3722
|
}
|
|
3704
3723
|
|
|
3724
|
+
// Bound work per worker message and timeout, not the captured data itself.
|
|
3725
|
+
// A long request must not become one enormous structured-clone operation.
|
|
3726
|
+
if (events.length > 64) {
|
|
3727
|
+
const materialized: TraceEventRecord[] = [];
|
|
3728
|
+
for (let offset = 0; offset < events.length; offset += 64) {
|
|
3729
|
+
materialized.push(...await materializePendingTraceEventsAsync(events.slice(offset, offset + 64), params));
|
|
3730
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
3731
|
+
}
|
|
3732
|
+
return materialized;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3705
3735
|
const privacy = params.resolvePrivacy();
|
|
3706
3736
|
if (!shouldOffloadPendingTraceEvents(events)) {
|
|
3707
3737
|
try {
|
|
@@ -3720,7 +3750,7 @@ async function materializePendingTraceEventsAsync(
|
|
|
3720
3750
|
}
|
|
3721
3751
|
}
|
|
3722
3752
|
try {
|
|
3723
|
-
|
|
3753
|
+
const materialized = await materializePendingTraceEventsOffThread(events, {
|
|
3724
3754
|
events,
|
|
3725
3755
|
cfg: {
|
|
3726
3756
|
tenantId: params.cfg.tenantId,
|
|
@@ -3736,6 +3766,15 @@ async function materializePendingTraceEventsAsync(
|
|
|
3736
3766
|
fullValueCaptureEnabled: __FULL_VALUE_CAPTURE_ENABLED,
|
|
3737
3767
|
valueNamespace: params.valueNamespace,
|
|
3738
3768
|
});
|
|
3769
|
+
for (const event of materialized) {
|
|
3770
|
+
for (const [field, captureField] of [['args', 'argsValueCapture'], ['returnValue', 'returnValueCapture'], ['error', 'errorValueCapture']] as const) {
|
|
3771
|
+
if (event[captureField]?.status === 'failed-policy') {
|
|
3772
|
+
recordRequestCaptureFailure(params.captureScope ?? getCtx().captureScope,
|
|
3773
|
+
'privacy.full_value', `trace.${field}`, 'worker_value_materialization_failed');
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
return materialized;
|
|
3739
3778
|
} catch (err) {
|
|
3740
3779
|
try {
|
|
3741
3780
|
return await materializePendingTraceEvents(events, {
|
|
@@ -3772,10 +3811,14 @@ export async function __materializePendingTraceEventsForWorker(
|
|
|
3772
3811
|
resolvePrivacy: () => payload.privacy,
|
|
3773
3812
|
valueNamespace: payload.valueNamespace,
|
|
3774
3813
|
});
|
|
3814
|
+
const values = new Map<string, any>();
|
|
3775
3815
|
return materialized.map((event) => {
|
|
3776
3816
|
const entries = getEventTraceValueEntries(event);
|
|
3777
3817
|
if (entries?.length) {
|
|
3778
|
-
(event as PendingTraceEventRecord).__reproTraceValueEntries = entries.map((entry) =>
|
|
3818
|
+
(event as PendingTraceEventRecord).__reproTraceValueEntries = entries.map((entry) => {
|
|
3819
|
+
if (!values.has(entry.id)) values.set(entry.id, entry.value);
|
|
3820
|
+
return { ...entry, value: values.get(entry.id) };
|
|
3821
|
+
});
|
|
3779
3822
|
}
|
|
3780
3823
|
return event as PendingTraceEventRecord;
|
|
3781
3824
|
});
|
|
@@ -3922,7 +3965,7 @@ type RuntimePrivacyState = {
|
|
|
3922
3965
|
|
|
3923
3966
|
const RUNTIME_PRIVACY_STATE_SYMBOL = Symbol('repro.runtimePrivacyState');
|
|
3924
3967
|
const RUNTIME_PRIVACY_RETRY_COOLDOWN_MS = 5000;
|
|
3925
|
-
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS =
|
|
3968
|
+
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 15000;
|
|
3926
3969
|
const DEFAULT_RUNTIME_PRIVACY_STARTUP_RETRY_DELAY_MS = 150;
|
|
3927
3970
|
|
|
3928
3971
|
function normalizeMaskTargets(target: ReproMaskTarget | ReproMaskTarget[]): ReproMaskTarget[] {
|
|
@@ -4101,7 +4144,7 @@ function handleRuntimePrivacyFetchResult(
|
|
|
4101
4144
|
cfg,
|
|
4102
4145
|
state,
|
|
4103
4146
|
`network:${result.error}:${apiBase ?? 'no-api-base'}`,
|
|
4104
|
-
`privacy policy fetch failed while contacting ${apiBase}`,
|
|
4147
|
+
`privacy policy fetch failed while contacting ${apiBase}: ${result.error}`,
|
|
4105
4148
|
);
|
|
4106
4149
|
state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
|
|
4107
4150
|
return null;
|
|
@@ -5139,6 +5182,8 @@ async function cloneFullValueCandidateAsync(
|
|
|
5139
5182
|
seen: WeakMap<object, string> = new WeakMap(),
|
|
5140
5183
|
valuePath = '$',
|
|
5141
5184
|
): Promise<any> {
|
|
5185
|
+
const pause = yieldCaptureWork();
|
|
5186
|
+
if (pause) await pause;
|
|
5142
5187
|
if (value === undefined) return undefined;
|
|
5143
5188
|
if (value === null || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
5144
5189
|
if (typeof value === 'string') {
|
package/src/ingest/client.ts
CHANGED
|
@@ -44,6 +44,7 @@ type QueuedIngestBatch = {
|
|
|
44
44
|
sessionId: string;
|
|
45
45
|
entries: LegacyEntry[];
|
|
46
46
|
attempts: number;
|
|
47
|
+
enqueuedAt: number;
|
|
47
48
|
priority: 'normal' | 'high' | 'critical';
|
|
48
49
|
};
|
|
49
50
|
|
|
@@ -59,13 +60,13 @@ let draining = false;
|
|
|
59
60
|
let criticalDraining = false;
|
|
60
61
|
let backoffMs = 0;
|
|
61
62
|
let criticalBackoffMs = 0;
|
|
62
|
-
let shouldDeferDrain: (() => boolean) | null = null;
|
|
63
|
+
let shouldDeferDrain: ((enqueuedAt?: number) => boolean) | null = null;
|
|
63
64
|
let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
|
|
64
65
|
const throttledUntil = new Map<string, number>();
|
|
65
66
|
const throttleDelay = (config: IngestClientConfig) => Math.max(0, (throttledUntil.get(resolveIngestUrl(config)) ?? 0) - Date.now());
|
|
66
67
|
|
|
67
68
|
export const configureIngestQueueDrain = (options: {
|
|
68
|
-
shouldDefer?: (() => boolean) | null;
|
|
69
|
+
shouldDefer?: ((enqueuedAt?: number) => boolean) | null;
|
|
69
70
|
deferMs?: number;
|
|
70
71
|
}): void => {
|
|
71
72
|
shouldDeferDrain = options.shouldDefer ?? null;
|
|
@@ -79,7 +80,7 @@ export const kickIngestQueueDrain = (): void => {
|
|
|
79
80
|
scheduleCriticalDrain();
|
|
80
81
|
}
|
|
81
82
|
if (ingestQueue.length > 0 && !drainTimer) {
|
|
82
|
-
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
83
|
+
scheduleDrain(shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt) ? deferDrainMs : 0);
|
|
83
84
|
}
|
|
84
85
|
};
|
|
85
86
|
|
|
@@ -288,12 +289,12 @@ const scheduleDrain = (delayMs = 0): void => {
|
|
|
288
289
|
if (drainTimer) {
|
|
289
290
|
return;
|
|
290
291
|
}
|
|
291
|
-
const nextDelay = shouldDeferDrain?.()
|
|
292
|
+
const nextDelay = shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)
|
|
292
293
|
? Math.max(delayMs, deferDrainMs)
|
|
293
294
|
: delayMs;
|
|
294
295
|
drainTimer = setTimeout(() => {
|
|
295
296
|
drainTimer = null;
|
|
296
|
-
if (shouldDeferDrain?.()) {
|
|
297
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
297
298
|
scheduleDrain(deferDrainMs);
|
|
298
299
|
return;
|
|
299
300
|
}
|
|
@@ -365,14 +366,14 @@ const drainIngestQueue = async (): Promise<void> => {
|
|
|
365
366
|
if (draining) {
|
|
366
367
|
return;
|
|
367
368
|
}
|
|
368
|
-
if (shouldDeferDrain?.()) {
|
|
369
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
369
370
|
scheduleDrain(deferDrainMs);
|
|
370
371
|
return;
|
|
371
372
|
}
|
|
372
373
|
draining = true;
|
|
373
374
|
try {
|
|
374
375
|
while (ingestQueue.length > 0) {
|
|
375
|
-
if (shouldDeferDrain?.()) {
|
|
376
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
376
377
|
scheduleDrain(deferDrainMs);
|
|
377
378
|
return;
|
|
378
379
|
}
|
|
@@ -444,6 +445,7 @@ export const enqueueIngestEntries = (
|
|
|
444
445
|
sessionId,
|
|
445
446
|
entries: entries.slice(),
|
|
446
447
|
attempts: 0,
|
|
448
|
+
enqueuedAt: Date.now(),
|
|
447
449
|
priority,
|
|
448
450
|
};
|
|
449
451
|
if (priority === 'critical') {
|
package/src/privacy.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
+
import { yieldCaptureWork } from './capture-work-budget';
|
|
2
3
|
import { sanitizeUnknownBoundaryTextWithDetectors } from './privacy-fallback';
|
|
3
4
|
import { redactEmailDeterministic, redactEmailsInText } from './privacy-redaction';
|
|
4
5
|
|
|
@@ -236,7 +237,7 @@ export function resolvePrivacyApiBase(cfg?: RuntimePrivacyConfig | null): string
|
|
|
236
237
|
return String(value).replace(/\/+$/, '');
|
|
237
238
|
}
|
|
238
239
|
|
|
239
|
-
const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS =
|
|
240
|
+
const DEFAULT_RUNTIME_PRIVACY_FETCH_TIMEOUT_MS = 10000;
|
|
240
241
|
|
|
241
242
|
function resolveRuntimePrivacyFetchTimeoutMs(value?: number | null): number {
|
|
242
243
|
if (!Number.isFinite(value)) {
|
|
@@ -971,6 +972,8 @@ async function transformValueAsync(
|
|
|
971
972
|
path: string[],
|
|
972
973
|
fallbackInput: RuntimePrivacyAction | TransformFallback = null,
|
|
973
974
|
): Promise<any> {
|
|
975
|
+
const pause = yieldCaptureWork();
|
|
976
|
+
if (pause) await pause;
|
|
974
977
|
const fallback = resolveTransformFallback(value, fallbackInput);
|
|
975
978
|
const objectId = coercePrivacyObjectId(value);
|
|
976
979
|
if (objectId !== null) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '2000';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '200';
|
|
4
|
+
process.env.TRACE_IDLE_FLUSH_MS = '10';
|
|
5
|
+
process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
|
|
6
|
+
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const http = require('node:http');
|
|
9
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
10
|
+
const { initRepro } = require('../dist');
|
|
11
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
12
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
const events = [];
|
|
16
|
+
let rejectedOnce = false;
|
|
17
|
+
const sink = http.createServer((req, res) => {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
20
|
+
req.on('end', () => {
|
|
21
|
+
const batch = JSON.parse(Buffer.concat(chunks)).events;
|
|
22
|
+
if (!rejectedOnce && batch.some(event => event.event_type === 'backend_request')) {
|
|
23
|
+
rejectedOnce = true;
|
|
24
|
+
res.writeHead(503); res.end('{}'); return;
|
|
25
|
+
}
|
|
26
|
+
events.push(...batch);
|
|
27
|
+
res.writeHead(202); res.end('{}');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
await new Promise(resolve => sink.listen(0, '127.0.0.1', resolve));
|
|
31
|
+
let mongo, app, polling;
|
|
32
|
+
let stopped = false, polls = 0, maxPollGap = 0;
|
|
33
|
+
try {
|
|
34
|
+
mongo = await new GenericContainer('mongo:7').withExposedPorts(27017)
|
|
35
|
+
.withWaitStrategy(Wait.forListeningPorts()).start();
|
|
36
|
+
const config = { tenantId: 'TENANT_busy', appId: 'APP_busy', appSecret: 'test',
|
|
37
|
+
privacy: { environment: 'dev', captureRaw: true },
|
|
38
|
+
ingestBase: `http://127.0.0.1:${sink.address().port}`,
|
|
39
|
+
tracing: { fullValueCapture: true, disableFunctionTypes: ['constructor'], logFunctionCalls: false } };
|
|
40
|
+
await initRepro(config);
|
|
41
|
+
app = await require('./fixtures/live-capture-limits-server').start(config,
|
|
42
|
+
`mongodb://${mongo.getHost()}:${mongo.getMappedPort(27017)}/busy`);
|
|
43
|
+
const base = `http://127.0.0.1:${app.server.address().port}`;
|
|
44
|
+
polling = (async () => {
|
|
45
|
+
let previous = Date.now();
|
|
46
|
+
while (!stopped) {
|
|
47
|
+
// Untagged, short requests also participate in the SDK's activity tracking.
|
|
48
|
+
const response = await fetch(`${base}/uncorrelated`);
|
|
49
|
+
await response.text();
|
|
50
|
+
maxPollGap = Math.max(maxPollGap, Date.now() - previous);
|
|
51
|
+
previous = Date.now();
|
|
52
|
+
polls++;
|
|
53
|
+
await sleep(20);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
for (const mode of ['live', 'staging']) {
|
|
57
|
+
const response = await fetch(`${base}/shipment?__repro_sid=S_busy_${mode}&__repro_aid=A_${mode}&__repro_mode=${mode}`);
|
|
58
|
+
assert.equal(response.status, 200);
|
|
59
|
+
const body = await response.json();
|
|
60
|
+
assert.equal(body.rows.length, 157, 'capture must not change business responses');
|
|
61
|
+
assert.equal(body.rows[156].id, 156);
|
|
62
|
+
}
|
|
63
|
+
const complete = () => ['live', 'staging'].every(mode => {
|
|
64
|
+
const scoped = events.filter(event => event.session_id === `S_busy_${mode}`);
|
|
65
|
+
return ['request_capture_start', 'backend_request', 'request_capture_manifest', 'trace_batch', 'db_change']
|
|
66
|
+
.every(type => scoped.some(event => event.event_type === type));
|
|
67
|
+
});
|
|
68
|
+
// Do not call flushIngestQueue: automatic delivery must work while traffic continues.
|
|
69
|
+
const deadline = Date.now() + 6000;
|
|
70
|
+
while (!complete() && Date.now() < deadline) await sleep(25);
|
|
71
|
+
assert(polls > 5, 'continuous traffic must overlap capture finalization');
|
|
72
|
+
assert(maxPollGap < 2000, `traffic must not accidentally create an idle window: ${maxPollGap}ms`);
|
|
73
|
+
assert(complete(), `uploads starved during short requests: ${JSON.stringify(events.map(e => [e.session_id, e.event_type]))}`);
|
|
74
|
+
assert(rejectedOnce, 'the test must exercise a real collector retry');
|
|
75
|
+
for (const mode of ['live', 'staging']) {
|
|
76
|
+
const scoped = events.filter(event => event.session_id === `S_busy_${mode}`);
|
|
77
|
+
const requests = scoped.filter(event => event.event_type === 'backend_request');
|
|
78
|
+
assert.equal(requests.length, 1, 'finish and close must not duplicate requests');
|
|
79
|
+
const request = requests[0].payload.request;
|
|
80
|
+
const values = scoped.flatMap(e => [...(e.payload.requestValues || []), ...(e.payload.traceValues || []), ...(e.payload.dbValues || [])]);
|
|
81
|
+
const body = values.find(v => v.id === request.respBodyValueCapture?.valueId)?.value || request.respBody;
|
|
82
|
+
assert.equal(body.rows.length, mode === 'live' ? 101 : 157);
|
|
83
|
+
const trace = scoped.flatMap(e => e.payload.trace || []).find(e => e.fn === 'inspectShipment' && e.type === 'enter');
|
|
84
|
+
assert(trace, 'instrumented function arguments must survive background delivery');
|
|
85
|
+
const args = values.find(v => v.id === trace.argsValueCapture?.valueId)?.value || trace.args;
|
|
86
|
+
assert.equal(args[0].rows.length, mode === 'live' ? 101 : 157);
|
|
87
|
+
assert(scoped.flatMap(e => e.payload.db || []).some(change => change.op === 'findOne'));
|
|
88
|
+
}
|
|
89
|
+
console.log(`continuous short HTTP traffic: complete live/staging captures delivered during ${polls} requests`);
|
|
90
|
+
} finally {
|
|
91
|
+
stopped = true;
|
|
92
|
+
if (polling) await polling;
|
|
93
|
+
if (app) await app.close();
|
|
94
|
+
await sleep(2100);
|
|
95
|
+
await flushIngestQueue();
|
|
96
|
+
await new Promise(resolve => sink.close(resolve));
|
|
97
|
+
if (mongo) await mongo.stop();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const http = require('node:http');
|
|
3
|
+
const { fork } = require('node:child_process');
|
|
4
|
+
const { once } = require('node:events');
|
|
5
|
+
const { performance } = require('node:perf_hooks');
|
|
6
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
7
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
8
|
+
const policy = { environment: 'dev', statements: [
|
|
9
|
+
...Array.from({ length: 5000 }, (_, i) => ({ sid: `f${i}`, when: { scope: 'FUNCTION', selector: `Service${i}.method`, target: 'trace.args|trace.returnValue' }, apply: { action: 'KEEP_EXACT' } })),
|
|
10
|
+
{ sid: 'secret', when: { scope: 'FIELD', selector: 'field:internalSecret' }, apply: { action: 'DROP' } },
|
|
11
|
+
] };
|
|
12
|
+
async function main() {
|
|
13
|
+
const events = [];
|
|
14
|
+
const sink = http.createServer((req, res) => {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
17
|
+
req.on('end', () => {
|
|
18
|
+
const body = JSON.parse(Buffer.concat(chunks));
|
|
19
|
+
events.push(...(body.events || []));
|
|
20
|
+
res.writeHead(202); res.end('{}');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
let mongo, child;
|
|
24
|
+
await new Promise(resolve => sink.listen(0, '127.0.0.1', resolve));
|
|
25
|
+
const results = [];
|
|
26
|
+
try {
|
|
27
|
+
mongo = await new GenericContainer('mongo:7').withExposedPorts(27017).withWaitStrategy(Wait.forListeningPorts()).start();
|
|
28
|
+
for (const variant of ['off', 'live-raw', 'staging-raw', 'live-policy', 'staging-policy', 'live-policy-fallback', 'staging-policy-fallback', 'staging-policy-fault']) {
|
|
29
|
+
const mode = variant.split('-')[0];
|
|
30
|
+
child = fork(require.resolve('./fixtures/capture-latency-server'), [], {
|
|
31
|
+
env: { ...process.env, CAPTURE_TEST_MODE: mode,
|
|
32
|
+
CAPTURE_TEST_MONGO: `mongodb://${mongo.getHost()}:${mongo.getMappedPort(27017)}/latency_${variant}`,
|
|
33
|
+
CAPTURE_TEST_SINK: `http://127.0.0.1:${sink.address().port}`,
|
|
34
|
+
CAPTURE_TEST_POLICY: variant.includes('-policy') ? JSON.stringify(policy) : '',
|
|
35
|
+
REPRO_TRACE_MATERIALIZE_OFFTHREAD: variant.endsWith('fallback') ? 'false' : 'true',
|
|
36
|
+
CAPTURE_TEST_WORKER_FAULT: variant.endsWith('fault') ? 'true' : '',
|
|
37
|
+
REPRO_SDK_BACKGROUND_IDLE_DELAY_MS: '0', REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS: '0',
|
|
38
|
+
REPRO_SDK_BACKGROUND_MAX_DEFER_MS: '0', TRACE_IDLE_FLUSH_MS: '10', TRACE_LINGER_AFTER_FINISH_MS: '0',
|
|
39
|
+
}, stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
40
|
+
});
|
|
41
|
+
let stderr = '';
|
|
42
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
43
|
+
const ready = await Promise.race([once(child, 'message').then(([value]) => value), once(child, 'exit').then(() => { throw new Error(stderr); })]);
|
|
44
|
+
const base = `http://127.0.0.1:${ready.port}`;
|
|
45
|
+
const healthTimes = [], requestTimes = [];
|
|
46
|
+
let stop = false;
|
|
47
|
+
const polling = (async () => {
|
|
48
|
+
while (!stop) {
|
|
49
|
+
const start = performance.now();
|
|
50
|
+
await (await fetch(`${base}/health`)).json();
|
|
51
|
+
healthTimes.push(performance.now() - start);
|
|
52
|
+
await sleep(10);
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
try {
|
|
56
|
+
// Both startup-idle and between-wave idle must permit a fresh worker.
|
|
57
|
+
for (let wave = 0; wave < 2; wave++) {
|
|
58
|
+
await sleep(800);
|
|
59
|
+
const sid = `S_latency_${variant}_${wave}`;
|
|
60
|
+
const start = performance.now();
|
|
61
|
+
const body = await (await fetch(`${base}/names${mode === 'off' ? '' : `?__repro_sid=${sid}&__repro_aid=A_${wave}&__repro_mode=${mode}`}`)).json();
|
|
62
|
+
requestTimes.push(performance.now() - start);
|
|
63
|
+
assert.equal(body.rows.length, 120);
|
|
64
|
+
assert.equal(body.rows[119].internalSecret, 'secret-119', 'business responses must not be redacted or limited');
|
|
65
|
+
assert.equal(body.phase, 11);
|
|
66
|
+
if (mode !== 'off') {
|
|
67
|
+
const deadline = Date.now() + 60000;
|
|
68
|
+
const scoped = () => events.filter(e => e.session_id === sid);
|
|
69
|
+
const complete = () => {
|
|
70
|
+
const capture = scoped();
|
|
71
|
+
const manifest = capture.find(e => e.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest;
|
|
72
|
+
return manifest && ['backend_request', 'db_change'].every(t => capture.some(e => e.event_type === t))
|
|
73
|
+
&& capture.filter(e => e.event_type === 'trace_batch').length >= manifest.expected.traceBatch;
|
|
74
|
+
};
|
|
75
|
+
while (!complete() && Date.now() < deadline) await sleep(25);
|
|
76
|
+
assert(complete(), `incomplete ${variant}: ${stderr.slice(-2000)}`);
|
|
77
|
+
const captured = scoped();
|
|
78
|
+
assert(!JSON.stringify(captured).includes('__reproTraceValueEntries'), 'worker-only attachments must never be serialized into each occurrence');
|
|
79
|
+
const values = new Map(captured.flatMap(e => [...(e.payload.traceValues || []), ...(e.payload.requestValues || []), ...(e.payload.dbValues || [])]).map(v => [v.id, v.value]));
|
|
80
|
+
const trace = captured.flatMap(e => e.payload.trace || []);
|
|
81
|
+
// The tracer also emits the function-body entry; verify the twelve callsites.
|
|
82
|
+
const observations = [...new Map(trace.filter(e => e.fn === 'observeRows' && e.line === 6 && e.type === 'enter' && (e.args || e.argsValueCapture))
|
|
83
|
+
.map(e => [e.spanId, e])).values()];
|
|
84
|
+
const resolve = (e, field) => values.get(e[`${field}ValueCapture`]?.valueId) ?? e[field];
|
|
85
|
+
assert.equal(observations.length, 12, JSON.stringify(observations.map(e => ({spanId:e.spanId, file:e.file, line:e.line, phase:resolve(e,'args')?.[0]?.phase}))));
|
|
86
|
+
if (variant.endsWith('fault')) {
|
|
87
|
+
assert(observations.some(e => e.argsValueCapture?.status === 'failed-policy'));
|
|
88
|
+
const manifest = captured.find(e => e.event_type === 'request_capture_manifest').payload.requestCaptureManifest;
|
|
89
|
+
assert.equal(manifest.privacy.status, 'failed', 'worker errors must not falsely attest applied privacy');
|
|
90
|
+
assert(manifest.captureFailures.some(f => f.reason === 'worker_value_materialization_failed'));
|
|
91
|
+
assert(!JSON.stringify(captured).includes('secret-0'), 'failed policy capture must never fall back to raw');
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
assert.deepEqual(observations.map(e => resolve(e, 'args')[0].phase), Array.from({ length: 12 }, (_, i) => i));
|
|
95
|
+
const document = resolve(observations[0], 'args')[0];
|
|
96
|
+
assert.equal(document.rows.length, mode === 'live' ? 101 : 120);
|
|
97
|
+
if (mode === 'live') assert.equal(document.rows[100].reason, 'max-entries');
|
|
98
|
+
assert.equal(document.rows[0].internalSecret, variant.includes('-policy') ? '[dropped]' : 'secret-0');
|
|
99
|
+
const manifest = captured.find(e => e.event_type === 'request_capture_manifest').payload.requestCaptureManifest;
|
|
100
|
+
assert(!manifest.captureFailures?.length, JSON.stringify(manifest.captureFailures));
|
|
101
|
+
assert.equal(manifest.privacy.status, variant.includes('-policy') ? 'applied' : 'bypassed');
|
|
102
|
+
const stats = await (await fetch(`${base}/health`)).json();
|
|
103
|
+
if (variant.endsWith('fallback')) assert.equal(stats.tasks, 0);
|
|
104
|
+
else if (!process.env.REPRO_BENCHMARK_ONLY) assert(stats.tasks >= wave + 1, `${variant}: trace worker stopped accepting large captures after idle: ${JSON.stringify(stats)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const stats = await (await fetch(`${base}/health`)).json();
|
|
108
|
+
results.push({ variant, requestMs: requestTimes.map(Math.round), maxHealthMs: Math.round(Math.max(...healthTimes)), workers: stats.workers, tasks: stats.tasks });
|
|
109
|
+
console.log(JSON.stringify(results.at(-1)));
|
|
110
|
+
} finally { stop = true; await polling; }
|
|
111
|
+
const exited = once(child, 'exit'); child.send('stop'); await exited; child = null;
|
|
112
|
+
}
|
|
113
|
+
if (!process.env.REPRO_BENCHMARK_ONLY) {
|
|
114
|
+
for (const result of results.slice(1)) assert(result.maxHealthMs < 250, `capture stalled unrelated HTTP: ${JSON.stringify(result)}`);
|
|
115
|
+
}
|
|
116
|
+
console.log('live/staging capture latency and exact evidence testcontainers OK');
|
|
117
|
+
} finally {
|
|
118
|
+
if (child) { const exited = once(child, 'exit'); child.kill(); await exited; }
|
|
119
|
+
await new Promise(resolve => sink.close(resolve));
|
|
120
|
+
if (mongo) await mongo.stop();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -118,15 +118,19 @@ async function runRequest(config, sessionId, repeatedPayload, changedPayload = m
|
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
const deadline = Date.now() +
|
|
121
|
+
const deadline = Date.now() + 30000;
|
|
122
|
+
let completed = false;
|
|
122
123
|
while (Date.now() < deadline) {
|
|
123
124
|
const events = posts.flatMap((post) => post.events || []);
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
const manifest = events.find(event => event.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest;
|
|
126
|
+
if (manifest && events.some(event => event.event_type === 'backend_request') &&
|
|
127
|
+
events.filter(event => event.event_type === 'trace_batch').length === manifest.expected.traceBatch) {
|
|
128
|
+
completed = true;
|
|
126
129
|
break;
|
|
127
130
|
}
|
|
128
131
|
await sleep(20);
|
|
129
132
|
}
|
|
133
|
+
assert(completed, `capture did not finish for ${sessionId}`);
|
|
130
134
|
await __reproTestHooks.drainSdkBackgroundQueueForTest();
|
|
131
135
|
await flushIngestQueue();
|
|
132
136
|
return posts;
|
|
@@ -228,7 +232,11 @@ async function main() {
|
|
|
228
232
|
makePayload(false, 'policy-secret-alpha'),
|
|
229
233
|
makePayload(false, 'policy-secret-beta'),
|
|
230
234
|
));
|
|
231
|
-
assert.equal(policyCapture.refs.length, 122
|
|
235
|
+
assert.equal(policyCapture.refs.length, 122, JSON.stringify({
|
|
236
|
+
eventTypes: policyCapture.ingestEvents.map(event => event.event_type),
|
|
237
|
+
observations: policyCapture.observedEvents.slice(0, 2),
|
|
238
|
+
manifests: policyCapture.ingestEvents.filter(event => event.event_type === 'request_capture_manifest'),
|
|
239
|
+
}));
|
|
232
240
|
assert.equal(new Set(policyCapture.refs).size, 1);
|
|
233
241
|
assert.equal(policyCapture.definitions.length, 1);
|
|
234
242
|
const policySerialized = JSON.stringify(policyCapture.definitions);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function observeRows(document) { return document; }
|
|
2
|
+
async function loadRows(Model) {
|
|
3
|
+
const document = await Model.findOne({ reference: 'LATENCY' }).lean().exec();
|
|
4
|
+
for (let i = 0; i < 12; i++) {
|
|
5
|
+
document.phase = i;
|
|
6
|
+
if (observeRows(document) !== document) throw new Error('return identity changed');
|
|
7
|
+
}
|
|
8
|
+
return document;
|
|
9
|
+
}
|
|
10
|
+
module.exports = { loadRows };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const workerThreads = require('node:worker_threads');
|
|
2
|
+
const metrics = { workers: 0, tasks: 0 };
|
|
3
|
+
const RealWorker = workerThreads.Worker;
|
|
4
|
+
workerThreads.Worker = class extends RealWorker {
|
|
5
|
+
constructor(...args) {
|
|
6
|
+
if (process.env.CAPTURE_TEST_WORKER_FAULT && String(args[0]).includes('trace-materializer-worker')) {
|
|
7
|
+
args[1] = { ...args[1], execArgv: ['--require', require.resolve('./worker-value-failure')] };
|
|
8
|
+
}
|
|
9
|
+
super(...args);
|
|
10
|
+
if (String(args[0]).includes('trace-materializer-worker')) {
|
|
11
|
+
metrics.workers++;
|
|
12
|
+
const post = this.postMessage.bind(this);
|
|
13
|
+
this.postMessage = (...messages) => { metrics.tasks++; return post(...messages); };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const http = require('node:http');
|
|
18
|
+
const mode = process.env.CAPTURE_TEST_MODE;
|
|
19
|
+
const { initRepro, reproMongoosePlugin } = require(process.env.CAPTURE_TEST_SDK || '../../dist');
|
|
20
|
+
async function main() {
|
|
21
|
+
const config = {
|
|
22
|
+
tenantId: 'TENANT_latency', appId: 'APP_latency', appSecret: 'test-only',
|
|
23
|
+
appName: 'latency-test', serviceName: 'Latency API', liveMode: false,
|
|
24
|
+
tracing: { include: [/capture-latency-business\.js$/], disableFunctionTypes: ['constructor'], logFunctionCalls: false },
|
|
25
|
+
privacy: { environment: 'dev', captureRaw: !process.env.CAPTURE_TEST_POLICY,
|
|
26
|
+
...(process.env.CAPTURE_TEST_POLICY ? { policy: JSON.parse(process.env.CAPTURE_TEST_POLICY) } : {}) },
|
|
27
|
+
ingestBase: process.env.CAPTURE_TEST_SINK,
|
|
28
|
+
};
|
|
29
|
+
if (mode !== 'off') await initRepro(config);
|
|
30
|
+
const mongoose = require('mongoose');
|
|
31
|
+
const express = require('express');
|
|
32
|
+
const connection = await mongoose.createConnection(process.env.CAPTURE_TEST_MONGO).asPromise();
|
|
33
|
+
const schema = new mongoose.Schema({ reference: String, phase: Number, rows: [Object] });
|
|
34
|
+
if (mode !== 'off') schema.plugin(reproMongoosePlugin, config);
|
|
35
|
+
const Model = connection.model('Latency', schema);
|
|
36
|
+
await Model.create({ reference: 'LATENCY', phase: -1, rows: Array.from({ length: 120 }, (_, id) => ({
|
|
37
|
+
id, status: 'available', internalSecret: `secret-${id}`, description: 'Operational depot description '.repeat(32),
|
|
38
|
+
})) });
|
|
39
|
+
const { loadRows } = require('./capture-latency-business');
|
|
40
|
+
const app = express();
|
|
41
|
+
app.get('/health', (_req, res) => res.json({ ok: true, ...metrics }));
|
|
42
|
+
app.get('/names', async (_req, res, next) => {
|
|
43
|
+
try { res.json(await loadRows(Model)); } catch (e) { next(e); }
|
|
44
|
+
});
|
|
45
|
+
const server = http.createServer(app);
|
|
46
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
47
|
+
process.send({ port: server.address().port });
|
|
48
|
+
process.once('message', async () => {
|
|
49
|
+
await new Promise(resolve => server.close(resolve));
|
|
50
|
+
await connection.close();
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
main().catch(error => { console.error(error); process.exit(1); });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Fault injection inside a real worker: preview succeeds, full-value policy
|
|
2
|
+
// transformation fails. The parent must not publish a successful attestation.
|
|
3
|
+
const privacy = require('../../dist/privacy');
|
|
4
|
+
const apply = privacy.applyRuntimePrivacyPolicyAsync;
|
|
5
|
+
let calls = 0;
|
|
6
|
+
privacy.applyRuntimePrivacyPolicyAsync = async (...args) => {
|
|
7
|
+
if (args[1] === 'trace.args' && args[2]?.[0]?.rows?.length === 120 && ++calls % 2 === 0) {
|
|
8
|
+
throw new Error('injected full-value policy failure');
|
|
9
|
+
}
|
|
10
|
+
return apply(...args);
|
|
11
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const http = require('node:http');
|
|
3
|
+
const { fetchApprovedRuntimePrivacyPolicy } = require('../dist/privacy');
|
|
4
|
+
const policy = { environment: 'dev', statements: [{ sid: 'secret', when: { scope: 'FIELD', selector: 'field:internalSecret' }, apply: { action: 'DROP' } }], notes: 'policy-analysis '.repeat(140000) };
|
|
5
|
+
async function main() {
|
|
6
|
+
const server = http.createServer((req, res) => {
|
|
7
|
+
const service = new URL(req.url, 'http://local').searchParams.get('serviceName');
|
|
8
|
+
if (service === 'forbidden') { res.writeHead(403); res.end('{}'); return; }
|
|
9
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
10
|
+
res.flushHeaders();
|
|
11
|
+
const timer = setTimeout(() => res.end(JSON.stringify({ enabled: true, policy })), 2800);
|
|
12
|
+
res.once('close', () => clearTimeout(timer));
|
|
13
|
+
});
|
|
14
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
15
|
+
const params = { appId: 'APP_test', appSecret: 'test-only', environment: 'dev', apiBase: `http://127.0.0.1:${server.address().port}` };
|
|
16
|
+
try {
|
|
17
|
+
const result = await fetchApprovedRuntimePrivacyPolicy(params);
|
|
18
|
+
assert.equal(result.status, 'ok', 'a valid multi-megabyte policy must survive the observed production response latency');
|
|
19
|
+
assert.equal(result.policy.statements.length, 1);
|
|
20
|
+
assert.deepEqual(await fetchApprovedRuntimePrivacyPolicy({ ...params, fetchTimeoutMs: 250 }), { status: 'network_error', error: 'timeout' });
|
|
21
|
+
assert.deepEqual(await fetchApprovedRuntimePrivacyPolicy({ ...params, serviceName: 'forbidden' }), { status: 'http_error', httpStatus: 403 });
|
|
22
|
+
console.log('policy body timeout and explicit timeout/auth failures OK');
|
|
23
|
+
} finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
|
|
24
|
+
}
|
|
25
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|