Skip to content

Commit be6381a

Browse files
committed
feat(language): publish on graphql:parse tracing channel
Wraps parse() with the graphql:parse channel when any subscribers are attached. The published context carries the Source (or string) handed to parse() so APM tools can record the GraphQL source on their span. Adds two shared helpers on src/diagnostics.ts used by every subsequent emission site: maybeTraceSync(name, ctxFactory, fn) maybeTracePromise(name, ctxFactory, fn) They share a shouldTrace gate that uses hasSubscribers !== false so runtimes without an aggregated hasSubscribers getter (notably Node 18, see nodejs/node#54470) still publish; Node's traceSync/tracePromise gate each sub-channel internally. Context construction is lazy through the factory so the no-subscriber path allocates nothing beyond the closure.
1 parent 044e252 commit be6381a

File tree

3 files changed

+286
-8
lines changed

3 files changed

+286
-8
lines changed

src/diagnostics.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
export interface MinimalChannel {
2121
readonly hasSubscribers: boolean;
2222
publish: (message: unknown) => void;
23-
runStores: <T, ContextType>(
23+
runStores: <T, ContextType extends object>(
2424
context: ContextType,
2525
fn: (this: ContextType, ...args: Array<unknown>) => T,
2626
thisArg?: unknown,
@@ -125,3 +125,59 @@ export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void {
125125
subscribe: dc.tracingChannel('graphql:subscribe'),
126126
};
127127
}
128+
129+
/**
130+
* Gate for emission sites. Returns `true` when the named channel exists and
131+
* publishing should proceed.
132+
*
133+
* Uses `!== false` rather than a truthy check so runtimes which do not
134+
* implement the aggregated `hasSubscribers` getter on `TracingChannel` still
135+
* publish. Notably Node 18 (nodejs/node#54470), where the aggregated getter
136+
* returns `undefined` while sub-channels behave correctly.
137+
*
138+
* @internal
139+
*/
140+
function shouldTrace(
141+
channel: MinimalTracingChannel | undefined,
142+
): channel is MinimalTracingChannel {
143+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
144+
return channel !== undefined && channel.hasSubscribers !== false;
145+
}
146+
147+
/**
148+
* Publish a synchronous operation through the named graphql tracing channel,
149+
* short-circuiting to `fn()` when the channel isn't registered or nothing is
150+
* listening.
151+
*
152+
* @internal
153+
*/
154+
export function maybeTraceSync<T>(
155+
name: keyof GraphQLChannels,
156+
ctxFactory: () => object,
157+
fn: () => T,
158+
): T {
159+
const channel = getChannels()?.[name];
160+
if (!shouldTrace(channel)) {
161+
return fn();
162+
}
163+
return channel.traceSync(fn, ctxFactory());
164+
}
165+
166+
/**
167+
* Publish a promise-returning operation through the named graphql tracing
168+
* channel, short-circuiting to `fn()` when the channel isn't registered or
169+
* nothing is listening.
170+
*
171+
* @internal
172+
*/
173+
export function maybeTracePromise<T>(
174+
name: keyof GraphQLChannels,
175+
ctxFactory: () => object,
176+
fn: () => Promise<T>,
177+
): Promise<T> {
178+
const channel = getChannels()?.[name];
179+
if (!shouldTrace(channel)) {
180+
return fn();
181+
}
182+
return channel.tracePromise(fn, ctxFactory());
183+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { expect } from 'chai';
2+
import { afterEach, beforeEach, describe, it } from 'mocha';
3+
4+
import type {
5+
MinimalChannel,
6+
MinimalDiagnosticsChannel,
7+
MinimalTracingChannel,
8+
} from '../../diagnostics.js';
9+
import { enableDiagnosticsChannel } from '../../diagnostics.js';
10+
11+
import { parse } from '../parser.js';
12+
13+
type Listener = (message: unknown) => void;
14+
15+
class FakeChannel implements MinimalChannel {
16+
listeners: Array<Listener> = [];
17+
get hasSubscribers(): boolean {
18+
return this.listeners.length > 0;
19+
}
20+
21+
publish(message: unknown): void {
22+
for (const l of this.listeners) {
23+
l(message);
24+
}
25+
}
26+
27+
runStores<T, ContextType extends object>(
28+
_ctx: ContextType,
29+
fn: (this: ContextType, ...args: Array<unknown>) => T,
30+
thisArg?: unknown,
31+
...args: Array<unknown>
32+
): T {
33+
return fn.apply(thisArg as ContextType, args);
34+
}
35+
36+
subscribe(listener: Listener): void {
37+
this.listeners.push(listener);
38+
}
39+
40+
unsubscribe(listener: Listener): void {
41+
const idx = this.listeners.indexOf(listener);
42+
if (idx >= 0) {
43+
this.listeners.splice(idx, 1);
44+
}
45+
}
46+
}
47+
48+
class FakeTracingChannel implements MinimalTracingChannel {
49+
start = new FakeChannel();
50+
end = new FakeChannel();
51+
asyncStart = new FakeChannel();
52+
asyncEnd = new FakeChannel();
53+
error = new FakeChannel();
54+
55+
get hasSubscribers(): boolean {
56+
return (
57+
this.start.hasSubscribers ||
58+
this.end.hasSubscribers ||
59+
this.asyncStart.hasSubscribers ||
60+
this.asyncEnd.hasSubscribers ||
61+
this.error.hasSubscribers
62+
);
63+
}
64+
65+
traceSync<T>(
66+
fn: (...args: Array<unknown>) => T,
67+
ctx: object,
68+
thisArg?: unknown,
69+
...args: Array<unknown>
70+
): T {
71+
this.start.publish(ctx);
72+
try {
73+
return this.end.runStores(ctx, fn, thisArg, ...args);
74+
} catch (err) {
75+
(ctx as { error: unknown }).error = err;
76+
this.error.publish(ctx);
77+
throw err;
78+
} finally {
79+
this.end.publish(ctx);
80+
}
81+
}
82+
83+
tracePromise<T>(
84+
fn: (...args: Array<unknown>) => Promise<T>,
85+
ctx: object,
86+
thisArg?: unknown,
87+
...args: Array<unknown>
88+
): Promise<T> {
89+
this.start.publish(ctx);
90+
let promise: Promise<T>;
91+
try {
92+
promise = this.end.runStores(ctx, fn, thisArg, ...args);
93+
} catch (err) {
94+
(ctx as { error: unknown }).error = err;
95+
this.error.publish(ctx);
96+
this.end.publish(ctx);
97+
throw err;
98+
}
99+
this.end.publish(ctx);
100+
return promise.then(
101+
(result) => {
102+
(ctx as { result: unknown }).result = result;
103+
this.asyncStart.publish(ctx);
104+
this.asyncEnd.publish(ctx);
105+
return result;
106+
},
107+
(err: unknown) => {
108+
(ctx as { error: unknown }).error = err;
109+
this.error.publish(ctx);
110+
this.asyncStart.publish(ctx);
111+
this.asyncEnd.publish(ctx);
112+
throw err;
113+
},
114+
);
115+
}
116+
}
117+
118+
class FakeDc implements MinimalDiagnosticsChannel {
119+
private cache = new Map<string, FakeTracingChannel>();
120+
121+
tracingChannel(name: string): FakeTracingChannel {
122+
let existing = this.cache.get(name);
123+
if (existing === undefined) {
124+
existing = new FakeTracingChannel();
125+
this.cache.set(name, existing);
126+
}
127+
return existing;
128+
}
129+
}
130+
131+
const fakeDc = new FakeDc();
132+
const parseChannel = fakeDc.tracingChannel('graphql:parse');
133+
134+
interface Event {
135+
kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error';
136+
source: unknown;
137+
error?: unknown;
138+
}
139+
140+
function collectEvents(): { events: Array<Event>; unsubscribe: () => void } {
141+
const events: Array<Event> = [];
142+
const startL: Listener = (m) =>
143+
events.push({ kind: 'start', source: (m as { source: unknown }).source });
144+
const endL: Listener = (m) =>
145+
events.push({ kind: 'end', source: (m as { source: unknown }).source });
146+
const asyncStartL: Listener = (m) =>
147+
events.push({
148+
kind: 'asyncStart',
149+
source: (m as { source: unknown }).source,
150+
});
151+
const asyncEndL: Listener = (m) =>
152+
events.push({
153+
kind: 'asyncEnd',
154+
source: (m as { source: unknown }).source,
155+
});
156+
const errorL: Listener = (m) => {
157+
const msg = m as { source: unknown; error: unknown };
158+
events.push({ kind: 'error', source: msg.source, error: msg.error });
159+
};
160+
parseChannel.start.subscribe(startL);
161+
parseChannel.end.subscribe(endL);
162+
parseChannel.asyncStart.subscribe(asyncStartL);
163+
parseChannel.asyncEnd.subscribe(asyncEndL);
164+
parseChannel.error.subscribe(errorL);
165+
return {
166+
events,
167+
unsubscribe() {
168+
parseChannel.start.unsubscribe(startL);
169+
parseChannel.end.unsubscribe(endL);
170+
parseChannel.asyncStart.unsubscribe(asyncStartL);
171+
parseChannel.asyncEnd.unsubscribe(asyncEndL);
172+
parseChannel.error.unsubscribe(errorL);
173+
},
174+
};
175+
}
176+
177+
describe('parse diagnostics channel', () => {
178+
let active: ReturnType<typeof collectEvents> | undefined;
179+
180+
beforeEach(() => {
181+
enableDiagnosticsChannel(fakeDc);
182+
});
183+
184+
afterEach(() => {
185+
active?.unsubscribe();
186+
active = undefined;
187+
});
188+
189+
it('emits start and end around a successful parse', () => {
190+
active = collectEvents();
191+
192+
const doc = parse('{ field }');
193+
194+
expect(doc.kind).to.equal('Document');
195+
expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']);
196+
expect(active.events[0].source).to.equal('{ field }');
197+
expect(active.events[1].source).to.equal('{ field }');
198+
});
199+
200+
it('emits start, error, and end when the parser throws', () => {
201+
active = collectEvents();
202+
203+
expect(() => parse('{ ')).to.throw();
204+
205+
const kinds = active.events.map((e) => e.kind);
206+
expect(kinds).to.deep.equal(['start', 'error', 'end']);
207+
expect(active.events[1].error).to.be.instanceOf(Error);
208+
});
209+
210+
it('does nothing when no subscribers are attached', () => {
211+
const doc = parse('{ field }');
212+
expect(doc.kind).to.equal('Document');
213+
});
214+
});

src/language/parser.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { Maybe } from '../jsutils/Maybe.js';
33
import type { GraphQLError } from '../error/GraphQLError.js';
44
import { syntaxError } from '../error/syntaxError.js';
55

6+
import { maybeTraceSync } from '../diagnostics.js';
7+
68
import type {
79
ArgumentCoordinateNode,
810
ArgumentNode,
@@ -132,13 +134,19 @@ export function parse(
132134
source: string | Source,
133135
options?: ParseOptions,
134136
): DocumentNode {
135-
const parser = new Parser(source, options);
136-
const document = parser.parseDocument();
137-
Object.defineProperty(document, 'tokenCount', {
138-
enumerable: false,
139-
value: parser.tokenCount,
140-
});
141-
return document;
137+
return maybeTraceSync(
138+
'parse',
139+
() => ({ source }),
140+
() => {
141+
const parser = new Parser(source, options);
142+
const document = parser.parseDocument();
143+
Object.defineProperty(document, 'tokenCount', {
144+
enumerable: false,
145+
value: parser.tokenCount,
146+
});
147+
return document;
148+
},
149+
);
142150
}
143151

144152
/**

0 commit comments

Comments
 (0)