@reproapp/node-sdk 0.0.46 → 0.0.48
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.d.ts +16 -3
- package/dist/index.js +327 -159
- package/dist/ingest/client.js +11 -0
- package/dist/privacy.d.ts +1 -0
- package/dist/privacy.js +306 -7
- package/dist/semantic-serialization.d.ts +9 -0
- package/dist/semantic-serialization.js +171 -0
- package/package.json +3 -3
- package/src/index.ts +513 -144
- package/src/ingest/client.ts +13 -0
- package/src/privacy.ts +409 -7
- package/src/semantic-serialization.ts +182 -0
- package/test/content-addressed-mongodb.testcontainers.test.js +348 -0
- package/test/content-addressed-trace-values.test.js +264 -0
- package/test/fixtures/semantic-serialization-trace-controller.js +85 -0
- package/test/fixtures/semantic-serialization-trace-server.js +19 -0
- package/test/ingest-high-priority-fairness.test.js +76 -0
- package/test/policy-capture-attestation.test.js +234 -0
- package/test/policy-session-completion.testcontainers.test.js +271 -0
- package/test/privacy-policy-index.test.js +194 -0
- package/test/sdk-background-timeout-isolation.test.js +39 -0
- package/test/semantic-serialization-trace.test.js +152 -0
- package/test/semantic-serialization.test.js +229 -0
- package/test/session-storage-budget.test.js +298 -0
- package/test/trace-batch-size.test.js +37 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
process.env.REPRO_INGEST_REQUEST_TIMEOUT_MS = '100';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '50';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
|
|
4
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
5
|
+
process.env.REPRO_SDK_TRACE_INLINE_VALUE_MAX_SERIALIZED_CHARS = '2048';
|
|
6
|
+
process.env.TRACE_IDLE_FLUSH_MS = '10';
|
|
7
|
+
process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
|
|
8
|
+
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const { EventEmitter } = require('node:events');
|
|
11
|
+
const mongoose = require('mongoose');
|
|
12
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
initReproTracing,
|
|
16
|
+
reproMiddleware,
|
|
17
|
+
reproMongoosePlugin,
|
|
18
|
+
__reproTestHooks,
|
|
19
|
+
} = require('../dist');
|
|
20
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
21
|
+
|
|
22
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
const byteLength = (value) => Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
24
|
+
|
|
25
|
+
class Logger {
|
|
26
|
+
constructor(context) {
|
|
27
|
+
this.context = context;
|
|
28
|
+
this.buffers = Array.from({ length: 200 }, () => 'mongo-test-logger-buffer');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
log() {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class ConfigService {
|
|
35
|
+
constructor() {
|
|
36
|
+
this.cache = {
|
|
37
|
+
DATABASE_URL: 'mongodb://captured-config-secret@database/repro',
|
|
38
|
+
};
|
|
39
|
+
this.internalConfig = Array.from({ length: 1000 }, (_, index) => ({
|
|
40
|
+
index,
|
|
41
|
+
credential: `mongo-config-credential-${index}`,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get(key) {
|
|
46
|
+
return this.cache[key];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class SubjectRepository {
|
|
51
|
+
constructor(model, configService) {
|
|
52
|
+
this.model = model;
|
|
53
|
+
this.configService = configService;
|
|
54
|
+
this.logger = new Logger('SubjectRepository');
|
|
55
|
+
this.collectionName = 'subjects';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadSubjectRepeatedly(repository, query) {
|
|
60
|
+
const docs = [];
|
|
61
|
+
for (let index = 0; index < 6; index += 1) {
|
|
62
|
+
docs.push(await repository.model.findOne(query).lean().exec());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let duplicateCode = null;
|
|
66
|
+
try {
|
|
67
|
+
await repository.model.create({
|
|
68
|
+
...docs[0],
|
|
69
|
+
_id: undefined,
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
duplicateCode = error?.code ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
repositoryIdentityPreserved: repository instanceof SubjectRepository,
|
|
77
|
+
duplicateCode,
|
|
78
|
+
docs,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeSubject() {
|
|
83
|
+
return {
|
|
84
|
+
subjectNumber: 'SUBJECT-CONTENT-1001',
|
|
85
|
+
status: 'screened',
|
|
86
|
+
visits: Array.from({ length: 80 }, (_, visitIndex) => ({
|
|
87
|
+
visitNumber: `VISIT-${String(visitIndex).padStart(3, '0')}`,
|
|
88
|
+
assessments: Array.from({ length: 5 }, (_, assessmentIndex) => ({
|
|
89
|
+
code: `ASSESSMENT-${visitIndex}-${assessmentIndex}`,
|
|
90
|
+
result: visitIndex * 10 + assessmentIndex,
|
|
91
|
+
referenceRange: { min: 0, max: 1000, unit: 'mg' },
|
|
92
|
+
flags: assessmentIndex === 4 ? ['review'] : [],
|
|
93
|
+
})),
|
|
94
|
+
})),
|
|
95
|
+
metadata: {
|
|
96
|
+
source: 'clinical-import',
|
|
97
|
+
audit: {
|
|
98
|
+
importedBy: 'SYSTEM',
|
|
99
|
+
importBatch: 'BATCH-CONTENT-1',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function makeReqRes(sessionId, actionId) {
|
|
106
|
+
const req = new EventEmitter();
|
|
107
|
+
req.method = 'POST';
|
|
108
|
+
req.url = '/subjects/repeated-read';
|
|
109
|
+
req.originalUrl = req.url;
|
|
110
|
+
req.headers = {
|
|
111
|
+
'x-bug-session-id': sessionId,
|
|
112
|
+
'x-bug-action-id': actionId,
|
|
113
|
+
'x-bug-request-start': String(Date.now()),
|
|
114
|
+
};
|
|
115
|
+
req.body = {};
|
|
116
|
+
req.params = {};
|
|
117
|
+
req.query = {};
|
|
118
|
+
|
|
119
|
+
const res = new EventEmitter();
|
|
120
|
+
res.statusCode = 200;
|
|
121
|
+
res.getHeader = () => undefined;
|
|
122
|
+
res.getHeaders = () => ({});
|
|
123
|
+
res.setHeader = () => {};
|
|
124
|
+
res.write = () => true;
|
|
125
|
+
res.end = function end() {
|
|
126
|
+
this.emit('finish');
|
|
127
|
+
return this;
|
|
128
|
+
};
|
|
129
|
+
return { req, res };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function flattenEvents(posts) {
|
|
133
|
+
return posts.flatMap((body) => Array.isArray(body?.events) ? body.events : []);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function dbChanges(events) {
|
|
137
|
+
return events
|
|
138
|
+
.filter((event) => event.event_type === 'db_change')
|
|
139
|
+
.flatMap((event) => event.payload?.db || []);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function manifests(events) {
|
|
143
|
+
return events
|
|
144
|
+
.filter((event) => event.event_type === 'request_capture_manifest')
|
|
145
|
+
.map((event) => event.payload?.requestCaptureManifest ?? event.payload)
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function startMongo() {
|
|
150
|
+
if (process.env.REPRO_MONGO_TEST_URI) {
|
|
151
|
+
return { uri: process.env.REPRO_MONGO_TEST_URI, stop: async () => {} };
|
|
152
|
+
}
|
|
153
|
+
const container = await new GenericContainer('mongo:7')
|
|
154
|
+
.withExposedPorts(27017)
|
|
155
|
+
.withTmpFs({ '/data/db': 'rw,noexec,nosuid,size=1g' })
|
|
156
|
+
.withCommand(['mongod', '--bind_ip_all', '--setParameter', 'diagnosticDataCollectionEnabled=false'])
|
|
157
|
+
.withWaitStrategy(Wait.forListeningPorts())
|
|
158
|
+
.start();
|
|
159
|
+
return {
|
|
160
|
+
uri: `mongodb://${container.getHost()}:${container.getMappedPort(27017)}/repro_content_addressed`,
|
|
161
|
+
stop: () => container.stop(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function main() {
|
|
166
|
+
const posts = [];
|
|
167
|
+
const originalFetch = global.fetch;
|
|
168
|
+
global.fetch = async (_url, init = {}) => {
|
|
169
|
+
posts.push(JSON.parse(String(init.body || '{}')));
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
status: 202,
|
|
173
|
+
json: async () => ({ ok: true }),
|
|
174
|
+
text: async () => '{"ok":true}',
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
let mongo;
|
|
179
|
+
let connection;
|
|
180
|
+
try {
|
|
181
|
+
mongo = await startMongo();
|
|
182
|
+
connection = await mongoose.createConnection(mongo.uri).asPromise();
|
|
183
|
+
|
|
184
|
+
const config = {
|
|
185
|
+
tenantId: 'TENANT_content_addressed_mongo',
|
|
186
|
+
appId: 'APP_content_addressed_mongo',
|
|
187
|
+
appSecret: 'content-addressed-mongo-secret',
|
|
188
|
+
captureHeaders: false,
|
|
189
|
+
privacy: { environment: 'dev', captureRaw: true },
|
|
190
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
191
|
+
};
|
|
192
|
+
const assessmentSchema = new mongoose.Schema({
|
|
193
|
+
code: String,
|
|
194
|
+
result: Number,
|
|
195
|
+
referenceRange: { min: Number, max: Number, unit: String },
|
|
196
|
+
flags: [String],
|
|
197
|
+
}, { _id: false });
|
|
198
|
+
const visitSchema = new mongoose.Schema({
|
|
199
|
+
visitNumber: String,
|
|
200
|
+
assessments: [assessmentSchema],
|
|
201
|
+
}, { _id: false });
|
|
202
|
+
const subjectSchema = new mongoose.Schema({
|
|
203
|
+
subjectNumber: { type: String, unique: true },
|
|
204
|
+
status: String,
|
|
205
|
+
visits: [visitSchema],
|
|
206
|
+
metadata: mongoose.Schema.Types.Mixed,
|
|
207
|
+
});
|
|
208
|
+
reproMongoosePlugin(config)(subjectSchema);
|
|
209
|
+
const Subject = connection.model('ContentSubject', subjectSchema, 'content_subjects');
|
|
210
|
+
await Subject.init();
|
|
211
|
+
await Subject.create(makeSubject());
|
|
212
|
+
|
|
213
|
+
initReproTracing({ instrument: false, fullValueCapture: true, logFunctionCalls: false });
|
|
214
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
215
|
+
const middleware = reproMiddleware(config);
|
|
216
|
+
const { req, res } = makeReqRes('S_content_addressed_mongo', 'A_content_addressed_mongo');
|
|
217
|
+
const repository = new SubjectRepository(Subject, new ConfigService());
|
|
218
|
+
let operationResult;
|
|
219
|
+
|
|
220
|
+
await new Promise((resolve, reject) => {
|
|
221
|
+
middleware(req, res, async (error) => {
|
|
222
|
+
if (error) {
|
|
223
|
+
reject(error);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
operationResult = await global.__repro_call(
|
|
228
|
+
loadSubjectRepeatedly,
|
|
229
|
+
null,
|
|
230
|
+
[repository, { subjectNumber: 'SUBJECT-CONTENT-1001' }],
|
|
231
|
+
'/app/src/subjects.repository.ts',
|
|
232
|
+
120,
|
|
233
|
+
'loadSubjectRepeatedly',
|
|
234
|
+
false,
|
|
235
|
+
);
|
|
236
|
+
res.end();
|
|
237
|
+
resolve();
|
|
238
|
+
} catch (operationError) {
|
|
239
|
+
reject(operationError);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
assert.equal(operationResult.repositoryIdentityPreserved, true);
|
|
245
|
+
assert.equal(operationResult.duplicateCode, 11000);
|
|
246
|
+
assert.equal(operationResult.docs.length, 6);
|
|
247
|
+
assert.equal(operationResult.docs[0].visits.length, 80);
|
|
248
|
+
assert.equal(operationResult.docs[5].visits[79].assessments[4].code, 'ASSESSMENT-79-4');
|
|
249
|
+
|
|
250
|
+
const deadline = Date.now() + 8000;
|
|
251
|
+
while (Date.now() < deadline) {
|
|
252
|
+
const events = flattenEvents(posts);
|
|
253
|
+
if (dbChanges(events).length >= 7 && manifests(events).length === 1 &&
|
|
254
|
+
events.some((event) => event.event_type === 'trace_batch')) {
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
await sleep(25);
|
|
258
|
+
}
|
|
259
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest();
|
|
260
|
+
await flushIngestQueue();
|
|
261
|
+
|
|
262
|
+
const events = flattenEvents(posts);
|
|
263
|
+
const changes = dbChanges(events);
|
|
264
|
+
const manifest = manifests(events)[0];
|
|
265
|
+
assert(manifest, 'expected request capture manifest');
|
|
266
|
+
assert.equal(manifest.expected.dbChange, changes.length);
|
|
267
|
+
|
|
268
|
+
const readChanges = changes.filter((change) =>
|
|
269
|
+
change.collection === 'content_subjects' &&
|
|
270
|
+
change.op === 'findOne' &&
|
|
271
|
+
change.resultMetaValueCapture?.status === 'stored'
|
|
272
|
+
);
|
|
273
|
+
assert(readChanges.length >= 6, JSON.stringify(changes));
|
|
274
|
+
const resultValueIds = new Set(
|
|
275
|
+
readChanges.map((change) => change.resultMetaValueCapture.valueId),
|
|
276
|
+
);
|
|
277
|
+
assert.equal(resultValueIds.size, 1, 'identical query results must share one content ID');
|
|
278
|
+
|
|
279
|
+
const dbValueDefinitions = events
|
|
280
|
+
.filter((event) => event.event_type === 'db_change')
|
|
281
|
+
.flatMap((event) => event.payload?.dbValues || []);
|
|
282
|
+
const resultDefinitions = dbValueDefinitions.filter((entry) =>
|
|
283
|
+
resultValueIds.has(entry.id) && entry.target === 'db.resultMeta'
|
|
284
|
+
);
|
|
285
|
+
assert.equal(resultDefinitions.length, 1, 'repeated DB result must be transported once');
|
|
286
|
+
const fullResult = resultDefinitions[0].value;
|
|
287
|
+
assert.equal(fullResult.subjectNumber, 'SUBJECT-CONTENT-1001');
|
|
288
|
+
assert.equal(fullResult.visits.length, 80);
|
|
289
|
+
assert.equal(fullResult.visits[79].assessments[4].code, 'ASSESSMENT-79-4');
|
|
290
|
+
const legacyRepeatedResultBytes = readChanges.length * byteLength(fullResult);
|
|
291
|
+
const optimizedResultBytes = byteLength(fullResult) + readChanges.reduce(
|
|
292
|
+
(total, change) => total + byteLength(change.resultMetaValueCapture),
|
|
293
|
+
0,
|
|
294
|
+
);
|
|
295
|
+
const resultReduction = 1 - optimizedResultBytes / legacyRepeatedResultBytes;
|
|
296
|
+
assert(
|
|
297
|
+
resultReduction > 0.75,
|
|
298
|
+
`repeated DB result storage reduced only ${(resultReduction * 100).toFixed(1)}%`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const failedWrite = changes.find((change) =>
|
|
302
|
+
change.collection === 'content_subjects' && change.error?.code === 11000
|
|
303
|
+
);
|
|
304
|
+
assert(failedWrite, 'duplicate-key failure must remain captured');
|
|
305
|
+
|
|
306
|
+
const traceBatches = events.filter((event) => event.event_type === 'trace_batch');
|
|
307
|
+
const traceEvents = traceBatches.flatMap((event) => event.payload?.trace || []);
|
|
308
|
+
const enter = traceEvents.find((event) =>
|
|
309
|
+
event.fn === 'loadSubjectRepeatedly' && event.type === 'enter'
|
|
310
|
+
);
|
|
311
|
+
assert(enter, JSON.stringify(traceEvents));
|
|
312
|
+
const traceDefinitions = traceBatches.flatMap((event) => event.payload?.traceValues || []);
|
|
313
|
+
const argsDefinition = traceDefinitions.find((entry) =>
|
|
314
|
+
entry.id === enter.argsValueCapture?.valueId
|
|
315
|
+
);
|
|
316
|
+
assert(argsDefinition, JSON.stringify(traceDefinitions));
|
|
317
|
+
assert.deepEqual(argsDefinition.value[0].configService, {
|
|
318
|
+
__kind: 'repro-semantic',
|
|
319
|
+
category: 'configuration-service',
|
|
320
|
+
className: 'ConfigService',
|
|
321
|
+
});
|
|
322
|
+
assert.equal(argsDefinition.value[1].subjectNumber, 'SUBJECT-CONTENT-1001');
|
|
323
|
+
|
|
324
|
+
const capturedJson = JSON.stringify(posts);
|
|
325
|
+
assert(!capturedJson.includes('captured-config-secret'));
|
|
326
|
+
assert(!capturedJson.includes('mongo-config-credential-999'));
|
|
327
|
+
assert(!capturedJson.includes('mongo-test-logger-buffer'));
|
|
328
|
+
|
|
329
|
+
console.log('content-addressed MongoDB testcontainers coverage OK', JSON.stringify({
|
|
330
|
+
repeatedResultOccurrences: readChanges.length,
|
|
331
|
+
uniqueResultDefinitions: resultDefinitions.length,
|
|
332
|
+
legacyRepeatedResultBytes,
|
|
333
|
+
optimizedResultBytes,
|
|
334
|
+
resultReductionPercent: Number((resultReduction * 100).toFixed(2)),
|
|
335
|
+
}));
|
|
336
|
+
} finally {
|
|
337
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest().catch(() => {});
|
|
338
|
+
await flushIngestQueue().catch(() => {});
|
|
339
|
+
global.fetch = originalFetch;
|
|
340
|
+
if (connection) await connection.close().catch(() => {});
|
|
341
|
+
if (mongo) await mongo.stop().catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
main().catch((error) => {
|
|
346
|
+
console.error(error);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '50';
|
|
2
|
+
process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
|
|
3
|
+
process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
|
|
4
|
+
process.env.REPRO_SDK_TRACE_INLINE_VALUE_MAX_SERIALIZED_CHARS = '2048';
|
|
5
|
+
process.env.REPRO_TRACE_BATCH_MAX_SERIALIZED_BYTES = '65536';
|
|
6
|
+
process.env.TRACE_IDLE_FLUSH_MS = '10';
|
|
7
|
+
process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
|
|
8
|
+
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const { EventEmitter } = require('node:events');
|
|
11
|
+
const { initReproTracing, reproMiddleware, __reproTestHooks } = require('../dist');
|
|
12
|
+
const { flushIngestQueue } = require('../dist/ingest/client');
|
|
13
|
+
const { normalizeRuntimePrivacyPolicy } = require('../dist/privacy');
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
|
|
17
|
+
function makeReqRes(sessionId, actionId) {
|
|
18
|
+
const req = new EventEmitter();
|
|
19
|
+
req.method = 'POST';
|
|
20
|
+
req.url = '/content-addressed-values';
|
|
21
|
+
req.originalUrl = req.url;
|
|
22
|
+
req.headers = {
|
|
23
|
+
'x-bug-session-id': sessionId,
|
|
24
|
+
'x-bug-action-id': actionId,
|
|
25
|
+
'x-bug-request-start': String(Date.now()),
|
|
26
|
+
};
|
|
27
|
+
req.body = {};
|
|
28
|
+
req.params = {};
|
|
29
|
+
req.query = {};
|
|
30
|
+
|
|
31
|
+
const res = new EventEmitter();
|
|
32
|
+
res.statusCode = 204;
|
|
33
|
+
res.getHeader = () => undefined;
|
|
34
|
+
res.getHeaders = () => ({});
|
|
35
|
+
res.setHeader = () => {};
|
|
36
|
+
res.write = () => true;
|
|
37
|
+
res.end = function end() {
|
|
38
|
+
this.emit('finish');
|
|
39
|
+
return this;
|
|
40
|
+
};
|
|
41
|
+
return { req, res };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makePayload(changed = false, internalSecret) {
|
|
45
|
+
const payload = {
|
|
46
|
+
eventType: 'clinical.shipment.observed',
|
|
47
|
+
shipmentNumber: changed ? 'SHIP-CONTENT-CHANGED' : 'SHIP-CONTENT-STABLE',
|
|
48
|
+
rows: Array.from({ length: 300 }, (_, index) => ({
|
|
49
|
+
index,
|
|
50
|
+
depot: `DEPOT-${String(index).padStart(4, '0')}`,
|
|
51
|
+
site: {
|
|
52
|
+
number: `SITE-${index % 20}`,
|
|
53
|
+
country: { code: 'US', region: 'FDA' },
|
|
54
|
+
},
|
|
55
|
+
status: index % 2 === 0 ? 'available' : 'released',
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
if (internalSecret !== undefined) {
|
|
59
|
+
payload.internalSecret = internalSecret;
|
|
60
|
+
}
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function observePayload(payload) {
|
|
65
|
+
return payload.shipmentNumber;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function runRequest(config, sessionId, repeatedPayload, changedPayload = makePayload(true)) {
|
|
69
|
+
const posts = [];
|
|
70
|
+
const previousFetch = global.fetch;
|
|
71
|
+
global.fetch = async (_url, init = {}) => {
|
|
72
|
+
posts.push(JSON.parse(String(init.body || '{}')));
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
status: 202,
|
|
76
|
+
json: async () => ({ ok: true }),
|
|
77
|
+
text: async () => '{"ok":true}',
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const middleware = reproMiddleware(config);
|
|
82
|
+
const { req, res } = makeReqRes(sessionId, `A_${sessionId}`);
|
|
83
|
+
try {
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
middleware(req, res, async (error) => {
|
|
86
|
+
if (error) {
|
|
87
|
+
reject(error);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
for (let index = 0; index < 60; index += 1) {
|
|
92
|
+
const result = global.__repro_call(
|
|
93
|
+
observePayload,
|
|
94
|
+
null,
|
|
95
|
+
[repeatedPayload],
|
|
96
|
+
'/app/shipments.service.ts',
|
|
97
|
+
70,
|
|
98
|
+
'observePayload',
|
|
99
|
+
false,
|
|
100
|
+
);
|
|
101
|
+
assert.equal(result, repeatedPayload.shipmentNumber);
|
|
102
|
+
}
|
|
103
|
+
const changedResult = global.__repro_call(
|
|
104
|
+
observePayload,
|
|
105
|
+
null,
|
|
106
|
+
[changedPayload],
|
|
107
|
+
'/app/shipments.service.ts',
|
|
108
|
+
75,
|
|
109
|
+
'observePayload',
|
|
110
|
+
false,
|
|
111
|
+
);
|
|
112
|
+
assert.equal(changedResult, changedPayload.shipmentNumber);
|
|
113
|
+
res.end();
|
|
114
|
+
resolve();
|
|
115
|
+
} catch (traceError) {
|
|
116
|
+
reject(traceError);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const deadline = Date.now() + 5000;
|
|
122
|
+
while (Date.now() < deadline) {
|
|
123
|
+
const events = posts.flatMap((post) => post.events || []);
|
|
124
|
+
if (events.some((event) => event.event_type === 'backend_request') &&
|
|
125
|
+
events.some((event) => event.event_type === 'trace_batch')) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
await sleep(20);
|
|
129
|
+
}
|
|
130
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest();
|
|
131
|
+
await flushIngestQueue();
|
|
132
|
+
return posts;
|
|
133
|
+
} finally {
|
|
134
|
+
global.fetch = previousFetch;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function inspectCapture(posts) {
|
|
139
|
+
const ingestEvents = posts.flatMap((post) => post.events || []);
|
|
140
|
+
const traceBatches = ingestEvents.filter((event) => event.event_type === 'trace_batch');
|
|
141
|
+
const traceEvents = traceBatches.flatMap((event) => event.payload?.trace || []);
|
|
142
|
+
const observedEvents = traceEvents.filter((event) => event.fn === 'observePayload');
|
|
143
|
+
const definitions = traceBatches.flatMap((event) => event.payload?.traceValues || []);
|
|
144
|
+
const refs = observedEvents
|
|
145
|
+
.map((event) => event.argsValueCapture?.valueId)
|
|
146
|
+
.filter((valueId) => typeof valueId === 'string');
|
|
147
|
+
return { ingestEvents, traceBatches, observedEvents, definitions, refs };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function main() {
|
|
151
|
+
const config = {
|
|
152
|
+
tenantId: 'TENANT_content_addressed',
|
|
153
|
+
appId: 'APP_content_addressed',
|
|
154
|
+
appSecret: 'content-addressed-test-secret',
|
|
155
|
+
captureHeaders: false,
|
|
156
|
+
privacy: { environment: 'dev', captureRaw: true },
|
|
157
|
+
ingestBase: 'http://127.0.0.1:65535',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
initReproTracing({ instrument: false, fullValueCapture: true, logFunctionCalls: false });
|
|
161
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
162
|
+
|
|
163
|
+
const repeatedPayload = makePayload(false);
|
|
164
|
+
const first = inspectCapture(await runRequest(config, 'S_content_addressed_one', repeatedPayload));
|
|
165
|
+
|
|
166
|
+
assert.equal(first.observedEvents.length, 122, 'each invocation must retain enter and exit evidence');
|
|
167
|
+
assert.equal(first.refs.length, 122, 'every large argument occurrence must retain a resolvable ref');
|
|
168
|
+
assert.equal(new Set(first.refs).size, 2, 'only stable and changed values should receive IDs');
|
|
169
|
+
assert.equal(first.definitions.length, 2, 'each distinct value must be defined once per request');
|
|
170
|
+
assert.equal(new Set(first.definitions.map((entry) => entry.id)).size, 2);
|
|
171
|
+
|
|
172
|
+
for (const ref of first.refs) {
|
|
173
|
+
assert(first.definitions.some((entry) => entry.id === ref), `unresolved captured value ref ${ref}`);
|
|
174
|
+
}
|
|
175
|
+
const stableDefinition = first.definitions.find((entry) =>
|
|
176
|
+
entry.value?.[0]?.shipmentNumber === 'SHIP-CONTENT-STABLE'
|
|
177
|
+
);
|
|
178
|
+
assert(stableDefinition, JSON.stringify(first.definitions));
|
|
179
|
+
assert.equal(stableDefinition.value[0].rows.length, 300);
|
|
180
|
+
assert.equal(stableDefinition.value[0].rows[299].depot, 'DEPOT-0299');
|
|
181
|
+
|
|
182
|
+
const serializedBytes = Buffer.byteLength(JSON.stringify(first.traceBatches));
|
|
183
|
+
assert(serializedBytes < 250000, `repeated values inflated trace transport to ${serializedBytes} bytes`);
|
|
184
|
+
|
|
185
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
186
|
+
const second = inspectCapture(await runRequest(config, 'S_content_addressed_two', repeatedPayload));
|
|
187
|
+
assert.equal(second.definitions.length, 2);
|
|
188
|
+
assert.notEqual(
|
|
189
|
+
first.definitions[0].id,
|
|
190
|
+
second.definitions[0].id,
|
|
191
|
+
'content IDs must be session-scoped to prevent cross-session correlation',
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const secretPolicy = normalizeRuntimePrivacyPolicy({
|
|
195
|
+
environment: 'dev',
|
|
196
|
+
groups: [
|
|
197
|
+
{
|
|
198
|
+
gid: 'content-addressed-secret-policy',
|
|
199
|
+
scope: 'FIELD',
|
|
200
|
+
selector: 'field:internalSecret',
|
|
201
|
+
statements: [
|
|
202
|
+
{
|
|
203
|
+
sid: 'drop-trace-argument-secret',
|
|
204
|
+
when: {
|
|
205
|
+
scope: 'FIELD',
|
|
206
|
+
selector: 'field:internalSecret',
|
|
207
|
+
target: 'trace.args[].internalSecret',
|
|
208
|
+
},
|
|
209
|
+
apply: { action: 'DROP' },
|
|
210
|
+
meta: { priority: 1 },
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
assert(secretPolicy);
|
|
217
|
+
|
|
218
|
+
const policyConfig = {
|
|
219
|
+
...config,
|
|
220
|
+
appId: 'APP_content_addressed_policy',
|
|
221
|
+
privacy: { environment: 'dev' },
|
|
222
|
+
};
|
|
223
|
+
__reproTestHooks.setRuntimePrivacyPolicyForTest(policyConfig, secretPolicy);
|
|
224
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
225
|
+
const policyCapture = inspectCapture(await runRequest(
|
|
226
|
+
policyConfig,
|
|
227
|
+
'S_content_addressed_policy',
|
|
228
|
+
makePayload(false, 'policy-secret-alpha'),
|
|
229
|
+
makePayload(false, 'policy-secret-beta'),
|
|
230
|
+
));
|
|
231
|
+
assert.equal(policyCapture.refs.length, 122);
|
|
232
|
+
assert.equal(new Set(policyCapture.refs).size, 1);
|
|
233
|
+
assert.equal(policyCapture.definitions.length, 1);
|
|
234
|
+
const policySerialized = JSON.stringify(policyCapture.definitions);
|
|
235
|
+
assert(!policySerialized.includes('policy-secret-alpha'), policySerialized);
|
|
236
|
+
assert(!policySerialized.includes('policy-secret-beta'), policySerialized);
|
|
237
|
+
assert(policySerialized.includes('[dropped]'), policySerialized);
|
|
238
|
+
|
|
239
|
+
const rawConfig = {
|
|
240
|
+
...config,
|
|
241
|
+
appId: 'APP_content_addressed_raw',
|
|
242
|
+
privacy: { environment: 'dev', captureRaw: true },
|
|
243
|
+
};
|
|
244
|
+
__reproTestHooks.setRuntimePrivacyPolicyForTest(rawConfig, secretPolicy);
|
|
245
|
+
__reproTestHooks.resetSdkBackgroundQueuesForTest();
|
|
246
|
+
const rawCapture = inspectCapture(await runRequest(
|
|
247
|
+
rawConfig,
|
|
248
|
+
'S_content_addressed_raw',
|
|
249
|
+
makePayload(false, 'raw-secret-alpha'),
|
|
250
|
+
makePayload(false, 'raw-secret-beta'),
|
|
251
|
+
));
|
|
252
|
+
assert.equal(new Set(rawCapture.refs).size, 2);
|
|
253
|
+
assert.equal(rawCapture.definitions.length, 2);
|
|
254
|
+
const rawSerialized = JSON.stringify(rawCapture.definitions);
|
|
255
|
+
assert(rawSerialized.includes('raw-secret-alpha'), rawSerialized);
|
|
256
|
+
assert(rawSerialized.includes('raw-secret-beta'), rawSerialized);
|
|
257
|
+
|
|
258
|
+
console.log('content-addressed trace value deduplication OK');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
main().catch((error) => {
|
|
262
|
+
console.error(error);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
class Logger {
|
|
2
|
+
constructor(context) {
|
|
3
|
+
this.context = context;
|
|
4
|
+
this.internalBuffer = Array.from({ length: 200 }, () => 'logger-runtime-state');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
log() {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class ConfigService {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.cache = {
|
|
13
|
+
DATABASE_URL: 'mongodb://runtime-user:runtime-password@database/repro',
|
|
14
|
+
SENDGRID_API_KEY: 'sendgrid-runtime-secret',
|
|
15
|
+
};
|
|
16
|
+
this.internalConfig = {
|
|
17
|
+
modules: Array.from({ length: 800 }, (_, index) => ({
|
|
18
|
+
index,
|
|
19
|
+
secret: `config-secret-${index}`,
|
|
20
|
+
nested: { host: `database-${index}.internal`, port: 27017 },
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(key) {
|
|
26
|
+
return this.cache[key];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class ApiConfigService {
|
|
31
|
+
constructor(configService) {
|
|
32
|
+
this.configService = configService;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class ShipmentGenerator {
|
|
37
|
+
constructor(configService) {
|
|
38
|
+
this.apiConfigService = new ApiConfigService(configService);
|
|
39
|
+
this.logger = new Logger('ShipmentGenerator');
|
|
40
|
+
this.triggerAction = 'ShipmentCreated';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class ShipmentDto {
|
|
45
|
+
constructor() {
|
|
46
|
+
this.shipmentNumber = 'SHIP-TRACE-1001';
|
|
47
|
+
this.site = {
|
|
48
|
+
number: 'SITE-0042',
|
|
49
|
+
address: {
|
|
50
|
+
country: {
|
|
51
|
+
code: 'US',
|
|
52
|
+
regulatory: { region: 'FDA', enabled: true },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
this.kits = Array.from({ length: 160 }, (_, index) => ({
|
|
57
|
+
kitNumber: `KIT-${String(index).padStart(4, '0')}`,
|
|
58
|
+
status: index % 2 === 0 ? 'available' : 'released',
|
|
59
|
+
temperature: { minC: 2, maxC: 8 },
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function recordShipment(generator, shipment) {
|
|
65
|
+
return {
|
|
66
|
+
accepted: true,
|
|
67
|
+
originalGeneratorObserved: generator instanceof ShipmentGenerator,
|
|
68
|
+
originalShipmentObserved: shipment instanceof ShipmentDto,
|
|
69
|
+
runtimeConfigStillReadable:
|
|
70
|
+
generator.apiConfigService.configService.cache.DATABASE_URL.startsWith('mongodb://'),
|
|
71
|
+
shipmentNumber: shipment.shipmentNumber,
|
|
72
|
+
lastKit: shipment.kits[shipment.kits.length - 1].kitNumber,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function handleSemanticTrace(_req, res) {
|
|
77
|
+
const configService = new ConfigService();
|
|
78
|
+
const generator = new ShipmentGenerator(configService);
|
|
79
|
+
const shipment = new ShipmentDto();
|
|
80
|
+
res.json(recordShipment(generator, shipment));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
handleSemanticTrace,
|
|
85
|
+
};
|