Skip to content

Commit d7491c4

Browse files
committed
feat(execution): publish on graphql:execute tracing channel
Wraps the public execute / experimentalExecuteIncrementally / executeIgnoringIncremental / executeSubscriptionEvent entry points with maybeTraceMixed so subscribers see a single graphql:execute span per top-level operation invocation, including every per-event execution of a subscription stream. The context exposes the document, schema, variableValues, operationName, and operationType. operationName and operationType are lazy getters that only resolve the operation AST (getOperationAST) if a subscriber reads them, keeping the gate cheap for APMs that do not need them. The ValidatedExecutionArgs variant used by executeSubscriptionEvent uses the already-resolved operation from validation and therefore needs no lazy lookup; it carries the resolved operation in place of the (unavailable) document. Adds a shared maybeTraceMixed helper to src/diagnostics.ts for PromiseOrValue-returning functions. It delegates start / end / error to Node's traceSync (which also runs fn inside end.runStores for AsyncLocalStorage propagation) and adds the asyncStart / asyncEnd / error-on-rejection emission when the return value is a promise.
1 parent 46de138 commit d7491c4

File tree

4 files changed

+332
-32
lines changed

4 files changed

+332
-32
lines changed

integrationTests/diagnostics/test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import dc from 'node:diagnostics_channel';
88
import {
99
buildSchema,
1010
enableDiagnosticsChannel,
11+
execute,
1112
parse,
1213
validate,
1314
} from 'graphql';
@@ -103,6 +104,51 @@ enableDiagnosticsChannel(dc);
103104
}
104105
}
105106

107+
// graphql:execute - sync path, ctx carries operationType, operationName,
108+
// document, schema.
109+
{
110+
const schema = buildSchema(`type Query { hello: String }`);
111+
const document = parse('query Greeting { hello }');
112+
113+
const events = [];
114+
const handler = {
115+
start: (msg) =>
116+
events.push({
117+
kind: 'start',
118+
operationType: msg.operationType,
119+
operationName: msg.operationName,
120+
document: msg.document,
121+
schema: msg.schema,
122+
}),
123+
end: () => events.push({ kind: 'end' }),
124+
asyncStart: () => events.push({ kind: 'asyncStart' }),
125+
asyncEnd: () => events.push({ kind: 'asyncEnd' }),
126+
error: (msg) => events.push({ kind: 'error', error: msg.error }),
127+
};
128+
129+
const channel = dc.tracingChannel('graphql:execute');
130+
channel.subscribe(handler);
131+
132+
try {
133+
const result = execute({
134+
schema,
135+
document,
136+
rootValue: { hello: 'world' },
137+
});
138+
assert.equal(result.data.hello, 'world');
139+
assert.deepEqual(
140+
events.map((e) => e.kind),
141+
['start', 'end'],
142+
);
143+
assert.equal(events[0].operationType, 'query');
144+
assert.equal(events[0].operationName, 'Greeting');
145+
assert.equal(events[0].document, document);
146+
assert.equal(events[0].schema, schema);
147+
} finally {
148+
channel.unsubscribe(handler);
149+
}
150+
}
151+
106152
// No-op when nothing is subscribed - parse still succeeds.
107153
{
108154
const doc = parse('{ field }');

src/diagnostics.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* same `TracingChannel` instances and all subscribers coexist.
1414
*/
1515

16+
import { isPromise } from './jsutils/isPromise.js';
17+
1618
/**
1719
* Structural subset of `DiagnosticsChannel` sufficient for publishing and
1820
* subscriber gating. `node:diagnostics_channel`'s `Channel` satisfies this.
@@ -181,3 +183,52 @@ export function maybeTracePromise<T>(
181183
}
182184
return channel.tracePromise(fn, ctxFactory());
183185
}
186+
187+
/**
188+
* Publish a mixed sync-or-promise operation through the named graphql tracing
189+
* channel. Delegates the start/end/error lifecycle to Node's `traceSync`
190+
* (which also runs `fn` inside `end.runStores` for AsyncLocalStorage context
191+
* propagation) and, when `fn` returns a promise, appends `asyncStart` and
192+
* `asyncEnd` on settlement plus `error` on rejection.
193+
*
194+
* Use this when the function may return either a value or a promise, which
195+
* is common on graphql-js's execution path where async-ness is determined
196+
* by resolvers only after the call begins. Short-circuits to `fn()` when
197+
* the channel isn't registered or nothing is listening.
198+
*
199+
* @internal
200+
*/
201+
export function maybeTraceMixed<T>(
202+
name: keyof GraphQLChannels,
203+
ctxFactory: () => object,
204+
fn: () => T | Promise<T>,
205+
): T | Promise<T> {
206+
const channel = getChannels()?.[name];
207+
if (!shouldTrace(channel)) {
208+
return fn();
209+
}
210+
const ctx = ctxFactory() as {
211+
error?: unknown;
212+
result?: unknown;
213+
};
214+
const result = channel.traceSync(fn, ctx);
215+
if (!isPromise(result)) {
216+
ctx.result = result;
217+
return result;
218+
}
219+
return result.then(
220+
(value) => {
221+
ctx.result = value;
222+
channel.asyncStart.publish(ctx);
223+
channel.asyncEnd.publish(ctx);
224+
return value;
225+
},
226+
(err: unknown) => {
227+
ctx.error = err;
228+
channel.error.publish(ctx);
229+
channel.asyncStart.publish(ctx);
230+
channel.asyncEnd.publish(ctx);
231+
throw err;
232+
},
233+
);
234+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { expect } from 'chai';
2+
import { afterEach, beforeEach, describe, it } from 'mocha';
3+
4+
import {
5+
collectEvents,
6+
FakeDc,
7+
} from '../../__testUtils__/fakeDiagnosticsChannel.js';
8+
9+
import { parse } from '../../language/parser.js';
10+
11+
import { buildSchema } from '../../utilities/buildASTSchema.js';
12+
13+
import { enableDiagnosticsChannel } from '../../diagnostics.js';
14+
15+
import type { ExecutionArgs } from '../execute.js';
16+
import {
17+
execute,
18+
executeSubscriptionEvent,
19+
executeSync,
20+
validateExecutionArgs,
21+
} from '../execute.js';
22+
23+
const schema = buildSchema(`
24+
type Query {
25+
sync: String
26+
async: String
27+
}
28+
`);
29+
30+
const rootValue = {
31+
sync: () => 'hello',
32+
async: () => Promise.resolve('hello-async'),
33+
};
34+
35+
const fakeDc = new FakeDc();
36+
const executeChannel = fakeDc.tracingChannel('graphql:execute');
37+
38+
describe('execute diagnostics channel', () => {
39+
let active: ReturnType<typeof collectEvents> | undefined;
40+
41+
beforeEach(() => {
42+
enableDiagnosticsChannel(fakeDc);
43+
});
44+
45+
afterEach(() => {
46+
active?.unsubscribe();
47+
active = undefined;
48+
});
49+
50+
it('emits start and end around a synchronous execute', () => {
51+
active = collectEvents(executeChannel);
52+
53+
const document = parse('query Q { sync }');
54+
const result = execute({ schema, document, rootValue });
55+
56+
expect(result).to.deep.equal({ data: { sync: 'hello' } });
57+
expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']);
58+
expect(active.events[0].ctx.operationType).to.equal('query');
59+
expect(active.events[0].ctx.operationName).to.equal('Q');
60+
expect(active.events[0].ctx.document).to.equal(document);
61+
expect(active.events[0].ctx.schema).to.equal(schema);
62+
});
63+
64+
it('emits start, end, and async lifecycle when execute returns a promise', async () => {
65+
active = collectEvents(executeChannel);
66+
67+
const document = parse('query { async }');
68+
const result = await execute({ schema, document, rootValue });
69+
70+
expect(result).to.deep.equal({ data: { async: 'hello-async' } });
71+
expect(active.events.map((e) => e.kind)).to.deep.equal([
72+
'start',
73+
'end',
74+
'asyncStart',
75+
'asyncEnd',
76+
]);
77+
});
78+
79+
it('emits once for executeSync via experimentalExecuteIncrementally', () => {
80+
active = collectEvents(executeChannel);
81+
82+
const document = parse('{ sync }');
83+
executeSync({ schema, document, rootValue });
84+
85+
expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']);
86+
});
87+
88+
it('emits start, error, and end when execute throws synchronously', () => {
89+
active = collectEvents(executeChannel);
90+
91+
const schemaWithDefer = buildSchema(`
92+
directive @defer on FIELD
93+
type Query { sync: String }
94+
`);
95+
const document = parse('{ sync }');
96+
expect(() =>
97+
execute({ schema: schemaWithDefer, document, rootValue }),
98+
).to.throw();
99+
100+
expect(active.events.map((e) => e.kind)).to.deep.equal([
101+
'start',
102+
'error',
103+
'end',
104+
]);
105+
});
106+
107+
it('emits for each executeSubscriptionEvent call with resolved operation ctx', () => {
108+
const args: ExecutionArgs = {
109+
schema,
110+
document: parse('query Q { sync }'),
111+
rootValue,
112+
};
113+
const validated = validateExecutionArgs(args);
114+
if (!('schema' in validated)) {
115+
throw new Error('unexpected validation failure');
116+
}
117+
118+
active = collectEvents(executeChannel);
119+
120+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
121+
executeSubscriptionEvent(validated);
122+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
123+
executeSubscriptionEvent(validated);
124+
125+
const starts = active.events.filter((e) => e.kind === 'start');
126+
expect(starts.length).to.equal(2);
127+
for (const ev of starts) {
128+
expect(ev.ctx.operationType).to.equal('query');
129+
expect(ev.ctx.operationName).to.equal('Q');
130+
expect(ev.ctx.schema).to.equal(schema);
131+
}
132+
});
133+
134+
it('does nothing when no subscribers are attached', () => {
135+
const document = parse('{ sync }');
136+
const result = execute({ schema, document, rootValue });
137+
expect(result).to.deep.equal({ data: { sync: 'hello' } });
138+
});
139+
});

0 commit comments

Comments
 (0)