test_runner: publish to TracingChannel for OTel instrumentation · nodejs/node@5c27704 · GitHub
Skip to content

Commit 5c27704

Browse files
MoLowaduh95
authored andcommitted
test_runner: publish to TracingChannel for OTel instrumentation
Signed-off-by: Moshe Atlow <moshe@atlow.co.il> PR-URL: #62502 Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com> Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
1 parent d14029b commit 5c27704

5 files changed

Lines changed: 309 additions & 2 deletions

File tree

doc/api/test.md

Lines changed: 87 additions & 0 deletions

lib/internal/test_runner/test.js

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const {
5555
countCompletedTest,
5656
isTestFailureError,
5757
reporterScope,
58+
testChannel,
5859
} = require('internal/test_runner/utils');
5960
const {
6061
kEmptyObject,
@@ -1217,6 +1218,17 @@ class Test extends AsyncResource {
12171218
}
12181219
this.startTime ??= hrtime();
12191220

1221+
// Channel context object shared across all lifecycle events for this test run.
1222+
// Only tests emit events; hooks do not. This way, the test's span encompasses
1223+
// its before/beforeEach hooks, the test body, and its afterEach/after hooks.
1224+
const channelContext = this.hookType === undefined ? {
1225+
__proto__: null,
1226+
name: this.name,
1227+
nesting: this.nesting,
1228+
file: this.entryFile,
1229+
type: this.reportedType,
1230+
} : null;
1231+
12201232
if (this[kShouldAbort]()) {
12211233
this.postRun();
12221234
return;
@@ -1254,7 +1266,20 @@ class Test extends AsyncResource {
12541266
}
12551267
stopPromise = stopTest(this.timeout, this.signal);
12561268
const runArgs = ArrayPrototypeSlice(args);
1257-
ArrayPrototypeUnshift(runArgs, this.fn, ctx);
1269+
1270+
// Wrap the test function with runStores if the channel has subscribers.
1271+
// The wrapped function is what gets passed to runInAsyncScope, ensuring that
1272+
// the test runs within both the runStores context (for AsyncLocalStorage/bindStore)
1273+
// AND the AsyncResource scope. It's critical that runStores wraps the function,
1274+
// not the runInAsyncScope call itself, to maintain AsyncLocalStorage bindings.
1275+
let testFn = this.fn;
1276+
if (channelContext !== null && testChannel.start.hasSubscribers) {
1277+
testFn = (...fnArgs) => testChannel.start.runStores(channelContext,
1278+
() => ReflectApply(this.fn, this, fnArgs),
1279+
);
1280+
}
1281+
1282+
ArrayPrototypeUnshift(runArgs, testFn, ctx);
12581283

12591284
const promises = [];
12601285
if (this.fn.length === runArgs.length - 1) {
@@ -1303,6 +1328,10 @@ class Test extends AsyncResource {
13031328
await afterEach();
13041329
await after();
13051330
} catch (err) {
1331+
// Publish diagnostics_channel error event if the channel has subscribers
1332+
if (channelContext !== null && testChannel.error.hasSubscribers) {
1333+
testChannel.error.publish({ __proto__: null, ...channelContext, error: err });
1334+
}
13061335
if (isTestFailureError(err)) {
13071336
if (err.failureType === kTestTimeoutFailure) {
13081337
this.#cancel(err);
@@ -1322,6 +1351,11 @@ class Test extends AsyncResource {
13221351
if (this.parent !== null) {
13231352
this.abortController.abort();
13241353
}
1354+
1355+
// Publish diagnostics_channel end event if the channel has subscribers (in both success and error cases)
1356+
if (channelContext !== null && testChannel.end.hasSubscribers) {
1357+
testChannel.end.publish(channelContext);
1358+
}
13251359
}
13261360

13271361
if (this.parent !== null || typeof this.hookType === 'string') {
@@ -1649,13 +1683,42 @@ class Suite extends Test {
16491683
}
16501684

16511685
async createBuild() {
1686+
const channelContext = {
1687+
__proto__: null,
1688+
name: this.name,
1689+
nesting: this.nesting,
1690+
file: this.entryFile,
1691+
type: this.reportedType,
1692+
};
16521693
try {
16531694
const { ctx, args } = this.getRunArgs();
1654-
const runArgs = [this.fn, ctx];
1695+
1696+
// Wrap the suite function with runStores if the channel has subscribers.
1697+
// The wrapped function is what gets passed to runInAsyncScope, ensuring that
1698+
// the suite runs within both the runStores context (for AsyncLocalStorage/bindStore)
1699+
// AND the AsyncResource scope. It's critical that runStores wraps the function,
1700+
// not the runInAsyncScope call itself, to maintain AsyncLocalStorage bindings.
1701+
let suiteFn = this.fn;
1702+
if (testChannel.start.hasSubscribers) {
1703+
const baseFn = this.fn;
1704+
suiteFn = (...fnArgs) => testChannel.start.runStores(channelContext,
1705+
() => ReflectApply(baseFn, this, fnArgs),
1706+
);
1707+
}
1708+
1709+
const runArgs = [suiteFn, ctx];
16551710
ArrayPrototypePushApply(runArgs, args);
1711+
16561712
await ReflectApply(this.runInAsyncScope, this, runArgs);
16571713
} catch (err) {
1714+
if (testChannel.error.hasSubscribers) {
1715+
testChannel.error.publish({ __proto__: null, ...channelContext, error: err });
1716+
}
16581717
this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure));
1718+
} finally {
1719+
if (testChannel.end.hasSubscribers) {
1720+
testChannel.end.publish(channelContext);
1721+
}
16591722
}
16601723

16611724
this.buildPhaseFinished = true;

lib/internal/test_runner/utils.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const {
3131
} = primordials;
3232

3333
const { AsyncResource } = require('async_hooks');
34+
const { tracingChannel } = require('diagnostics_channel');
3435
const { relative, sep, resolve } = require('path');
3536
const { createWriteStream, readFileSync } = require('fs');
3637
const { pathToFileURL } = require('internal/url');
@@ -256,6 +257,7 @@ async function getReportersMap(reporters, destinations) {
256257
}
257258

258259
const reporterScope = new AsyncResource('TestReporterScope');
260+
const testChannel = tracingChannel('node.test');
259261
let globalTestOptions;
260262

261263
function parseCommandLine() {
@@ -730,4 +732,5 @@ module.exports = {
730732
getCoverageReport,
731733
setupGlobalSetupTeardownFunctions,
732734
parsePreviousRuns,
735+
testChannel,
733736
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict';
2+
const dc = require('node:diagnostics_channel');
3+
const { test } = require('node:test');
4+
5+
const events = [];
6+
dc.subscribe('tracing:node.test:error', (data) => {
7+
events.push(data.name);
8+
});
9+
10+
test('test that intentionally fails', async () => {
11+
throw new Error('expected failure for error event testing');
12+
});
13+
14+
// Report events on exit
15+
process.on('exit', () => {
16+
console.log(JSON.stringify({ errorEvents: events }));
17+
});
Lines changed: 137 additions & 0 deletions

0 commit comments

Comments
 (0)