@reproapp/node-sdk 0.0.58 → 0.0.60
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/package.json +5 -3
- package/test/express-trace-http-args.test.js +4 -0
- package/test/fixtures/optional-chain-app.cjs +52 -0
- package/test/fixtures/optional-chain-business.cjs +6 -0
- package/test/fixtures/optional-chain-esm.mjs +27 -0
- package/test/optional-chain-http.testcontainers.test.js +55 -0
- package/test/optional-chain-semantics.test.js +145 -0
- package/test/optional-chain.testcontainers.test.js +24 -0
- package/test/semantic-serialization-trace.test.js +4 -0
- package/tracer/cjs-hook.js +0 -2
- package/tracer/wrap-plugin.js +8 -124
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reproapp/node-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.60",
|
|
4
4
|
"description": "Repro Nest SDK",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
"build": "tsc -p tsconfig.json",
|
|
19
19
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
20
20
|
"prepublishOnly": "npm run build",
|
|
21
|
-
"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",
|
|
22
|
-
"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"
|
|
21
|
+
"test": "npm run build && node test/optional-chain-semantics.test.js && 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",
|
|
22
|
+
"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",
|
|
23
|
+
"test:optional-chains": "npm run build && node test/optional-chain-semantics.test.js && node test/optional-chain.testcontainers.test.js && node test/optional-chain-http.testcontainers.test.js"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
26
|
"express": "^5.1.0",
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"@babel/core": "^7.28.4",
|
|
46
47
|
"@babel/generator": "^7.28.3",
|
|
47
48
|
"@babel/parser": "^7.28.4",
|
|
49
|
+
"@babel/plugin-transform-optional-chaining": "^7.29.7",
|
|
48
50
|
"@babel/plugin-transform-typescript": "^7.28.0",
|
|
49
51
|
"@babel/traverse": "^7.28.4",
|
|
50
52
|
"@babel/types": "^7.28.4",
|
|
@@ -8,6 +8,7 @@ const capturedBodies = [];
|
|
|
8
8
|
|
|
9
9
|
global.fetch = async (url, init = {}) => {
|
|
10
10
|
const target = String(url || '');
|
|
11
|
+
if (target.endsWith('/v1/sdk/runtime-services')) return { ok: true };
|
|
11
12
|
if (!target.includes('/v1/ingest/events')) {
|
|
12
13
|
throw new Error(`unexpected fetch target: ${target}`);
|
|
13
14
|
}
|
|
@@ -56,6 +57,9 @@ async function main() {
|
|
|
56
57
|
appSecret: 'secret',
|
|
57
58
|
appName: 'express-trace-http-args',
|
|
58
59
|
serviceName: 'express-trace-http-args',
|
|
60
|
+
// This fixture tests HTTP arguments, not hosted policy initialization.
|
|
61
|
+
privacy: { enabled: false },
|
|
62
|
+
apiBase: 'http://127.0.0.1:65535',
|
|
59
63
|
ingestBase: 'http://127.0.0.1:65535',
|
|
60
64
|
tracing: {
|
|
61
65
|
disableFunctionTypes: ['constructor'],
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const http = require('node:http');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { MongoClient } = require('mongodb');
|
|
4
|
+
const { initRepro, __reproTestHooks } = require('../../dist');
|
|
5
|
+
const { flushIngestQueue } = require('../../dist/ingest/client');
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
const [mode, uri, collector] = process.argv.slice(2);
|
|
9
|
+
if (mode === 'traced') await initRepro({ tenantId: 'TENANT_test', appId: 'APP_test', appSecret: 'test-only',
|
|
10
|
+
apiBase: collector, ingestBase: collector, privacy: { captureRaw: true },
|
|
11
|
+
tracing: { include: [/optional-chain-business\.cjs$/], logFunctionCalls: false } });
|
|
12
|
+
const { processReturn } = require('./optional-chain-business.cjs');
|
|
13
|
+
const client = await new MongoClient(uri).connect();
|
|
14
|
+
const db = client.db('optional_chain');
|
|
15
|
+
const server = http.createServer(async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const value = await processReturn(db.collection('preferences'), db.collection('writes'), req.url.slice(1), mode);
|
|
18
|
+
res.end(JSON.stringify({ value }));
|
|
19
|
+
} catch (error) { res.statusCode = 500; res.end(JSON.stringify({ error: error.name })); }
|
|
20
|
+
});
|
|
21
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
22
|
+
try {
|
|
23
|
+
const responses = [];
|
|
24
|
+
for (const id of ['missing', 'empty', 'valid', 'invalid']) {
|
|
25
|
+
responses.push(await new Promise((resolve, reject) => {
|
|
26
|
+
http.get({ hostname: '127.0.0.1', port: server.address().port, path: `/${id}`,
|
|
27
|
+
headers: { 'x-bug-session-id': `S_optional_${mode}`, 'x-bug-action-id': `A_${id}` } }, res => {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
30
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks)) }));
|
|
31
|
+
}).on('error', reject);
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
assert.deepEqual(responses, [
|
|
35
|
+
{ status: 200, body: { value: null } }, { status: 200, body: { value: null } },
|
|
36
|
+
{ status: 200, body: { value: 1 } }, { status: 500, body: { error: 'TypeError' } },
|
|
37
|
+
]);
|
|
38
|
+
if (mode === 'traced') {
|
|
39
|
+
for (let i = 0; i < 20; i++) {
|
|
40
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
41
|
+
await __reproTestHooks.drainSdkBackgroundQueueForTest();
|
|
42
|
+
await flushIngestQueue();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
assert.equal(await db.collection('writes').countDocuments({ mode }), 1, 'No duplicate/short-circuited writes');
|
|
46
|
+
console.log(`${mode}: HTTP responses and MongoDB side effects passed`);
|
|
47
|
+
} finally {
|
|
48
|
+
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
|
|
49
|
+
await client.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
exports.processReturn = async function processReturn(collection, writes, id, mode) {
|
|
2
|
+
const productReturnsModule = await collection.findOne({ _id: id });
|
|
3
|
+
const preference = productReturnsModule?.productReturnsPreferences.find(entry => entry.id === 1);
|
|
4
|
+
if (preference) await writes.insertOne({ id, mode, preference: preference.id });
|
|
5
|
+
return preference?.id ?? null;
|
|
6
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import runtime from '../../tracer/runtime.js';
|
|
3
|
+
|
|
4
|
+
runtime.setFunctionLogsEnabled(false);
|
|
5
|
+
const events = [];
|
|
6
|
+
const unsubscribe = runtime.trace.on(event => events.push(event));
|
|
7
|
+
function selectPreferences(productReturnsModule) {
|
|
8
|
+
return productReturnsModule?.productReturnsPreferences.find(entry => entry.id === 1)?.id;
|
|
9
|
+
}
|
|
10
|
+
await runtime.trace.withTrace('optional-esm', async () => {
|
|
11
|
+
assert.equal(selectPreferences(null), undefined);
|
|
12
|
+
assert.equal(selectPreferences(undefined), undefined);
|
|
13
|
+
assert.equal(selectPreferences({ productReturnsPreferences: [{ id: 1 }] }), 1);
|
|
14
|
+
assert.throws(() => selectPreferences({}), TypeError);
|
|
15
|
+
let calls = 0;
|
|
16
|
+
const increment = () => ++calls;
|
|
17
|
+
const empty = null;
|
|
18
|
+
assert.equal(empty?.nested.method(increment()).value, undefined);
|
|
19
|
+
assert.equal(calls, 0);
|
|
20
|
+
const object = { value: 7, method() { return this; } };
|
|
21
|
+
assert.equal((object?.method)().value, 7);
|
|
22
|
+
assert.equal(object?.method?.().value, 7);
|
|
23
|
+
});
|
|
24
|
+
unsubscribe();
|
|
25
|
+
assert.ok(events.some(event => event.type === 'enter' && event.fn === 'selectPreferences'));
|
|
26
|
+
assert.ok(events.some(event => event.type === 'exit' && event.fn === 'selectPreferences' && event.returnValue === 1));
|
|
27
|
+
console.log('native ESM optional chain and trace evidence passed');
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const http = require('node:http');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { promisify } = require('node:util');
|
|
5
|
+
const run = promisify(require('node:child_process').execFile);
|
|
6
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
7
|
+
const { MongoClient } = require('mongodb');
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
let container, mongo, server;
|
|
11
|
+
const captured = [];
|
|
12
|
+
try {
|
|
13
|
+
container = await new GenericContainer('mongo:7').withExposedPorts(27017)
|
|
14
|
+
.withWaitStrategy(Wait.forListeningPorts()).start();
|
|
15
|
+
const uri = `mongodb://${container.getHost()}:${container.getMappedPort(27017)}`;
|
|
16
|
+
mongo = await new MongoClient(uri).connect();
|
|
17
|
+
await mongo.db('optional_chain').collection('preferences').insertMany([
|
|
18
|
+
{ _id: 'empty', productReturnsPreferences: [] },
|
|
19
|
+
{ _id: 'valid', productReturnsPreferences: [{ id: 1 }] }, { _id: 'invalid' },
|
|
20
|
+
]);
|
|
21
|
+
server = http.createServer(async (req, res) => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
24
|
+
const body = JSON.parse(Buffer.concat(chunks).toString() || '{}');
|
|
25
|
+
captured.push(...(body.events || []));
|
|
26
|
+
res.writeHead(202, { 'content-type': 'application/json' });
|
|
27
|
+
res.end(JSON.stringify({ accepted: body.events?.length ?? 0 }));
|
|
28
|
+
});
|
|
29
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
30
|
+
const collector = `http://127.0.0.1:${server.address().port}`;
|
|
31
|
+
for (const mode of ['baseline', 'traced']) {
|
|
32
|
+
const result = await run(process.execPath, [path.join(__dirname, 'fixtures/optional-chain-app.cjs'), mode, uri, collector], {
|
|
33
|
+
timeout: 30000, env: { ...process.env, REPRO_INGEST_BASE: collector, REPRO_API_BASE: collector,
|
|
34
|
+
REPRO_API_BASE_URL: collector, REPRO_PRIVACY_API_BASE: collector, TRACE_IDLE_FLUSH_MS: '10',
|
|
35
|
+
TRACE_LINGER_AFTER_FINISH_MS: '0', REPRO_SDK_BACKGROUND_IDLE_DELAY_MS: '0', REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS: '0' },
|
|
36
|
+
});
|
|
37
|
+
console.log(result.stdout.trim());
|
|
38
|
+
}
|
|
39
|
+
const requests = captured.filter(event => event.event_type === 'backend_request');
|
|
40
|
+
assert.equal(requests.length, 4, 'All four requests must remain captured');
|
|
41
|
+
assert.ok(captured.some(event => event.event_type === 'db_change'), 'Database evidence must remain captured');
|
|
42
|
+
const traces = captured.filter(event => event.event_type === 'trace_batch').flatMap(event => event.payload.trace || []);
|
|
43
|
+
assert.ok(traces.some(event => event.fn === 'processReturn' && event.type === 'enter'), 'Arguments/call evidence still present');
|
|
44
|
+
assert.ok(traces.some(event => event.fn === 'processReturn' && event.type === 'exit' && event.returnValue === 1), 'Return-value evidence still present');
|
|
45
|
+
assert.ok(traces.some(event => event.fn === 'processReturn' && event.type === 'exit' && event.threw), 'Real application errors remain captured');
|
|
46
|
+
const manifests = captured.filter(event => event.event_type === 'request_capture_manifest');
|
|
47
|
+
assert.equal(manifests.length, 4);
|
|
48
|
+
assert.ok(manifests.every(event => !event.payload.requestCaptureManifest.captureFailures?.length));
|
|
49
|
+
console.log('HTTP -> traced optional chains -> MongoDB -> collector: outcomes, writes, traces and manifests verified');
|
|
50
|
+
} finally {
|
|
51
|
+
if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
|
|
52
|
+
await mongo?.close(); await container?.stop();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const vm = require('node:vm');
|
|
3
|
+
const babel = require('@babel/core');
|
|
4
|
+
const ts = require('typescript');
|
|
5
|
+
const Module = require('node:module');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { installCJS } = require('../tracer/cjs-hook');
|
|
8
|
+
const makeWrap = require('../tracer/wrap-plugin');
|
|
9
|
+
const { trace, setFunctionLogsEnabled } = require('../tracer/runtime');
|
|
10
|
+
setFunctionLogsEnabled(false);
|
|
11
|
+
|
|
12
|
+
const cases = [
|
|
13
|
+
'root?.productReturnsPreferences.find(x => x.id === 1)',
|
|
14
|
+
'root?.productReturnsPreferences.find(x => x.id === 1).id',
|
|
15
|
+
'root?.productReturnsPreferences.find(x => x.id === 2)?.id',
|
|
16
|
+
'root?.nested.method(arg())',
|
|
17
|
+
'root?.nested.method?.(arg())',
|
|
18
|
+
'root?.missing.method(arg())',
|
|
19
|
+
'root?.missing?.method(arg())',
|
|
20
|
+
'root?.method(arg()).value',
|
|
21
|
+
'root?.method(arg())?.value',
|
|
22
|
+
'root?.method(arg()).next(arg()).value',
|
|
23
|
+
'root?.[key()]?.(arg())',
|
|
24
|
+
'root?.nested[key()](arg())',
|
|
25
|
+
'root?.nested[key()]?.(arg())',
|
|
26
|
+
'root?.getter(arg())',
|
|
27
|
+
'root?.getter?.(arg())',
|
|
28
|
+
'root?.absent?.(arg())',
|
|
29
|
+
'root?.notCallable?.(arg())',
|
|
30
|
+
'(root?.method)(arg())',
|
|
31
|
+
'(root?.method)?.(arg())',
|
|
32
|
+
'(root?.nested.method)(arg())',
|
|
33
|
+
'(root?.nested.method)?.(arg())',
|
|
34
|
+
'(root?.nested).method(arg())',
|
|
35
|
+
'root?.method(...spread)',
|
|
36
|
+
'root?.method(arg(), root?.nested.method(arg()))',
|
|
37
|
+
'root?.method(await asyncArg())',
|
|
38
|
+
'root?.nested.method(await asyncArg())?.value',
|
|
39
|
+
'delete root?.method(arg()).value',
|
|
40
|
+
'delete root?.nested.value',
|
|
41
|
+
'root?.method(arg()) ?? "fallback"',
|
|
42
|
+
'root?.method(arg()) && arg()',
|
|
43
|
+
'root?.method(arg()) ? arg() : "false"',
|
|
44
|
+
'(root?.method(arg()), arg())',
|
|
45
|
+
'root?.returnsNull().value',
|
|
46
|
+
'root?.returnsNull()?.value',
|
|
47
|
+
'root?.returnsNull().method(arg())',
|
|
48
|
+
'root?.returnsNull()?.method(arg())',
|
|
49
|
+
'fn?.(arg()).value',
|
|
50
|
+
'fn?.(arg())?.value',
|
|
51
|
+
'fn?.(arg()).next(arg()).value',
|
|
52
|
+
'(fn?.(arg())).value',
|
|
53
|
+
'root?.nested.method.call(root, arg())',
|
|
54
|
+
'root?.method.bind(root)(arg())',
|
|
55
|
+
'root?.throwing?.(arg())',
|
|
56
|
+
'root?.method(root?.getter(arg()))',
|
|
57
|
+
'root?.method(root?.absent?.(arg()))',
|
|
58
|
+
'root?.[await asyncKey()](await asyncArg())',
|
|
59
|
+
'root?.method(...(root?.productReturnsPreferences ?? []))',
|
|
60
|
+
'root?.method(arg()).next?.(arg()).value',
|
|
61
|
+
'root?.method?.(arg()).next?.(arg()).value',
|
|
62
|
+
'root?.nested.method?.(root?.method(arg()))',
|
|
63
|
+
'new (root?.Ctor)(arg())',
|
|
64
|
+
'eval?.("typeof effects")',
|
|
65
|
+
'(function(undefined) { return root?.absent?.(); })("shadowed")',
|
|
66
|
+
'(function(x = root?.nested.method(arg())) { return x; })()',
|
|
67
|
+
'(function*() { return root?.nested.method(yield "pause"); })().next()',
|
|
68
|
+
'(new Derived()).read(root)',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function source(expression) {
|
|
72
|
+
return `module.exports = async function run(kind) {
|
|
73
|
+
const effects = [];
|
|
74
|
+
function arg() { effects.push('arg'); return 7; }
|
|
75
|
+
async function asyncArg() { effects.push('await'); return 7; }
|
|
76
|
+
async function asyncKey() { effects.push('await-key'); return 'method'; }
|
|
77
|
+
function key() { effects.push('key'); return 'method'; }
|
|
78
|
+
const result = { value: 11, next(value) { effects.push('next:' + value); return this; } };
|
|
79
|
+
const object = {
|
|
80
|
+
value: 3, productReturnsPreferences: [{ id: 1 }], notCallable: 4,
|
|
81
|
+
method(value) { effects.push('this:' + this.value + ':' + value); return result; },
|
|
82
|
+
nested: { value: 5, method(value) { effects.push('nested:' + this.value + ':' + value); return result; } },
|
|
83
|
+
get getter() { effects.push('getter'); return this.method; },
|
|
84
|
+
get throwing() { effects.push('throwing'); throw new RangeError('expected'); },
|
|
85
|
+
Ctor: class { constructor(value) { this.value = value; } },
|
|
86
|
+
returnsNull() { effects.push('null'); return null; }
|
|
87
|
+
};
|
|
88
|
+
class Base { read(value) { effects.push('super'); return value; } }
|
|
89
|
+
class Derived extends Base {
|
|
90
|
+
#get(value) { effects.push('private'); return value; }
|
|
91
|
+
read(value) { return super.read?.(value)?.method(this.#get?.(7)); }
|
|
92
|
+
}
|
|
93
|
+
const root = kind === 'null' ? null : kind === 'undefined' ? undefined : object;
|
|
94
|
+
const fn = kind === 'null' ? null : kind === 'undefined' ? undefined : value => { effects.push('fn:' + value); return result; };
|
|
95
|
+
const spread = { *[Symbol.iterator]() { effects.push('spread'); yield 7; } };
|
|
96
|
+
try {
|
|
97
|
+
const value = ${expression};
|
|
98
|
+
return JSON.stringify({ value, effects });
|
|
99
|
+
} catch (error) { return JSON.stringify({ error: error.name, effects }); }
|
|
100
|
+
};`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function load(code, filename) {
|
|
104
|
+
const module = { exports: {} };
|
|
105
|
+
vm.runInNewContext(code, { module, exports: module.exports,
|
|
106
|
+
__trace: global.__trace, __repro_call: global.__repro_call }, { filename });
|
|
107
|
+
return module.exports;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function main() {
|
|
111
|
+
installCJS({ include: [/optional-chain-virtual-/] });
|
|
112
|
+
let count = 0;
|
|
113
|
+
for (const expression of cases) {
|
|
114
|
+
const filename = `/test/optional-chain-${count}.js`;
|
|
115
|
+
const code = source(expression);
|
|
116
|
+
const transformed = babel.transformSync(code, { filename, configFile: false, babelrc: false,
|
|
117
|
+
plugins: [makeWrap(filename)] }).code;
|
|
118
|
+
const original = load(code, filename);
|
|
119
|
+
const variants = [['babel', load(transformed, filename), original]];
|
|
120
|
+
for (const target of [ts.ScriptTarget.ES2019, ts.ScriptTarget.ES2022]) {
|
|
121
|
+
const typescript = code.replace('run(kind)', 'run(kind: string)').replace('const effects = []', 'const effects: string[] = []');
|
|
122
|
+
const compiled = ts.transpileModule(typescript, { compilerOptions: { target, module: ts.ModuleKind.CommonJS } }).outputText;
|
|
123
|
+
const moduleFilename = path.join(__dirname, `fixtures/optional-chain-virtual-${count}-${target}.cjs`);
|
|
124
|
+
const mod = new Module(moduleFilename, module);
|
|
125
|
+
mod.filename = moduleFilename;
|
|
126
|
+
mod.paths = module.paths;
|
|
127
|
+
mod._compile(compiled, moduleFilename);
|
|
128
|
+
assert.equal(mod.exports.__repro_instrumented, true, 'CJS hook must actually instrument, not silently fall back');
|
|
129
|
+
// Compare to the code handed to the hook, including the TS compiler's
|
|
130
|
+
// own downleveling semantics, not to a different compilation target.
|
|
131
|
+
variants.push([`CJS/TS-${target}`, mod.exports, load(compiled, moduleFilename)]);
|
|
132
|
+
}
|
|
133
|
+
for (const [variant, instrumented, baseline] of variants) {
|
|
134
|
+
for (const kind of ['null', 'undefined', 'present']) {
|
|
135
|
+
const expected = await baseline(kind);
|
|
136
|
+
assert.equal(await instrumented(kind), expected, `${expression} (${variant}, ${kind}, idle)`);
|
|
137
|
+
assert.equal(await trace.withTrace(`optional-${count}`, () => instrumented(kind)), expected,
|
|
138
|
+
`${expression} (${variant}, ${kind}, recording)`);
|
|
139
|
+
count += 2;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
console.log(`optional chaining: ${count} native/instrumented semantic comparisons passed`);
|
|
144
|
+
}
|
|
145
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { GenericContainer, Wait } = require('testcontainers');
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
for (const image of ['node:18-bookworm-slim', 'node:20-alpine', 'node:22.22.0-bookworm-slim']) {
|
|
7
|
+
const container = await new GenericContainer(image)
|
|
8
|
+
.withBindMounts([{ source: path.resolve(__dirname, '..'), target: '/sdk', mode: 'ro' }])
|
|
9
|
+
.withWorkingDir('/sdk')
|
|
10
|
+
.withCommand(['node', '-e', 'console.log("ready"); setInterval(() => {}, 60000)'])
|
|
11
|
+
.withWaitStrategy(Wait.forLogMessage('ready')).start();
|
|
12
|
+
try {
|
|
13
|
+
for (const command of [
|
|
14
|
+
['node', 'test/optional-chain-semantics.test.js'],
|
|
15
|
+
['node', '--require', './tracer/runtime.js', '--experimental-loader', './tracer/esm-loader.mjs', 'test/fixtures/optional-chain-esm.mjs'],
|
|
16
|
+
]) {
|
|
17
|
+
const result = await container.exec(command);
|
|
18
|
+
assert.equal(result.exitCode, 0, `${image}: ${result.output}`);
|
|
19
|
+
console.log(`${image}: ${result.output.trim()}`);
|
|
20
|
+
}
|
|
21
|
+
} finally { await container.stop(); }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -10,6 +10,7 @@ const originalFetch = global.fetch;
|
|
|
10
10
|
const capturedBodies = [];
|
|
11
11
|
|
|
12
12
|
global.fetch = async (url, init = {}) => {
|
|
13
|
+
if (String(url).endsWith('/v1/sdk/runtime-services')) return { ok: true };
|
|
13
14
|
assert.match(String(url), /\/v1\/ingest\/events$/);
|
|
14
15
|
capturedBodies.push(JSON.parse(String(init.body || '{}')));
|
|
15
16
|
return {
|
|
@@ -59,6 +60,9 @@ async function main() {
|
|
|
59
60
|
appSecret: 'secret',
|
|
60
61
|
appName: 'semantic-trace',
|
|
61
62
|
serviceName: 'semantic-trace',
|
|
63
|
+
// Exercise the built-in fallback without depending on a hosted policy.
|
|
64
|
+
privacy: { enabled: false },
|
|
65
|
+
apiBase: 'http://127.0.0.1:65535',
|
|
62
66
|
ingestBase: 'http://127.0.0.1:65535',
|
|
63
67
|
tracing: {
|
|
64
68
|
disableFunctionTypes: ['constructor'],
|
package/tracer/cjs-hook.js
CHANGED
|
@@ -126,7 +126,6 @@ function installCJS({ include, exclude, parserPlugins } = {}) {
|
|
|
126
126
|
mapOriginalPosition = sourceInfo.mapOriginalPosition;
|
|
127
127
|
}
|
|
128
128
|
if (shouldHandle(filename, metaFilename)) {
|
|
129
|
-
try { process.stderr.write(`[trace-debug] compile: ${filename}\n`); } catch {}
|
|
130
129
|
// Transform the already-compiled JS (keeps Nest’s decorator metadata intact).
|
|
131
130
|
const res = babel.transformSync(code, {
|
|
132
131
|
filename,
|
|
@@ -161,7 +160,6 @@ function installCJS({ include, exclude, parserPlugins } = {}) {
|
|
|
161
160
|
}
|
|
162
161
|
} catch (err) {
|
|
163
162
|
out = code; // never break the app if transform fails
|
|
164
|
-
try { process.stderr.write(`[trace-debug] compile error: ${filename} :: ${err?.message || err}\n`); } catch {}
|
|
165
163
|
}
|
|
166
164
|
|
|
167
165
|
const ret = origCompile.call(this, out, filename);
|
package/tracer/wrap-plugin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// wrap-plugin.js
|
|
2
|
+
const transformOptionalChaining = require('@babel/plugin-transform-optional-chaining').default;
|
|
2
3
|
module.exports = function makeWrapPlugin(filenameForMeta, opts = {}) {
|
|
3
4
|
return ({ types: t }) => {
|
|
4
5
|
const {
|
|
@@ -453,10 +454,13 @@ module.exports = function makeWrapPlugin(filenameForMeta, opts = {}) {
|
|
|
453
454
|
if (n.__repro_call_wrapped) return;
|
|
454
455
|
if (n.__repro_internal) return;
|
|
455
456
|
|
|
456
|
-
//
|
|
457
|
+
// Preserve special call semantics and avoid instrumenting our helpers.
|
|
457
458
|
if (t.isIdentifier(n.callee, { name: '__repro_call' })) return;
|
|
458
459
|
if (t.isIdentifier(n.callee, { name: '__awaiter' })) return;
|
|
459
460
|
if (t.isIdentifier(n.callee, { name: '__generator' })) return;
|
|
461
|
+
// Routing direct eval through a helper turns it into indirect eval
|
|
462
|
+
// and changes its lexical scope (including TS-downleveled chains).
|
|
463
|
+
if (t.isIdentifier(n.callee, { name: 'eval' })) return;
|
|
460
464
|
if (t.isSuper(n.callee)) return;
|
|
461
465
|
if (isSuperMemberCall(n.callee)) return;
|
|
462
466
|
if (t.isImport(n.callee)) return;
|
|
@@ -558,127 +562,12 @@ module.exports = function makeWrapPlugin(filenameForMeta, opts = {}) {
|
|
|
558
562
|
path.node.__repro_call_wrapped = true;
|
|
559
563
|
}
|
|
560
564
|
|
|
561
|
-
function wrapOptionalCall(path, state) {
|
|
562
|
-
const { node: n } = path;
|
|
563
|
-
if (n.__repro_call_wrapped) return;
|
|
564
|
-
if (n.__repro_internal) return;
|
|
565
|
-
|
|
566
|
-
if (t.isIdentifier(n.callee, { name: '__repro_call' })) return;
|
|
567
|
-
if (t.isIdentifier(n.callee, { name: '__awaiter' })) return;
|
|
568
|
-
if (t.isIdentifier(n.callee, { name: '__generator' })) return;
|
|
569
|
-
if (t.isSuper(n.callee)) return;
|
|
570
|
-
if (isSuperMemberCall(n.callee)) return;
|
|
571
|
-
if (t.isImport(n.callee)) return;
|
|
572
|
-
|
|
573
|
-
const loc = n.loc?.start || null;
|
|
574
|
-
const mapped = loc && typeof mapOriginalPosition === 'function'
|
|
575
|
-
? mapOriginalPosition(loc.line ?? null, loc.column ?? 0)
|
|
576
|
-
: null;
|
|
577
|
-
|
|
578
|
-
const file = mapped?.file || filenameForMeta || state.file.opts.filename || '';
|
|
579
|
-
const line = mapped?.line ?? loc?.line ?? 0;
|
|
580
|
-
|
|
581
|
-
const fileLit = t.stringLiteral(file ?? '');
|
|
582
|
-
const lineLit = t.numericLiteral(line ?? 0);
|
|
583
|
-
|
|
584
|
-
const unawaited = !isAwaitedCall(path);
|
|
585
|
-
const label = describeExpression(n.callee) || '';
|
|
586
|
-
const makeArgsArray = () => t.arrayExpression(n.arguments.map(arg => t.cloneNode(arg, true)));
|
|
587
|
-
|
|
588
|
-
const isOptMember = (t.isOptionalMemberExpression && t.isOptionalMemberExpression(n.callee)) || false;
|
|
589
|
-
if (t.isMemberExpression(n.callee) || isOptMember) {
|
|
590
|
-
const callee = n.callee;
|
|
591
|
-
const objOrig = callee.object;
|
|
592
|
-
const prop = callee.property;
|
|
593
|
-
const computed = callee.computed === true;
|
|
594
|
-
|
|
595
|
-
const needsObjGuard = Boolean(callee.optional);
|
|
596
|
-
const needsCalleeGuard = n.optional === true;
|
|
597
|
-
|
|
598
|
-
const objId = path.scope.generateUidIdentifierBasedOnNode(objOrig, 'obj');
|
|
599
|
-
const fnId = path.scope.generateUidIdentifier('fn');
|
|
600
|
-
|
|
601
|
-
const member = t.memberExpression(objId, prop, computed);
|
|
602
|
-
const fnInit = needsObjGuard
|
|
603
|
-
? t.conditionalExpression(
|
|
604
|
-
t.binaryExpression('==', objId, t.nullLiteral()),
|
|
605
|
-
t.identifier('undefined'),
|
|
606
|
-
member
|
|
607
|
-
)
|
|
608
|
-
: member;
|
|
609
|
-
|
|
610
|
-
const tests = [];
|
|
611
|
-
if (needsObjGuard) tests.push(t.binaryExpression('==', objId, t.nullLiteral()));
|
|
612
|
-
if (needsCalleeGuard) tests.push(t.binaryExpression('==', fnId, t.nullLiteral()));
|
|
613
|
-
|
|
614
|
-
let guardTest = null;
|
|
615
|
-
if (tests.length === 1) guardTest = tests[0];
|
|
616
|
-
else if (tests.length === 2) guardTest = t.logicalExpression('||', tests[0], tests[1]);
|
|
617
|
-
|
|
618
|
-
const reproCall = t.callExpression(
|
|
619
|
-
t.identifier('__repro_call'),
|
|
620
|
-
[
|
|
621
|
-
fnId,
|
|
622
|
-
objId,
|
|
623
|
-
makeArgsArray(),
|
|
624
|
-
fileLit,
|
|
625
|
-
lineLit,
|
|
626
|
-
t.stringLiteral(label || ''),
|
|
627
|
-
t.booleanLiteral(unawaited)
|
|
628
|
-
]
|
|
629
|
-
);
|
|
630
|
-
|
|
631
|
-
const guardedCall = guardTest
|
|
632
|
-
? t.conditionalExpression(guardTest, t.identifier('undefined'), reproCall)
|
|
633
|
-
: reproCall;
|
|
634
|
-
|
|
635
|
-
const seq = t.sequenceExpression([
|
|
636
|
-
t.assignmentExpression('=', objId, objOrig),
|
|
637
|
-
t.assignmentExpression('=', fnId, fnInit),
|
|
638
|
-
guardedCall
|
|
639
|
-
]);
|
|
640
|
-
|
|
641
|
-
path.scope.push({ id: objId });
|
|
642
|
-
path.scope.push({ id: fnId });
|
|
643
|
-
path.replaceWith(seq);
|
|
644
|
-
path.node.__repro_call_wrapped = true;
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const fnOrig = n.callee;
|
|
649
|
-
const fnId = path.scope.generateUidIdentifier('fn');
|
|
650
|
-
const needsCalleeGuard = n.optional === true;
|
|
651
|
-
const guardTest = needsCalleeGuard ? t.binaryExpression('==', fnId, t.nullLiteral()) : null;
|
|
652
|
-
|
|
653
|
-
const reproCall = t.callExpression(
|
|
654
|
-
t.identifier('__repro_call'),
|
|
655
|
-
[
|
|
656
|
-
fnId,
|
|
657
|
-
t.nullLiteral(),
|
|
658
|
-
makeArgsArray(),
|
|
659
|
-
fileLit,
|
|
660
|
-
lineLit,
|
|
661
|
-
t.stringLiteral(label || ''),
|
|
662
|
-
t.booleanLiteral(unawaited)
|
|
663
|
-
]
|
|
664
|
-
);
|
|
665
|
-
|
|
666
|
-
const guardedCall = guardTest
|
|
667
|
-
? t.conditionalExpression(guardTest, t.identifier('undefined'), reproCall)
|
|
668
|
-
: reproCall;
|
|
669
|
-
|
|
670
|
-
const seq = t.sequenceExpression([
|
|
671
|
-
t.assignmentExpression('=', fnId, fnOrig),
|
|
672
|
-
guardedCall
|
|
673
|
-
]);
|
|
674
|
-
|
|
675
|
-
path.scope.push({ id: fnId });
|
|
676
|
-
path.replaceWith(seq);
|
|
677
|
-
path.node.__repro_call_wrapped = true;
|
|
678
|
-
}
|
|
679
565
|
|
|
680
566
|
return {
|
|
681
567
|
name: 'omnitrace-wrap-functions-and-calls',
|
|
568
|
+
// Lower complete chains before wrapping their calls; guarding only
|
|
569
|
+
// the immediate receiver loses short-circuiting across chain segments.
|
|
570
|
+
inherits: transformOptionalChaining,
|
|
682
571
|
visitor: {
|
|
683
572
|
// function body enter/exit
|
|
684
573
|
FunctionDeclaration: wrap,
|
|
@@ -692,11 +581,6 @@ module.exports = function makeWrapPlugin(filenameForMeta, opts = {}) {
|
|
|
692
581
|
CallExpression: {
|
|
693
582
|
exit(path, state) { wrapCall(path, state); }
|
|
694
583
|
},
|
|
695
|
-
OptionalCallExpression: {
|
|
696
|
-
exit(path, state) { wrapOptionalCall(path, state); }
|
|
697
|
-
},
|
|
698
|
-
// (If you also want to wrap OptionalCallExpression in older Babel ASTs,
|
|
699
|
-
// add the same handler here)
|
|
700
584
|
}
|
|
701
585
|
};
|
|
702
586
|
};
|