Skip to content

Commit 3da22d3

Browse files
committed
refactor(graphql): Drop test-only reset and redundant subscriber bookkeeping
Split option-matrix tests into process-isolated files instead of resetting and re-subscribing the process-global channels.
1 parent 72faa39 commit 3da22d3

5 files changed

Lines changed: 321 additions & 258 deletions

File tree

packages/server-utils/src/graphql/graphql-dc-subscriber.ts

Lines changed: 47 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ export interface GraphqlDiagnosticChannelsOptions {
130130
export type GraphqlTracingChannelFactory = <T extends object>(name: string) => TracingChannel<T, T>;
131131

132132
let subscribed = false;
133-
let activeUnbinds: Array<() => void> = [];
134133

135134
/**
136135
* Subscribe Sentry span handlers to graphql's diagnostics-channel events
@@ -159,15 +158,13 @@ export function subscribeGraphqlDiagnosticChannels(
159158
const ignoreTrivialResolveSpans = options.ignoreTrivialResolveSpans !== false;
160159

161160
try {
162-
activeUnbinds.push(
163-
setupParseChannel(tracingChannel),
164-
setupValidateChannel(tracingChannel),
165-
setupOperationChannel(tracingChannel, GRAPHQL_DC_CHANNEL_EXECUTE, SPAN_NAME_EXECUTE),
166-
setupOperationChannel(tracingChannel, GRAPHQL_DC_CHANNEL_SUBSCRIBE, SPAN_NAME_SUBSCRIBE),
167-
);
161+
setupParseChannel(tracingChannel);
162+
setupValidateChannel(tracingChannel);
163+
setupOperationChannel(tracingChannel, GRAPHQL_DC_CHANNEL_EXECUTE, SPAN_NAME_EXECUTE);
164+
setupOperationChannel(tracingChannel, GRAPHQL_DC_CHANNEL_SUBSCRIBE, SPAN_NAME_SUBSCRIBE);
168165

169166
if (!ignoreResolveSpans) {
170-
activeUnbinds.push(setupResolveChannel(tracingChannel, ignoreTrivialResolveSpans));
167+
setupResolveChannel(tracingChannel, ignoreTrivialResolveSpans);
171168
}
172169
} catch {
173170
// The factory relies on `node:diagnostics_channel`, which isn't always
@@ -176,26 +173,24 @@ export function subscribeGraphqlDiagnosticChannels(
176173
}
177174
}
178175

179-
function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): () => void {
180-
return bindTracingChannelToSpan(
181-
tracingChannel<GraphqlParseData>(GRAPHQL_DC_CHANNEL_PARSE),
182-
() =>
183-
startInactiveSpan({
184-
name: SPAN_NAME_PARSE,
185-
attributes: {
186-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
187-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP,
188-
},
189-
}),
190-
{ captureError: false },
191-
).unbind;
176+
function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void {
177+
bindTracingChannelToSpan(tracingChannel<GraphqlParseData>(GRAPHQL_DC_CHANNEL_PARSE), () =>
178+
startInactiveSpan({
179+
name: SPAN_NAME_PARSE,
180+
attributes: {
181+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
182+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP,
183+
},
184+
}),
185+
);
192186
}
193187

194-
function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): () => void {
195-
return bindTracingChannelToSpan(
188+
function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): void {
189+
bindTracingChannelToSpan(
196190
tracingChannel<GraphqlValidateData>(GRAPHQL_DC_CHANNEL_VALIDATE),
197191
data => {
198192
const document = redactGraphqlDocument(data.document);
193+
199194
return startInactiveSpan({
200195
name: SPAN_NAME_VALIDATE,
201196
attributes: {
@@ -212,20 +207,20 @@ function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): ()
212207
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' });
213208
}
214209
},
215-
captureError: false,
216210
},
217-
).unbind;
211+
);
218212
}
219213

220214
function setupOperationChannel(
221215
tracingChannel: GraphqlTracingChannelFactory,
222216
channelName: string,
223217
fallbackName: string,
224-
): () => void {
225-
return bindTracingChannelToSpan(
218+
): void {
219+
bindTracingChannelToSpan(
226220
tracingChannel<GraphqlOperationData>(channelName),
227221
data => {
228222
const document = redactGraphqlDocument(data.document);
223+
229224
return startInactiveSpan({
230225
name: getOperationSpanName(data, fallbackName),
231226
attributes: {
@@ -244,40 +239,30 @@ function setupOperationChannel(
244239
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
245240
}
246241
},
247-
// Execution errors are surfaced to the caller in the result; only annotate the span so we
248-
// don't emit a duplicate error event for every failed operation.
249-
captureError: false,
250242
},
251-
).unbind;
243+
);
252244
}
253245

254-
function setupResolveChannel(
255-
tracingChannel: GraphqlTracingChannelFactory,
256-
ignoreTrivialResolveSpans: boolean,
257-
): () => void {
258-
return bindTracingChannelToSpan(
259-
tracingChannel<GraphqlResolveData>(GRAPHQL_DC_CHANNEL_RESOLVE),
260-
data => {
261-
// Returning `undefined` opts this field out: no span is created and the active context is left
262-
// untouched, so the field still resolves under its parent span.
263-
if (ignoreTrivialResolveSpans && data.isDefaultResolver) {
264-
return undefined;
265-
}
266-
return startInactiveSpan({
267-
name: `${SPAN_NAME_RESOLVE} ${data.fieldPath}`,
268-
attributes: {
269-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
270-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP,
271-
[GRAPHQL_FIELD_NAME]: data.fieldName,
272-
[GRAPHQL_FIELD_PATH]: data.fieldPath,
273-
[GRAPHQL_FIELD_TYPE]: data.fieldType,
274-
[GRAPHQL_PARENT_NAME]: data.parentType,
275-
},
276-
});
277-
},
278-
// Resolver errors also surface in the enclosing execution result; only annotate the span.
279-
{ captureError: false },
280-
).unbind;
246+
function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignoreTrivialResolveSpans: boolean): void {
247+
bindTracingChannelToSpan(tracingChannel<GraphqlResolveData>(GRAPHQL_DC_CHANNEL_RESOLVE), data => {
248+
// Returning `undefined` opts this field out: no span is created and the active context is left
249+
// untouched, so the field still resolves under its parent span.
250+
if (ignoreTrivialResolveSpans && data.isDefaultResolver) {
251+
return undefined;
252+
}
253+
254+
return startInactiveSpan({
255+
name: `${SPAN_NAME_RESOLVE} ${data.fieldPath}`,
256+
attributes: {
257+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
258+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP,
259+
[GRAPHQL_FIELD_NAME]: data.fieldName,
260+
[GRAPHQL_FIELD_PATH]: data.fieldPath,
261+
[GRAPHQL_FIELD_TYPE]: data.fieldType,
262+
[GRAPHQL_PARENT_NAME]: data.parentType,
263+
},
264+
});
265+
});
281266
}
282267

283268
/**
@@ -292,14 +277,17 @@ function getOperationSpanName(data: GraphqlOperationData, fallbackName: string):
292277
if (operationType) {
293278
return operationType;
294279
}
280+
295281
return fallbackName;
296282
}
297283

298284
function hasResultErrors(result: unknown): boolean {
299285
if (result && typeof result === 'object' && 'errors' in result) {
300286
const errors = (result as { errors?: unknown }).errors;
287+
301288
return Array.isArray(errors) && errors.length > 0;
302289
}
290+
303291
return false;
304292
}
305293

@@ -332,15 +320,9 @@ function redactGraphqlDocument(document: GraphqlDocumentNode | undefined): strin
332320
const replacement = kind === 'String' || kind === 'BlockString' ? '"*"' : '*';
333321
out = out.slice(0, start) + replacement + out.slice(end);
334322
}
323+
335324
return out;
336325
} catch {
337326
return undefined;
338327
}
339328
}
340-
341-
/** Test-only: detach all channel bindings and reset module-local subscribe state. */
342-
export function _resetGraphqlDiagnosticChannelsForTesting(): void {
343-
activeUnbinds.forEach(unbind => unbind());
344-
activeUnbinds = [];
345-
subscribed = false;
346-
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getCurrentScope, getGlobalScope, setAsyncContextStrategy, spanToJSON } from '@sentry/core';
2+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import {
4+
GRAPHQL_DC_CHANNEL_RESOLVE,
5+
subscribeGraphqlDiagnosticChannels,
6+
} from '../../src/graphql/graphql-dc-subscriber';
7+
import { factory, initTestClient, installTestAsyncContextStrategy, traceOperation } from './helpers';
8+
9+
const resolveData = {
10+
fieldName: 'name',
11+
parentType: 'User',
12+
fieldType: 'String',
13+
fieldPath: 'user.name',
14+
isDefaultResolver: true,
15+
};
16+
17+
// Own file so it can subscribe with its own options (see sibling resolve test for why). Here: resolve
18+
// spans on AND trivial spans kept, so even graphql's default property resolver gets a span.
19+
describe('subscribeGraphqlDiagnosticChannels (resolve + trivial spans enabled)', () => {
20+
beforeAll(() => {
21+
installTestAsyncContextStrategy();
22+
subscribeGraphqlDiagnosticChannels(factory, { ignoreResolveSpans: false, ignoreTrivialResolveSpans: false });
23+
});
24+
25+
afterAll(() => {
26+
setAsyncContextStrategy(undefined);
27+
});
28+
29+
beforeEach(() => {
30+
initTestClient();
31+
});
32+
33+
afterEach(() => {
34+
getCurrentScope().clear();
35+
getCurrentScope().setClient(undefined);
36+
getGlobalScope().clear();
37+
vi.clearAllMocks();
38+
});
39+
40+
it('emits a span for the default resolver', async () => {
41+
const { span } = await traceOperation(GRAPHQL_DC_CHANNEL_RESOLVE, resolveData, { result: 'a' });
42+
expect(spanToJSON(span!).description).toBe('graphql.resolve user.name');
43+
});
44+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { getCurrentScope, getGlobalScope, setAsyncContextStrategy, spanToJSON } from '@sentry/core';
2+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import {
4+
GRAPHQL_DC_CHANNEL_RESOLVE,
5+
subscribeGraphqlDiagnosticChannels,
6+
} from '../../src/graphql/graphql-dc-subscriber';
7+
import { factory, initTestClient, installTestAsyncContextStrategy, traceOperation } from './helpers';
8+
9+
const resolveData = {
10+
fieldName: 'name',
11+
parentType: 'User',
12+
fieldType: 'String',
13+
fieldPath: 'user.name',
14+
isDefaultResolver: false,
15+
};
16+
17+
// `subscribeGraphqlDiagnosticChannels` is process-global and idempotent, so each option configuration
18+
// is exercised in its own file — Vitest isolates files in separate processes. Here: resolve spans on,
19+
// trivial (default-resolver) spans still ignored (the `ignoreTrivialResolveSpans` default).
20+
describe('subscribeGraphqlDiagnosticChannels (resolve spans enabled)', () => {
21+
beforeAll(() => {
22+
installTestAsyncContextStrategy();
23+
subscribeGraphqlDiagnosticChannels(factory, { ignoreResolveSpans: false });
24+
});
25+
26+
afterAll(() => {
27+
setAsyncContextStrategy(undefined);
28+
});
29+
30+
beforeEach(() => {
31+
initTestClient();
32+
});
33+
34+
afterEach(() => {
35+
getCurrentScope().clear();
36+
getCurrentScope().setClient(undefined);
37+
getGlobalScope().clear();
38+
vi.clearAllMocks();
39+
});
40+
41+
it('creates a graphql.resolve span with field attributes', async () => {
42+
const { span } = await traceOperation(GRAPHQL_DC_CHANNEL_RESOLVE, resolveData, { result: 'a' });
43+
44+
const json = spanToJSON(span!);
45+
expect(json.description).toBe('graphql.resolve user.name');
46+
expect(json.op).toBe('graphql');
47+
expect(json.origin).toBe('auto.graphql.diagnostic_channel');
48+
expect(json.data['graphql.field.name']).toBe('name');
49+
expect(json.data['graphql.field.path']).toBe('user.name');
50+
expect(json.data['graphql.field.type']).toBe('String');
51+
expect(json.data['graphql.parent.name']).toBe('User');
52+
});
53+
54+
it('skips the default property resolver while ignoreTrivialResolveSpans is true (default)', async () => {
55+
const { span } = await traceOperation(
56+
GRAPHQL_DC_CHANNEL_RESOLVE,
57+
{ ...resolveData, isDefaultResolver: true },
58+
{ result: 'a' },
59+
);
60+
expect(span).toBeUndefined();
61+
});
62+
});

0 commit comments

Comments
 (0)