Skip to content

Commit 24b945d

Browse files
nicohrubeccodex
andauthored
test(node): Port GraphQL tracing-channel tests to span streaming (#25055)
Port the GraphQL 17 tracing-channel integration suites to span streaming. Part of #24139 --------- Co-authored-by: GPT-6 <codex@openai.com>
1 parent cfa5c75 commit 24b945d

8 files changed

Lines changed: 218 additions & 110 deletions

File tree

‎dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/instrument.mjs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
22
import { loggingTransport } from '@sentry-internal/node-integration-tests';
33

44
Sentry.init({
5-
traceLifecycle: 'static',
65
dsn: 'https://public@dsn.ingest.sentry.io/1337',
76
release: '1.0',
87
tracesSampleRate: 1.0,

‎dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/instrument-trivial.mjs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
22
import { loggingTransport } from '@sentry-internal/node-integration-tests';
33

44
Sentry.init({
5-
traceLifecycle: 'static',
65
dsn: 'https://public@dsn.ingest.sentry.io/1337',
76
release: '1.0',
87
tracesSampleRate: 1.0,

‎dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/instrument.mjs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node';
22
import { loggingTransport } from '@sentry-internal/node-integration-tests';
33

44
Sentry.init({
5-
traceLifecycle: 'static',
65
dsn: 'https://public@dsn.ingest.sentry.io/1337',
76
release: '1.0',
87
tracesSampleRate: 1.0,

‎dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/test.ts‎

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,47 +10,85 @@ conditionalTest({ min: 22 })('GraphQL tracing channel Test > resolve spans', ()
1010
cleanupChildProcesses();
1111
});
1212

13-
const expectedResolveSpan = (path: string, fieldName: string, parentName: string) =>
14-
expect.objectContaining({
15-
description: `graphql.resolve ${path}`,
16-
op: 'graphql',
17-
origin: 'auto.graphql.diagnostic_channel',
18-
data: expect.objectContaining({
19-
'graphql.field.name': fieldName,
20-
'graphql.field.path': path,
21-
'graphql.parent.name': parentName,
22-
}),
23-
});
24-
25-
const EXPECTED_TRANSACTION = {
26-
// Root span renamed with the (sorted) operation names. useOperationNameForRootSpan defaults to true.
27-
transaction: 'Test Transaction (query, query GetUser)',
28-
spans: expect.arrayContaining([
29-
expect.objectContaining({ description: 'query', op: 'graphql' }),
30-
expect.objectContaining({ description: 'query GetUser', op: 'graphql' }),
31-
expectedResolveSpan('hello', 'hello', 'Query'),
32-
expectedResolveSpan('user', 'user', 'Query'),
33-
]),
34-
};
35-
3613
createEsmAndCjsTests(
3714
__dirname,
3815
'scenario.mjs',
3916
'instrument.mjs',
4017
(createTestRunner, test) => {
4118
test('emits resolver spans when ignoreResolveSpans is false', async () => {
42-
await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
19+
await createTestRunner()
20+
.expect({
21+
span: container => {
22+
const segment = container.items.find(span => span.is_segment);
23+
const children = container.items.filter(span => !span.is_segment);
24+
25+
expect(segment?.name).toBe('Test Transaction');
26+
expect(segment?.attributes['sentry.graphql.operation']).toEqual({
27+
value: ['query', 'query GetUser'],
28+
type: 'array',
29+
});
30+
const executeSpan = children.find(
31+
span =>
32+
span.attributes['graphql.processing.type']?.value === 'execute' &&
33+
span.attributes['graphql.operation.name'] === undefined,
34+
);
35+
expect(executeSpan?.name).toBe('GraphQL query');
36+
expect(executeSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
37+
expect(executeSpan?.attributes['graphql.document']).toEqual({ value: '{ hello }', type: 'string' });
38+
const getUserSpan = children.find(
39+
span =>
40+
span.attributes['graphql.processing.type']?.value === 'execute' &&
41+
span.attributes['graphql.operation.name']?.value === 'GetUser',
42+
);
43+
expect(getUserSpan?.name).toBe('GraphQL query');
44+
expect(getUserSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
45+
expect(getUserSpan?.attributes['graphql.operation.name']).toEqual({ value: 'GetUser', type: 'string' });
46+
const helloResolverSpan = children.find(
47+
span =>
48+
span.attributes['graphql.processing.type']?.value === 'resolve' &&
49+
span.attributes['graphql.field.path']?.value === 'hello',
50+
);
51+
expect(helloResolverSpan?.name).toBe('GraphQL resolve');
52+
expect(helloResolverSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
53+
expect(helloResolverSpan?.attributes['sentry.origin']).toEqual({
54+
value: 'auto.graphql.diagnostic_channel',
55+
type: 'string',
56+
});
57+
expect(helloResolverSpan?.attributes['graphql.field.name']).toEqual({ value: 'hello', type: 'string' });
58+
expect(helloResolverSpan?.attributes['graphql.field.path']).toEqual({ value: 'hello', type: 'string' });
59+
expect(helloResolverSpan?.attributes['graphql.parent.name']).toEqual({ value: 'Query', type: 'string' });
60+
const userResolverSpan = children.find(
61+
span =>
62+
span.attributes['graphql.processing.type']?.value === 'resolve' &&
63+
span.attributes['graphql.field.path']?.value === 'user',
64+
);
65+
expect(userResolverSpan?.name).toBe('GraphQL resolve');
66+
expect(userResolverSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
67+
expect(userResolverSpan?.attributes['sentry.origin']).toEqual({
68+
value: 'auto.graphql.diagnostic_channel',
69+
type: 'string',
70+
});
71+
expect(userResolverSpan?.attributes['graphql.field.name']).toEqual({ value: 'user', type: 'string' });
72+
expect(userResolverSpan?.attributes['graphql.field.path']).toEqual({ value: 'user', type: 'string' });
73+
expect(userResolverSpan?.attributes['graphql.parent.name']).toEqual({ value: 'Query', type: 'string' });
74+
},
75+
})
76+
.start()
77+
.completed();
4378
});
4479

4580
test('skips the default property resolver (trivial resolve) by default', async () => {
4681
await createTestRunner()
4782
.expect({
48-
transaction: event => {
49-
const spans = event.spans || [];
83+
span: container => {
84+
const segment = container.items.find(span => span.is_segment);
85+
expect(segment?.name).toBe('Test Transaction');
86+
const spans = container.items.filter(span => !span.is_segment);
87+
5088
// `user.name` uses graphql's default property resolver, so no span is emitted for it.
51-
expect(spans.find(span => span.description === 'graphql.resolve user.name')).toBeUndefined();
89+
expect(spans.find(span => span.attributes['graphql.field.path']?.value === 'user.name')).toBeUndefined();
5290
// ...but the user-defined resolvers do produce spans.
53-
expect(spans.find(span => span.description === 'graphql.resolve user')).toBeDefined();
91+
expect(spans.find(span => span.attributes['graphql.field.path']?.value === 'user')).toBeDefined();
5492
},
5593
})
5694
.start()
@@ -70,9 +108,12 @@ conditionalTest({ min: 22 })('GraphQL tracing channel Test > resolve spans', ()
70108
test('emits a span for the trivial default resolver when ignoreTrivialResolveSpans is false', async () => {
71109
await createTestRunner()
72110
.expect({
73-
transaction: event => {
74-
const spans = event.spans || [];
75-
expect(spans.find(span => span.description === 'graphql.resolve user.name')).toBeDefined();
111+
span: container => {
112+
const segment = container.items.find(span => span.is_segment);
113+
expect(segment?.name).toBe('Test Transaction');
114+
const spans = container.items.filter(span => !span.is_segment);
115+
116+
expect(spans.find(span => span.attributes['graphql.field.path']?.value === 'user.name')).toBeDefined();
76117
},
77118
})
78119
.start()

‎dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/test.ts‎

Lines changed: 113 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,64 +2,84 @@ import { afterAll, expect } from 'vitest';
22
import { conditionalTest } from '../../../utils';
33
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
44

5-
// graphql >= 17 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes to
6-
// those channels (`subscribeGraphqlDiagnosticChannels`) instead of the vendored OTel patcher. This
7-
// suite pins `^17` and asserts the diagnostics-channel path: graphql semconv attributes, redacted
8-
// document text, span relationships, and that the legacy OTel path does NOT also fire (no double
9-
// instrumentation). graphql 17 requires Node >= 22, so this suite is skipped on older Node.
5+
// GraphQL 17 requires Node >= 22, so this suite is skipped on older Node.
106
conditionalTest({ min: 22 })('GraphQL tracing channel Test', () => {
117
afterAll(() => {
128
cleanupChildProcesses();
139
});
1410

15-
const expectedExecuteSpan = (description: string, extraData: Record<string, unknown> = {}) =>
16-
expect.objectContaining({
17-
description,
18-
op: 'graphql',
19-
origin: 'auto.graphql.diagnostic_channel',
20-
data: expect.objectContaining(extraData),
21-
});
22-
23-
const EXPECTED_TRANSACTION = {
24-
// Root span renamed with the (sorted) operation names. useOperationNameForRootSpan defaults to true.
25-
transaction: 'Test Transaction (mutation Login, query, query Boom, query GetUser)',
26-
spans: expect.arrayContaining([
27-
expect.objectContaining({ description: 'graphql.parse', op: 'graphql' }),
28-
expect.objectContaining({ description: 'graphql.validate', op: 'graphql' }),
29-
// anonymous query -> span named after the operation type only
30-
expectedExecuteSpan('query', { 'graphql.operation.type': 'query' }),
31-
expectedExecuteSpan('query GetUser', {
32-
'graphql.operation.type': 'query',
33-
'graphql.operation.name': 'GetUser',
34-
// the inline `42` literal is redacted out of the document
35-
'graphql.document': 'query GetUser { user(id: *) { name } }',
36-
}),
37-
expectedExecuteSpan('mutation Login', {
38-
'graphql.operation.type': 'mutation',
39-
// the inline email literal must be redacted to `"*"`, so the raw value can never leak
40-
'graphql.document': 'mutation Login { login(email: "*") }',
41-
}),
42-
]),
43-
};
44-
4511
createEsmAndCjsTests(
4612
__dirname,
4713
'scenario.mjs',
4814
'instrument.mjs',
4915
(createTestRunner, test) => {
5016
test('subscribes to graphql >= 17 diagnostics channels with graphql semconv attributes', async () => {
51-
await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed();
52-
});
53-
54-
test('does not double-instrument: the vendored OTel graphql patcher does not fire on 17', async () => {
5517
await createTestRunner()
5618
.expect({
57-
transaction: event => {
58-
const spans = event.spans || [];
59-
// The vendored OTel path (origin `auto.graphql.graphql`) must be inactive on 17+.
60-
expect(spans.find(span => span.origin === 'auto.graphql.graphql')).toBeUndefined();
61-
// ...while the diagnostics-channel path is active.
62-
expect(spans.find(span => span.origin === 'auto.graphql.diagnostic_channel')).toBeDefined();
19+
span: container => {
20+
const segment = container.items.find(span => span.is_segment);
21+
const children = container.items.filter(span => !span.is_segment);
22+
23+
expect(segment?.name).toBe('Test Transaction');
24+
expect(segment?.attributes['sentry.graphql.operation']).toEqual({
25+
value: ['query', 'query GetUser', 'mutation Login', 'query Boom'],
26+
type: 'array',
27+
});
28+
const parseSpan = children.find(span => span.attributes['graphql.processing.type']?.value === 'parse');
29+
expect(parseSpan?.name).toBe('GraphQL parse');
30+
expect(parseSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
31+
const validateSpan = children.find(
32+
span => span.attributes['graphql.processing.type']?.value === 'validate',
33+
);
34+
expect(validateSpan?.name).toBe('GraphQL validate');
35+
expect(validateSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
36+
const executeSpan = children.find(
37+
span =>
38+
span.attributes['graphql.processing.type']?.value === 'execute' &&
39+
span.attributes['graphql.operation.name'] === undefined,
40+
);
41+
expect(executeSpan?.name).toBe('GraphQL query');
42+
expect(executeSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
43+
expect(executeSpan?.attributes['sentry.origin']).toEqual({
44+
value: 'auto.graphql.diagnostic_channel',
45+
type: 'string',
46+
});
47+
expect(executeSpan?.attributes['graphql.operation.type']).toEqual({ value: 'query', type: 'string' });
48+
expect(executeSpan?.attributes['graphql.document']).toEqual({ value: '{ hello }', type: 'string' });
49+
const getUserSpan = children.find(
50+
span =>
51+
span.attributes['graphql.processing.type']?.value === 'execute' &&
52+
span.attributes['graphql.operation.name']?.value === 'GetUser',
53+
);
54+
expect(getUserSpan?.name).toBe('GraphQL query');
55+
expect(getUserSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
56+
expect(getUserSpan?.attributes['sentry.origin']).toEqual({
57+
value: 'auto.graphql.diagnostic_channel',
58+
type: 'string',
59+
});
60+
expect(getUserSpan?.attributes['graphql.operation.type']).toEqual({ value: 'query', type: 'string' });
61+
expect(getUserSpan?.attributes['graphql.operation.name']).toEqual({ value: 'GetUser', type: 'string' });
62+
expect(getUserSpan?.attributes['graphql.document']).toEqual({
63+
value: 'query GetUser { user(id: *) { name } }',
64+
type: 'string',
65+
});
66+
const loginSpan = children.find(
67+
span =>
68+
span.attributes['graphql.processing.type']?.value === 'execute' &&
69+
span.attributes['graphql.operation.name']?.value === 'Login',
70+
);
71+
expect(loginSpan?.name).toBe('GraphQL mutation');
72+
expect(loginSpan?.attributes['sentry.op']).toEqual({ value: 'graphql', type: 'string' });
73+
expect(loginSpan?.attributes['sentry.origin']).toEqual({
74+
value: 'auto.graphql.diagnostic_channel',
75+
type: 'string',
76+
});
77+
expect(loginSpan?.attributes['graphql.operation.name']).toEqual({ value: 'Login', type: 'string' });
78+
expect(loginSpan?.attributes['graphql.operation.type']).toEqual({ value: 'mutation', type: 'string' });
79+
expect(loginSpan?.attributes['graphql.document']).toEqual({
80+
value: 'mutation Login { login(email: "*") }',
81+
type: 'string',
82+
});
6383
},
6484
})
6585
.start()
@@ -69,14 +89,22 @@ conditionalTest({ min: 22 })('GraphQL tracing channel Test', () => {
6989
test('never leaks raw inline literal values into graphql.document', async () => {
7090
await createTestRunner()
7191
.expect({
72-
transaction: event => {
73-
const spans = event.spans || [];
74-
for (const span of spans) {
75-
const document = span.data?.['graphql.document'];
76-
if (typeof document === 'string') {
77-
expect(document).not.toContain('secret@example.com');
78-
}
79-
}
92+
span: container => {
93+
const segment = container.items.find(span => span.is_segment);
94+
expect(segment?.name).toBe('Test Transaction');
95+
const spans = container.items.filter(span => !span.is_segment);
96+
97+
const loginSpan = spans.find(
98+
span =>
99+
span.attributes['graphql.processing.type']?.value === 'execute' &&
100+
span.attributes['graphql.operation.name']?.value === 'Login',
101+
);
102+
expect(loginSpan?.attributes['graphql.document']).toEqual({
103+
value: 'mutation Login { login(email: "*") }',
104+
type: 'string',
105+
});
106+
const documents = spans.map(span => span.attributes['graphql.document']?.value);
107+
expect(documents.join('\n')).not.toContain('secret@example.com');
80108
},
81109
})
82110
.start()
@@ -86,25 +114,39 @@ conditionalTest({ min: 22 })('GraphQL tracing channel Test', () => {
86114
test('flags the execute span as errored when a resolver throws', async () => {
87115
await createTestRunner()
88116
.expect({
89-
transaction: event => {
90-
const spans = event.spans || [];
91-
const boomSpan = spans.find(span => span.description === 'query Boom');
92-
expect(boomSpan).toBeDefined();
93-
expect(boomSpan?.status).toBe('internal_error');
117+
span: container => {
118+
const segment = container.items.find(span => span.is_segment);
119+
expect(segment?.name).toBe('Test Transaction');
120+
const spans = container.items.filter(span => !span.is_segment);
121+
122+
const boomSpan = spans.find(
123+
span =>
124+
span.attributes['graphql.processing.type']?.value === 'execute' &&
125+
span.attributes['graphql.operation.name']?.value === 'Boom',
126+
);
127+
expect(boomSpan?.status).toBe('error');
128+
expect(boomSpan?.attributes['sentry.status.message']?.value).toBe('internal_error');
94129
},
95130
})
96131
.start()
97132
.completed();
98133
});
99134

100-
test('parents the execute span to the surrounding transaction', async () => {
135+
test('parents the execute span to the surrounding segment', async () => {
101136
await createTestRunner()
102137
.expect({
103-
transaction: event => {
104-
const spans = event.spans || [];
105-
const executeSpan = spans.find(span => span.description === 'query GetUser');
138+
span: container => {
139+
const segment = container.items.find(span => span.is_segment);
140+
expect(segment?.name).toBe('Test Transaction');
141+
const spans = container.items.filter(span => !span.is_segment);
142+
143+
const executeSpan = spans.find(
144+
span =>
145+
span.attributes['graphql.processing.type']?.value === 'execute' &&
146+
span.attributes['graphql.operation.name']?.value === 'GetUser',
147+
);
106148
expect(executeSpan).toBeDefined();
107-
expect(executeSpan?.parent_span_id).toBe(event.contexts?.trace?.span_id);
149+
expect(executeSpan?.parent_span_id).toBe(segment?.span_id);
108150
},
109151
})
110152
.start()
@@ -124,11 +166,14 @@ conditionalTest({ min: 22 })('GraphQL tracing channel Test', () => {
124166
test('flags the validate span as errored for an invalid document', async () => {
125167
await createTestRunner()
126168
.expect({
127-
transaction: event => {
128-
const spans = event.spans || [];
129-
const validateSpan = spans.find(span => span.description === 'graphql.validate');
130-
expect(validateSpan).toBeDefined();
131-
expect(validateSpan?.status).toBe('invalid_argument');
169+
span: container => {
170+
const segment = container.items.find(span => span.is_segment);
171+
expect(segment?.name).toBe('Test Transaction');
172+
const spans = container.items.filter(span => !span.is_segment);
173+
174+
const validateSpan = spans.find(span => span.attributes['graphql.processing.type']?.value === 'validate');
175+
expect(validateSpan?.status).toBe('error');
176+
expect(validateSpan?.attributes['sentry.status.message']?.value).toBe('invalid_argument');
132177
},
133178
})
134179
.start()

0 commit comments

Comments
 (0)