Skip to content

Commit 044e252

Browse files
committed
feat(diagnostics): add enableDiagnosticsChannel and channel registry
Introduces src/diagnostics.ts which exposes a single public function, enableDiagnosticsChannel(dc), that APMs use to register a node:diagnostics_channel-compatible module with graphql-js. Channel names (graphql:parse, graphql:validate, graphql:execute, graphql:resolve, graphql:subscribe) are owned by graphql-js so multiple APM subscribers converge on the same cached TracingChannel instances. Structural MinimalChannel / MinimalTracingChannel / MinimalDiagnosticsChannel types describe the subset of the Node API graphql-js needs; no dependency on @types/node is introduced, and no runtime-specific import is added to the core. Subsequent commits wire emission sites into parse, validate, execute, subscribe, and the resolver path.
1 parent e09f7da commit 044e252

File tree

3 files changed

+249
-0
lines changed

3 files changed

+249
-0
lines changed

src/__tests__/diagnostics-test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { invariant } from '../jsutils/invariant.js';
5+
6+
import type {
7+
MinimalDiagnosticsChannel,
8+
MinimalTracingChannel,
9+
} from '../diagnostics.js';
10+
import { enableDiagnosticsChannel, getChannels } from '../diagnostics.js';
11+
12+
function fakeTracingChannel(name: string): MinimalTracingChannel {
13+
const noop: MinimalTracingChannel['start'] = {
14+
hasSubscribers: false,
15+
publish: () => {
16+
/* noop */
17+
},
18+
runStores: <T>(
19+
_ctx: unknown,
20+
fn: (this: unknown, ...args: Array<unknown>) => T,
21+
): T => fn(),
22+
};
23+
const channel: MinimalTracingChannel & { _name: string } = {
24+
_name: name,
25+
hasSubscribers: false,
26+
start: noop,
27+
end: noop,
28+
asyncStart: noop,
29+
asyncEnd: noop,
30+
error: noop,
31+
traceSync: <T>(fn: (...args: Array<unknown>) => T): T => fn(),
32+
tracePromise: <T>(
33+
fn: (...args: Array<unknown>) => Promise<T>,
34+
): Promise<T> => fn(),
35+
};
36+
return channel;
37+
}
38+
39+
function fakeDc(): MinimalDiagnosticsChannel & {
40+
created: Array<string>;
41+
} {
42+
const created: Array<string> = [];
43+
const cache = new Map<string, MinimalTracingChannel>();
44+
return {
45+
created,
46+
tracingChannel(name: string) {
47+
let existing = cache.get(name);
48+
if (existing === undefined) {
49+
created.push(name);
50+
existing = fakeTracingChannel(name);
51+
cache.set(name, existing);
52+
}
53+
return existing;
54+
},
55+
};
56+
}
57+
58+
describe('diagnostics', () => {
59+
it('registers the five graphql tracing channels', () => {
60+
const dc = fakeDc();
61+
enableDiagnosticsChannel(dc);
62+
63+
expect(dc.created).to.deep.equal([
64+
'graphql:execute',
65+
'graphql:parse',
66+
'graphql:validate',
67+
'graphql:resolve',
68+
'graphql:subscribe',
69+
]);
70+
71+
const channels = getChannels();
72+
invariant(channels !== undefined);
73+
expect(channels.execute).to.not.equal(undefined);
74+
expect(channels.parse).to.not.equal(undefined);
75+
expect(channels.validate).to.not.equal(undefined);
76+
expect(channels.resolve).to.not.equal(undefined);
77+
expect(channels.subscribe).to.not.equal(undefined);
78+
});
79+
80+
it('re-registration with the same module preserves channel identity', () => {
81+
const dc = fakeDc();
82+
enableDiagnosticsChannel(dc);
83+
const first = getChannels();
84+
invariant(first !== undefined);
85+
86+
enableDiagnosticsChannel(dc);
87+
const second = getChannels();
88+
invariant(second !== undefined);
89+
90+
expect(second.execute).to.equal(first.execute);
91+
expect(second.parse).to.equal(first.parse);
92+
expect(second.validate).to.equal(first.validate);
93+
expect(second.resolve).to.equal(first.resolve);
94+
expect(second.subscribe).to.equal(first.subscribe);
95+
});
96+
97+
it('re-registration with a different module replaces stored references', () => {
98+
const dc1 = fakeDc();
99+
const dc2 = fakeDc();
100+
101+
enableDiagnosticsChannel(dc1);
102+
const first = getChannels();
103+
invariant(first !== undefined);
104+
105+
enableDiagnosticsChannel(dc2);
106+
const second = getChannels();
107+
invariant(second !== undefined);
108+
109+
expect(second.execute).to.not.equal(first.execute);
110+
});
111+
});

src/diagnostics.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* TracingChannel integration.
3+
*
4+
* graphql-js exposes a set of named tracing channels that APM tools can
5+
* subscribe to in order to observe parse, validate, execute, subscribe, and
6+
* resolver lifecycle events. To preserve the isomorphic invariant of the
7+
* core (no runtime-specific imports in `src/`), graphql-js does not import
8+
* `node:diagnostics_channel` itself. Instead, APMs (or runtime-specific
9+
* adapters) hand in a module satisfying `MinimalDiagnosticsChannel` via
10+
* `enableDiagnosticsChannel`.
11+
*
12+
* Channel names are owned by graphql-js so multiple APMs converge on the
13+
* same `TracingChannel` instances and all subscribers coexist.
14+
*/
15+
16+
/**
17+
* Structural subset of `DiagnosticsChannel` sufficient for publishing and
18+
* subscriber gating. `node:diagnostics_channel`'s `Channel` satisfies this.
19+
*/
20+
export interface MinimalChannel {
21+
readonly hasSubscribers: boolean;
22+
publish: (message: unknown) => void;
23+
runStores: <T, ContextType>(
24+
context: ContextType,
25+
fn: (this: ContextType, ...args: Array<unknown>) => T,
26+
thisArg?: unknown,
27+
...args: Array<unknown>
28+
) => T;
29+
}
30+
31+
/**
32+
* Structural subset of Node's `TracingChannel`. The `node:diagnostics_channel`
33+
* `TracingChannel` satisfies this by duck typing, so graphql-js does not need
34+
* a dependency on `@types/node` or on the runtime itself.
35+
*/
36+
export interface MinimalTracingChannel {
37+
readonly hasSubscribers: boolean;
38+
readonly start: MinimalChannel;
39+
readonly end: MinimalChannel;
40+
readonly asyncStart: MinimalChannel;
41+
readonly asyncEnd: MinimalChannel;
42+
readonly error: MinimalChannel;
43+
44+
traceSync: <T>(
45+
fn: (...args: Array<unknown>) => T,
46+
ctx: object,
47+
thisArg?: unknown,
48+
...args: Array<unknown>
49+
) => T;
50+
51+
tracePromise: <T>(
52+
fn: (...args: Array<unknown>) => Promise<T>,
53+
ctx: object,
54+
thisArg?: unknown,
55+
...args: Array<unknown>
56+
) => Promise<T>;
57+
}
58+
59+
/**
60+
* Structural subset of `node:diagnostics_channel` covering just what
61+
* graphql-js needs at registration time.
62+
*/
63+
export interface MinimalDiagnosticsChannel {
64+
tracingChannel: (name: string) => MinimalTracingChannel;
65+
}
66+
67+
/**
68+
* The collection of tracing channels graphql-js emits on. APMs subscribe to
69+
* these by name on their own `node:diagnostics_channel` import; both paths
70+
* land on the same channel instance because `tracingChannel(name)` is cached
71+
* by name.
72+
*/
73+
export interface GraphQLChannels {
74+
execute: MinimalTracingChannel;
75+
parse: MinimalTracingChannel;
76+
validate: MinimalTracingChannel;
77+
resolve: MinimalTracingChannel;
78+
subscribe: MinimalTracingChannel;
79+
}
80+
81+
let channels: GraphQLChannels | undefined;
82+
83+
/**
84+
* Internal accessor used at emission sites. Returns `undefined` when no
85+
* `diagnostics_channel` module has been registered, allowing emission sites
86+
* to short-circuit on a single property access.
87+
*
88+
* @internal
89+
*/
90+
export function getChannels(): GraphQLChannels | undefined {
91+
return channels;
92+
}
93+
94+
/**
95+
* Register a `node:diagnostics_channel`-compatible module with graphql-js.
96+
*
97+
* After calling this, graphql-js will publish lifecycle events on the
98+
* following tracing channels whenever subscribers are present:
99+
*
100+
* - `graphql:parse`
101+
* - `graphql:validate`
102+
* - `graphql:execute`
103+
* - `graphql:subscribe`
104+
* - `graphql:resolve`
105+
*
106+
* Calling this repeatedly is safe: subsequent calls replace the stored
107+
* channel references, but since `tracingChannel(name)` is cached by name,
108+
* the channel identities remain stable across registrations from the same
109+
* underlying module.
110+
*
111+
* @example
112+
* ```ts
113+
* import dc from 'node:diagnostics_channel';
114+
* import { enableDiagnosticsChannel } from 'graphql';
115+
*
116+
* enableDiagnosticsChannel(dc);
117+
* ```
118+
*/
119+
export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void {
120+
channels = {
121+
execute: dc.tracingChannel('graphql:execute'),
122+
parse: dc.tracingChannel('graphql:parse'),
123+
validate: dc.tracingChannel('graphql:validate'),
124+
resolve: dc.tracingChannel('graphql:resolve'),
125+
subscribe: dc.tracingChannel('graphql:subscribe'),
126+
};
127+
}

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ export { version, versionInfo } from './version.js';
3232
// Enable development mode for additional checks.
3333
export { enableDevMode, isDevModeEnabled } from './devMode.js';
3434

35+
// Register a `node:diagnostics_channel`-compatible module to enable
36+
// tracing channel emission from parse, validate, execute, subscribe,
37+
// and resolver lifecycles.
38+
export { enableDiagnosticsChannel } from './diagnostics.js';
39+
export type {
40+
MinimalChannel,
41+
MinimalTracingChannel,
42+
MinimalDiagnosticsChannel,
43+
GraphQLChannels,
44+
} from './diagnostics.js';
45+
3546
// The primary entry point into fulfilling a GraphQL request.
3647
export type { GraphQLArgs } from './graphql.js';
3748
export { graphql, graphqlSync } from './graphql.js';

0 commit comments

Comments
 (0)