@reproapp/node-sdk 0.0.51 → 0.0.53
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 +65 -17
- package/dist/privacy.js +10 -5
- package/package.json +5 -4
- package/src/capture-work-budget.ts +16 -0
- package/src/index.ts +57 -14
- package/src/privacy.ts +8 -5
- 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/object-id-shapes.test.js +36 -0
- package/test/privacy-fetch-timeout.test.js +25 -0
- package/test/twelve-element-capture.testcontainers.test.js +116 -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 =============================
|
|
@@ -1170,6 +1172,7 @@ function scheduleTraceMaterializerIdleShutdown() {
|
|
|
1170
1172
|
const worker = traceMaterializerWorker;
|
|
1171
1173
|
traceMaterializerWorker = null;
|
|
1172
1174
|
if (worker) {
|
|
1175
|
+
traceMaterializerExpectedTerminations.add(worker);
|
|
1173
1176
|
try {
|
|
1174
1177
|
void worker.terminate?.();
|
|
1175
1178
|
}
|
|
@@ -1195,7 +1198,17 @@ function handleTraceMaterializerMessage(message) {
|
|
|
1195
1198
|
traceMaterializerPending.delete(message.taskId);
|
|
1196
1199
|
clearTraceMaterializerTaskTimeout(message.taskId);
|
|
1197
1200
|
if (message.ok) {
|
|
1198
|
-
|
|
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);
|
|
1199
1212
|
scheduleTraceMaterializerIdleShutdown();
|
|
1200
1213
|
return;
|
|
1201
1214
|
}
|
|
@@ -1256,9 +1269,15 @@ function getTraceMaterializerWorker() {
|
|
|
1256
1269
|
catch { }
|
|
1257
1270
|
worker.on('message', handleTraceMaterializerMessage);
|
|
1258
1271
|
worker.on('error', (err) => {
|
|
1272
|
+
if (worker !== traceMaterializerWorker || traceMaterializerExpectedTerminations.has(worker))
|
|
1273
|
+
return;
|
|
1259
1274
|
disableTraceMaterializerWorker(err instanceof Error ? err : new Error(String(err)));
|
|
1260
1275
|
});
|
|
1261
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;
|
|
1262
1281
|
const hadPending = traceMaterializerPending.size > 0;
|
|
1263
1282
|
const expectedTermination = traceMaterializerExpectedTerminations.has(worker);
|
|
1264
1283
|
clearTraceMaterializerIdleTimer();
|
|
@@ -1340,18 +1359,12 @@ function shouldOffloadPendingTraceEvents(events) {
|
|
|
1340
1359
|
if (!pending)
|
|
1341
1360
|
continue;
|
|
1342
1361
|
if (pending.argsRaw !== undefined) {
|
|
1343
|
-
if (traceInlineValueExceedsLimit(pending.argsRaw))
|
|
1344
|
-
return false;
|
|
1345
1362
|
pendingValueCount += 1;
|
|
1346
1363
|
}
|
|
1347
1364
|
if (pending.returnValueRaw !== undefined) {
|
|
1348
|
-
if (traceInlineValueExceedsLimit(pending.returnValueRaw))
|
|
1349
|
-
return false;
|
|
1350
1365
|
pendingValueCount += 1;
|
|
1351
1366
|
}
|
|
1352
1367
|
if (pending.errorRaw !== undefined) {
|
|
1353
|
-
if (traceInlineValueExceedsLimit(pending.errorRaw))
|
|
1354
|
-
return false;
|
|
1355
1368
|
pendingValueCount += 1;
|
|
1356
1369
|
}
|
|
1357
1370
|
if (pendingValueCount >= TRACE_MATERIALIZE_OFFTHREAD_MIN_VALUES) {
|
|
@@ -2327,12 +2340,15 @@ function coerceMongoId(value) {
|
|
|
2327
2340
|
if (typeof value !== 'object') {
|
|
2328
2341
|
return null;
|
|
2329
2342
|
}
|
|
2343
|
+
// Argument lists and query-result arrays are business data, not BSON ids.
|
|
2344
|
+
if (Array.isArray(value))
|
|
2345
|
+
return null;
|
|
2330
2346
|
const bsonType = typeof value?._bsontype === 'string' ? String(value._bsontype).toLowerCase() : '';
|
|
2331
2347
|
const ctorName = typeof value?.constructor?.name === 'string' ? String(value.constructor.name).toLowerCase() : '';
|
|
2332
2348
|
const looksLikeObjectId = bsonType.includes('objectid') ||
|
|
2333
2349
|
ctorName === 'objectid' ||
|
|
2334
2350
|
typeof value.toHexString === 'function';
|
|
2335
|
-
const fromBufferShape = coerceMongoIdFromBufferShape(value,
|
|
2351
|
+
const fromBufferShape = looksLikeObjectId ? coerceMongoIdFromBufferShape(value, true) : null;
|
|
2336
2352
|
if (fromBufferShape) {
|
|
2337
2353
|
return fromBufferShape;
|
|
2338
2354
|
}
|
|
@@ -2409,12 +2425,11 @@ function coerceMongoIdFromBufferShape(value, includeIdCandidate = false) {
|
|
|
2409
2425
|
if (!Array.isArray(bytes) || bytes.length !== 12) {
|
|
2410
2426
|
return null;
|
|
2411
2427
|
}
|
|
2412
|
-
|
|
2413
|
-
if (normalized.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
2428
|
+
if (bytes.some((byte) => typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
2414
2429
|
return null;
|
|
2415
2430
|
}
|
|
2416
2431
|
try {
|
|
2417
|
-
return Buffer.from(
|
|
2432
|
+
return Buffer.from(bytes).toString('hex');
|
|
2418
2433
|
}
|
|
2419
2434
|
catch {
|
|
2420
2435
|
return null;
|
|
@@ -2820,9 +2835,16 @@ function internTraceObservationSnapshot(snapshot, captureScope) {
|
|
|
2820
2835
|
if (!captureScope || snapshot === null || typeof snapshot !== 'object') {
|
|
2821
2836
|
return snapshot;
|
|
2822
2837
|
}
|
|
2823
|
-
|
|
2824
|
-
|
|
2838
|
+
// These are already detached data snapshots. Binary serialization avoids
|
|
2839
|
+
// sorting and cloning the whole graph again on the application's call stack,
|
|
2840
|
+
// while preserving undefined, NaN, and other non-JSON primitive distinctions.
|
|
2841
|
+
let serialized;
|
|
2842
|
+
try {
|
|
2843
|
+
serialized = (0, node_v8_1.serialize)(snapshot);
|
|
2844
|
+
}
|
|
2845
|
+
catch {
|
|
2825
2846
|
return snapshot;
|
|
2847
|
+
} // Interning is optional; the snapshot is not.
|
|
2826
2848
|
const digest = (0, crypto_1.createHash)('sha256')
|
|
2827
2849
|
.update('repro-trace-observation-v1\0')
|
|
2828
2850
|
.update(serialized)
|
|
@@ -3002,6 +3024,16 @@ async function materializePendingTraceEventsAsync(events, params) {
|
|
|
3002
3024
|
if (!events.some((evt) => evt.__reproPending)) {
|
|
3003
3025
|
return events;
|
|
3004
3026
|
}
|
|
3027
|
+
// Bound work per worker message and timeout, not the captured data itself.
|
|
3028
|
+
// A long request must not become one enormous structured-clone operation.
|
|
3029
|
+
if (events.length > 64) {
|
|
3030
|
+
const materialized = [];
|
|
3031
|
+
for (let offset = 0; offset < events.length; offset += 64) {
|
|
3032
|
+
materialized.push(...await materializePendingTraceEventsAsync(events.slice(offset, offset + 64), params));
|
|
3033
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
3034
|
+
}
|
|
3035
|
+
return materialized;
|
|
3036
|
+
}
|
|
3005
3037
|
const privacy = params.resolvePrivacy();
|
|
3006
3038
|
if (!shouldOffloadPendingTraceEvents(events)) {
|
|
3007
3039
|
try {
|
|
@@ -3016,7 +3048,7 @@ async function materializePendingTraceEventsAsync(events, params) {
|
|
|
3016
3048
|
}
|
|
3017
3049
|
}
|
|
3018
3050
|
try {
|
|
3019
|
-
|
|
3051
|
+
const materialized = await materializePendingTraceEventsOffThread(events, {
|
|
3020
3052
|
events,
|
|
3021
3053
|
cfg: {
|
|
3022
3054
|
tenantId: params.cfg.tenantId,
|
|
@@ -3032,6 +3064,14 @@ async function materializePendingTraceEventsAsync(events, params) {
|
|
|
3032
3064
|
fullValueCaptureEnabled: __FULL_VALUE_CAPTURE_ENABLED,
|
|
3033
3065
|
valueNamespace: params.valueNamespace,
|
|
3034
3066
|
});
|
|
3067
|
+
for (const event of materialized) {
|
|
3068
|
+
for (const [field, captureField] of [['args', 'argsValueCapture'], ['returnValue', 'returnValueCapture'], ['error', 'errorValueCapture']]) {
|
|
3069
|
+
if (event[captureField]?.status === 'failed-policy') {
|
|
3070
|
+
recordRequestCaptureFailure(params.captureScope ?? getCtx().captureScope, 'privacy.full_value', `trace.${field}`, 'worker_value_materialization_failed');
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
return materialized;
|
|
3035
3075
|
}
|
|
3036
3076
|
catch (err) {
|
|
3037
3077
|
try {
|
|
@@ -3059,10 +3099,15 @@ async function __materializePendingTraceEventsForWorker(payload) {
|
|
|
3059
3099
|
resolvePrivacy: () => payload.privacy,
|
|
3060
3100
|
valueNamespace: payload.valueNamespace,
|
|
3061
3101
|
});
|
|
3102
|
+
const values = new Map();
|
|
3062
3103
|
return materialized.map((event) => {
|
|
3063
3104
|
const entries = getEventTraceValueEntries(event);
|
|
3064
3105
|
if (entries?.length) {
|
|
3065
|
-
event.__reproTraceValueEntries = entries.map((entry) =>
|
|
3106
|
+
event.__reproTraceValueEntries = entries.map((entry) => {
|
|
3107
|
+
if (!values.has(entry.id))
|
|
3108
|
+
values.set(entry.id, entry.value);
|
|
3109
|
+
return { ...entry, value: values.get(entry.id) };
|
|
3110
|
+
});
|
|
3066
3111
|
}
|
|
3067
3112
|
return event;
|
|
3068
3113
|
});
|
|
@@ -3188,7 +3233,7 @@ function sanitizeHeaders(headers, rawCfg) {
|
|
|
3188
3233
|
}
|
|
3189
3234
|
const RUNTIME_PRIVACY_STATE_SYMBOL = Symbol('repro.runtimePrivacyState');
|
|
3190
3235
|
const RUNTIME_PRIVACY_RETRY_COOLDOWN_MS = 5000;
|
|
3191
|
-
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS =
|
|
3236
|
+
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 15000;
|
|
3192
3237
|
const DEFAULT_RUNTIME_PRIVACY_STARTUP_RETRY_DELAY_MS = 150;
|
|
3193
3238
|
function normalizeMaskTargets(target) {
|
|
3194
3239
|
const out = Array.isArray(target) ? target : [target];
|
|
@@ -3334,7 +3379,7 @@ function handleRuntimePrivacyFetchResult(cfg, state, apiBase, result) {
|
|
|
3334
3379
|
state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
|
|
3335
3380
|
return null;
|
|
3336
3381
|
}
|
|
3337
|
-
reportRuntimePrivacyOutcome(cfg, state, `network:${result.error}:${apiBase ?? 'no-api-base'}`, `privacy policy fetch failed while contacting ${apiBase}`);
|
|
3382
|
+
reportRuntimePrivacyOutcome(cfg, state, `network:${result.error}:${apiBase ?? 'no-api-base'}`, `privacy policy fetch failed while contacting ${apiBase}: ${result.error}`);
|
|
3338
3383
|
state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
|
|
3339
3384
|
return null;
|
|
3340
3385
|
}
|
|
@@ -4196,6 +4241,9 @@ function cloneFullValueCandidate(value, ctx, limits, depth = 0, seen = new WeakM
|
|
|
4196
4241
|
return out;
|
|
4197
4242
|
}
|
|
4198
4243
|
async function cloneFullValueCandidateAsync(value, ctx, limits, depth = 0, seen = new WeakMap(), valuePath = '$') {
|
|
4244
|
+
const pause = (0, capture_work_budget_1.yieldCaptureWork)();
|
|
4245
|
+
if (pause)
|
|
4246
|
+
await pause;
|
|
4199
4247
|
if (value === undefined)
|
|
4200
4248
|
return undefined;
|
|
4201
4249
|
if (value === null || typeof value === 'number' || typeof value === 'boolean')
|
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) {
|
|
@@ -2887,12 +2891,14 @@ function coercePrivacyObjectId(value) {
|
|
|
2887
2891
|
if (typeof value !== 'object') {
|
|
2888
2892
|
return null;
|
|
2889
2893
|
}
|
|
2894
|
+
if (Array.isArray(value))
|
|
2895
|
+
return null;
|
|
2890
2896
|
const bsonType = typeof value?._bsontype === 'string' ? String(value._bsontype).toLowerCase() : '';
|
|
2891
2897
|
const ctorName = typeof value?.constructor?.name === 'string' ? String(value.constructor.name).toLowerCase() : '';
|
|
2892
2898
|
const looksLikeObjectId = bsonType.includes('objectid') ||
|
|
2893
2899
|
ctorName === 'objectid' ||
|
|
2894
2900
|
typeof value.toHexString === 'function';
|
|
2895
|
-
const fromBufferShape = coercePrivacyObjectIdFromBufferShape(value,
|
|
2901
|
+
const fromBufferShape = looksLikeObjectId ? coercePrivacyObjectIdFromBufferShape(value, true) : null;
|
|
2896
2902
|
if (fromBufferShape) {
|
|
2897
2903
|
return fromBufferShape;
|
|
2898
2904
|
}
|
|
@@ -2966,12 +2972,11 @@ function coercePrivacyObjectIdFromBufferShape(value, includeIdCandidate = false)
|
|
|
2966
2972
|
if (!Array.isArray(bytes) || bytes.length !== 12) {
|
|
2967
2973
|
return null;
|
|
2968
2974
|
}
|
|
2969
|
-
|
|
2970
|
-
if (normalized.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
2975
|
+
if (bytes.some((byte) => typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
2971
2976
|
return null;
|
|
2972
2977
|
}
|
|
2973
2978
|
try {
|
|
2974
|
-
return Buffer.from(
|
|
2979
|
+
return Buffer.from(bytes).toString('hex');
|
|
2975
2980
|
}
|
|
2976
2981
|
catch {
|
|
2977
2982
|
return 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.53",
|
|
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:
|
|
12
|
+
"test:latency": "npm run build && node test/privacy-fetch-timeout.test.js && node test/capture-latency.testcontainers.test.js",
|
|
13
|
+
"test:live-capture": "npm run build && node test/twelve-element-capture.testcontainers.test.js && 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
|
-
"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/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"
|
|
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/object-id-shapes.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",
|
|
19
|
+
"test:containers": "npm run build && node test/twelve-element-capture.testcontainers.test.js && 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
|
/**
|
|
@@ -1625,6 +1627,7 @@ function scheduleTraceMaterializerIdleShutdown(): void {
|
|
|
1625
1627
|
const worker = traceMaterializerWorker;
|
|
1626
1628
|
traceMaterializerWorker = null;
|
|
1627
1629
|
if (worker) {
|
|
1630
|
+
traceMaterializerExpectedTerminations.add(worker);
|
|
1628
1631
|
try { void worker.terminate?.(); } catch {}
|
|
1629
1632
|
}
|
|
1630
1633
|
}, TRACE_MATERIALIZE_WORKER_IDLE_SHUTDOWN_MS);
|
|
@@ -1649,7 +1652,15 @@ function handleTraceMaterializerMessage(message: TraceMaterializationWorkerRespo
|
|
|
1649
1652
|
traceMaterializerPending.delete(message.taskId);
|
|
1650
1653
|
clearTraceMaterializerTaskTimeout(message.taskId);
|
|
1651
1654
|
if (message.ok) {
|
|
1652
|
-
|
|
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);
|
|
1653
1664
|
scheduleTraceMaterializerIdleShutdown();
|
|
1654
1665
|
return;
|
|
1655
1666
|
}
|
|
@@ -1700,9 +1711,13 @@ function getTraceMaterializerWorker(): any | null {
|
|
|
1700
1711
|
try { worker.unref?.(); } catch {}
|
|
1701
1712
|
worker.on('message', handleTraceMaterializerMessage);
|
|
1702
1713
|
worker.on('error', (err: Error) => {
|
|
1714
|
+
if (worker !== traceMaterializerWorker || traceMaterializerExpectedTerminations.has(worker)) return;
|
|
1703
1715
|
disableTraceMaterializerWorker(err instanceof Error ? err : new Error(String(err)));
|
|
1704
1716
|
});
|
|
1705
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;
|
|
1706
1721
|
const hadPending = traceMaterializerPending.size > 0;
|
|
1707
1722
|
const expectedTermination = traceMaterializerExpectedTerminations.has(worker);
|
|
1708
1723
|
clearTraceMaterializerIdleTimer();
|
|
@@ -1779,15 +1794,12 @@ function shouldOffloadPendingTraceEvents(events: PendingTraceEventRecord[]): boo
|
|
|
1779
1794
|
const pending = event.__reproPending;
|
|
1780
1795
|
if (!pending) continue;
|
|
1781
1796
|
if (pending.argsRaw !== undefined) {
|
|
1782
|
-
if (traceInlineValueExceedsLimit(pending.argsRaw)) return false;
|
|
1783
1797
|
pendingValueCount += 1;
|
|
1784
1798
|
}
|
|
1785
1799
|
if (pending.returnValueRaw !== undefined) {
|
|
1786
|
-
if (traceInlineValueExceedsLimit(pending.returnValueRaw)) return false;
|
|
1787
1800
|
pendingValueCount += 1;
|
|
1788
1801
|
}
|
|
1789
1802
|
if (pending.errorRaw !== undefined) {
|
|
1790
|
-
if (traceInlineValueExceedsLimit(pending.errorRaw)) return false;
|
|
1791
1803
|
pendingValueCount += 1;
|
|
1792
1804
|
}
|
|
1793
1805
|
if (pendingValueCount >= TRACE_MATERIALIZE_OFFTHREAD_MIN_VALUES) {
|
|
@@ -2894,6 +2906,8 @@ function coerceMongoId(value: any): string | null {
|
|
|
2894
2906
|
if (typeof value !== 'object') {
|
|
2895
2907
|
return null;
|
|
2896
2908
|
}
|
|
2909
|
+
// Argument lists and query-result arrays are business data, not BSON ids.
|
|
2910
|
+
if (Array.isArray(value)) return null;
|
|
2897
2911
|
|
|
2898
2912
|
const bsonType = typeof (value as any)?._bsontype === 'string' ? String((value as any)._bsontype).toLowerCase() : '';
|
|
2899
2913
|
const ctorName = typeof value?.constructor?.name === 'string' ? String(value.constructor.name).toLowerCase() : '';
|
|
@@ -2902,7 +2916,7 @@ function coerceMongoId(value: any): string | null {
|
|
|
2902
2916
|
ctorName === 'objectid' ||
|
|
2903
2917
|
typeof (value as any).toHexString === 'function';
|
|
2904
2918
|
|
|
2905
|
-
const fromBufferShape = coerceMongoIdFromBufferShape(value,
|
|
2919
|
+
const fromBufferShape = looksLikeObjectId ? coerceMongoIdFromBufferShape(value, true) : null;
|
|
2906
2920
|
if (fromBufferShape) {
|
|
2907
2921
|
return fromBufferShape;
|
|
2908
2922
|
}
|
|
@@ -2982,13 +2996,12 @@ function coerceMongoIdFromBufferShape(value: any, includeIdCandidate: boolean =
|
|
|
2982
2996
|
return null;
|
|
2983
2997
|
}
|
|
2984
2998
|
|
|
2985
|
-
|
|
2986
|
-
if (normalized.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
2999
|
+
if (bytes.some((byte) => typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
2987
3000
|
return null;
|
|
2988
3001
|
}
|
|
2989
3002
|
|
|
2990
3003
|
try {
|
|
2991
|
-
return Buffer.from(
|
|
3004
|
+
return Buffer.from(bytes).toString('hex');
|
|
2992
3005
|
} catch {
|
|
2993
3006
|
return null;
|
|
2994
3007
|
}
|
|
@@ -3430,8 +3443,12 @@ function internTraceObservationSnapshot(
|
|
|
3430
3443
|
return snapshot;
|
|
3431
3444
|
}
|
|
3432
3445
|
|
|
3433
|
-
|
|
3434
|
-
|
|
3446
|
+
// These are already detached data snapshots. Binary serialization avoids
|
|
3447
|
+
// sorting and cloning the whole graph again on the application's call stack,
|
|
3448
|
+
// while preserving undefined, NaN, and other non-JSON primitive distinctions.
|
|
3449
|
+
let serialized: Buffer;
|
|
3450
|
+
try { serialized = serializeSnapshot(snapshot); }
|
|
3451
|
+
catch { return snapshot; } // Interning is optional; the snapshot is not.
|
|
3435
3452
|
|
|
3436
3453
|
const digest = createHash('sha256')
|
|
3437
3454
|
.update('repro-trace-observation-v1\0')
|
|
@@ -3705,6 +3722,17 @@ async function materializePendingTraceEventsAsync(
|
|
|
3705
3722
|
return events;
|
|
3706
3723
|
}
|
|
3707
3724
|
|
|
3725
|
+
// Bound work per worker message and timeout, not the captured data itself.
|
|
3726
|
+
// A long request must not become one enormous structured-clone operation.
|
|
3727
|
+
if (events.length > 64) {
|
|
3728
|
+
const materialized: TraceEventRecord[] = [];
|
|
3729
|
+
for (let offset = 0; offset < events.length; offset += 64) {
|
|
3730
|
+
materialized.push(...await materializePendingTraceEventsAsync(events.slice(offset, offset + 64), params));
|
|
3731
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
3732
|
+
}
|
|
3733
|
+
return materialized;
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3708
3736
|
const privacy = params.resolvePrivacy();
|
|
3709
3737
|
if (!shouldOffloadPendingTraceEvents(events)) {
|
|
3710
3738
|
try {
|
|
@@ -3723,7 +3751,7 @@ async function materializePendingTraceEventsAsync(
|
|
|
3723
3751
|
}
|
|
3724
3752
|
}
|
|
3725
3753
|
try {
|
|
3726
|
-
|
|
3754
|
+
const materialized = await materializePendingTraceEventsOffThread(events, {
|
|
3727
3755
|
events,
|
|
3728
3756
|
cfg: {
|
|
3729
3757
|
tenantId: params.cfg.tenantId,
|
|
@@ -3739,6 +3767,15 @@ async function materializePendingTraceEventsAsync(
|
|
|
3739
3767
|
fullValueCaptureEnabled: __FULL_VALUE_CAPTURE_ENABLED,
|
|
3740
3768
|
valueNamespace: params.valueNamespace,
|
|
3741
3769
|
});
|
|
3770
|
+
for (const event of materialized) {
|
|
3771
|
+
for (const [field, captureField] of [['args', 'argsValueCapture'], ['returnValue', 'returnValueCapture'], ['error', 'errorValueCapture']] as const) {
|
|
3772
|
+
if (event[captureField]?.status === 'failed-policy') {
|
|
3773
|
+
recordRequestCaptureFailure(params.captureScope ?? getCtx().captureScope,
|
|
3774
|
+
'privacy.full_value', `trace.${field}`, 'worker_value_materialization_failed');
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
return materialized;
|
|
3742
3779
|
} catch (err) {
|
|
3743
3780
|
try {
|
|
3744
3781
|
return await materializePendingTraceEvents(events, {
|
|
@@ -3775,10 +3812,14 @@ export async function __materializePendingTraceEventsForWorker(
|
|
|
3775
3812
|
resolvePrivacy: () => payload.privacy,
|
|
3776
3813
|
valueNamespace: payload.valueNamespace,
|
|
3777
3814
|
});
|
|
3815
|
+
const values = new Map<string, any>();
|
|
3778
3816
|
return materialized.map((event) => {
|
|
3779
3817
|
const entries = getEventTraceValueEntries(event);
|
|
3780
3818
|
if (entries?.length) {
|
|
3781
|
-
(event as PendingTraceEventRecord).__reproTraceValueEntries = entries.map((entry) =>
|
|
3819
|
+
(event as PendingTraceEventRecord).__reproTraceValueEntries = entries.map((entry) => {
|
|
3820
|
+
if (!values.has(entry.id)) values.set(entry.id, entry.value);
|
|
3821
|
+
return { ...entry, value: values.get(entry.id) };
|
|
3822
|
+
});
|
|
3782
3823
|
}
|
|
3783
3824
|
return event as PendingTraceEventRecord;
|
|
3784
3825
|
});
|
|
@@ -3925,7 +3966,7 @@ type RuntimePrivacyState = {
|
|
|
3925
3966
|
|
|
3926
3967
|
const RUNTIME_PRIVACY_STATE_SYMBOL = Symbol('repro.runtimePrivacyState');
|
|
3927
3968
|
const RUNTIME_PRIVACY_RETRY_COOLDOWN_MS = 5000;
|
|
3928
|
-
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS =
|
|
3969
|
+
const DEFAULT_RUNTIME_PRIVACY_STARTUP_MAX_WAIT_MS = 15000;
|
|
3929
3970
|
const DEFAULT_RUNTIME_PRIVACY_STARTUP_RETRY_DELAY_MS = 150;
|
|
3930
3971
|
|
|
3931
3972
|
function normalizeMaskTargets(target: ReproMaskTarget | ReproMaskTarget[]): ReproMaskTarget[] {
|
|
@@ -4104,7 +4145,7 @@ function handleRuntimePrivacyFetchResult(
|
|
|
4104
4145
|
cfg,
|
|
4105
4146
|
state,
|
|
4106
4147
|
`network:${result.error}:${apiBase ?? 'no-api-base'}`,
|
|
4107
|
-
`privacy policy fetch failed while contacting ${apiBase}`,
|
|
4148
|
+
`privacy policy fetch failed while contacting ${apiBase}: ${result.error}`,
|
|
4108
4149
|
);
|
|
4109
4150
|
state.nextRetryAt = Date.now() + RUNTIME_PRIVACY_RETRY_COOLDOWN_MS;
|
|
4110
4151
|
return null;
|
|
@@ -5142,6 +5183,8 @@ async function cloneFullValueCandidateAsync(
|
|
|
5142
5183
|
seen: WeakMap<object, string> = new WeakMap(),
|
|
5143
5184
|
valuePath = '$',
|
|
5144
5185
|
): Promise<any> {
|
|
5186
|
+
const pause = yieldCaptureWork();
|
|
5187
|
+
if (pause) await pause;
|
|
5145
5188
|
if (value === undefined) return undefined;
|
|
5146
5189
|
if (value === null || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
5147
5190
|
if (typeof value === 'string') {
|
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) {
|
|
@@ -3682,6 +3685,7 @@ function coercePrivacyObjectId(value: any): string | null {
|
|
|
3682
3685
|
if (typeof value !== 'object') {
|
|
3683
3686
|
return null;
|
|
3684
3687
|
}
|
|
3688
|
+
if (Array.isArray(value)) return null;
|
|
3685
3689
|
|
|
3686
3690
|
const bsonType = typeof (value as any)?._bsontype === 'string' ? String((value as any)._bsontype).toLowerCase() : '';
|
|
3687
3691
|
const ctorName = typeof value?.constructor?.name === 'string' ? String(value.constructor.name).toLowerCase() : '';
|
|
@@ -3690,7 +3694,7 @@ function coercePrivacyObjectId(value: any): string | null {
|
|
|
3690
3694
|
ctorName === 'objectid' ||
|
|
3691
3695
|
typeof value.toHexString === 'function';
|
|
3692
3696
|
|
|
3693
|
-
const fromBufferShape = coercePrivacyObjectIdFromBufferShape(value,
|
|
3697
|
+
const fromBufferShape = looksLikeObjectId ? coercePrivacyObjectIdFromBufferShape(value, true) : null;
|
|
3694
3698
|
if (fromBufferShape) {
|
|
3695
3699
|
return fromBufferShape;
|
|
3696
3700
|
}
|
|
@@ -3771,13 +3775,12 @@ function coercePrivacyObjectIdFromBufferShape(value: any, includeIdCandidate: bo
|
|
|
3771
3775
|
return null;
|
|
3772
3776
|
}
|
|
3773
3777
|
|
|
3774
|
-
|
|
3775
|
-
if (normalized.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
3778
|
+
if (bytes.some((byte) => typeof byte !== 'number' || !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
3776
3779
|
return null;
|
|
3777
3780
|
}
|
|
3778
3781
|
|
|
3779
3782
|
try {
|
|
3780
|
-
return Buffer.from(
|
|
3783
|
+
return Buffer.from(bytes).toString('hex');
|
|
3781
3784
|
} catch {
|
|
3782
3785
|
return null;
|
|
3783
3786
|
}
|
|
@@ -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,36 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { ObjectId } = require('mongodb');
|
|
3
|
+
const { __reproTestHooks } = require('../dist');
|
|
4
|
+
const { applyRuntimePrivacyPolicy, applyRuntimePrivacyPolicyAsync } = require('../dist/privacy');
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const serializers = {
|
|
8
|
+
trace: value => __reproTestHooks.sanitizeTraceValueForPrivacyForTest(value),
|
|
9
|
+
policy: value => applyRuntimePrivacyPolicy(null, 'trace.args', value, { appId: 'APP_test' }),
|
|
10
|
+
asyncPolicy: value => applyRuntimePrivacyPolicyAsync(null, 'db.resultMeta', value, { appId: 'APP_test' }),
|
|
11
|
+
};
|
|
12
|
+
const bytes = Array.from({ length: 12 }, (_, i) => i);
|
|
13
|
+
const hex = '000102030405060708090a0b';
|
|
14
|
+
for (const [name, serialize] of Object.entries(serializers)) {
|
|
15
|
+
const rows = bytes.map(ordinal => Object.assign(Object.create(null), { ordinal, status: 'active' }));
|
|
16
|
+
const numericKeys = Object.fromEntries(bytes.map(i => [i, i]));
|
|
17
|
+
for (const input of [bytes, rows, numericKeys, { buffer: bytes }, { type: 'Buffer', data: bytes }]) {
|
|
18
|
+
const result = await serialize(input);
|
|
19
|
+
assert.deepEqual(JSON.parse(JSON.stringify(result)), JSON.parse(JSON.stringify(input)),
|
|
20
|
+
`${name}: ordinary data must not be collapsed to a BSON id`);
|
|
21
|
+
}
|
|
22
|
+
for (const input of [new ObjectId(hex), { $oid: hex }, { _bsontype: 'ObjectId', buffer: bytes }]) {
|
|
23
|
+
assert.equal(await serialize(input), hex, `${name}: genuine ObjectId representation must remain supported`);
|
|
24
|
+
}
|
|
25
|
+
let coercions = 0;
|
|
26
|
+
const invalidByte = { valueOf() { coercions += 1; throw new Error('must not coerce application objects'); } };
|
|
27
|
+
for (const invalid of [invalidByte, Object.create(null), '1', -1, 256, 1.5, NaN]) {
|
|
28
|
+
const input = { _bsontype: 'ObjectId', buffer: [...bytes.slice(0, 11), invalid] };
|
|
29
|
+
assert.equal(await serialize(input), '[mongo-id]', `${name}: invalid bytes must not become a fabricated ObjectId`);
|
|
30
|
+
}
|
|
31
|
+
assert.equal(coercions, 0);
|
|
32
|
+
}
|
|
33
|
+
console.log('ObjectId discrimination passed for trace, synchronous and asynchronous privacy serialization');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -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; });
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '30';
|
|
4
|
+
process.env.TRACE_IDLE_FLUSH_MS = '10';
|
|
5
|
+
process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
|
|
6
|
+
const assert = require('node:assert/strict');
|
|
7
|
+
const http = require('node:http');
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const mongoose = require('mongoose');
|
|
10
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
11
|
+
const { initReproTracing, reproMiddleware, reproMongoosePlugin } = require('../dist');
|
|
12
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
13
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
let container, connection, server, sink;
|
|
17
|
+
const posts = [];
|
|
18
|
+
try {
|
|
19
|
+
sink = http.createServer(async (req, res) => {
|
|
20
|
+
const parts = []; for await (const part of req) parts.push(part);
|
|
21
|
+
posts.push(JSON.parse(Buffer.concat(parts).toString()));
|
|
22
|
+
res.writeHead(202); res.end('{}');
|
|
23
|
+
});
|
|
24
|
+
await new Promise(resolve => sink.listen(0, '127.0.0.1', resolve));
|
|
25
|
+
container = await new GenericContainer('mongo:7').withExposedPorts(27017)
|
|
26
|
+
.withWaitStrategy(Wait.forListeningPorts()).start();
|
|
27
|
+
connection = await mongoose.createConnection(`mongodb://${container.getHost()}:${container.getMappedPort(27017)}/twelve_elements`).asPromise();
|
|
28
|
+
initReproTracing({ instrument: false, logFunctionCalls: false, fullValueCapture: true });
|
|
29
|
+
const app = express();
|
|
30
|
+
const policy = { environment: 'dev', statements: [{ sid: 'drop-secret',
|
|
31
|
+
when: { scope: 'FIELD', selector: 'field:internalSecret', target: '**.internalSecret' },
|
|
32
|
+
apply: { action: 'DROP' }, meta: { priority: 1 } }] };
|
|
33
|
+
for (const privacy of ['policy', 'raw']) {
|
|
34
|
+
const config = { tenantId: 'TENANT_twelve', appId: 'APP_twelve', appSecret: 'test',
|
|
35
|
+
serviceName: `depot-${privacy}`, captureHeaders: false,
|
|
36
|
+
privacy: { environment: 'dev', captureRaw: privacy === 'raw', ...(privacy === 'policy' ? { policy } : {}) },
|
|
37
|
+
ingestBase: `http://127.0.0.1:${sink.address().port}` };
|
|
38
|
+
const schema = new mongoose.Schema({ ordinal: Number, location: String, internalSecret: String });
|
|
39
|
+
schema.plugin(reproMongoosePlugin(config));
|
|
40
|
+
const Depot = connection.model(`Depot_${privacy}`, schema);
|
|
41
|
+
await Depot.insertMany(Array.from({ length: 13 }, (_, ordinal) => ({ ordinal, location: `Depot ${ordinal}`, internalSecret: 'private-fixture-secret' })));
|
|
42
|
+
app.use(`/${privacy}`, reproMiddleware(config));
|
|
43
|
+
app.get(`/${privacy}/:count`, async (req, res, next) => {
|
|
44
|
+
try {
|
|
45
|
+
const rows = await Depot.find({}).sort({ ordinal: 1 }).limit(Number(req.params.count)).lean().exec();
|
|
46
|
+
function projectDepots(input) { return input; }
|
|
47
|
+
const observed = global.__repro_call(projectDepots, null, [rows], __filename, 1, 'projectDepots', false);
|
|
48
|
+
function numericArguments(...values) { return values; }
|
|
49
|
+
const numbers = global.__repro_call(numericArguments, null, Array.from({ length: 12 }, (_, i) => i), __filename, 2, 'numericArguments', false);
|
|
50
|
+
res.json({ rows: observed, numbers });
|
|
51
|
+
} catch (error) { next(error); }
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
server = await new Promise(resolve => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
|
|
55
|
+
const scenarios = [];
|
|
56
|
+
for (const mode of ['live', 'staging']) for (const privacy of ['policy', 'raw']) for (const count of [12, 11, 13]) {
|
|
57
|
+
const sid = `S_${mode}_${privacy}_${count}`;
|
|
58
|
+
const response = await fetch(`http://127.0.0.1:${server.address().port}/${privacy}/${count}`, { headers: {
|
|
59
|
+
'x-bug-session-id': sid, 'x-bug-action-id': `A_${sid}`, 'x-repro-capture-mode': mode,
|
|
60
|
+
} });
|
|
61
|
+
assert.equal(response.status, 200);
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
assert.equal(data.rows.length, count);
|
|
64
|
+
assert.equal(data.rows[0].internalSecret, 'private-fixture-secret', 'SDK must not mutate business results');
|
|
65
|
+
assert.deepEqual(data.numbers, Array.from({ length: 12 }, (_, i) => i));
|
|
66
|
+
scenarios.push({ sid, mode, privacy, count });
|
|
67
|
+
}
|
|
68
|
+
let events;
|
|
69
|
+
const deadline = Date.now() + 30000;
|
|
70
|
+
do {
|
|
71
|
+
await flushIngestQueue();
|
|
72
|
+
events = posts.flatMap(post => post.events || []);
|
|
73
|
+
if (events.filter(e => e.event_type === 'request_capture_manifest').length >= scenarios.length) break;
|
|
74
|
+
await sleep(50);
|
|
75
|
+
} while (Date.now() < deadline);
|
|
76
|
+
for (const { sid, privacy, count } of scenarios) {
|
|
77
|
+
const scoped = events.filter(e => e.session_id === sid);
|
|
78
|
+
const manifest = scoped.find(e => e.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest;
|
|
79
|
+
assert(manifest, `missing manifest ${sid}`);
|
|
80
|
+
assert.equal(manifest.captureFailures, undefined, `${sid}: ${JSON.stringify(manifest.captureFailures)}`);
|
|
81
|
+
if (privacy === 'policy') assert.equal(manifest.privacy.status, 'applied');
|
|
82
|
+
const values = scoped.flatMap(e => [...(e.payload.traceValues || []), ...(e.payload.requestValues || []), ...(e.payload.dbValues || [])]);
|
|
83
|
+
const resolve = (record, ref, field) => values.find(v => v.id === record?.[ref]?.valueId)?.value ?? record?.[field];
|
|
84
|
+
const trace = scoped.flatMap(e => e.payload.trace || []);
|
|
85
|
+
const args = resolve(trace.find(e => e.fn === 'projectDepots' && e.type === 'enter'), 'argsValueCapture', 'args');
|
|
86
|
+
const result = resolve(trace.find(e => e.fn === 'projectDepots' && e.type === 'exit'), 'returnValueCapture', 'returnValue');
|
|
87
|
+
for (const rows of [args?.[0], result]) {
|
|
88
|
+
assert.equal(rows?.length, count, `missing business array ${sid}`);
|
|
89
|
+
assert.equal(rows.at(-1).ordinal, count - 1);
|
|
90
|
+
assert.equal(rows[0].internalSecret, privacy === 'policy' ? '[dropped]' : 'private-fixture-secret');
|
|
91
|
+
}
|
|
92
|
+
const numbers = resolve(trace.find(e => e.fn === 'numericArguments' && e.type === 'enter'), 'argsValueCapture', 'args');
|
|
93
|
+
assert.deepEqual(numbers, Array.from({ length: 12 }, (_, i) => i), 'numeric argument list must not become an ObjectId');
|
|
94
|
+
const request = scoped.find(e => e.event_type === 'backend_request')?.payload.request;
|
|
95
|
+
const body = resolve(request, 'respBodyValueCapture', 'respBody');
|
|
96
|
+
assert.equal(body.rows.length, count);
|
|
97
|
+
const dbRead = scoped.flatMap(e => e.payload.db || []).find(e => e.op === 'find');
|
|
98
|
+
assert(dbRead, `missing Mongo read ${sid}`);
|
|
99
|
+
const dbResult = resolve(dbRead, 'resultMetaValueCapture', 'resultMeta');
|
|
100
|
+
const rows = Array.isArray(dbResult) ? dbResult : dbResult?.result;
|
|
101
|
+
assert.equal(rows?.length, count, `missing Mongo result ${sid}: ${JSON.stringify({ dbRead, dbResult })}`);
|
|
102
|
+
assert.equal(rows[0].internalSecret, privacy === 'policy' ? '[dropped]' : 'private-fixture-secret');
|
|
103
|
+
assert.equal(scoped.filter(e => e.event_type === 'trace_batch').length, manifest.expected.traceBatch);
|
|
104
|
+
assert.equal(scoped.flatMap(e => e.payload.db || []).length, manifest.expected.dbChange);
|
|
105
|
+
if (privacy === 'policy') assert(!JSON.stringify(scoped).includes('private-fixture-secret'), 'secret leaked into captured evidence');
|
|
106
|
+
}
|
|
107
|
+
console.log('12 live/staging policy/raw scenarios: business arrays, numeric arguments, Mongo results and manifests passed');
|
|
108
|
+
} finally {
|
|
109
|
+
if (server) await new Promise(resolve => server.close(resolve));
|
|
110
|
+
if (connection) await connection.close();
|
|
111
|
+
await flushIngestQueue();
|
|
112
|
+
if (sink) await new Promise(resolve => sink.close(resolve));
|
|
113
|
+
if (container) await container.stop();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|