Skip to content

Commit a9a340f

Browse files
logaretmyaacovCR
andauthored
feat(v17): Implement tracing channels (#4670)
This PR implements tracing channel support for `graphql-js`. graphql-js resolves `node:diagnostics_channel` at module load through `process.getBuiltinModule`. On runtimes where the built-in is unavailable, such as browsers, channel handles are `undefined` and emission sites short-circuit. APMs subscribe through their own `node:diagnostics_channel` import by channel name; no GraphQL API call or option is required. The implementation adds the diagnostics channel helper, internal structural typings for the Node tracing-channel surface, subscriber-facing context types exported from the root package, and wrappers around parse, validate, execute, subscribe, root-selection-set execution, variable coercion, and resolver execution. The no-subscriber path is guarded by `shouldTrace`/`hasSubscribers` checks, with a sub-channel fallback for runtimes that do not expose aggregate `TracingChannel.hasSubscribers`. Traced execution uses `runStores` to preserve tracing-channel async context propagation, does not normalize synchronous execution to async, and handles promise-like resolver results without `Promise.resolve` normalization. Test coverage exercises lifecycle emission, lazy context fields, no-subscriber no-op behavior, missing `node:diagnostics_channel` behavior, exported context types, promise-like resolver results, and runtime compatibility coverage for Bun and Deno. ### API graphql-js vendors a minimal structural subset of the tracing channel types for internal use so it does not depend on Node typings or on `node:diagnostics_channel` being present at runtime. It also exports subscriber context types from the root package, including `GraphQLChannelContextByName` and the per-channel context interfaces. Each channel publishes lifecycle events through Node tracing channels. The table lists the base context fields present at `start`; successful sync work adds `result` by `end`, successful async work adds `result` by `asyncEnd`, and thrown or rejected work adds `error` on the `error` lifecycle. Variable coercion is synchronous, so it only emits `start`/`end` and `error` when coercion throws abruptly. | Channel | Context fields | |---|---| | `graphql:parse` | `source` | | `graphql:validate` | `schema`, `document` | | `graphql:execute` | `schema`, `document`, `rawVariableValues`, `operationName` *(lazy)*, `operationType` *(lazy)* | | `graphql:execute:variableCoercion` | `schema`, `document`, `operation`, `rawVariableValues`, `operationName`, `operationType` | | `graphql:execute:rootSelectionSet` | `schema`, `document`, `operation`, `rawVariableValues`, `operationName`, `operationType` | | `graphql:subscribe` | `schema`, `document`, `rawVariableValues`, `operationName` *(lazy)*, `operationType` *(lazy)* | | `graphql:resolve` | `fieldName`, `alias`, `parentType`, `fieldType`, `args`, `isDefaultResolver`, `fieldPath` *(lazy)* | `graphql:execute:variableCoercion` publishes the caller-provided values as `rawVariableValues`; its successful `result` contains the coerced `{ variableValues }`, or `{ errors }` when coercion returns request errors. `graphql:execute:rootSelectionSet` fires once for the operation root selection set. For subscriptions, it fires once per emitted subscription event, matching the OTel `graphql.subscription.event` shape; subscribers can distinguish operation kinds through `operationType`. ### Usage ```ts import dc from 'node:diagnostics_channel'; import type { GraphQLResolveContext } from 'graphql'; dc.tracingChannel('graphql:resolve').subscribe({ start: (context: GraphQLResolveContext) => { // start span }, end: (context: GraphQLResolveContext) => { // sync resolver result is available here as context.result }, asyncEnd: (context: GraphQLResolveContext) => { // async resolver result is available here as context.result }, error: (context: GraphQLResolveContext) => { // report context.error }, }); ``` Closes #4629 --------- Co-authored-by: Yaacov Rydzinski <yaacovCR@gmail.com>
1 parent c3e5513 commit a9a340f

30 files changed

Lines changed: 4496 additions & 12 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"description": "graphql-js tracing channels should publish on node:diagnostics_channel (Bun)",
3+
"private": true,
4+
"scripts": {
5+
"test": "docker run --rm --volume \"$PWD\":/usr/src/app -w /usr/src/app oven/bun:\"$BUN_VERSION\"-slim bun test.js"
6+
},
7+
"dependencies": {
8+
"graphql": "file:../graphql.tgz"
9+
}
10+
}
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
// TracingChannel is marked experimental in Node's docs but is shipped on
2+
// every runtime graphql-js supports. This test exercises it directly.
3+
/* eslint-disable n/no-unsupported-features/node-builtins */
4+
5+
import assert from 'node:assert/strict';
6+
import { AsyncLocalStorage } from 'node:async_hooks';
7+
import dc from 'node:diagnostics_channel';
8+
9+
import { buildSchema, execute, parse, subscribe, validate } from 'graphql';
10+
11+
function runParseCases() {
12+
// graphql:parse - synchronous.
13+
{
14+
const events = [];
15+
const handler = {
16+
start: (msg) => events.push({ kind: 'start', source: msg.source }),
17+
end: (msg) => events.push({ kind: 'end', source: msg.source }),
18+
asyncStart: (msg) =>
19+
events.push({ kind: 'asyncStart', source: msg.source }),
20+
asyncEnd: (msg) => events.push({ kind: 'asyncEnd', source: msg.source }),
21+
error: (msg) =>
22+
events.push({ kind: 'error', source: msg.source, error: msg.error }),
23+
};
24+
25+
const channel = dc.tracingChannel('graphql:parse');
26+
channel.subscribe(handler);
27+
28+
try {
29+
const doc = parse('{ field }');
30+
assert.equal(doc.kind, 'Document');
31+
assert.deepEqual(
32+
events.map((e) => e.kind),
33+
['start', 'end'],
34+
);
35+
assert.equal(events[0].source, '{ field }');
36+
assert.equal(events[1].source, '{ field }');
37+
} finally {
38+
channel.unsubscribe(handler);
39+
}
40+
}
41+
42+
// graphql:parse - error path fires start, error, end.
43+
{
44+
const events = [];
45+
const handler = {
46+
start: (msg) => events.push({ kind: 'start', source: msg.source }),
47+
end: (msg) => events.push({ kind: 'end', source: msg.source }),
48+
error: (msg) =>
49+
events.push({ kind: 'error', source: msg.source, error: msg.error }),
50+
};
51+
52+
const channel = dc.tracingChannel('graphql:parse');
53+
channel.subscribe(handler);
54+
55+
try {
56+
assert.throws(() => parse('{ '));
57+
assert.deepEqual(
58+
events.map((e) => e.kind),
59+
['start', 'error', 'end'],
60+
);
61+
assert.ok(events[1].error instanceof Error);
62+
} finally {
63+
channel.unsubscribe(handler);
64+
}
65+
}
66+
}
67+
68+
function runValidateCase() {
69+
const schema = buildSchema(`type Query { field: String }`);
70+
const doc = parse('{ field }');
71+
72+
const events = [];
73+
const handler = {
74+
start: (msg) =>
75+
events.push({
76+
kind: 'start',
77+
schema: msg.schema,
78+
document: msg.document,
79+
}),
80+
end: () => events.push({ kind: 'end' }),
81+
error: (msg) => events.push({ kind: 'error', error: msg.error }),
82+
};
83+
84+
const channel = dc.tracingChannel('graphql:validate');
85+
channel.subscribe(handler);
86+
87+
try {
88+
const errors = validate(schema, doc);
89+
assert.deepEqual(errors, []);
90+
assert.deepEqual(
91+
events.map((e) => e.kind),
92+
['start', 'end'],
93+
);
94+
assert.equal(events[0].schema, schema);
95+
assert.equal(events[0].document, doc);
96+
} finally {
97+
channel.unsubscribe(handler);
98+
}
99+
}
100+
101+
function runExecuteCase() {
102+
const schema = buildSchema(`type Query { hello: String }`);
103+
const document = parse('query Greeting { hello }');
104+
105+
const events = [];
106+
const handler = {
107+
start: (msg) =>
108+
events.push({
109+
kind: 'start',
110+
schema: msg.schema,
111+
document: msg.document,
112+
variableValues: msg.variableValues,
113+
operationName: msg.operationName,
114+
operationType: msg.operationType,
115+
}),
116+
end: () => events.push({ kind: 'end' }),
117+
asyncStart: () => events.push({ kind: 'asyncStart' }),
118+
asyncEnd: () => events.push({ kind: 'asyncEnd' }),
119+
error: (msg) => events.push({ kind: 'error', error: msg.error }),
120+
};
121+
122+
const channel = dc.tracingChannel('graphql:execute');
123+
channel.subscribe(handler);
124+
125+
try {
126+
const result = execute({
127+
schema,
128+
document,
129+
rootValue: { hello: 'world' },
130+
});
131+
assert.equal(result.data.hello, 'world');
132+
assert.deepEqual(
133+
events.map((e) => e.kind),
134+
['start', 'end'],
135+
);
136+
assert.equal(events[0].operationType, 'query');
137+
assert.equal(events[0].operationName, 'Greeting');
138+
assert.equal(events[0].document, document);
139+
assert.equal(events[0].schema, schema);
140+
} finally {
141+
channel.unsubscribe(handler);
142+
}
143+
}
144+
145+
function runExecuteRootSelectionSetCase() {
146+
const schema = buildSchema(`type Query { hello: String }`);
147+
const document = parse('query Greeting { hello }');
148+
const operation = document.definitions[0];
149+
150+
const events = [];
151+
const handler = {
152+
start: (msg) =>
153+
events.push({
154+
kind: 'start',
155+
schema: msg.schema,
156+
document: msg.document,
157+
operation: msg.operation,
158+
variableValues: msg.variableValues,
159+
operationName: msg.operationName,
160+
operationType: msg.operationType,
161+
}),
162+
end: (msg) => events.push({ kind: 'end', result: msg.result }),
163+
asyncStart: () => events.push({ kind: 'asyncStart' }),
164+
asyncEnd: () => events.push({ kind: 'asyncEnd' }),
165+
error: (msg) => events.push({ kind: 'error', error: msg.error }),
166+
};
167+
168+
const channel = dc.tracingChannel('graphql:execute:rootSelectionSet');
169+
channel.subscribe(handler);
170+
171+
try {
172+
const result = execute({
173+
schema,
174+
document,
175+
rootValue: { hello: 'world' },
176+
});
177+
assert.equal(result.data.hello, 'world');
178+
assert.deepEqual(
179+
events.map((e) => e.kind),
180+
['start', 'end'],
181+
);
182+
assert.equal(events[0].operationType, 'query');
183+
assert.equal(events[0].operationName, 'Greeting');
184+
assert.equal(events[0].document, document);
185+
assert.equal(events[0].operation, operation);
186+
assert.equal(events[0].schema, schema);
187+
assert.equal(events[1].result, result);
188+
} finally {
189+
channel.unsubscribe(handler);
190+
}
191+
}
192+
193+
async function runSubscribeCase() {
194+
async function* ticks() {
195+
yield { tick: 'one' };
196+
}
197+
198+
const schema = buildSchema(`
199+
type Query { dummy: String }
200+
type Subscription { tick: String }
201+
`);
202+
// buildSchema doesn't attach a subscribe resolver to fields; inject one.
203+
schema.getSubscriptionType().getFields().tick.subscribe = () => ticks();
204+
205+
const document = parse('subscription Tick { tick }');
206+
207+
const events = [];
208+
const handler = {
209+
start: (msg) =>
210+
events.push({
211+
kind: 'start',
212+
schema: msg.schema,
213+
document: msg.document,
214+
variableValues: msg.variableValues,
215+
operationName: msg.operationName,
216+
operationType: msg.operationType,
217+
}),
218+
end: () => events.push({ kind: 'end' }),
219+
asyncStart: () => events.push({ kind: 'asyncStart' }),
220+
asyncEnd: () => events.push({ kind: 'asyncEnd' }),
221+
error: (msg) => events.push({ kind: 'error', error: msg.error }),
222+
};
223+
224+
const channel = dc.tracingChannel('graphql:subscribe');
225+
channel.subscribe(handler);
226+
227+
try {
228+
const result = subscribe({ schema, document });
229+
const stream = typeof result.then === 'function' ? await result : result;
230+
if (stream[Symbol.asyncIterator]) {
231+
await stream.return?.();
232+
}
233+
// Subscription setup is synchronous here; start/end fire, no async tail.
234+
assert.deepEqual(
235+
events.map((e) => e.kind),
236+
['start', 'end'],
237+
);
238+
assert.equal(events[0].operationType, 'subscription');
239+
assert.equal(events[0].operationName, 'Tick');
240+
} finally {
241+
channel.unsubscribe(handler);
242+
}
243+
}
244+
245+
function runResolveCase() {
246+
const schema = buildSchema(
247+
`type Query { hello: String nested: Nested } type Nested { leaf: String }`,
248+
);
249+
const document = parse('{ hello nested { leaf } }');
250+
251+
const events = [];
252+
const handler = {
253+
start: (msg) =>
254+
events.push({
255+
kind: 'start',
256+
fieldName: msg.fieldName,
257+
parentType: msg.parentType,
258+
fieldType: msg.fieldType,
259+
args: msg.args,
260+
isDefaultResolver: msg.isDefaultResolver,
261+
fieldPath: msg.fieldPath,
262+
}),
263+
end: () => events.push({ kind: 'end' }),
264+
asyncStart: () => events.push({ kind: 'asyncStart' }),
265+
asyncEnd: () => events.push({ kind: 'asyncEnd' }),
266+
error: (msg) => events.push({ kind: 'error', error: msg.error }),
267+
};
268+
269+
const channel = dc.tracingChannel('graphql:resolve');
270+
channel.subscribe(handler);
271+
272+
try {
273+
const rootValue = { hello: () => 'world', nested: { leaf: 'leaf-value' } };
274+
execute({ schema, document, rootValue });
275+
276+
const starts = events.filter((e) => e.kind === 'start');
277+
const paths = starts.map((e) => e.fieldPath);
278+
assert.deepEqual(paths, ['hello', 'nested', 'nested.leaf']);
279+
280+
const hello = starts.find((e) => e.fieldName === 'hello');
281+
assert.equal(hello.parentType, 'Query');
282+
assert.equal(hello.fieldType, 'String');
283+
// buildSchema never attaches field.resolve; all fields report as trivial.
284+
assert.equal(hello.isDefaultResolver, true);
285+
} finally {
286+
channel.unsubscribe(handler);
287+
}
288+
}
289+
290+
function runNoSubscriberCase() {
291+
const doc = parse('{ field }');
292+
assert.equal(doc.kind, 'Document');
293+
}
294+
295+
async function runAlsPropagationCase() {
296+
// A subscriber that binds a store on the `start` sub-channel should be able
297+
// to read it in every lifecycle handler (start, end, asyncStart, asyncEnd).
298+
// This is what APMs use to parent child spans to the current operation
299+
// without threading state through the context object.
300+
const als = new AsyncLocalStorage();
301+
const channel = dc.tracingChannel('graphql:execute');
302+
channel.start.bindStore(als, (context) => ({
303+
operationName: context.operationName,
304+
}));
305+
306+
const seen = {};
307+
const handler = {
308+
start: () => (seen.start = als.getStore()),
309+
end: () => (seen.end = als.getStore()),
310+
asyncStart: () => (seen.asyncStart = als.getStore()),
311+
asyncEnd: () => (seen.asyncEnd = als.getStore()),
312+
};
313+
channel.subscribe(handler);
314+
315+
try {
316+
const schema = buildSchema(`type Query { slow: String }`);
317+
const document = parse('query Slow { slow }');
318+
const rootValue = { slow: () => Promise.resolve('done') };
319+
320+
await execute({ schema, document, rootValue });
321+
322+
assert.deepEqual(seen.start, { operationName: 'Slow' });
323+
assert.deepEqual(seen.end, { operationName: 'Slow' });
324+
assert.deepEqual(seen.asyncStart, { operationName: 'Slow' });
325+
assert.deepEqual(seen.asyncEnd, { operationName: 'Slow' });
326+
} finally {
327+
channel.unsubscribe(handler);
328+
channel.start.unbindStore(als);
329+
}
330+
}
331+
332+
async function main() {
333+
runParseCases();
334+
runValidateCase();
335+
runExecuteCase();
336+
runExecuteRootSelectionSetCase();
337+
await runSubscribeCase();
338+
runResolveCase();
339+
await runAlsPropagationCase();
340+
runNoSubscriberCase();
341+
console.log('diagnostics integration test passed');
342+
}
343+
344+
main();
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"imports": {
3+
"graphql": "../graphql-deno-dist/index.ts",
4+
"graphql/": "../graphql-deno-dist/"
5+
}
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"description": "graphql-js tracing channels should publish on node:diagnostics_channel (Deno with deno build)",
3+
"private": true,
4+
"scripts": {
5+
"test": "docker run --rm --volume \"$PWD/..\":/usr/src/app -w /usr/src/app/diagnostics-deno-with-deno-build denoland/deno:alpine-\"$DENO_VERSION\" deno run test.js"
6+
}
7+
}

0 commit comments

Comments
 (0)