Skip to content

Commit 46de138

Browse files
committed
feat(validation): publish on graphql:validate tracing channel
Wraps the public validate() entry point with maybeTraceSync. The context carries both the schema being validated against and the parsed document, so APM tools can associate the span with a concrete GraphQL operation. validate() is synchronous so traceSync is appropriate; errors thrown by assertValidSchema or the visitor rethrow propagate through the channel's error sub-channel. Also extracts the in-memory TracingChannel fake used by parser-diagnostics into src/__testUtils__/fakeDiagnosticsChannel.ts so subsequent emission sites (validate, execute, subscribe, resolve) can reuse it without duplicating the lifecycle simulation.
1 parent 8fcc36f commit 46de138

File tree

5 files changed

+359
-209
lines changed

5 files changed

+359
-209
lines changed

integrationTests/diagnostics/test.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
import assert from 'node:assert/strict';
66
import dc from 'node:diagnostics_channel';
77

8-
import { enableDiagnosticsChannel, parse } from 'graphql';
8+
import {
9+
buildSchema,
10+
enableDiagnosticsChannel,
11+
parse,
12+
validate,
13+
} from 'graphql';
914

1015
enableDiagnosticsChannel(dc);
1116

@@ -64,6 +69,40 @@ enableDiagnosticsChannel(dc);
6469
}
6570
}
6671

72+
// graphql:validate - synchronous, with schema/document context
73+
{
74+
const schema = buildSchema(`type Query { field: String }`);
75+
const doc = parse('{ field }');
76+
77+
const events = [];
78+
const handler = {
79+
start: (msg) =>
80+
events.push({
81+
kind: 'start',
82+
schema: msg.schema,
83+
document: msg.document,
84+
}),
85+
end: () => events.push({ kind: 'end' }),
86+
error: (msg) => events.push({ kind: 'error', error: msg.error }),
87+
};
88+
89+
const channel = dc.tracingChannel('graphql:validate');
90+
channel.subscribe(handler);
91+
92+
try {
93+
const errors = validate(schema, doc);
94+
assert.deepEqual(errors, []);
95+
assert.deepEqual(
96+
events.map((e) => e.kind),
97+
['start', 'end'],
98+
);
99+
assert.equal(events[0].schema, schema);
100+
assert.equal(events[0].document, doc);
101+
} finally {
102+
channel.unsubscribe(handler);
103+
}
104+
}
105+
67106
// No-op when nothing is subscribed - parse still succeeds.
68107
{
69108
const doc = parse('{ field }');
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import type {
2+
MinimalChannel,
3+
MinimalDiagnosticsChannel,
4+
MinimalTracingChannel,
5+
} from '../diagnostics.js';
6+
7+
export type Listener = (message: unknown) => void;
8+
9+
/**
10+
* In-memory `MinimalChannel` implementation used by the unit tests. Tracks
11+
* subscribers and replays Node's `runStores` semantics by simply invoking
12+
* `fn`.
13+
*/
14+
export class FakeChannel implements MinimalChannel {
15+
listeners: Array<Listener> = [];
16+
17+
get [Symbol.toStringTag]() {
18+
return 'FakeChannel';
19+
}
20+
21+
get hasSubscribers(): boolean {
22+
return this.listeners.length > 0;
23+
}
24+
25+
publish(message: unknown): void {
26+
for (const l of this.listeners) {
27+
l(message);
28+
}
29+
}
30+
31+
runStores<T, ContextType extends object>(
32+
_ctx: ContextType,
33+
fn: (this: ContextType, ...args: Array<unknown>) => T,
34+
thisArg?: unknown,
35+
...args: Array<unknown>
36+
): T {
37+
return fn.apply(thisArg as ContextType, args);
38+
}
39+
40+
subscribe(listener: Listener): void {
41+
this.listeners.push(listener);
42+
}
43+
44+
unsubscribe(listener: Listener): void {
45+
const idx = this.listeners.indexOf(listener);
46+
if (idx >= 0) {
47+
this.listeners.splice(idx, 1);
48+
}
49+
}
50+
}
51+
52+
/**
53+
* Structurally-faithful `MinimalTracingChannel` implementation mirroring
54+
* Node's `TracingChannel.traceSync` / `tracePromise` lifecycle (start,
55+
* runStores, error, asyncStart, asyncEnd, end).
56+
*/
57+
export class FakeTracingChannel implements MinimalTracingChannel {
58+
start: FakeChannel = new FakeChannel();
59+
end: FakeChannel = new FakeChannel();
60+
asyncStart: FakeChannel = new FakeChannel();
61+
asyncEnd: FakeChannel = new FakeChannel();
62+
error: FakeChannel = new FakeChannel();
63+
64+
get [Symbol.toStringTag]() {
65+
return 'FakeTracingChannel';
66+
}
67+
68+
get hasSubscribers(): boolean {
69+
return (
70+
this.start.hasSubscribers ||
71+
this.end.hasSubscribers ||
72+
this.asyncStart.hasSubscribers ||
73+
this.asyncEnd.hasSubscribers ||
74+
this.error.hasSubscribers
75+
);
76+
}
77+
78+
traceSync<T>(
79+
fn: (...args: Array<unknown>) => T,
80+
ctx: object,
81+
thisArg?: unknown,
82+
...args: Array<unknown>
83+
): T {
84+
this.start.publish(ctx);
85+
try {
86+
return this.end.runStores(ctx, fn, thisArg, ...args);
87+
} catch (err) {
88+
(ctx as { error: unknown }).error = err;
89+
this.error.publish(ctx);
90+
throw err;
91+
} finally {
92+
this.end.publish(ctx);
93+
}
94+
}
95+
96+
tracePromise<T>(
97+
fn: (...args: Array<unknown>) => Promise<T>,
98+
ctx: object,
99+
thisArg?: unknown,
100+
...args: Array<unknown>
101+
): Promise<T> {
102+
this.start.publish(ctx);
103+
let promise: Promise<T>;
104+
try {
105+
promise = this.end.runStores(ctx, fn, thisArg, ...args);
106+
} catch (err) {
107+
(ctx as { error: unknown }).error = err;
108+
this.error.publish(ctx);
109+
this.end.publish(ctx);
110+
throw err;
111+
}
112+
this.end.publish(ctx);
113+
return promise.then(
114+
(result) => {
115+
(ctx as { result: unknown }).result = result;
116+
this.asyncStart.publish(ctx);
117+
this.asyncEnd.publish(ctx);
118+
return result;
119+
},
120+
(err: unknown) => {
121+
(ctx as { error: unknown }).error = err;
122+
this.error.publish(ctx);
123+
this.asyncStart.publish(ctx);
124+
this.asyncEnd.publish(ctx);
125+
throw err;
126+
},
127+
);
128+
}
129+
}
130+
131+
export class FakeDc implements MinimalDiagnosticsChannel {
132+
private cache = new Map<string, FakeTracingChannel>();
133+
134+
get [Symbol.toStringTag]() {
135+
return 'FakeDc';
136+
}
137+
138+
tracingChannel(name: string): FakeTracingChannel {
139+
let existing = this.cache.get(name);
140+
if (existing === undefined) {
141+
existing = new FakeTracingChannel();
142+
this.cache.set(name, existing);
143+
}
144+
return existing;
145+
}
146+
}
147+
148+
export interface CollectedEvent {
149+
kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error';
150+
ctx: { [key: string]: unknown };
151+
}
152+
153+
/**
154+
* Attach listeners to every sub-channel on a FakeTracingChannel and return
155+
* the captured event buffer plus an unsubscribe hook.
156+
*/
157+
export function collectEvents(channel: FakeTracingChannel): {
158+
events: Array<CollectedEvent>;
159+
unsubscribe: () => void;
160+
} {
161+
const events: Array<CollectedEvent> = [];
162+
const make =
163+
(kind: CollectedEvent['kind']): Listener =>
164+
(m) =>
165+
events.push({ kind, ctx: m as { [key: string]: unknown } });
166+
const startL = make('start');
167+
const endL = make('end');
168+
const asyncStartL = make('asyncStart');
169+
const asyncEndL = make('asyncEnd');
170+
const errorL = make('error');
171+
channel.start.subscribe(startL);
172+
channel.end.subscribe(endL);
173+
channel.asyncStart.subscribe(asyncStartL);
174+
channel.asyncEnd.subscribe(asyncEndL);
175+
channel.error.subscribe(errorL);
176+
return {
177+
events,
178+
unsubscribe() {
179+
channel.start.unsubscribe(startL);
180+
channel.end.unsubscribe(endL);
181+
channel.asyncStart.unsubscribe(asyncStartL);
182+
channel.asyncEnd.unsubscribe(asyncEndL);
183+
channel.error.unsubscribe(errorL);
184+
},
185+
};
186+
}

0 commit comments

Comments
 (0)