@reproapp/node-sdk 0.0.53 → 0.0.55
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/dist/index.js +23 -6
- package/dist/privacy.js +41 -7
- package/dist/trace-materializer-worker.js +14 -3
- package/package.json +4 -2
- package/src/index.ts +32 -8
- package/src/privacy.ts +50 -7
- package/src/trace-materializer-worker.ts +13 -3
- package/test/amqplib-timing.testcontainers.test.js +198 -0
- package/test/capture-latency.testcontainers.test.js +4 -1
- package/test/fixtures/capture-latency-business.js +1 -1
- package/test/fixtures/capture-latency-server.js +7 -4
- package/test/privacy-policy-index.test.js +15 -0
- package/test/trace-throughput.testcontainers.test.js +110 -0
- package/test/trace-worker-policy-reference.test.js +43 -0
package/dist/index.js
CHANGED
|
@@ -1116,6 +1116,7 @@ let traceMaterializerWorkerDisabled = !TRACE_MATERIALIZE_OFFTHREAD_ENABLED;
|
|
|
1116
1116
|
let traceMaterializerTaskSeq = 0;
|
|
1117
1117
|
let traceMaterializerIdleTimer = null;
|
|
1118
1118
|
const traceMaterializerExpectedTerminations = new WeakSet();
|
|
1119
|
+
const traceMaterializerWorkerPolicies = new WeakMap();
|
|
1119
1120
|
const traceMaterializerPending = new Map();
|
|
1120
1121
|
function hasActiveClientRequests() {
|
|
1121
1122
|
return activeClientRequestCount > 0;
|
|
@@ -1332,12 +1333,22 @@ async function materializePendingTraceEventsOffThread(events, payload) {
|
|
|
1332
1333
|
timeout,
|
|
1333
1334
|
});
|
|
1334
1335
|
try {
|
|
1336
|
+
const previousPolicy = traceMaterializerWorkerPolicies.get(worker);
|
|
1337
|
+
const sendPolicy = !previousPolicy || previousPolicy.policy !== payload.privacy;
|
|
1338
|
+
const policyState = sendPolicy ? { policy: payload.privacy, id: taskId } : previousPolicy;
|
|
1339
|
+
const { policy: _inlinePolicy, ...privacyConfig } = payload.cfg.privacy ?? {};
|
|
1335
1340
|
const request = {
|
|
1336
1341
|
taskId,
|
|
1337
1342
|
type: 'materialize-trace-events',
|
|
1338
|
-
payload
|
|
1343
|
+
payload: {
|
|
1344
|
+
...payload,
|
|
1345
|
+
cfg: { ...payload.cfg, privacy: privacyConfig },
|
|
1346
|
+
privacy: sendPolicy ? payload.privacy : undefined,
|
|
1347
|
+
policyId: policyState.id,
|
|
1348
|
+
},
|
|
1339
1349
|
};
|
|
1340
1350
|
worker.postMessage(request);
|
|
1351
|
+
traceMaterializerWorkerPolicies.set(worker, policyState);
|
|
1341
1352
|
}
|
|
1342
1353
|
catch (err) {
|
|
1343
1354
|
traceMaterializerPending.delete(taskId);
|
|
@@ -7859,7 +7870,7 @@ function scheduleAmqpPublishCapture(params) {
|
|
|
7859
7870
|
url: params.path,
|
|
7860
7871
|
path: params.path,
|
|
7861
7872
|
status: params.status,
|
|
7862
|
-
durMs:
|
|
7873
|
+
durMs: params.durationMs,
|
|
7863
7874
|
key: params.key,
|
|
7864
7875
|
headers: {
|
|
7865
7876
|
transport: 'amqp',
|
|
@@ -7879,7 +7890,7 @@ function scheduleAmqpPublishCapture(params) {
|
|
|
7879
7890
|
entries: [{
|
|
7880
7891
|
actionId: params.aid ?? null,
|
|
7881
7892
|
request: requestPayload,
|
|
7882
|
-
t:
|
|
7893
|
+
t: params.completedAt,
|
|
7883
7894
|
}],
|
|
7884
7895
|
});
|
|
7885
7896
|
post(params.cfg, params.sid, {
|
|
@@ -7941,6 +7952,9 @@ function patchAmqplibPublishMethod(channel, cfg, methodName) {
|
|
|
7941
7952
|
if (finished)
|
|
7942
7953
|
return;
|
|
7943
7954
|
finished = true;
|
|
7955
|
+
// Capture queue and privacy processing time are not broker latency.
|
|
7956
|
+
const durationMs = Math.max(0, Date.now() - t0);
|
|
7957
|
+
const completedAt = alignedNow();
|
|
7944
7958
|
scheduleAmqpPublishCapture({
|
|
7945
7959
|
cfg,
|
|
7946
7960
|
sid,
|
|
@@ -7956,7 +7970,8 @@ function patchAmqplibPublishMethod(channel, cfg, methodName) {
|
|
|
7956
7970
|
content,
|
|
7957
7971
|
options: nextOptions,
|
|
7958
7972
|
span,
|
|
7959
|
-
|
|
7973
|
+
durationMs,
|
|
7974
|
+
completedAt,
|
|
7960
7975
|
status,
|
|
7961
7976
|
error,
|
|
7962
7977
|
result,
|
|
@@ -8089,6 +8104,8 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
|
|
|
8089
8104
|
finished = true;
|
|
8090
8105
|
if (!sid)
|
|
8091
8106
|
return;
|
|
8107
|
+
const durationMs = Math.max(0, Date.now() - t0);
|
|
8108
|
+
const completedAt = alignedNow();
|
|
8092
8109
|
const fields = message?.fields ?? {};
|
|
8093
8110
|
const messagePayload = buildAmqpMessagePayload(message?.content, properties);
|
|
8094
8111
|
const body = {
|
|
@@ -8118,7 +8135,7 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
|
|
|
8118
8135
|
url: `amqp://queue/${queueName}`,
|
|
8119
8136
|
path: `amqp://queue/${queueName}`,
|
|
8120
8137
|
status: threw ? 500 : 200,
|
|
8121
|
-
durMs:
|
|
8138
|
+
durMs: durationMs,
|
|
8122
8139
|
key: `AMQP_CONSUME ${queueName}`,
|
|
8123
8140
|
headers: normalizeAmqpHeadersForPayload(headers),
|
|
8124
8141
|
body: bodyMaterialization.value,
|
|
@@ -8129,7 +8146,7 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
|
|
|
8129
8146
|
entries: [{
|
|
8130
8147
|
actionId: aid ?? null,
|
|
8131
8148
|
request: requestPayload,
|
|
8132
|
-
t:
|
|
8149
|
+
t: completedAt,
|
|
8133
8150
|
}],
|
|
8134
8151
|
});
|
|
8135
8152
|
scheduleAmqpTraceFlush(cfg, amqpCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
|
package/dist/privacy.js
CHANGED
|
@@ -11,6 +11,7 @@ const EMPTY_RUNTIME_PRIVACY_POLICY = {
|
|
|
11
11
|
fingerprint: 'empty',
|
|
12
12
|
};
|
|
13
13
|
const runtimePrivacyStatementIndexes = new WeakMap();
|
|
14
|
+
const comparableTokenCache = new Map();
|
|
14
15
|
const RUNTIME_PRIVACY_CANDIDATE_CACHE_MAX_ENTRIES = 2048;
|
|
15
16
|
const RUNTIME_PRIVACY_PROVENANCE_MAX_ENTRIES = 1000;
|
|
16
17
|
const RUNTIME_PRIVACY_PROVENANCE_MAX_SERIALIZED_LENGTH = 200000;
|
|
@@ -453,6 +454,7 @@ function buildRuntimePrivacyStatementIndex(statements) {
|
|
|
453
454
|
functionStatements,
|
|
454
455
|
candidateCache: new Map(),
|
|
455
456
|
hasNumericTargetSegments,
|
|
457
|
+
scoredCandidateCache: new Map(),
|
|
456
458
|
};
|
|
457
459
|
}
|
|
458
460
|
function getRuntimePrivacyStatementIndex(policy) {
|
|
@@ -874,6 +876,8 @@ function rememberSerializedRuntimePrivacyProvenance(value, provenance) {
|
|
|
874
876
|
}
|
|
875
877
|
}
|
|
876
878
|
function lookupSerializedRuntimePrivacyProvenance(value) {
|
|
879
|
+
if (serializedRuntimePrivacyProvenance.size === 0)
|
|
880
|
+
return null;
|
|
877
881
|
for (const serialized of runtimePrivacyProvenanceSerializations(value)) {
|
|
878
882
|
const provenance = serializedRuntimePrivacyProvenance.get(hashRuntimePrivacyProvenance(serialized));
|
|
879
883
|
if (provenance) {
|
|
@@ -1228,6 +1232,26 @@ async function transformSerializedStructuredStringAsChildrenAsync(policy, surfac
|
|
|
1228
1232
|
return JSON.stringify(transformed);
|
|
1229
1233
|
}
|
|
1230
1234
|
function matchStatement(policy, surface, value, context, path) {
|
|
1235
|
+
const index = getRuntimePrivacyStatementIndex(policy);
|
|
1236
|
+
// Scores and non-pattern selectors depend on the context/path, not the
|
|
1237
|
+
// observed value. Preserve numeric positions here even for wildcard rules.
|
|
1238
|
+
const key = JSON.stringify([surface, context.trace?.fn, context.routeKey,
|
|
1239
|
+
context.db?.type, context.db?.collection, context.db?.op, path]);
|
|
1240
|
+
let scored = index.scoredCandidateCache.get(key);
|
|
1241
|
+
if (!scored) {
|
|
1242
|
+
scored = scoreStatementCandidates(policy, surface, context, path);
|
|
1243
|
+
if (index.scoredCandidateCache.size >= RUNTIME_PRIVACY_CANDIDATE_CACHE_MAX_ENTRIES) {
|
|
1244
|
+
index.scoredCandidateCache.clear();
|
|
1245
|
+
}
|
|
1246
|
+
index.scoredCandidateCache.set(key, scored);
|
|
1247
|
+
}
|
|
1248
|
+
const leaf = path[path.length - 1] ?? '';
|
|
1249
|
+
const joinedPath = normalizeComparablePath(path);
|
|
1250
|
+
const candidates = scored.filter(({ statement }) => statement.scope !== 'PATTERN'
|
|
1251
|
+
|| patternMatches(statement.pattern, leaf, joinedPath, value));
|
|
1252
|
+
return selectStatementCandidate(candidates, surface, value, path);
|
|
1253
|
+
}
|
|
1254
|
+
function scoreStatementCandidates(policy, surface, context, path) {
|
|
1231
1255
|
let candidates = [];
|
|
1232
1256
|
for (const statement of candidateStatementsForMatch(policy, surface, context, path)) {
|
|
1233
1257
|
if (shouldIgnoreBroadContainerRuleOnDescendant(statement, surface, path)) {
|
|
@@ -1240,7 +1264,7 @@ function matchStatement(policy, surface, value, context, path) {
|
|
|
1240
1264
|
if (targetScore < 0) {
|
|
1241
1265
|
continue;
|
|
1242
1266
|
}
|
|
1243
|
-
if (!matchesScope(statement, surface,
|
|
1267
|
+
if (statement.scope !== 'PATTERN' && !matchesScope(statement, surface, undefined, context, path)) {
|
|
1244
1268
|
continue;
|
|
1245
1269
|
}
|
|
1246
1270
|
const selectorScore = matchSelectorSpecificity(statement, surface, context, path);
|
|
@@ -1254,10 +1278,9 @@ function matchStatement(policy, surface, value, context, path) {
|
|
|
1254
1278
|
statement.priority,
|
|
1255
1279
|
});
|
|
1256
1280
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
candidates = candidates.filter(({ statement }) => !isBroadContextKeepExact(statement, surface, path));
|
|
1281
|
+
return candidates.filter(({ statement }) => !isBroadContextKeepExact(statement, surface, path));
|
|
1282
|
+
}
|
|
1283
|
+
function selectStatementCandidate(candidates, surface, value, path) {
|
|
1261
1284
|
if (!candidates.length) {
|
|
1262
1285
|
return null;
|
|
1263
1286
|
}
|
|
@@ -1632,7 +1655,10 @@ function normalizeComparablePath(path) {
|
|
|
1632
1655
|
.join('.');
|
|
1633
1656
|
}
|
|
1634
1657
|
function normalizeComparableToken(value) {
|
|
1635
|
-
|
|
1658
|
+
const cached = comparableTokenCache.get(value);
|
|
1659
|
+
if (cached !== undefined)
|
|
1660
|
+
return cached;
|
|
1661
|
+
const normalized = value
|
|
1636
1662
|
.trim()
|
|
1637
1663
|
.replace(/\s+/g, '')
|
|
1638
1664
|
.replace(/\[\*\]/g, '')
|
|
@@ -1641,6 +1667,12 @@ function normalizeComparableToken(value) {
|
|
|
1641
1667
|
.replace(/^.*->/, '')
|
|
1642
1668
|
.replace(/\|/g, '')
|
|
1643
1669
|
.toLowerCase();
|
|
1670
|
+
if (value.length <= 1024) {
|
|
1671
|
+
if (comparableTokenCache.size >= 2048)
|
|
1672
|
+
comparableTokenCache.clear();
|
|
1673
|
+
comparableTokenCache.set(value, normalized);
|
|
1674
|
+
}
|
|
1675
|
+
return normalized;
|
|
1644
1676
|
}
|
|
1645
1677
|
function relaxedFieldTargetMatchScore(statement, surface, path) {
|
|
1646
1678
|
if (!path.length) {
|
|
@@ -2379,7 +2411,9 @@ function partiallyMaskUnknownString(value) {
|
|
|
2379
2411
|
return normalizePrivacyReplacementSpacing(output);
|
|
2380
2412
|
}
|
|
2381
2413
|
function normalizePrivacyReplacementSpacing(value) {
|
|
2382
|
-
|
|
2414
|
+
// Most captured strings contain no replacement markers. The token regex
|
|
2415
|
+
// backtracks over long ordinary words even though no token can match.
|
|
2416
|
+
if (!value || (!value.includes('[') && !/_tok_/i.test(value))) {
|
|
2383
2417
|
return value;
|
|
2384
2418
|
}
|
|
2385
2419
|
const bracketMarker = String.raw `(?:\[(?:dropped|secret-token|connection-string|auth-token|session-secret|private-key|basic-auth|jwt|credit-card|phone|person|ssn|masked)\])`;
|
|
@@ -9,13 +9,24 @@ function isTraceMaterializationWorkerRequest(value) {
|
|
|
9
9
|
const message = value;
|
|
10
10
|
return Number.isFinite(message.taskId) && message.type === 'materialize-trace-events' && !!message.payload;
|
|
11
11
|
}
|
|
12
|
+
let policyId;
|
|
13
|
+
let privacy;
|
|
14
|
+
let queue = Promise.resolve();
|
|
12
15
|
node_worker_threads_1.parentPort?.on('message', (message) => {
|
|
13
16
|
if (!isTraceMaterializationWorkerRequest(message)) {
|
|
14
17
|
return;
|
|
15
18
|
}
|
|
16
|
-
|
|
19
|
+
// FIFO also isolates the materializer's full-value-capture flag across tasks.
|
|
20
|
+
queue = queue.then(async () => {
|
|
17
21
|
try {
|
|
18
|
-
|
|
22
|
+
if (message.payload.privacy !== undefined) {
|
|
23
|
+
policyId = message.payload.policyId;
|
|
24
|
+
privacy = message.payload.privacy;
|
|
25
|
+
}
|
|
26
|
+
else if (policyId === undefined || policyId !== message.payload.policyId) {
|
|
27
|
+
throw new Error('trace materialization policy reference is unavailable');
|
|
28
|
+
}
|
|
29
|
+
const events = await (0, index_1.__materializePendingTraceEventsForWorker)({ ...message.payload, privacy });
|
|
19
30
|
node_worker_threads_1.parentPort?.postMessage({
|
|
20
31
|
taskId: message.taskId,
|
|
21
32
|
ok: true,
|
|
@@ -29,5 +40,5 @@ node_worker_threads_1.parentPort?.on('message', (message) => {
|
|
|
29
40
|
error: err instanceof Error ? err.message : String(err),
|
|
30
41
|
});
|
|
31
42
|
}
|
|
32
|
-
})
|
|
43
|
+
});
|
|
33
44
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reproapp/node-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.55",
|
|
4
4
|
"description": "Repro Nest SDK",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
"node": ">=18"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
+
"test:throughput": "npm run build && node test/trace-worker-policy-reference.test.js && node test/privacy-policy-index.test.js && node test/trace-throughput.testcontainers.test.js",
|
|
12
13
|
"test:latency": "npm run build && node test/privacy-fetch-timeout.test.js && node test/capture-latency.testcontainers.test.js",
|
|
13
14
|
"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",
|
|
14
15
|
"test:live-brokers": "npm run build && node test/live-broker-limits.testcontainers.test.js",
|
|
16
|
+
"test:amqp-timing": "npm run build && node test/amqplib-integration.test.js && node test/amqplib-timing.testcontainers.test.js",
|
|
15
17
|
"build": "tsc -p tsconfig.json",
|
|
16
18
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
17
19
|
"prepublishOnly": "npm run build",
|
|
18
20
|
"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"
|
|
21
|
+
"test:containers": "npm run build && node test/trace-worker-policy-reference.test.js && node test/trace-throughput.testcontainers.test.js && 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"
|
|
20
22
|
},
|
|
21
23
|
"peerDependencies": {
|
|
22
24
|
"express": "^5.1.0",
|
package/src/index.ts
CHANGED
|
@@ -445,7 +445,10 @@ type TraceMaterializationWorkerPayload = {
|
|
|
445
445
|
type TraceMaterializationWorkerRequest = {
|
|
446
446
|
taskId: number;
|
|
447
447
|
type: 'materialize-trace-events';
|
|
448
|
-
payload: TraceMaterializationWorkerPayload
|
|
448
|
+
payload: Omit<TraceMaterializationWorkerPayload, 'privacy'> & {
|
|
449
|
+
privacy?: NormalizedRuntimePrivacyPolicy | null;
|
|
450
|
+
policyId: number;
|
|
451
|
+
};
|
|
449
452
|
};
|
|
450
453
|
|
|
451
454
|
type TraceMaterializationWorkerResponse =
|
|
@@ -1564,6 +1567,10 @@ let traceMaterializerWorkerDisabled = !TRACE_MATERIALIZE_OFFTHREAD_ENABLED;
|
|
|
1564
1567
|
let traceMaterializerTaskSeq = 0;
|
|
1565
1568
|
let traceMaterializerIdleTimer: NodeJS.Timeout | null = null;
|
|
1566
1569
|
const traceMaterializerExpectedTerminations = new WeakSet<object>();
|
|
1570
|
+
const traceMaterializerWorkerPolicies = new WeakMap<object, {
|
|
1571
|
+
policy: NormalizedRuntimePrivacyPolicy | null;
|
|
1572
|
+
id: number;
|
|
1573
|
+
}>();
|
|
1567
1574
|
const traceMaterializerPending = new Map<number, {
|
|
1568
1575
|
resolve: (events: PendingTraceEventRecord[]) => void;
|
|
1569
1576
|
reject: (error: Error) => void;
|
|
@@ -1771,12 +1778,22 @@ async function materializePendingTraceEventsOffThread(
|
|
|
1771
1778
|
});
|
|
1772
1779
|
|
|
1773
1780
|
try {
|
|
1781
|
+
const previousPolicy = traceMaterializerWorkerPolicies.get(worker);
|
|
1782
|
+
const sendPolicy = !previousPolicy || previousPolicy.policy !== payload.privacy;
|
|
1783
|
+
const policyState = sendPolicy ? { policy: payload.privacy, id: taskId } : previousPolicy!;
|
|
1784
|
+
const { policy: _inlinePolicy, ...privacyConfig } = payload.cfg.privacy ?? {};
|
|
1774
1785
|
const request: TraceMaterializationWorkerRequest = {
|
|
1775
1786
|
taskId,
|
|
1776
1787
|
type: 'materialize-trace-events',
|
|
1777
|
-
payload
|
|
1788
|
+
payload: {
|
|
1789
|
+
...payload,
|
|
1790
|
+
cfg: { ...payload.cfg, privacy: privacyConfig },
|
|
1791
|
+
privacy: sendPolicy ? payload.privacy : undefined,
|
|
1792
|
+
policyId: policyState.id,
|
|
1793
|
+
},
|
|
1778
1794
|
};
|
|
1779
1795
|
worker.postMessage(request);
|
|
1796
|
+
traceMaterializerWorkerPolicies.set(worker, policyState);
|
|
1780
1797
|
} catch (err) {
|
|
1781
1798
|
traceMaterializerPending.delete(taskId);
|
|
1782
1799
|
try { clearTimeout(timeout); } catch {}
|
|
@@ -9481,7 +9498,8 @@ function scheduleAmqpPublishCapture(params: {
|
|
|
9481
9498
|
content: any;
|
|
9482
9499
|
options: any;
|
|
9483
9500
|
span: SpanContext | null;
|
|
9484
|
-
|
|
9501
|
+
durationMs: number;
|
|
9502
|
+
completedAt: number;
|
|
9485
9503
|
status: number;
|
|
9486
9504
|
error?: any;
|
|
9487
9505
|
result?: any;
|
|
@@ -9523,7 +9541,7 @@ function scheduleAmqpPublishCapture(params: {
|
|
|
9523
9541
|
url: params.path,
|
|
9524
9542
|
path: params.path,
|
|
9525
9543
|
status: params.status,
|
|
9526
|
-
durMs:
|
|
9544
|
+
durMs: params.durationMs,
|
|
9527
9545
|
key: params.key,
|
|
9528
9546
|
headers: {
|
|
9529
9547
|
transport: 'amqp',
|
|
@@ -9544,7 +9562,7 @@ function scheduleAmqpPublishCapture(params: {
|
|
|
9544
9562
|
entries: [{
|
|
9545
9563
|
actionId: params.aid ?? null,
|
|
9546
9564
|
request: requestPayload,
|
|
9547
|
-
t:
|
|
9565
|
+
t: params.completedAt,
|
|
9548
9566
|
}],
|
|
9549
9567
|
});
|
|
9550
9568
|
post(params.cfg, params.sid!, {
|
|
@@ -9617,6 +9635,9 @@ function patchAmqplibPublishMethod(
|
|
|
9617
9635
|
const finish = (status: number, error?: any, result?: any) => {
|
|
9618
9636
|
if (finished) return;
|
|
9619
9637
|
finished = true;
|
|
9638
|
+
// Capture queue and privacy processing time are not broker latency.
|
|
9639
|
+
const durationMs = Math.max(0, Date.now() - t0);
|
|
9640
|
+
const completedAt = alignedNow();
|
|
9620
9641
|
scheduleAmqpPublishCapture({
|
|
9621
9642
|
cfg,
|
|
9622
9643
|
sid,
|
|
@@ -9632,7 +9653,8 @@ function patchAmqplibPublishMethod(
|
|
|
9632
9653
|
content,
|
|
9633
9654
|
options: nextOptions,
|
|
9634
9655
|
span,
|
|
9635
|
-
|
|
9656
|
+
durationMs,
|
|
9657
|
+
completedAt,
|
|
9636
9658
|
status,
|
|
9637
9659
|
error,
|
|
9638
9660
|
result,
|
|
@@ -9762,6 +9784,8 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
|
|
|
9762
9784
|
if (finished) return;
|
|
9763
9785
|
finished = true;
|
|
9764
9786
|
if (!sid) return;
|
|
9787
|
+
const durationMs = Math.max(0, Date.now() - t0);
|
|
9788
|
+
const completedAt = alignedNow();
|
|
9765
9789
|
const fields = message?.fields ?? {};
|
|
9766
9790
|
const messagePayload = buildAmqpMessagePayload(message?.content, properties);
|
|
9767
9791
|
const body = {
|
|
@@ -9797,7 +9821,7 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
|
|
|
9797
9821
|
url: `amqp://queue/${queueName}`,
|
|
9798
9822
|
path: `amqp://queue/${queueName}`,
|
|
9799
9823
|
status: threw ? 500 : 200,
|
|
9800
|
-
durMs:
|
|
9824
|
+
durMs: durationMs,
|
|
9801
9825
|
key: `AMQP_CONSUME ${queueName}`,
|
|
9802
9826
|
headers: normalizeAmqpHeadersForPayload(headers),
|
|
9803
9827
|
body: bodyMaterialization.value,
|
|
@@ -9809,7 +9833,7 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
|
|
|
9809
9833
|
entries: [{
|
|
9810
9834
|
actionId: aid ?? null,
|
|
9811
9835
|
request: requestPayload,
|
|
9812
|
-
t:
|
|
9836
|
+
t: completedAt,
|
|
9813
9837
|
}],
|
|
9814
9838
|
});
|
|
9815
9839
|
scheduleAmqpTraceFlush(cfg, amqpCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
|
package/src/privacy.ts
CHANGED
|
@@ -97,6 +97,7 @@ type RuntimePrivacyStatementIndex = {
|
|
|
97
97
|
functionStatements: FunctionStatementTrieNode;
|
|
98
98
|
candidateCache: Map<string, NormalizedStatement[]>;
|
|
99
99
|
hasNumericTargetSegments: boolean;
|
|
100
|
+
scoredCandidateCache: Map<string, Array<{ statement: NormalizedStatement; targetScore: number; score: number }>>;
|
|
100
101
|
};
|
|
101
102
|
|
|
102
103
|
type TransformFallback = {
|
|
@@ -150,6 +151,7 @@ const EMPTY_RUNTIME_PRIVACY_POLICY: NormalizedRuntimePrivacyPolicy = {
|
|
|
150
151
|
};
|
|
151
152
|
|
|
152
153
|
const runtimePrivacyStatementIndexes = new WeakMap<object, RuntimePrivacyStatementIndex>();
|
|
154
|
+
const comparableTokenCache = new Map<string, string>();
|
|
153
155
|
const RUNTIME_PRIVACY_CANDIDATE_CACHE_MAX_ENTRIES = 2048;
|
|
154
156
|
const RUNTIME_PRIVACY_PROVENANCE_MAX_ENTRIES = 1000;
|
|
155
157
|
const RUNTIME_PRIVACY_PROVENANCE_MAX_SERIALIZED_LENGTH = 200_000;
|
|
@@ -685,6 +687,7 @@ function buildRuntimePrivacyStatementIndex(
|
|
|
685
687
|
functionStatements,
|
|
686
688
|
candidateCache: new Map<string, NormalizedStatement[]>(),
|
|
687
689
|
hasNumericTargetSegments,
|
|
690
|
+
scoredCandidateCache: new Map(),
|
|
688
691
|
};
|
|
689
692
|
}
|
|
690
693
|
|
|
@@ -1222,6 +1225,7 @@ function rememberSerializedRuntimePrivacyProvenance(value: any, provenance: Runt
|
|
|
1222
1225
|
}
|
|
1223
1226
|
|
|
1224
1227
|
function lookupSerializedRuntimePrivacyProvenance(value: any): TransformFallback {
|
|
1228
|
+
if (serializedRuntimePrivacyProvenance.size === 0) return null;
|
|
1225
1229
|
for (const serialized of runtimePrivacyProvenanceSerializations(value)) {
|
|
1226
1230
|
const provenance = serializedRuntimePrivacyProvenance.get(hashRuntimePrivacyProvenance(serialized));
|
|
1227
1231
|
if (provenance) {
|
|
@@ -1691,6 +1695,32 @@ function matchStatement(
|
|
|
1691
1695
|
context: RuntimePrivacyApplyContext,
|
|
1692
1696
|
path: string[],
|
|
1693
1697
|
): NormalizedStatement | null {
|
|
1698
|
+
const index = getRuntimePrivacyStatementIndex(policy);
|
|
1699
|
+
// Scores and non-pattern selectors depend on the context/path, not the
|
|
1700
|
+
// observed value. Preserve numeric positions here even for wildcard rules.
|
|
1701
|
+
const key = JSON.stringify([surface, context.trace?.fn, context.routeKey,
|
|
1702
|
+
context.db?.type, context.db?.collection, context.db?.op, path]);
|
|
1703
|
+
let scored = index.scoredCandidateCache.get(key);
|
|
1704
|
+
if (!scored) {
|
|
1705
|
+
scored = scoreStatementCandidates(policy, surface, context, path);
|
|
1706
|
+
if (index.scoredCandidateCache.size >= RUNTIME_PRIVACY_CANDIDATE_CACHE_MAX_ENTRIES) {
|
|
1707
|
+
index.scoredCandidateCache.clear();
|
|
1708
|
+
}
|
|
1709
|
+
index.scoredCandidateCache.set(key, scored);
|
|
1710
|
+
}
|
|
1711
|
+
const leaf = path[path.length - 1] ?? '';
|
|
1712
|
+
const joinedPath = normalizeComparablePath(path);
|
|
1713
|
+
const candidates = scored.filter(({ statement }) => statement.scope !== 'PATTERN'
|
|
1714
|
+
|| patternMatches(statement.pattern, leaf, joinedPath, value));
|
|
1715
|
+
return selectStatementCandidate(candidates, surface, value, path);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function scoreStatementCandidates(
|
|
1719
|
+
policy: NormalizedRuntimePrivacyPolicy,
|
|
1720
|
+
surface: RuntimePrivacySurface,
|
|
1721
|
+
context: RuntimePrivacyApplyContext,
|
|
1722
|
+
path: string[],
|
|
1723
|
+
): Array<{ statement: NormalizedStatement; targetScore: number; score: number }> {
|
|
1694
1724
|
let candidates: Array<{ statement: NormalizedStatement; targetScore: number; score: number }> = [];
|
|
1695
1725
|
for (const statement of candidateStatementsForMatch(policy, surface, context, path)) {
|
|
1696
1726
|
if (shouldIgnoreBroadContainerRuleOnDescendant(statement, surface, path)) {
|
|
@@ -1703,7 +1733,7 @@ function matchStatement(
|
|
|
1703
1733
|
if (targetScore < 0) {
|
|
1704
1734
|
continue;
|
|
1705
1735
|
}
|
|
1706
|
-
if (!matchesScope(statement, surface,
|
|
1736
|
+
if (statement.scope !== 'PATTERN' && !matchesScope(statement, surface, undefined, context, path)) {
|
|
1707
1737
|
continue;
|
|
1708
1738
|
}
|
|
1709
1739
|
const selectorScore = matchSelectorSpecificity(statement, surface, context, path);
|
|
@@ -1718,11 +1748,15 @@ function matchStatement(
|
|
|
1718
1748
|
statement.priority,
|
|
1719
1749
|
});
|
|
1720
1750
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
}
|
|
1751
|
+
return candidates.filter(({ statement }) => !isBroadContextKeepExact(statement, surface, path));
|
|
1752
|
+
}
|
|
1724
1753
|
|
|
1725
|
-
|
|
1754
|
+
function selectStatementCandidate(
|
|
1755
|
+
candidates: Array<{ statement: NormalizedStatement; targetScore: number; score: number }>,
|
|
1756
|
+
surface: RuntimePrivacySurface,
|
|
1757
|
+
value: any,
|
|
1758
|
+
path: string[],
|
|
1759
|
+
): NormalizedStatement | null {
|
|
1726
1760
|
if (!candidates.length) {
|
|
1727
1761
|
return null;
|
|
1728
1762
|
}
|
|
@@ -2197,7 +2231,9 @@ function normalizeComparablePath(path: string[]): string {
|
|
|
2197
2231
|
}
|
|
2198
2232
|
|
|
2199
2233
|
function normalizeComparableToken(value: string): string {
|
|
2200
|
-
|
|
2234
|
+
const cached = comparableTokenCache.get(value);
|
|
2235
|
+
if (cached !== undefined) return cached;
|
|
2236
|
+
const normalized = value
|
|
2201
2237
|
.trim()
|
|
2202
2238
|
.replace(/\s+/g, '')
|
|
2203
2239
|
.replace(/\[\*\]/g, '')
|
|
@@ -2206,6 +2242,11 @@ function normalizeComparableToken(value: string): string {
|
|
|
2206
2242
|
.replace(/^.*->/, '')
|
|
2207
2243
|
.replace(/\|/g, '')
|
|
2208
2244
|
.toLowerCase();
|
|
2245
|
+
if (value.length <= 1024) {
|
|
2246
|
+
if (comparableTokenCache.size >= 2048) comparableTokenCache.clear();
|
|
2247
|
+
comparableTokenCache.set(value, normalized);
|
|
2248
|
+
}
|
|
2249
|
+
return normalized;
|
|
2209
2250
|
}
|
|
2210
2251
|
|
|
2211
2252
|
function relaxedFieldTargetMatchScore(
|
|
@@ -3082,7 +3123,9 @@ function partiallyMaskUnknownString(value: string): string {
|
|
|
3082
3123
|
}
|
|
3083
3124
|
|
|
3084
3125
|
function normalizePrivacyReplacementSpacing(value: string): string {
|
|
3085
|
-
|
|
3126
|
+
// Most captured strings contain no replacement markers. The token regex
|
|
3127
|
+
// backtracks over long ordinary words even though no token can match.
|
|
3128
|
+
if (!value || (!value.includes('[') && !/_tok_/i.test(value))) {
|
|
3086
3129
|
return value;
|
|
3087
3130
|
}
|
|
3088
3131
|
const bracketMarker = String.raw`(?:\[(?:dropped|secret-token|connection-string|auth-token|session-secret|private-key|basic-auth|jwt|credit-card|phone|person|ssn|masked)\])`;
|
|
@@ -15,14 +15,24 @@ function isTraceMaterializationWorkerRequest(value: unknown): value is TraceMate
|
|
|
15
15
|
return Number.isFinite(message.taskId) && message.type === 'materialize-trace-events' && !!message.payload;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
let policyId: number | undefined;
|
|
19
|
+
let privacy: any;
|
|
20
|
+
let queue = Promise.resolve();
|
|
18
21
|
parentPort?.on('message', (message: unknown) => {
|
|
19
22
|
if (!isTraceMaterializationWorkerRequest(message)) {
|
|
20
23
|
return;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
// FIFO also isolates the materializer's full-value-capture flag across tasks.
|
|
27
|
+
queue = queue.then(async () => {
|
|
24
28
|
try {
|
|
25
|
-
|
|
29
|
+
if (message.payload.privacy !== undefined) {
|
|
30
|
+
policyId = message.payload.policyId;
|
|
31
|
+
privacy = message.payload.privacy;
|
|
32
|
+
} else if (policyId === undefined || policyId !== message.payload.policyId) {
|
|
33
|
+
throw new Error('trace materialization policy reference is unavailable');
|
|
34
|
+
}
|
|
35
|
+
const events = await __materializePendingTraceEventsForWorker({ ...message.payload, privacy });
|
|
26
36
|
parentPort?.postMessage({
|
|
27
37
|
taskId: message.taskId,
|
|
28
38
|
ok: true,
|
|
@@ -35,5 +45,5 @@ parentPort?.on('message', (message: unknown) => {
|
|
|
35
45
|
error: err instanceof Error ? err.message : String(err),
|
|
36
46
|
});
|
|
37
47
|
}
|
|
38
|
-
})
|
|
48
|
+
});
|
|
39
49
|
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '0';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
4
|
+
process.env.TRACE_IDLE_FLUSH_MS = '0';
|
|
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 { once } = require('node:events');
|
|
10
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
11
|
+
const amqp = require('amqplib');
|
|
12
|
+
const { __reproTestHooks: hooks } = require('../dist');
|
|
13
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
14
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
const deferred = () => {
|
|
16
|
+
let resolve;
|
|
17
|
+
const promise = new Promise(done => { resolve = done; });
|
|
18
|
+
return { promise, resolve };
|
|
19
|
+
};
|
|
20
|
+
async function within(promise, label) {
|
|
21
|
+
let timer;
|
|
22
|
+
try {
|
|
23
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
24
|
+
timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 10000);
|
|
25
|
+
})]);
|
|
26
|
+
} finally { clearTimeout(timer); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const events = [], channels = [];
|
|
31
|
+
let rabbit, connection, release;
|
|
32
|
+
const sink = http.createServer(async (req, res) => {
|
|
33
|
+
const buffers = [];
|
|
34
|
+
for await (const chunk of req) buffers.push(chunk);
|
|
35
|
+
events.push(...(JSON.parse(Buffer.concat(buffers)).events || []));
|
|
36
|
+
res.writeHead(202); res.end('{}');
|
|
37
|
+
});
|
|
38
|
+
sink.listen(0, '127.0.0.1'); await once(sink, 'listening');
|
|
39
|
+
try {
|
|
40
|
+
rabbit = await new GenericContainer('rabbitmq:3.13-alpine')
|
|
41
|
+
.withEnvironment({ RABBITMQ_DEFAULT_USER: 'repro', RABBITMQ_DEFAULT_PASS: 'test' })
|
|
42
|
+
.withExposedPorts(5672).withWaitStrategy(Wait.forLogMessage('Server startup complete')).start();
|
|
43
|
+
connection = await amqp.connect(`amqp://repro:test@${rabbit.getHost()}:${rabbit.getMappedPort(5672)}`);
|
|
44
|
+
for (const mode of ['live', 'staging']) {
|
|
45
|
+
const sid = `S_amqp_timing_${mode}`;
|
|
46
|
+
const cfg = { tenantId: 'TENANT_timing', appId: 'APP_timing', appSecret: 'test',
|
|
47
|
+
privacy: { captureRaw: true, environment: 'dev' },
|
|
48
|
+
ingestBase: `http://127.0.0.1:${sink.address().port}`,
|
|
49
|
+
resolveContext: () => ({ sid, aid: `A_${mode}` }) };
|
|
50
|
+
const channel = await connection.createConfirmChannel(); channels.push(channel);
|
|
51
|
+
hooks.patchAmqplibChannelForTest(channel, cfg);
|
|
52
|
+
const queue = `timing-${mode}`;
|
|
53
|
+
await channel.assertQueue(queue, { durable: false });
|
|
54
|
+
const handled = deferred(), acknowledged = deferred(), gate = deferred(), started = deferred();
|
|
55
|
+
release = gate.resolve;
|
|
56
|
+
// Hold the real capture queue, not the broker or application callback.
|
|
57
|
+
hooks.scheduleSdkBackgroundWorkForTest(async () => {
|
|
58
|
+
started.resolve(); await gate.promise;
|
|
59
|
+
}, { priority: 'high' });
|
|
60
|
+
await within(started.promise, 'capture queue blocker');
|
|
61
|
+
let handlerStart, handlerEnd, publishEnd;
|
|
62
|
+
const input = { orderId: `order-${mode}`, lines: [{ sku: 'item', quantity: 3 }] };
|
|
63
|
+
const consumer = await channel.consume(queue, async message => {
|
|
64
|
+
handlerStart = Date.now();
|
|
65
|
+
assert.deepEqual(JSON.parse(message.content.toString()), input);
|
|
66
|
+
assert.equal(message.properties.headers['x-bug-session-id'], sid);
|
|
67
|
+
assert.equal(message.properties.headers['x-repro-capture-mode'], mode);
|
|
68
|
+
await sleep(60);
|
|
69
|
+
channel.ack(message);
|
|
70
|
+
handlerEnd = Date.now(); handled.resolve();
|
|
71
|
+
});
|
|
72
|
+
const publishStart = Date.now();
|
|
73
|
+
const options = { headers: { 'x-repro-capture-mode': mode }, persistent: false };
|
|
74
|
+
const result = hooks.runWithRequestContextForTest({ sid, aid: `A_${mode}`, captureScope: { liveCapture: mode === 'live' } },
|
|
75
|
+
() => channel.sendToQueue(queue, Buffer.from(JSON.stringify(input)), options, error => {
|
|
76
|
+
assert.ifError(error); publishEnd = Date.now(); acknowledged.resolve();
|
|
77
|
+
}));
|
|
78
|
+
assert.equal(typeof result, 'boolean', 'publish must preserve amqplib backpressure contract');
|
|
79
|
+
await within(Promise.all([handled.promise, acknowledged.promise]), 'broker confirm and consumer acknowledgement');
|
|
80
|
+
assert.equal(options.headers['x-bug-session-id'], undefined, 'caller options stay unchanged');
|
|
81
|
+
assert.equal(events.filter(e => e.session_id === sid && e.event_type === 'backend_request').length, 0);
|
|
82
|
+
await sleep(450);
|
|
83
|
+
const releasedAt = Date.now(); gate.resolve(); release = null;
|
|
84
|
+
const deadline = Date.now() + 10000;
|
|
85
|
+
let requests;
|
|
86
|
+
do {
|
|
87
|
+
await hooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
|
|
88
|
+
requests = events.filter(e => e.session_id === sid && e.event_type === 'backend_request');
|
|
89
|
+
if (requests.length === 2) break;
|
|
90
|
+
await sleep(20);
|
|
91
|
+
} while (Date.now() < deadline);
|
|
92
|
+
assert.equal(requests.length, 2, 'exactly one publish and one consume, despite sendToQueue delegating to publish');
|
|
93
|
+
for (const [method, start, end] of [
|
|
94
|
+
['AMQP_SEND_TO_QUEUE', publishStart, publishEnd],
|
|
95
|
+
['AMQP_CONSUME', handlerStart, handlerEnd],
|
|
96
|
+
]) {
|
|
97
|
+
const event = requests.find(e => e.payload.request.method === method);
|
|
98
|
+
assert(event, `${mode}: missing ${method}`);
|
|
99
|
+
const request = event.payload.request;
|
|
100
|
+
assert(request.durMs <= end - start + 30,
|
|
101
|
+
`${mode} ${method}: ${request.durMs}ms includes capture queue time; business took ${end - start}ms`);
|
|
102
|
+
assert(request.durMs >= end - start - 10, 'duration must still include the async business handler');
|
|
103
|
+
const timestamp = Date.parse(event.event_ts);
|
|
104
|
+
assert(timestamp <= end + 30 && timestamp >= start,
|
|
105
|
+
`event time must be operation completion, not background delivery: ${timestamp}, ${end}`);
|
|
106
|
+
assert(timestamp < releasedAt - 400);
|
|
107
|
+
assert.deepEqual(request.body.value, input, 'timing correction must not lose payload');
|
|
108
|
+
}
|
|
109
|
+
await channel.cancel(consumer.consumerTag);
|
|
110
|
+
console.log(`${mode}: broker confirms and async handler finish before capture drain; timings and payload exact`);
|
|
111
|
+
}
|
|
112
|
+
// Exercise failure/return contracts with real delivered envelopes. The
|
|
113
|
+
// adapter catches deliberate handler failures just as an application would.
|
|
114
|
+
const sid = 'S_amqp_failures';
|
|
115
|
+
const cfg = { tenantId: 'TENANT_timing', appId: 'APP_timing', appSecret: 'test',
|
|
116
|
+
privacy: { captureRaw: true, environment: 'dev' },
|
|
117
|
+
ingestBase: `http://127.0.0.1:${sink.address().port}`,
|
|
118
|
+
resolveContext: () => ({ sid, aid: 'A_failures' }) };
|
|
119
|
+
const raw = await connection.createChannel(); channels.push(raw);
|
|
120
|
+
await raw.assertQueue('timing-contracts', { durable: false });
|
|
121
|
+
const gate = deferred(), started = deferred(); release = gate.resolve;
|
|
122
|
+
hooks.scheduleSdkBackgroundWorkForTest(async () => { started.resolve(); await gate.promise; }, { priority: 'high' });
|
|
123
|
+
await within(started.promise, 'failure capture queue blocker');
|
|
124
|
+
const observations = new Map(), completed = deferred();
|
|
125
|
+
const identity = { result: 'unchanged' }, failure = new Error('application failure');
|
|
126
|
+
const wrapped = hooks.wrapAmqplibConsumeHandlerForTest(message => {
|
|
127
|
+
const { scenario } = JSON.parse(message.content.toString());
|
|
128
|
+
const observation = { start: Date.now(), calls: 1, scenario };
|
|
129
|
+
observations.set(scenario, observation);
|
|
130
|
+
const finish = () => {
|
|
131
|
+
observation.end = Date.now();
|
|
132
|
+
if (scenario.endsWith('error')) throw failure;
|
|
133
|
+
return identity;
|
|
134
|
+
};
|
|
135
|
+
return scenario.startsWith('async') ? sleep(60).then(finish) : finish();
|
|
136
|
+
}, cfg, 'timing-contracts');
|
|
137
|
+
let handled = 0;
|
|
138
|
+
const consumer = await raw.consume('timing-contracts', message => {
|
|
139
|
+
const { scenario } = JSON.parse(message.content.toString());
|
|
140
|
+
const complete = () => { raw.ack(message); if (++handled === 4) completed.resolve(); };
|
|
141
|
+
try {
|
|
142
|
+
const value = wrapped(message);
|
|
143
|
+
if (scenario.startsWith('async')) {
|
|
144
|
+
assert.equal(typeof value.then, 'function');
|
|
145
|
+
value.then(result => {
|
|
146
|
+
assert.strictEqual(result, identity); complete();
|
|
147
|
+
}, error => { assert.strictEqual(error, failure); complete(); });
|
|
148
|
+
} else { assert.strictEqual(value, identity); complete(); }
|
|
149
|
+
} catch (error) { assert.strictEqual(error, failure); complete(); }
|
|
150
|
+
});
|
|
151
|
+
for (const scenario of ['sync-success', 'async-success', 'sync-error', 'async-error']) {
|
|
152
|
+
raw.sendToQueue('timing-contracts', Buffer.from(JSON.stringify({ scenario })), { headers: {
|
|
153
|
+
'x-bug-session-id': sid, 'x-bug-action-id': 'A_failures', 'x-repro-capture-mode': 'live',
|
|
154
|
+
} });
|
|
155
|
+
}
|
|
156
|
+
await within(completed.promise, 'handler return and error contracts');
|
|
157
|
+
const invalid = await connection.createConfirmChannel(); channels.push(invalid);
|
|
158
|
+
invalid.on('error', () => {});
|
|
159
|
+
hooks.patchAmqplibChannelForTest(invalid, cfg);
|
|
160
|
+
const rejected = deferred();
|
|
161
|
+
const rejectStart = Date.now(); let rejectEnd;
|
|
162
|
+
invalid.publish('missing-exchange-timing', 'route', Buffer.from('message'), {}, error => {
|
|
163
|
+
assert(error, 'broker must reject the nonexistent exchange');
|
|
164
|
+
rejectEnd = Date.now(); rejected.resolve();
|
|
165
|
+
});
|
|
166
|
+
await within(rejected.promise, 'broker rejection');
|
|
167
|
+
await sleep(450); gate.resolve(); release = null;
|
|
168
|
+
const deadline = Date.now() + 10000;
|
|
169
|
+
let requests;
|
|
170
|
+
do {
|
|
171
|
+
await hooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
|
|
172
|
+
requests = events.filter(e => e.session_id === sid && e.event_type === 'backend_request');
|
|
173
|
+
if (requests.length === 5) break;
|
|
174
|
+
await sleep(20);
|
|
175
|
+
} while (Date.now() < deadline);
|
|
176
|
+
assert.equal(requests.length, 5, 'no retries/double captures on exceptions or broker channel close');
|
|
177
|
+
for (const event of requests) {
|
|
178
|
+
const request = event.payload.request;
|
|
179
|
+
const observation = request.method === 'AMQP_PUBLISH'
|
|
180
|
+
? { start: rejectStart, end: rejectEnd, scenario: 'broker-error' }
|
|
181
|
+
: observations.get(request.body.value.scenario);
|
|
182
|
+
assert(observation);
|
|
183
|
+
assert.equal(request.status, observation.scenario.endsWith('error') ? 500 : 200);
|
|
184
|
+
assert(request.durMs <= observation.end - observation.start + 30);
|
|
185
|
+
assert(Date.parse(event.event_ts) <= observation.end + 30);
|
|
186
|
+
}
|
|
187
|
+
await raw.cancel(consumer.consumerTag);
|
|
188
|
+
console.log('sync/async return and error identity preserved; broker rejection and failure timestamps exclude capture drain');
|
|
189
|
+
} finally {
|
|
190
|
+
release?.();
|
|
191
|
+
for (const channel of channels) await channel.close().catch(() => {});
|
|
192
|
+
if (connection) await connection.close();
|
|
193
|
+
await hooks.drainSdkBackgroundQueueForTest(); await flushIngestQueue();
|
|
194
|
+
sink.closeAllConnections(); await new Promise(resolve => sink.close(resolve));
|
|
195
|
+
if (rabbit) await rabbit.stop();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -25,7 +25,9 @@ async function main() {
|
|
|
25
25
|
const results = [];
|
|
26
26
|
try {
|
|
27
27
|
mongo = await new GenericContainer('mongo:7').withExposedPorts(27017).withWaitStrategy(Wait.forListeningPorts()).start();
|
|
28
|
-
|
|
28
|
+
const variants = ['off', 'live-raw', 'staging-raw', 'live-policy', 'staging-policy', 'live-policy-fallback', 'staging-policy-fallback', 'staging-policy-fault']
|
|
29
|
+
.filter(variant => !process.argv.includes('--raw-only') || !variant.includes('policy'));
|
|
30
|
+
for (const variant of variants) {
|
|
29
31
|
const mode = variant.split('-')[0];
|
|
30
32
|
child = fork(require.resolve('./fixtures/capture-latency-server'), [], {
|
|
31
33
|
env: { ...process.env, CAPTURE_TEST_MODE: mode,
|
|
@@ -70,6 +72,7 @@ async function main() {
|
|
|
70
72
|
const capture = scoped();
|
|
71
73
|
const manifest = capture.find(e => e.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest;
|
|
72
74
|
return manifest && ['backend_request', 'db_change'].every(t => capture.some(e => e.event_type === t))
|
|
75
|
+
&& capture.flatMap(e => e.payload.db || []).length === manifest.expected.dbChange
|
|
73
76
|
&& capture.filter(e => e.event_type === 'trace_batch').length >= manifest.expected.traceBatch;
|
|
74
77
|
};
|
|
75
78
|
while (!complete() && Date.now() < deadline) await sleep(25);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
function observeRows(document) { return document; }
|
|
2
2
|
async function loadRows(Model) {
|
|
3
3
|
const document = await Model.findOne({ reference: 'LATENCY' }).lean().exec();
|
|
4
|
-
for (let i = 0; i < 12; i++) {
|
|
4
|
+
for (let i = 0; i < Number(process.env.CAPTURE_TEST_OBSERVATIONS || 12); i++) {
|
|
5
5
|
document.phase = i;
|
|
6
6
|
if (observeRows(document) !== document) throw new Error('return identity changed');
|
|
7
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const workerThreads = require('node:worker_threads');
|
|
2
|
-
const metrics = { workers: 0, tasks: 0 };
|
|
2
|
+
const metrics = { workers: 0, tasks: 0, policyTransfers: 0 };
|
|
3
3
|
const RealWorker = workerThreads.Worker;
|
|
4
4
|
workerThreads.Worker = class extends RealWorker {
|
|
5
5
|
constructor(...args) {
|
|
@@ -10,13 +10,17 @@ workerThreads.Worker = class extends RealWorker {
|
|
|
10
10
|
if (String(args[0]).includes('trace-materializer-worker')) {
|
|
11
11
|
metrics.workers++;
|
|
12
12
|
const post = this.postMessage.bind(this);
|
|
13
|
-
this.postMessage = (...messages) => {
|
|
13
|
+
this.postMessage = (...messages) => {
|
|
14
|
+
metrics.tasks++;
|
|
15
|
+
if (messages[0]?.payload?.privacy !== undefined) metrics.policyTransfers++;
|
|
16
|
+
return post(...messages);
|
|
17
|
+
};
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
};
|
|
17
21
|
const http = require('node:http');
|
|
18
22
|
const mode = process.env.CAPTURE_TEST_MODE;
|
|
19
|
-
const { initRepro
|
|
23
|
+
const { initRepro } = require(process.env.CAPTURE_TEST_SDK || '../../dist');
|
|
20
24
|
async function main() {
|
|
21
25
|
const config = {
|
|
22
26
|
tenantId: 'TENANT_latency', appId: 'APP_latency', appSecret: 'test-only',
|
|
@@ -31,7 +35,6 @@ async function main() {
|
|
|
31
35
|
const express = require('express');
|
|
32
36
|
const connection = await mongoose.createConnection(process.env.CAPTURE_TEST_MONGO).asPromise();
|
|
33
37
|
const schema = new mongoose.Schema({ reference: String, phase: Number, rows: [Object] });
|
|
34
|
-
if (mode !== 'off') schema.plugin(reproMongoosePlugin, config);
|
|
35
38
|
const Model = connection.model('Latency', schema);
|
|
36
39
|
await Model.create({ reference: 'LATENCY', phase: -1, rows: Array.from({ length: 120 }, (_, id) => ({
|
|
37
40
|
id, status: 'available', internalSecret: `secret-${id}`, description: 'Operational depot description '.repeat(32),
|
|
@@ -185,6 +185,21 @@ async function main() {
|
|
|
185
185
|
);
|
|
186
186
|
assert.match(transformedPositionalTrace[4], /^4_tok_[a-f0-9]{12}$/);
|
|
187
187
|
|
|
188
|
+
// Cached selector scores must not cache value-dependent PATTERN decisions,
|
|
189
|
+
// numeric targets, or leak a previous route/function/database context.
|
|
190
|
+
const contexts = [
|
|
191
|
+
{ trace: { fn: 'CapturedService.loadNames' }, routeKey: 'GET /api/v1/depots/:id/names' },
|
|
192
|
+
{ trace: { fn: 'OtherService.load' }, routeKey: 'POST /other', db: { type: 'mongo', collection: 'other', op: 'insert' } },
|
|
193
|
+
];
|
|
194
|
+
for (let i = 0; i < 8; i++) {
|
|
195
|
+
const value = { data: [{ notes: i % 2 ? 'ordinary note' : 'secret-value', privateNote: 'private' }] };
|
|
196
|
+
const context = { appId: 'APP_test', appSecret: 'secret', ...contexts[i % 2] };
|
|
197
|
+
const warm = await applyRuntimePrivacyPolicyAsync(policy, 'response.body', value, context);
|
|
198
|
+
const cold = await applyRuntimePrivacyPolicyAsync(structuredClone(policy), 'response.body', value, context);
|
|
199
|
+
assert.deepEqual(warm, cold);
|
|
200
|
+
assert.equal(warm.data[0].notes, i % 2 ? 'ordinary note' : '[dropped]');
|
|
201
|
+
assert.equal(value.data[0].privateNote, 'private');
|
|
202
|
+
}
|
|
188
203
|
console.log(`privacy policy index OK (${elapsedMs}ms)`);
|
|
189
204
|
}
|
|
190
205
|
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
|
|
9
|
+
const policy = { statements: [
|
|
10
|
+
...Array.from({ length: 5000 }, (_, i) => ({ sid: `fn-${i}`, when: {
|
|
11
|
+
scope: 'FUNCTION', selector: `Service${i}.method`, target: 'trace.args|trace.returnValue',
|
|
12
|
+
}, apply: { action: 'KEEP_EXACT' } })),
|
|
13
|
+
...Array.from({ length: 9 }, (_, i) => ({ sid: `pattern-${i}`, when: {
|
|
14
|
+
scope: 'PATTERN', selector: `pattern-${i}`, pattern: `sensitive-value-${i}`,
|
|
15
|
+
}, apply: { action: 'DROP' } })),
|
|
16
|
+
{ sid: 'secret', when: { scope: 'FIELD', selector: 'field:internalSecret' }, apply: { action: 'DROP' } },
|
|
17
|
+
] };
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
let events = [], child, mongo;
|
|
21
|
+
const sink = http.createServer((req, res) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
24
|
+
req.on('end', () => {
|
|
25
|
+
events.push(...JSON.parse(Buffer.concat(chunks)).events);
|
|
26
|
+
setTimeout(() => { res.writeHead(202); res.end('{}'); }, 10);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
await new Promise(resolve => sink.listen(0, '127.0.0.1', resolve));
|
|
30
|
+
try {
|
|
31
|
+
mongo = await new GenericContainer('mongo:7').withExposedPorts(27017)
|
|
32
|
+
.withWaitStrategy(Wait.forListeningPorts()).start();
|
|
33
|
+
for (const mode of ['live', 'staging']) {
|
|
34
|
+
events = [];
|
|
35
|
+
child = fork(require.resolve('./fixtures/capture-latency-server'), [], {
|
|
36
|
+
env: { ...process.env, CAPTURE_TEST_MODE: mode, CAPTURE_TEST_OBSERVATIONS: '128',
|
|
37
|
+
CAPTURE_TEST_MONGO: `mongodb://${mongo.getHost()}:${mongo.getMappedPort(27017)}/throughput_${mode}`,
|
|
38
|
+
CAPTURE_TEST_SINK: `http://127.0.0.1:${sink.address().port}`, CAPTURE_TEST_POLICY: JSON.stringify(policy),
|
|
39
|
+
REPRO_SDK_BACKGROUND_IDLE_DELAY_MS: '0', REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS: '0',
|
|
40
|
+
REPRO_SDK_BACKGROUND_MAX_DEFER_MS: '0', TRACE_IDLE_FLUSH_MS: '10', TRACE_LINGER_AFTER_FINISH_MS: '0',
|
|
41
|
+
}, stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
42
|
+
});
|
|
43
|
+
let stderr = '';
|
|
44
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
45
|
+
const ready = await Promise.race([once(child, 'message').then(([v]) => v),
|
|
46
|
+
once(child, 'exit').then(() => { throw new Error(stderr); })]);
|
|
47
|
+
const base = `http://127.0.0.1:${ready.port}`;
|
|
48
|
+
const sessionIds = [0, 1].map(i => `S_throughput_${mode}_${i}`);
|
|
49
|
+
const started = performance.now();
|
|
50
|
+
const responses = await Promise.all(sessionIds.map((sid, i) =>
|
|
51
|
+
fetch(`${base}/names?__repro_sid=${sid}&__repro_aid=A_${i}&__repro_mode=${mode}`).then(r => r.json())));
|
|
52
|
+
const responseMs = performance.now() - started;
|
|
53
|
+
for (const response of responses) {
|
|
54
|
+
assert.equal(response.phase, 127);
|
|
55
|
+
assert.equal(response.rows.length, 120);
|
|
56
|
+
assert.equal(response.rows[0].internalSecret, 'secret-0');
|
|
57
|
+
}
|
|
58
|
+
const complete = sid => {
|
|
59
|
+
const scoped = events.filter(e => e.session_id === sid);
|
|
60
|
+
const manifest = scoped.find(e => e.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest;
|
|
61
|
+
return manifest && scoped.filter(e => e.event_type === 'trace_batch').length === manifest.expected.traceBatch
|
|
62
|
+
&& scoped.filter(e => e.event_type === 'backend_request').length === manifest.expected.backendRequest
|
|
63
|
+
&& scoped.reduce((sum, e) => sum + (Array.isArray(e.payload.db) ? e.payload.db.length : 0), 0) === manifest.expected.dbChange;
|
|
64
|
+
};
|
|
65
|
+
let maxHealthMs = 0;
|
|
66
|
+
while (!sessionIds.every(complete) && performance.now() - started < 60000) {
|
|
67
|
+
const t = performance.now();
|
|
68
|
+
await (await fetch(`${base}/health`)).json();
|
|
69
|
+
maxHealthMs = Math.max(maxHealthMs, performance.now() - t);
|
|
70
|
+
await sleep(20);
|
|
71
|
+
}
|
|
72
|
+
assert(sessionIds.every(complete), `missing evidence: ${JSON.stringify(sessionIds.map(sid => {
|
|
73
|
+
const scoped = events.filter(e => e.session_id === sid);
|
|
74
|
+
return { sid, counts: scoped.reduce((out, e) => { out[e.event_type] = (out[e.event_type] || 0) + 1; return out; }, {}),
|
|
75
|
+
manifest: scoped.find(e => e.event_type === 'request_capture_manifest')?.payload.requestCaptureManifest };
|
|
76
|
+
}))} ${stderr.slice(-2000)}`);
|
|
77
|
+
const completeMs = performance.now() - started;
|
|
78
|
+
for (const sid of sessionIds) {
|
|
79
|
+
const scoped = events.filter(e => e.session_id === sid);
|
|
80
|
+
const manifest = scoped.find(e => e.event_type === 'request_capture_manifest').payload.requestCaptureManifest;
|
|
81
|
+
assert.equal(manifest.privacy.status, 'applied');
|
|
82
|
+
assert(!manifest.captureFailures?.length);
|
|
83
|
+
const values = new Map(scoped.flatMap(e => [...(e.payload.traceValues || []), ...(e.payload.requestValues || []), ...(e.payload.dbValues || [])]).map(v => [v.id, v.value]));
|
|
84
|
+
const trace = scoped.flatMap(e => e.payload.trace || []);
|
|
85
|
+
const observations = [...new Map(trace.filter(e => e.fn === 'observeRows' && e.line === 6 && e.type === 'enter')
|
|
86
|
+
.map(e => [e.spanId, e])).values()];
|
|
87
|
+
assert.equal(observations.length, 128);
|
|
88
|
+
const args = observations.map(e => values.get(e.argsValueCapture?.valueId) ?? e.args);
|
|
89
|
+
assert.deepEqual(args.map(v => v[0].phase), Array.from({ length: 128 }, (_, i) => i));
|
|
90
|
+
for (const [document] of args) {
|
|
91
|
+
assert.equal(document.rows.length, mode === 'live' ? 101 : 120);
|
|
92
|
+
assert.equal(document.rows[0].internalSecret, '[dropped]');
|
|
93
|
+
}
|
|
94
|
+
assert(!JSON.stringify(scoped).includes('secret-0'), 'capture leaked a protected value');
|
|
95
|
+
}
|
|
96
|
+
const metrics = await (await fetch(`${base}/health`)).json();
|
|
97
|
+
assert(metrics.tasks > 4);
|
|
98
|
+
assert(metrics.policyTransfers <= metrics.workers, 'full policy was transferred again for every trace batch');
|
|
99
|
+
assert(completeMs < 25000, `${mode}: post-response capture backlog took ${completeMs}ms`);
|
|
100
|
+
assert(maxHealthMs < 500, `${mode}: background capture blocked HTTP for ${maxHealthMs}ms`);
|
|
101
|
+
console.log(JSON.stringify({ mode, responseMs: Math.round(responseMs), completeMs: Math.round(completeMs), maxHealthMs: Math.round(maxHealthMs), ...metrics }));
|
|
102
|
+
const exited = once(child, 'exit'); child.send('stop'); await exited; child = null;
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
if (child) { const exited = once(child, 'exit'); child.kill(); await exited; }
|
|
106
|
+
await new Promise(resolve => sink.close(resolve));
|
|
107
|
+
if (mongo) await mongo.stop();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { Worker } = require('node:worker_threads');
|
|
3
|
+
const { normalizeRuntimePrivacyPolicy } = require('../dist/privacy');
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
const worker = new Worker(require.resolve('../dist/trace-materializer-worker'));
|
|
7
|
+
let taskId = 0;
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
worker.on('message', message => { pending.get(message.taskId)(message); pending.delete(message.taskId); });
|
|
10
|
+
const policy = action => normalizeRuntimePrivacyPolicy({ statements: [
|
|
11
|
+
{ sid: action, when: { scope: 'FIELD', selector: 'field:businessNote' }, apply: { action } },
|
|
12
|
+
] });
|
|
13
|
+
const call = (policyId, privacy, tenant = 'T', raw = false) => new Promise(resolve => {
|
|
14
|
+
const id = ++taskId;
|
|
15
|
+
pending.set(id, resolve);
|
|
16
|
+
worker.postMessage({ taskId: id, type: 'materialize-trace-events', payload: {
|
|
17
|
+
policyId, privacy, fullValueCaptureEnabled: true,
|
|
18
|
+
cfg: { tenantId: tenant, appId: tenant, appSecret: tenant, privacy: { captureRaw: raw } },
|
|
19
|
+
maskReq: { key: 'GET /names' }, masking: null, valueNamespace: tenant,
|
|
20
|
+
events: [{ type: 'enter', fn: 'load', __reproPending: { argsRaw: [{ businessNote: 'operational-value' }] } }],
|
|
21
|
+
} });
|
|
22
|
+
});
|
|
23
|
+
try {
|
|
24
|
+
const missing = await call(1);
|
|
25
|
+
assert.equal(missing.ok, false);
|
|
26
|
+
assert.match(missing.error, /policy reference/);
|
|
27
|
+
// Queue interleaved policies before any result arrives. Old references must
|
|
28
|
+
// not accidentally use the new policy or a concurrent task's raw flag.
|
|
29
|
+
const responses = await Promise.all([
|
|
30
|
+
call(1, policy('DROP')), call(1), call(2, policy('KEEP_EXACT'), 'Other'),
|
|
31
|
+
call(2, undefined, 'Other'), call(1), call(3, null, 'Raw', true),
|
|
32
|
+
call(4, policy('DROP')), call(4),
|
|
33
|
+
]);
|
|
34
|
+
for (const i of [0, 1, 6, 7]) {
|
|
35
|
+
assert.equal(responses[i].ok, true);
|
|
36
|
+
assert.equal(responses[i].events[0].args[0].businessNote, '[dropped]');
|
|
37
|
+
}
|
|
38
|
+
for (const i of [2, 3, 5]) assert.equal(responses[i].events[0].args[0].businessNote, 'operational-value');
|
|
39
|
+
assert.equal(responses[4].ok, false, 'stale reference silently used another policy');
|
|
40
|
+
} finally { await worker.terminate(); }
|
|
41
|
+
console.log('trace worker policy references, updates, missing refs, and raw/policy isolation OK');
|
|
42
|
+
}
|
|
43
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|