Skip to content

Commit 3381a40

Browse files
committed
feat(execution): publish on graphql:resolve tracing channel
Wraps the single resolver invocation site in Executor.executeField with maybeTraceMixed so each field resolver call emits start/end (and the async tail when the resolver returns a promise, or error when it throws). The emission is inside the engine — no field.resolve mutation, no schema walking, nothing stacks across multiple APM subscribers. The published context captures the field-level metadata APM tools use to name and attribute per-field spans: - fieldName from info.fieldName - parentType from info.parentType.name - fieldType stringified info.returnType - args resolver arguments, by reference - isTrivialResolver (field.resolve === undefined), a hint for subscribers that want to skip default property-access resolvers - fieldPath lazy getter, serializes info.path only on first read since it is O(depth) work and many subscribers depth-filter without reading it graphql:resolve is the noisiest channel (one event per field per operation); all emission sites are gated on hasSubscribers before any context is constructed, so it remains zero-cost when no APM is loaded.
1 parent 90433d1 commit 3381a40

File tree

3 files changed

+251
-1
lines changed

3 files changed

+251
-1
lines changed

integrationTests/diagnostics/test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,50 @@ async function runSubscribeCase() {
198198
}
199199
}
200200

201+
function runResolveCase() {
202+
const schema = buildSchema(
203+
`type Query { hello: String nested: Nested } type Nested { leaf: String }`,
204+
);
205+
const document = parse('{ hello nested { leaf } }');
206+
207+
const events = [];
208+
const handler = {
209+
start: (msg) =>
210+
events.push({
211+
kind: 'start',
212+
fieldName: msg.fieldName,
213+
parentType: msg.parentType,
214+
fieldType: msg.fieldType,
215+
fieldPath: msg.fieldPath,
216+
isTrivialResolver: msg.isTrivialResolver,
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:resolve');
225+
channel.subscribe(handler);
226+
227+
try {
228+
const rootValue = { hello: () => 'world', nested: { leaf: 'leaf-value' } };
229+
execute({ schema, document, rootValue });
230+
231+
const starts = events.filter((e) => e.kind === 'start');
232+
const paths = starts.map((e) => e.fieldPath);
233+
assert.deepEqual(paths, ['hello', 'nested', 'nested.leaf']);
234+
235+
const hello = starts.find((e) => e.fieldName === 'hello');
236+
assert.equal(hello.parentType, 'Query');
237+
assert.equal(hello.fieldType, 'String');
238+
// buildSchema never attaches field.resolve; all fields report as trivial.
239+
assert.equal(hello.isTrivialResolver, true);
240+
} finally {
241+
channel.unsubscribe(handler);
242+
}
243+
}
244+
201245
function runNoSubscriberCase() {
202246
const doc = parse('{ field }');
203247
assert.equal(doc.kind, 'Document');
@@ -208,6 +252,7 @@ async function main() {
208252
runValidateCase();
209253
runExecuteCase();
210254
await runSubscribeCase();
255+
runResolveCase();
211256
runNoSubscriberCase();
212257
console.log('diagnostics integration test passed');
213258
}

src/execution/Executor.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
} from '../type/definition.js';
4545
import type { GraphQLSchema } from '../type/schema.js';
4646

47+
import { maybeTraceMixed } from '../diagnostics.js';
48+
4749
import { withCancellation } from './cancellablePromise.js';
4850
import type {
4951
DeferUsage,
@@ -608,7 +610,11 @@ export class Executor<
608610
// The resolve function's optional third argument is a context value that
609611
// is provided to every resolve function within an execution. It is commonly
610612
// used to represent an authenticated user, or request-specific caches.
611-
const result = resolveFn(source, args, contextValue, info);
613+
const result = maybeTraceMixed(
614+
'resolve',
615+
() => buildResolveCtx(info, args, fieldDef.resolve === undefined),
616+
() => resolveFn(source, args, contextValue, info),
617+
);
612618

613619
if (isPromise(result)) {
614620
return this.completePromisedValue(
@@ -1395,3 +1401,29 @@ export class Executor<
13951401
function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray<FieldNode> {
13961402
return fieldDetailsList.map((fieldDetails) => fieldDetails.node);
13971403
}
1404+
1405+
/**
1406+
* Build a graphql:resolve channel context for a single field invocation.
1407+
*
1408+
* `fieldPath` is exposed as a lazy getter because serializing the response
1409+
* path is O(depth) and APMs that depth-filter or skip trivial resolvers
1410+
* often never read it. `args` is passed through by reference.
1411+
*/
1412+
function buildResolveCtx(
1413+
info: GraphQLResolveInfo,
1414+
args: { readonly [argument: string]: unknown },
1415+
isTrivialResolver: boolean,
1416+
): object {
1417+
let cachedFieldPath: string | undefined;
1418+
return {
1419+
fieldName: info.fieldName,
1420+
parentType: info.parentType.name,
1421+
fieldType: String(info.returnType),
1422+
args,
1423+
isTrivialResolver,
1424+
get fieldPath() {
1425+
cachedFieldPath ??= pathToArray(info.path).join('.');
1426+
return cachedFieldPath;
1427+
},
1428+
};
1429+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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 { isPromise } from '../../jsutils/isPromise.js';
10+
11+
import { parse } from '../../language/parser.js';
12+
13+
import { GraphQLObjectType } from '../../type/definition.js';
14+
import { GraphQLString } from '../../type/scalars.js';
15+
import { GraphQLSchema } from '../../type/schema.js';
16+
17+
import { buildSchema } from '../../utilities/buildASTSchema.js';
18+
19+
import { enableDiagnosticsChannel } from '../../diagnostics.js';
20+
21+
import { execute } from '../execute.js';
22+
23+
const schema = buildSchema(`
24+
type Query {
25+
sync: String
26+
async: String
27+
fail: String
28+
plain: String
29+
nested: Nested
30+
}
31+
type Nested {
32+
leaf: String
33+
}
34+
`);
35+
36+
const rootValue = {
37+
sync: () => 'hello',
38+
async: () => Promise.resolve('hello-async'),
39+
fail: () => {
40+
throw new Error('boom');
41+
},
42+
// no `plain` resolver, default property-access is used.
43+
plain: 'plain-value',
44+
nested: { leaf: 'leaf-value' },
45+
};
46+
47+
const fakeDc = new FakeDc();
48+
const resolveChannel = fakeDc.tracingChannel('graphql:resolve');
49+
50+
describe('resolve diagnostics channel', () => {
51+
let active: ReturnType<typeof collectEvents> | undefined;
52+
53+
beforeEach(() => {
54+
enableDiagnosticsChannel(fakeDc);
55+
});
56+
57+
afterEach(() => {
58+
active?.unsubscribe();
59+
active = undefined;
60+
});
61+
62+
it('emits start and end around a synchronous resolver', () => {
63+
active = collectEvents(resolveChannel);
64+
65+
const result = execute({ schema, document: parse('{ sync }'), rootValue });
66+
if (isPromise(result)) {
67+
throw new Error('expected sync');
68+
}
69+
70+
const starts = active.events.filter((e) => e.kind === 'start');
71+
expect(starts.length).to.equal(1);
72+
expect(starts[0].ctx.fieldName).to.equal('sync');
73+
expect(starts[0].ctx.parentType).to.equal('Query');
74+
expect(starts[0].ctx.fieldType).to.equal('String');
75+
expect(starts[0].ctx.fieldPath).to.equal('sync');
76+
77+
const kinds = active.events.map((e) => e.kind);
78+
expect(kinds).to.deep.equal(['start', 'end']);
79+
});
80+
81+
it('emits the full async lifecycle when a resolver returns a promise', async () => {
82+
active = collectEvents(resolveChannel);
83+
84+
const result = execute({ schema, document: parse('{ async }'), rootValue });
85+
await result;
86+
87+
const kinds = active.events.map((e) => e.kind);
88+
expect(kinds).to.deep.equal(['start', 'end', 'asyncStart', 'asyncEnd']);
89+
});
90+
91+
it('emits start, error, end when a sync resolver throws', () => {
92+
active = collectEvents(resolveChannel);
93+
94+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
95+
execute({ schema, document: parse('{ fail }'), rootValue });
96+
97+
const kinds = active.events.map((e) => e.kind);
98+
expect(kinds).to.deep.equal(['start', 'error', 'end']);
99+
});
100+
101+
it('reports isTrivialResolver based on field.resolve presence', () => {
102+
const trivialSchema = new GraphQLSchema({
103+
query: new GraphQLObjectType({
104+
name: 'Query',
105+
fields: {
106+
trivial: { type: GraphQLString },
107+
custom: {
108+
type: GraphQLString,
109+
resolve: () => 'explicit',
110+
},
111+
},
112+
}),
113+
});
114+
115+
active = collectEvents(resolveChannel);
116+
117+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
118+
execute({
119+
schema: trivialSchema,
120+
document: parse('{ trivial custom }'),
121+
rootValue: { trivial: 'value' },
122+
});
123+
124+
const starts = active.events.filter((e) => e.kind === 'start');
125+
const byField = new Map(
126+
starts.map((e) => [e.ctx.fieldName, e.ctx.isTrivialResolver]),
127+
);
128+
expect(byField.get('trivial')).to.equal(true);
129+
expect(byField.get('custom')).to.equal(false);
130+
});
131+
132+
it('serializes fieldPath lazily, joining path keys with dots', () => {
133+
active = collectEvents(resolveChannel);
134+
135+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
136+
execute({
137+
schema,
138+
document: parse('{ nested { leaf } }'),
139+
rootValue,
140+
});
141+
142+
const starts = active.events.filter((e) => e.kind === 'start');
143+
const paths = starts.map((e) => e.ctx.fieldPath);
144+
expect(paths).to.deep.equal(['nested', 'nested.leaf']);
145+
});
146+
147+
it('fires once per field, not per schema walk', () => {
148+
active = collectEvents(resolveChannel);
149+
150+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
151+
execute({
152+
schema,
153+
document: parse('{ sync plain nested { leaf } }'),
154+
rootValue,
155+
});
156+
157+
const starts = active.events.filter((e) => e.kind === 'start');
158+
const endsSync = active.events.filter((e) => e.kind === 'end');
159+
expect(starts.length).to.equal(4); // sync, plain, nested, nested.leaf
160+
expect(endsSync.length).to.equal(4);
161+
});
162+
163+
it('does nothing when no subscribers are attached', () => {
164+
const result = execute({
165+
schema,
166+
document: parse('{ sync }'),
167+
rootValue,
168+
});
169+
if (isPromise(result)) {
170+
throw new Error('expected sync');
171+
}
172+
});
173+
});

0 commit comments

Comments
 (0)