From 5dcec12f664ad44cdef0458f48910f18fcc6ca2e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 24 Aug 2026 18:08:33 +0200 Subject: [PATCH 1/9] feat(server-utils): Emit low cardinality graphql span names With span streaming enabled, name graphql spans after the operation type or the graphql phase, and stop renaming the enclosing root span with the operation. Names are unchanged in static mode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- MIGRATION.md | 15 +- .../span-streaming/instrument.mjs | 11 + .../span-streaming/scenario.mjs | 22 ++ .../apollo-graphql/span-streaming/test.ts | 61 +++++ .../src/integrations/graphql/constants.ts | 8 + .../graphql/graphql-dc-subscriber.ts | 48 +++- .../src/integrations/graphql/resolvers.ts | 13 +- .../src/integrations/graphql/spans.ts | 34 ++- .../src/integrations/graphql/utils.ts | 19 +- .../graphql/graphql-dc-subscriber.test.ts | 224 ++++++++++++++++++ 10 files changed, 435 insertions(+), 20 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts create mode 100644 packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index 6edbb8ad61b1..f6a0f5f1f01b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -613,21 +613,26 @@ These changes are not caught by TypeScript. If you filter, group, or alert on sp ### Span name changes -Affected SDKs: All SDKs running in the browser. +Affected SDKs: All SDKs. With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/). -In v11, this only affects `pageload` spans. Further ops will follow in future releases. +In v11, this affects `pageload` and `graphql` spans. Further ops will follow in future releases. If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged. The following span names were adjusted: -| Span op | Before | After | -| ---------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | +| Span op | Before | After | +| ---------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | +| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type or the phase (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`), or `GraphQL Operation` where the SDK has neither | Some consequences to be aware of: +The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes. + +For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute. + Child spans of a pageload span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references. `ignoreSpans` is evaluated when a span **starts**, at which point a pageload span without a resolved route is already named `'Pageload'`, so filters matching a URL path no longer apply to it. Match on attributes instead: diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs new file mode 100644 index 000000000000..9346d06c93e9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'stream', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ ignoreResolveSpans: false })], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/scenario.mjs new file mode 100644 index 000000000000..4787383ac8b4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/scenario.mjs @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node'; + +async function run() { + const { createApolloServer } = await import('../../apollo-server.mjs'); + const server = createApolloServer(); + + await Sentry.startSpan({ name: 'Test Transaction', op: 'transaction' }, async span => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ query: 'query GetHello {hello}' }); + await server.executeOperation({ + query: 'mutation TestMutation($email: String) { login(email: $email) }', + variables: { email: 'test@email.com' }, + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts new file mode 100644 index 000000000000..25df63a3be14 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts @@ -0,0 +1,61 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +type StreamedSpan = SerializedStreamedSpanContainer['items'][number]; + +function graphqlSpans(container: SerializedStreamedSpanContainer): StreamedSpan[] { + return container.items.filter(item => item.attributes['sentry.op']?.value === 'graphql'); +} + +describe('GraphQL/Apollo Tests > span streaming', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('names graphql spans after the operation type, never the operation name or field path', async () => { + await createTestRunner() + .expect({ + span: container => { + const spans = graphqlSpans(container); + + const executeSpans = spans.filter(span => span.attributes['graphql.operation.type']); + expect(executeSpans.map(span => span.name)).toEqual(['GraphQL query', 'GraphQL mutation']); + + // Resolver spans keep the field path as an attribute, but it is unbounded, so it must not + // reach the span name. + const resolveSpans = spans.filter(span => span.attributes['graphql.field.path']); + expect(resolveSpans.map(span => span.attributes['graphql.field.path']?.value)).toEqual(['hello', 'login']); + expect(resolveSpans.map(span => span.name)).toEqual(['GraphQL resolve', 'GraphQL resolve']); + + // Parse and validate spans have no operation type, so they are named after the phase. + const otherSpans = spans.filter(span => !executeSpans.includes(span) && !resolveSpans.includes(span)); + expect(otherSpans.length).toBeGreaterThan(0); + expect(otherSpans.every(span => ['GraphQL parse', 'GraphQL validate'].includes(span.name))).toBe(true); + + expect(spans.some(span => span.name.includes('GetHello') || span.name.includes('TestMutation'))).toBe( + false, + ); + }, + }) + .start() + .completed(); + }); + + test('records the operations on the segment span without renaming it', async () => { + await createTestRunner() + .expect({ + span: container => { + // `Test Server Start` is a segment too, so pick the one the operations ran under. + const segmentSpan = container.items.find(item => item.is_segment && item.name === 'Test Transaction'); + + expect(segmentSpan).toBeDefined(); + expect(segmentSpan?.attributes['sentry.graphql.operation']).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/server-utils/src/integrations/graphql/constants.ts b/packages/server-utils/src/integrations/graphql/constants.ts index a990c825094a..6db5c7655a0e 100644 --- a/packages/server-utils/src/integrations/graphql/constants.ts +++ b/packages/server-utils/src/integrations/graphql/constants.ts @@ -13,6 +13,14 @@ export const SPAN_NAME_VALIDATE = 'graphql.validate'; export const SPAN_NAME_EXECUTE = 'graphql.execute'; export const SPAN_NAME_RESOLVE = 'graphql.resolve'; +// Span names used when span streaming is enabled, mirroring the same block in the native subscriber. +// The conventions name graphql spans `GraphQL {graphql.operation.type}`, and these phases are being +// added to that attribute's values, so a parse, validate or resolve span keeps a name of its own +// rather than taking the generic `GRAPHQL_SPAN_NAME_FALLBACK`. +export const STREAMED_SPAN_NAME_PARSE = 'GraphQL parse'; +export const STREAMED_SPAN_NAME_VALIDATE = 'GraphQL validate'; +export const STREAMED_SPAN_NAME_RESOLVE = 'GraphQL resolve'; + // Field-level resolver-span attributes; not in `@sentry/conventions`. export const GRAPHQL_FIELD_NAME = 'graphql.field.name'; export const GRAPHQL_FIELD_PATH = 'graphql.field.path'; diff --git a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts index 4b90246a762e..4c04dd511394 100644 --- a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts @@ -2,6 +2,9 @@ import type { TracingChannel } from 'node:diagnostics_channel'; import { GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE } from '@sentry/conventions/attributes'; import { GRAPHQL } from '@sentry/conventions/op'; import { + getClient, + GRAPHQL_SPAN_NAME_FALLBACK, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -28,6 +31,14 @@ const SPAN_NAME_EXECUTE = 'graphql.execute'; const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe'; const SPAN_NAME_RESOLVE = 'graphql.resolve'; +// Span names used when span streaming is enabled. The conventions name graphql spans +// `GraphQL {graphql.operation.type}`, and these phases are being added to that attribute's values, so +// a parse, validate or resolve span keeps a name of its own rather than taking the generic +// `GRAPHQL_SPAN_NAME_FALLBACK`. +const STREAMED_SPAN_NAME_PARSE = 'GraphQL parse'; +const STREAMED_SPAN_NAME_VALIDATE = 'GraphQL validate'; +const STREAMED_SPAN_NAME_RESOLVE = 'GraphQL resolve'; + // Field-level attributes for resolver spans. Not in `@sentry/conventions`; these match the keys the // vendored OTel instrumentation emits so there is no drift between the two paths. const GRAPHQL_FIELD_NAME = 'graphql.field.name'; @@ -101,6 +112,9 @@ export interface GraphQLOptions { /** * Rename the enclosing root span to include the operation name(s), e.g. * `GET /graphql` -> `GET /graphql (query GetUser)`. Defaults to `true`. + * + * With span streaming the root span is not renamed, because the operation name is supplied by the + * client. The operations are recorded on its `sentry.graphql.operation` attribute either way. */ useOperationNameForRootSpan?: boolean; } @@ -145,23 +159,27 @@ export function subscribeGraphqlDiagnosticChannels( } function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void { - bindTracingChannelToSpan(tracingChannel(GRAPHQL_DC_CHANNEL_PARSE), () => - startInactiveSpan({ - name: SPAN_NAME_PARSE, + bindTracingChannelToSpan(tracingChannel(GRAPHQL_DC_CHANNEL_PARSE), () => { + const client = getClient(); + + return startInactiveSpan({ + name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_PARSE : SPAN_NAME_PARSE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, }, - }), - ); + }); + }); } function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): void { bindTracingChannelToSpan( tracingChannel(GRAPHQL_DC_CHANNEL_VALIDATE), data => { + const client = getClient(); + return startInactiveSpan({ - name: SPAN_NAME_VALIDATE, + name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_VALIDATE : SPAN_NAME_VALIDATE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, @@ -189,8 +207,16 @@ function setupOperationChannel( bindTracingChannelToSpan( tracingChannel(channelName), data => { + const client = getClient(); + // The operation name is supplied by the client, so with span streaming only the operation type + // may reach the span name. + const streamedName = data.operationType ? `GraphQL ${data.operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; + const span = startInactiveSpan({ - name: getOperationSpanName(data.operationType, data.operationName, fallbackName), + name: + client && hasSpanStreamingEnabled(client) + ? streamedName + : getOperationSpanName(data.operationType, data.operationName, fallbackName), attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, @@ -225,8 +251,14 @@ function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignor return undefined; } + const client = getClient(); + return startInactiveSpan({ - name: `${SPAN_NAME_RESOLVE} ${data.fieldPath}`, + // The field path is unbounded, so with span streaming it stays on `graphql.field.path` only. + name: + client && hasSpanStreamingEnabled(client) + ? STREAMED_SPAN_NAME_RESOLVE + : `${SPAN_NAME_RESOLVE} ${data.fieldPath}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, diff --git a/packages/server-utils/src/integrations/graphql/resolvers.ts b/packages/server-utils/src/integrations/graphql/resolvers.ts index feac1cd136e8..89ac96893993 100644 --- a/packages/server-utils/src/integrations/graphql/resolvers.ts +++ b/packages/server-utils/src/integrations/graphql/resolvers.ts @@ -9,6 +9,8 @@ import { GRAPHQL } from '@sentry/conventions/op'; import type { Span, SpanAttributes } from '@sentry/core'; import { + getClient, + hasSpanStreamingEnabled, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -25,6 +27,7 @@ import { GRAPHQL_PATCHED_SYMBOL, ORIGIN, SPAN_NAME_RESOLVE, + STREAMED_SPAN_NAME_RESOLVE, } from './constants'; import type { DefinitionNode, @@ -186,7 +189,15 @@ function createResolverSpan(info: GraphQLResolveInfo, path: string[], parentSpan [GRAPHQL_PARENT_NAME]: info.parentType.name, }; - return startInactiveSpan({ name: `${SPAN_NAME_RESOLVE} ${path.join('.')}`, attributes, parentSpan }); + const client = getClient(); + + return startInactiveSpan({ + // The field path is unbounded, so with span streaming it stays on `graphql.field.path` only. + name: + client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_RESOLVE : `${SPAN_NAME_RESOLVE} ${path.join('.')}`, + attributes, + parentSpan, + }); } function addField(contextValue: ObjectWithGraphQLData, path: string[], field: { span: Span }): void { diff --git a/packages/server-utils/src/integrations/graphql/spans.ts b/packages/server-utils/src/integrations/graphql/spans.ts index bed749a7944b..c04131cbfd09 100644 --- a/packages/server-utils/src/integrations/graphql/spans.ts +++ b/packages/server-utils/src/integrations/graphql/spans.ts @@ -9,6 +9,9 @@ import { GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE } from import { GRAPHQL } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; import { + getClient, + GRAPHQL_SPAN_NAME_FALLBACK, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -16,7 +19,15 @@ import { } from '@sentry/core'; import type { GraphqlDocumentNode } from './types'; import { collectGraphqlDocument, getOperationSpanName, hasResultErrors, renameRootSpanWithOperation } from './utils'; -import { GRAPHQL_DATA_SYMBOL, ORIGIN, SPAN_NAME_EXECUTE, SPAN_NAME_PARSE, SPAN_NAME_VALIDATE } from './constants'; +import { + GRAPHQL_DATA_SYMBOL, + ORIGIN, + SPAN_NAME_EXECUTE, + SPAN_NAME_PARSE, + SPAN_NAME_VALIDATE, + STREAMED_SPAN_NAME_PARSE, + STREAMED_SPAN_NAME_VALIDATE, +} from './constants'; import { getOperation, wrapFields, wrapFieldResolver } from './resolvers'; import type { DocumentNode, @@ -33,13 +44,20 @@ const BASE_ATTRIBUTES = { } as const; export function startParseSpan(): Span { - return startInactiveSpan({ name: SPAN_NAME_PARSE, attributes: { ...BASE_ATTRIBUTES } }); + const client = getClient(); + + return startInactiveSpan({ + name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_PARSE : SPAN_NAME_PARSE, + attributes: { ...BASE_ATTRIBUTES }, + }); } /** `documentAST` is the 2nd argument to `validate(schema, documentAST, …)`. */ export function startValidateSpan(documentAST: unknown): Span { + const client = getClient(); + return startInactiveSpan({ - name: SPAN_NAME_VALIDATE, + name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_VALIDATE : SPAN_NAME_VALIDATE, attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(documentAST as GraphqlDocumentNode) }, }); } @@ -150,8 +168,16 @@ export function startExecuteSpan( const operationType = operation?.operation; const operationName = operation?.name?.value ?? args.operationName ?? undefined; + const client = getClient(); + // The operation name is supplied by the client, so with span streaming only the operation type may + // reach the span name. + const streamedName = operationType ? `GraphQL ${operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; + const span = startInactiveSpan({ - name: getOperationSpanName(operationType, operationName || undefined, SPAN_NAME_EXECUTE), + name: + client && hasSpanStreamingEnabled(client) + ? streamedName + : getOperationSpanName(operationType, operationName || undefined, SPAN_NAME_EXECUTE), attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_OPERATION_TYPE]: operationType, diff --git a/packages/server-utils/src/integrations/graphql/utils.ts b/packages/server-utils/src/integrations/graphql/utils.ts index c92f10d6da93..7b8efaf12d7d 100644 --- a/packages/server-utils/src/integrations/graphql/utils.ts +++ b/packages/server-utils/src/integrations/graphql/utils.ts @@ -1,6 +1,13 @@ import { SENTRY_GRAPHQL_OPERATION } from '@sentry/conventions/attributes'; import type { Span, SpanAttributeValue } from '@sentry/core'; -import { getClient, isObjectLike, getRootSpan, spanToJSON, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + getClient, + hasSpanStreamingEnabled, + isObjectLike, + getRootSpan, + spanToJSON, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; import type { GraphqlDocumentNode, GraphqlToken } from './types'; // Same key the OTel path uses, so renames stay consistent across both. @@ -12,7 +19,8 @@ const ORIGINAL_DESCRIPTION_ATTRIBUTE = 'original-description'; const REDACTED_LITERAL_KINDS = new Set(['Int', 'Float', 'String', 'BlockString']); /** - * Rename the enclosing root span to include the operation name(s), e.g. `GET /graphql (query GetUser)`. + * Record the operation name(s) on the enclosing root span and, unless span streaming is enabled, + * rename it to include them, e.g. `GET /graphql (query GetUser)`. */ export function renameRootSpanWithOperation(span: Span, operationType: string, operationName?: string): void { const rootSpan = getRootSpan(span); @@ -37,6 +45,13 @@ export function renameRootSpanWithOperation(span: Span, operationType: string, o } rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, operations); + // The operation name comes from the client, so appending it would make the root span name high + // cardinality. With span streaming the `sentry.graphql.operation` attribute carries it instead. + const client = getClient(); + if (client && hasSpanStreamingEnabled(client)) { + return; + } + // Keep the pre-rename name so repeated renames don't compound. const originalDescription = (rootSpanJson.attributes[ORIGINAL_DESCRIPTION_ATTRIBUTE] as string | undefined) ?? rootSpanJson.name; diff --git a/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts b/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts new file mode 100644 index 000000000000..488da12475a4 --- /dev/null +++ b/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts @@ -0,0 +1,224 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getActiveSpan, + getAsyncContextStrategy, + getDefaultCurrentScope, + getDefaultIsolationScope, + getMainCarrier, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { + GRAPHQL_DC_CHANNEL_EXECUTE, + GRAPHQL_DC_CHANNEL_PARSE, + GRAPHQL_DC_CHANNEL_RESOLVE, + GRAPHQL_DC_CHANNEL_SUBSCRIBE, + GRAPHQL_DC_CHANNEL_VALIDATE, + type GraphqlTracingChannelFactory, + subscribeGraphqlDiagnosticChannels, +} from '../../../src/integrations/graphql/graphql-dc-subscriber'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(traceLifecycle: 'static' | 'stream'): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + traceLifecycle, + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +/** + * Publishes one channel operation inside an enclosing span (the subscriber only creates a span when + * one is active) and returns the name of the span it bound, plus the enclosing span's final name. + */ +async function traceOperation( + channelName: string, + data: Record, +): Promise<{ spanName: string | undefined; enclosingSpanName: string | undefined }> { + const channel = tracingChannel(channelName); + let span: Span | undefined; + let enclosingSpanName: string | undefined; + + await startSpan({ name: 'GET /graphql' }, async enclosing => { + await channel.tracePromise(async () => { + span = getActiveSpan(); + }, data); + enclosingSpanName = spanToJSON(enclosing).name; + }); + + return { spanName: span && spanToJSON(span).name, enclosingSpanName }; +} + +const factory = tracingChannel as GraphqlTracingChannelFactory; + +describe('subscribeGraphqlDiagnosticChannels', () => { + // The subscriber captures the async-context strategy's ALS when it binds, so the strategy must be + // installed before we subscribe, and both stay fixed for the file. Only the client changes per test. + beforeAll(() => { + installTestAsyncContextStrategy(); + subscribeGraphqlDiagnosticChannels(factory, { ignoreResolveSpans: false }); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + afterEach(() => { + // Keep the async-context strategy the subscriber bound to in `beforeAll`; wiping it would strand + // the ALS it captured, so no further spans would be created. + const acs = getAsyncContextStrategy(getMainCarrier()); + getMainCarrier().__SENTRY__ = undefined; + setAsyncContextStrategy(acs); + }); + + describe('with span streaming', () => { + it.each([ + [GRAPHQL_DC_CHANNEL_PARSE, {}, 'GraphQL parse'], + [GRAPHQL_DC_CHANNEL_VALIDATE, {}, 'GraphQL validate'], + [ + GRAPHQL_DC_CHANNEL_RESOLVE, + { fieldName: 'name', parentType: 'User', fieldType: 'String', fieldPath: 'user.0.name' }, + 'GraphQL resolve', + ], + ])('names the %s span after the phase, dropping the field path', async (channel, data, expected) => { + initTestClient('stream'); + + const { spanName } = await traceOperation(channel, data); + + expect(spanName).toBe(expected); + }); + + it('names an operation span with the static fallback when no operation type is available', async () => { + initTestClient('stream'); + + const { spanName } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { operationName: 'GetUser' }); + + expect(spanName).toBe('GraphQL Operation'); + }); + + it.each([ + [GRAPHQL_DC_CHANNEL_EXECUTE, 'query', 'GraphQL query'], + [GRAPHQL_DC_CHANNEL_EXECUTE, 'mutation', 'GraphQL mutation'], + [GRAPHQL_DC_CHANNEL_SUBSCRIBE, 'subscription', 'GraphQL subscription'], + ])('names a %s span after the operation type, dropping the operation name', async (channel, type, expected) => { + initTestClient('stream'); + + const { spanName } = await traceOperation(channel, { operationType: type, operationName: 'GetUser' }); + + expect(spanName).toBe(expected); + }); + + it('records the operation on the root span without renaming it', async () => { + initTestClient('stream'); + + const { enclosingSpanName } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { + operationType: 'query', + operationName: 'GetUser', + }); + + expect(enclosingSpanName).toBe('GET /graphql'); + }); + }); + + describe('without span streaming', () => { + it.each([ + [GRAPHQL_DC_CHANNEL_PARSE, {}, 'graphql.parse'], + [GRAPHQL_DC_CHANNEL_VALIDATE, {}, 'graphql.validate'], + [ + GRAPHQL_DC_CHANNEL_RESOLVE, + { fieldName: 'name', parentType: 'User', fieldType: 'String', fieldPath: 'user.0.name' }, + 'graphql.resolve user.0.name', + ], + [GRAPHQL_DC_CHANNEL_EXECUTE, { operationType: 'query', operationName: 'GetUser' }, 'query GetUser'], + [GRAPHQL_DC_CHANNEL_EXECUTE, {}, 'graphql.execute'], + ])('keeps the %s span name', async (channel, data, expected) => { + initTestClient('static'); + + const { spanName } = await traceOperation(channel, data); + + expect(spanName).toBe(expected); + }); + + it('renames the root span with the operation', async () => { + initTestClient('static'); + + const { enclosingSpanName } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { + operationType: 'query', + operationName: 'GetUser', + }); + + expect(enclosingSpanName).toBe('GET /graphql (query GetUser)'); + }); + }); +}); From ca3bd089b62854a0ab58cbd8908db6e100f59694 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 24 Aug 2026 20:28:37 +0200 Subject: [PATCH 2/9] Name graphql phase spans with the fallback and mark them with graphql.processing.type Follows the GraphQL OpenTelemetry Working Group, which added a dedicated attribute for the processing type rather than widening graphql.operation.type. Parse, validate and resolve spans take the static fallback name and carry the phase as an attribute instead. Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- MIGRATION.md | 10 ++-- .../apollo-graphql/span-streaming/test.ts | 40 +++++++++++--- .../useOperationNameForRootSpan/test.ts | 5 ++ .../src/integrations/graphql/constants.ts | 18 ++++--- .../graphql/graphql-dc-subscriber.ts | 30 +++++++---- .../src/integrations/graphql/resolvers.ts | 7 ++- .../src/integrations/graphql/spans.ts | 21 +++++--- .../graphql/graphql-dc-subscriber.test.ts | 52 ++++++++++++++----- 8 files changed, 134 insertions(+), 49 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index f6a0f5f1f01b..3c08f6553c59 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -622,15 +622,17 @@ If you [opt out of span streaming](#opting-out-of-span-streaming), span names re The following span names were adjusted: -| Span op | Before | After | -| ---------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | -| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type or the phase (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`), or `GraphQL Operation` where the SDK has neither | +| Span op | Before | After | +| ---------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | +| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | `GraphQL ` (`GraphQL query`), or `GraphQL Operation` for parse, validate and resolve spans | Some consequences to be aware of: The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes. +Because a low-cardinality name cannot say which part of request processing a span covers, every graphql span now carries a `graphql.processing.type` attribute (`parse`, `validate`, `execute` or `resolve`). Use it to tell parse, validate and resolve spans apart. The attribute is set in both trace lifecycles. + For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute. Child spans of a pageload span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references. diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts index 25df63a3be14..75cbc89eb24c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts @@ -4,8 +4,14 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/r type StreamedSpan = SerializedStreamedSpanContainer['items'][number]; +// Scoped to the `Test Transaction` segment: creating the server parses the schema's typeDefs, which +// emits a parse span under `Test Server Start`. function graphqlSpans(container: SerializedStreamedSpanContainer): StreamedSpan[] { - return container.items.filter(item => item.attributes['sentry.op']?.value === 'graphql'); + return container.items.filter( + item => + item.attributes['sentry.op']?.value === 'graphql' && + item.attributes['sentry.segment.name']?.value === 'Test Transaction', + ); } describe('GraphQL/Apollo Tests > span streaming', () => { @@ -27,12 +33,10 @@ describe('GraphQL/Apollo Tests > span streaming', () => { // reach the span name. const resolveSpans = spans.filter(span => span.attributes['graphql.field.path']); expect(resolveSpans.map(span => span.attributes['graphql.field.path']?.value)).toEqual(['hello', 'login']); - expect(resolveSpans.map(span => span.name)).toEqual(['GraphQL resolve', 'GraphQL resolve']); - // Parse and validate spans have no operation type, so they are named after the phase. - const otherSpans = spans.filter(span => !executeSpans.includes(span) && !resolveSpans.includes(span)); - expect(otherSpans.length).toBeGreaterThan(0); - expect(otherSpans.every(span => ['GraphQL parse', 'GraphQL validate'].includes(span.name))).toBe(true); + // Parse, validate and resolve spans have no operation type to name them after. + const fallbackSpans = spans.filter(span => !executeSpans.includes(span)); + expect(fallbackSpans.every(span => span.name === 'GraphQL Operation')).toBe(true); expect(spans.some(span => span.name.includes('GetHello') || span.name.includes('TestMutation'))).toBe( false, @@ -43,6 +47,30 @@ describe('GraphQL/Apollo Tests > span streaming', () => { .completed(); }); + test('marks every graphql span with its processing type', async () => { + await createTestRunner() + .expect({ + span: container => { + const processingTypes = graphqlSpans(container).map( + span => span.attributes['graphql.processing.type']?.value, + ); + + expect(processingTypes.sort()).toEqual([ + 'execute', + 'execute', + 'parse', + 'parse', + 'resolve', + 'resolve', + 'validate', + 'validate', + ]); + }, + }) + .start() + .completed(); + }); + test('records the operations on the segment span without renaming it', async () => { await createTestRunner() .expect({ diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts index e7fc59b8b639..d414cabfa3d4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts @@ -22,6 +22,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { 'graphql.document': 'query GetHello {hello}', 'sentry.origin': 'auto.graphql.diagnostic_channel', 'sentry.op': 'graphql', + 'graphql.processing.type': 'execute', }, description: 'query GetHello', status: 'ok', @@ -54,6 +55,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { }`, 'sentry.origin': 'auto.graphql.diagnostic_channel', 'sentry.op': 'graphql', + 'graphql.processing.type': 'execute', }, description: 'mutation TestMutation', status: 'ok', @@ -83,6 +85,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { 'graphql.document': 'query {hello}', 'sentry.origin': 'auto.graphql.diagnostic_channel', 'sentry.op': 'graphql', + 'graphql.processing.type': 'execute', }, description: 'query', status: 'ok', @@ -113,6 +116,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { 'graphql.document': 'query GetHello {hello}', 'sentry.origin': 'auto.graphql.diagnostic_channel', 'sentry.op': 'graphql', + 'graphql.processing.type': 'execute', }, description: 'query GetHello', status: 'ok', @@ -125,6 +129,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { 'graphql.document': 'query GetWorld {world}', 'sentry.origin': 'auto.graphql.diagnostic_channel', 'sentry.op': 'graphql', + 'graphql.processing.type': 'execute', }, description: 'query GetWorld', status: 'ok', diff --git a/packages/server-utils/src/integrations/graphql/constants.ts b/packages/server-utils/src/integrations/graphql/constants.ts index 6db5c7655a0e..f71ef1769fa2 100644 --- a/packages/server-utils/src/integrations/graphql/constants.ts +++ b/packages/server-utils/src/integrations/graphql/constants.ts @@ -13,13 +13,17 @@ export const SPAN_NAME_VALIDATE = 'graphql.validate'; export const SPAN_NAME_EXECUTE = 'graphql.execute'; export const SPAN_NAME_RESOLVE = 'graphql.resolve'; -// Span names used when span streaming is enabled, mirroring the same block in the native subscriber. -// The conventions name graphql spans `GraphQL {graphql.operation.type}`, and these phases are being -// added to that attribute's values, so a parse, validate or resolve span keeps a name of its own -// rather than taking the generic `GRAPHQL_SPAN_NAME_FALLBACK`. -export const STREAMED_SPAN_NAME_PARSE = 'GraphQL parse'; -export const STREAMED_SPAN_NAME_VALIDATE = 'GraphQL validate'; -export const STREAMED_SPAN_NAME_RESOLVE = 'GraphQL resolve'; +// Which part of request processing a span covers. Low-cardinality span names cannot carry the phase, +// so consumers read it here instead. Inlined until `@sentry/conventions` ships it +// (https://github.com/getsentry/sentry-conventions/pull/572). +export const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type'; + +export const PROCESSING_TYPE_PARSE = 'parse'; +export const PROCESSING_TYPE_VALIDATE = 'validate'; +// graphql-js `subscribe()` runs a subscription operation, so it is an execute too; the operation +// itself is told apart by `graphql.operation.type`. +export const PROCESSING_TYPE_EXECUTE = 'execute'; +export const PROCESSING_TYPE_RESOLVE = 'resolve'; // Field-level resolver-span attributes; not in `@sentry/conventions`. export const GRAPHQL_FIELD_NAME = 'graphql.field.name'; diff --git a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts index 4c04dd511394..c97cfbc65b52 100644 --- a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts @@ -31,13 +31,17 @@ const SPAN_NAME_EXECUTE = 'graphql.execute'; const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe'; const SPAN_NAME_RESOLVE = 'graphql.resolve'; -// Span names used when span streaming is enabled. The conventions name graphql spans -// `GraphQL {graphql.operation.type}`, and these phases are being added to that attribute's values, so -// a parse, validate or resolve span keeps a name of its own rather than taking the generic -// `GRAPHQL_SPAN_NAME_FALLBACK`. -const STREAMED_SPAN_NAME_PARSE = 'GraphQL parse'; -const STREAMED_SPAN_NAME_VALIDATE = 'GraphQL validate'; -const STREAMED_SPAN_NAME_RESOLVE = 'GraphQL resolve'; +// Which part of request processing a span covers. Low-cardinality span names cannot carry the phase, +// so consumers read it here instead. Inlined until `@sentry/conventions` ships it +// (https://github.com/getsentry/sentry-conventions/pull/572). +const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type'; + +const PROCESSING_TYPE_PARSE = 'parse'; +const PROCESSING_TYPE_VALIDATE = 'validate'; +// graphql-js `subscribe()` runs a subscription operation, so it is an execute too; the operation +// itself is told apart by `graphql.operation.type`. +const PROCESSING_TYPE_EXECUTE = 'execute'; +const PROCESSING_TYPE_RESOLVE = 'resolve'; // Field-level attributes for resolver spans. Not in `@sentry/conventions`; these match the keys the // vendored OTel instrumentation emits so there is no drift between the two paths. @@ -163,10 +167,13 @@ function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void { const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_PARSE : SPAN_NAME_PARSE, + // No operation type is available here, so with span streaming the span takes the static + // fallback and `graphql.processing.type` is what tells it apart from the other phases. + name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE, }, }); }); @@ -179,10 +186,11 @@ function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): voi const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_VALIDATE : SPAN_NAME_VALIDATE, + name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_VALIDATE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_VALIDATE, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(data.document), }, }); @@ -220,6 +228,7 @@ function setupOperationChannel( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_EXECUTE, [GRAPHQL_OPERATION_TYPE]: data.operationType, [GRAPHQL_OPERATION_NAME]: data.operationName || undefined, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(data.document), @@ -257,11 +266,12 @@ function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignor // The field path is unbounded, so with span streaming it stays on `graphql.field.path` only. name: client && hasSpanStreamingEnabled(client) - ? STREAMED_SPAN_NAME_RESOLVE + ? GRAPHQL_SPAN_NAME_FALLBACK : `${SPAN_NAME_RESOLVE} ${data.fieldPath}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_RESOLVE, [GRAPHQL_FIELD_NAME]: data.fieldName, [GRAPHQL_FIELD_PATH]: data.fieldPath, [GRAPHQL_FIELD_TYPE]: data.fieldType, diff --git a/packages/server-utils/src/integrations/graphql/resolvers.ts b/packages/server-utils/src/integrations/graphql/resolvers.ts index 89ac96893993..a2d00a7f68e9 100644 --- a/packages/server-utils/src/integrations/graphql/resolvers.ts +++ b/packages/server-utils/src/integrations/graphql/resolvers.ts @@ -10,6 +10,7 @@ import { GRAPHQL } from '@sentry/conventions/op'; import type { Span, SpanAttributes } from '@sentry/core'; import { getClient, + GRAPHQL_SPAN_NAME_FALLBACK, hasSpanStreamingEnabled, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -25,9 +26,10 @@ import { GRAPHQL_FIELD_TYPE, GRAPHQL_PARENT_NAME, GRAPHQL_PATCHED_SYMBOL, + GRAPHQL_PROCESSING_TYPE, ORIGIN, + PROCESSING_TYPE_RESOLVE, SPAN_NAME_RESOLVE, - STREAMED_SPAN_NAME_RESOLVE, } from './constants'; import type { DefinitionNode, @@ -183,6 +185,7 @@ function createResolverSpan(info: GraphQLResolveInfo, path: string[], parentSpan const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_RESOLVE, [GRAPHQL_FIELD_NAME]: info.fieldName, [GRAPHQL_FIELD_PATH]: path.join('.'), [GRAPHQL_FIELD_TYPE]: info.returnType.toString(), @@ -194,7 +197,7 @@ function createResolverSpan(info: GraphQLResolveInfo, path: string[], parentSpan return startInactiveSpan({ // The field path is unbounded, so with span streaming it stays on `graphql.field.path` only. name: - client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_RESOLVE : `${SPAN_NAME_RESOLVE} ${path.join('.')}`, + client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : `${SPAN_NAME_RESOLVE} ${path.join('.')}`, attributes, parentSpan, }); diff --git a/packages/server-utils/src/integrations/graphql/spans.ts b/packages/server-utils/src/integrations/graphql/spans.ts index c04131cbfd09..c22b0395862d 100644 --- a/packages/server-utils/src/integrations/graphql/spans.ts +++ b/packages/server-utils/src/integrations/graphql/spans.ts @@ -21,12 +21,14 @@ import type { GraphqlDocumentNode } from './types'; import { collectGraphqlDocument, getOperationSpanName, hasResultErrors, renameRootSpanWithOperation } from './utils'; import { GRAPHQL_DATA_SYMBOL, + GRAPHQL_PROCESSING_TYPE, ORIGIN, + PROCESSING_TYPE_EXECUTE, + PROCESSING_TYPE_PARSE, + PROCESSING_TYPE_VALIDATE, SPAN_NAME_EXECUTE, SPAN_NAME_PARSE, SPAN_NAME_VALIDATE, - STREAMED_SPAN_NAME_PARSE, - STREAMED_SPAN_NAME_VALIDATE, } from './constants'; import { getOperation, wrapFields, wrapFieldResolver } from './resolvers'; import type { @@ -47,8 +49,10 @@ export function startParseSpan(): Span { const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_PARSE : SPAN_NAME_PARSE, - attributes: { ...BASE_ATTRIBUTES }, + // No operation type is available here, so with span streaming the span takes the static fallback + // and `graphql.processing.type` is what tells it apart from the other phases. + name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, + attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE }, }); } @@ -57,8 +61,12 @@ export function startValidateSpan(documentAST: unknown): Span { const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? STREAMED_SPAN_NAME_VALIDATE : SPAN_NAME_VALIDATE, - attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(documentAST as GraphqlDocumentNode) }, + name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_VALIDATE, + attributes: { + ...BASE_ATTRIBUTES, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_VALIDATE, + [GRAPHQL_DOCUMENT]: collectGraphqlDocument(documentAST as GraphqlDocumentNode), + }, }); } @@ -180,6 +188,7 @@ export function startExecuteSpan( : getOperationSpanName(operationType, operationName || undefined, SPAN_NAME_EXECUTE), attributes: { ...BASE_ATTRIBUTES, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_EXECUTE, [GRAPHQL_OPERATION_TYPE]: operationType, [GRAPHQL_OPERATION_NAME]: operationName || undefined, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(document), diff --git a/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts b/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts index 488da12475a4..33523bed947d 100644 --- a/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts +++ b/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts @@ -105,7 +105,11 @@ function installTestAsyncContextStrategy(): void { async function traceOperation( channelName: string, data: Record, -): Promise<{ spanName: string | undefined; enclosingSpanName: string | undefined }> { +): Promise<{ + spanName: string | undefined; + processingType: unknown; + enclosingSpanName: string | undefined; +}> { const channel = tracingChannel(channelName); let span: Span | undefined; let enclosingSpanName: string | undefined; @@ -117,7 +121,13 @@ async function traceOperation( enclosingSpanName = spanToJSON(enclosing).name; }); - return { spanName: span && spanToJSON(span).name, enclosingSpanName }; + const spanJson = span && spanToJSON(span); + + return { + spanName: spanJson?.name, + processingType: spanJson?.attributes['graphql.processing.type'], + enclosingSpanName, + }; } const factory = tracingChannel as GraphqlTracingChannelFactory; @@ -144,26 +154,18 @@ describe('subscribeGraphqlDiagnosticChannels', () => { describe('with span streaming', () => { it.each([ - [GRAPHQL_DC_CHANNEL_PARSE, {}, 'GraphQL parse'], - [GRAPHQL_DC_CHANNEL_VALIDATE, {}, 'GraphQL validate'], + [GRAPHQL_DC_CHANNEL_PARSE, {}], + [GRAPHQL_DC_CHANNEL_VALIDATE, {}], [ GRAPHQL_DC_CHANNEL_RESOLVE, { fieldName: 'name', parentType: 'User', fieldType: 'String', fieldPath: 'user.0.name' }, - 'GraphQL resolve', ], - ])('names the %s span after the phase, dropping the field path', async (channel, data, expected) => { + [GRAPHQL_DC_CHANNEL_EXECUTE, { operationName: 'GetUser' }], + ])('names the %s span with the static fallback when no operation type is available', async (channel, data) => { initTestClient('stream'); const { spanName } = await traceOperation(channel, data); - expect(spanName).toBe(expected); - }); - - it('names an operation span with the static fallback when no operation type is available', async () => { - initTestClient('stream'); - - const { spanName } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { operationName: 'GetUser' }); - expect(spanName).toBe('GraphQL Operation'); }); @@ -191,6 +193,28 @@ describe('subscribeGraphqlDiagnosticChannels', () => { }); }); + describe.each(['stream', 'static'] as const)('graphql.processing.type (%s)', traceLifecycle => { + it.each([ + [GRAPHQL_DC_CHANNEL_PARSE, {}, 'parse'], + [GRAPHQL_DC_CHANNEL_VALIDATE, {}, 'validate'], + [GRAPHQL_DC_CHANNEL_EXECUTE, { operationType: 'query' }, 'execute'], + // graphql-js `subscribe()` runs an operation, so it is an execute; `graphql.operation.type` + // is what marks it as a subscription. + [GRAPHQL_DC_CHANNEL_SUBSCRIBE, { operationType: 'subscription' }, 'execute'], + [ + GRAPHQL_DC_CHANNEL_RESOLVE, + { fieldName: 'name', parentType: 'User', fieldType: 'String', fieldPath: 'user.0.name' }, + 'resolve', + ], + ])('marks the %s span', async (channel, data, expected) => { + initTestClient(traceLifecycle); + + const { processingType } = await traceOperation(channel, data); + + expect(processingType).toBe(expected); + }); + }); + describe('without span streaming', () => { it.each([ [GRAPHQL_DC_CHANNEL_PARSE, {}, 'graphql.parse'], From d95710e8d2bf73b943991cf090a4827bf807270b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 24 Aug 2026 21:29:53 +0200 Subject: [PATCH 3/9] Assert the recorded operation value, not just its presence Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- .../apollo-graphql/span-streaming/test.ts | 6 +++++- .../graphql/graphql-dc-subscriber.test.ts | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts index 75cbc89eb24c..0b220d622f96 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts @@ -79,7 +79,11 @@ describe('GraphQL/Apollo Tests > span streaming', () => { const segmentSpan = container.items.find(item => item.is_segment && item.name === 'Test Transaction'); expect(segmentSpan).toBeDefined(); - expect(segmentSpan?.attributes['sentry.graphql.operation']).toBeDefined(); + // Both operations are recorded here rather than in the name. + expect(segmentSpan?.attributes['sentry.graphql.operation']?.value).toEqual([ + 'query GetHello', + 'mutation TestMutation', + ]); }, }) .start() diff --git a/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts b/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts index 33523bed947d..c0579c14678a 100644 --- a/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts +++ b/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts @@ -100,7 +100,7 @@ function installTestAsyncContextStrategy(): void { /** * Publishes one channel operation inside an enclosing span (the subscriber only creates a span when - * one is active) and returns the name of the span it bound, plus the enclosing span's final name. + * one is active) and returns the span it bound, plus the enclosing span's final name and operations. */ async function traceOperation( channelName: string, @@ -109,16 +109,21 @@ async function traceOperation( spanName: string | undefined; processingType: unknown; enclosingSpanName: string | undefined; + enclosingOperations: unknown; }> { const channel = tracingChannel(channelName); let span: Span | undefined; let enclosingSpanName: string | undefined; + let enclosingOperations: unknown; await startSpan({ name: 'GET /graphql' }, async enclosing => { await channel.tracePromise(async () => { span = getActiveSpan(); }, data); - enclosingSpanName = spanToJSON(enclosing).name; + + const enclosingJson = spanToJSON(enclosing); + enclosingSpanName = enclosingJson.name; + enclosingOperations = enclosingJson.attributes['sentry.graphql.operation']; }); const spanJson = span && spanToJSON(span); @@ -127,6 +132,7 @@ async function traceOperation( spanName: spanJson?.name, processingType: spanJson?.attributes['graphql.processing.type'], enclosingSpanName, + enclosingOperations, }; } @@ -184,12 +190,13 @@ describe('subscribeGraphqlDiagnosticChannels', () => { it('records the operation on the root span without renaming it', async () => { initTestClient('stream'); - const { enclosingSpanName } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { + const { enclosingSpanName, enclosingOperations } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { operationType: 'query', operationName: 'GetUser', }); expect(enclosingSpanName).toBe('GET /graphql'); + expect(enclosingOperations).toBe('query GetUser'); }); }); @@ -237,12 +244,13 @@ describe('subscribeGraphqlDiagnosticChannels', () => { it('renames the root span with the operation', async () => { initTestClient('static'); - const { enclosingSpanName } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { + const { enclosingSpanName, enclosingOperations } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { operationType: 'query', operationName: 'GetUser', }); expect(enclosingSpanName).toBe('GET /graphql (query GetUser)'); + expect(enclosingOperations).toBe('query GetUser'); }); }); }); From 09b2018f37890d39b39003c5ea7fad52f14ce21e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 25 Aug 2026 09:46:43 +0200 Subject: [PATCH 4/9] Share the graphql constants between both paths instead of mirroring them Duplicating them is how the two paths drift, which is the opposite of what the mirroring was for. The `graphql:*` channel names stay local to the subscriber, since only it uses them. Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- .../src/integrations/graphql/constants.ts | 17 ++++--- .../graphql/graphql-dc-subscriber.ts | 44 +++++++------------ 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/packages/server-utils/src/integrations/graphql/constants.ts b/packages/server-utils/src/integrations/graphql/constants.ts index f71ef1769fa2..a70cdfc10452 100644 --- a/packages/server-utils/src/integrations/graphql/constants.ts +++ b/packages/server-utils/src/integrations/graphql/constants.ts @@ -1,9 +1,11 @@ /* - * These mirror the constants in `@sentry/server-utils`'s native graphql subscriber - * (`src/graphql/graphql-dc-subscriber.ts`) so the orchestrion path (graphql v14–16) and the native - * `diagnostics_channel` path (graphql >= 17) emit identical spans — same origin, span names and - * field-attribute keys. `graphql.document`/`graphql.operation.*` and the span `op` come from - * `@sentry/conventions` directly and are imported where used. + * Shared by both graphql paths — the orchestrion one (graphql v14–16) and the native + * `diagnostics_channel` subscriber (graphql >= 17) — so they emit identical spans: same origin, span + * names, processing types and field-attribute keys. `graphql.document`/`graphql.operation.*` and the + * span `op` come from `@sentry/conventions` directly and are imported where used. + * + * The `graphql:*` channel names live in `graphql-dc-subscriber.ts` instead: only that path uses them, + * and they are hardcoded there so it never has to import graphql itself. */ export const ORIGIN = 'auto.graphql.diagnostic_channel'; @@ -11,6 +13,8 @@ export const ORIGIN = 'auto.graphql.diagnostic_channel'; export const SPAN_NAME_PARSE = 'graphql.parse'; export const SPAN_NAME_VALIDATE = 'graphql.validate'; export const SPAN_NAME_EXECUTE = 'graphql.execute'; +// Only graphql >= 17 publishes a subscribe channel; v14–16 routes subscriptions through `execute`. +export const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe'; export const SPAN_NAME_RESOLVE = 'graphql.resolve'; // Which part of request processing a span covers. Low-cardinality span names cannot carry the phase, @@ -25,7 +29,8 @@ export const PROCESSING_TYPE_VALIDATE = 'validate'; export const PROCESSING_TYPE_EXECUTE = 'execute'; export const PROCESSING_TYPE_RESOLVE = 'resolve'; -// Field-level resolver-span attributes; not in `@sentry/conventions`. +// Field-level resolver-span attributes; not in `@sentry/conventions`. These match the keys the +// vendored OTel instrumentation emitted, so upgrading users see no attribute rename. export const GRAPHQL_FIELD_NAME = 'graphql.field.name'; export const GRAPHQL_FIELD_PATH = 'graphql.field.path'; export const GRAPHQL_FIELD_TYPE = 'graphql.field.type'; diff --git a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts index c97cfbc65b52..8603f48ebd08 100644 --- a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts @@ -11,6 +11,23 @@ import { startInactiveSpan, } from '@sentry/core'; import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { + GRAPHQL_FIELD_NAME, + GRAPHQL_FIELD_PATH, + GRAPHQL_FIELD_TYPE, + GRAPHQL_PARENT_NAME, + GRAPHQL_PROCESSING_TYPE, + ORIGIN, + PROCESSING_TYPE_EXECUTE, + PROCESSING_TYPE_PARSE, + PROCESSING_TYPE_RESOLVE, + PROCESSING_TYPE_VALIDATE, + SPAN_NAME_EXECUTE, + SPAN_NAME_PARSE, + SPAN_NAME_RESOLVE, + SPAN_NAME_SUBSCRIBE, + SPAN_NAME_VALIDATE, +} from './constants'; import type { GraphqlDocumentNode } from './types'; import { collectGraphqlDocument, getOperationSpanName, hasResultErrors, renameRootSpanWithOperation } from './utils'; @@ -23,33 +40,6 @@ export const GRAPHQL_DC_CHANNEL_EXECUTE = 'graphql:execute'; export const GRAPHQL_DC_CHANNEL_SUBSCRIBE = 'graphql:subscribe'; export const GRAPHQL_DC_CHANNEL_RESOLVE = 'graphql:resolve'; -const ORIGIN = 'auto.graphql.diagnostic_channel'; - -const SPAN_NAME_PARSE = 'graphql.parse'; -const SPAN_NAME_VALIDATE = 'graphql.validate'; -const SPAN_NAME_EXECUTE = 'graphql.execute'; -const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe'; -const SPAN_NAME_RESOLVE = 'graphql.resolve'; - -// Which part of request processing a span covers. Low-cardinality span names cannot carry the phase, -// so consumers read it here instead. Inlined until `@sentry/conventions` ships it -// (https://github.com/getsentry/sentry-conventions/pull/572). -const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type'; - -const PROCESSING_TYPE_PARSE = 'parse'; -const PROCESSING_TYPE_VALIDATE = 'validate'; -// graphql-js `subscribe()` runs a subscription operation, so it is an execute too; the operation -// itself is told apart by `graphql.operation.type`. -const PROCESSING_TYPE_EXECUTE = 'execute'; -const PROCESSING_TYPE_RESOLVE = 'resolve'; - -// Field-level attributes for resolver spans. Not in `@sentry/conventions`; these match the keys the -// vendored OTel instrumentation emits so there is no drift between the two paths. -const GRAPHQL_FIELD_NAME = 'graphql.field.name'; -const GRAPHQL_FIELD_PATH = 'graphql.field.path'; -const GRAPHQL_FIELD_TYPE = 'graphql.field.type'; -const GRAPHQL_PARENT_NAME = 'graphql.parent.name'; - /** Context published on the sync-only `graphql:parse` channel. */ export interface GraphqlParseData { source: string | { body?: string }; From b748f87504a12e80681cfa7ec6500dc2304b10fc Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 25 Aug 2026 09:52:38 +0200 Subject: [PATCH 5/9] Trim the constant comments Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- .../src/integrations/graphql/constants.ts | 22 +++++-------------- .../graphql/graphql-dc-subscriber.ts | 6 ++--- .../src/integrations/graphql/spans.ts | 6 ++--- .../src/integrations/graphql/utils.ts | 3 +-- 4 files changed, 10 insertions(+), 27 deletions(-) diff --git a/packages/server-utils/src/integrations/graphql/constants.ts b/packages/server-utils/src/integrations/graphql/constants.ts index a70cdfc10452..15bb63087d56 100644 --- a/packages/server-utils/src/integrations/graphql/constants.ts +++ b/packages/server-utils/src/integrations/graphql/constants.ts @@ -1,36 +1,24 @@ -/* - * Shared by both graphql paths — the orchestrion one (graphql v14–16) and the native - * `diagnostics_channel` subscriber (graphql >= 17) — so they emit identical spans: same origin, span - * names, processing types and field-attribute keys. `graphql.document`/`graphql.operation.*` and the - * span `op` come from `@sentry/conventions` directly and are imported where used. - * - * The `graphql:*` channel names live in `graphql-dc-subscriber.ts` instead: only that path uses them, - * and they are hardcoded there so it never has to import graphql itself. - */ +// Shared by both graphql paths (orchestrion for v14–16, diagnostics channels for >= 17) so they emit +// identical spans. export const ORIGIN = 'auto.graphql.diagnostic_channel'; export const SPAN_NAME_PARSE = 'graphql.parse'; export const SPAN_NAME_VALIDATE = 'graphql.validate'; export const SPAN_NAME_EXECUTE = 'graphql.execute'; -// Only graphql >= 17 publishes a subscribe channel; v14–16 routes subscriptions through `execute`. export const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe'; export const SPAN_NAME_RESOLVE = 'graphql.resolve'; -// Which part of request processing a span covers. Low-cardinality span names cannot carry the phase, -// so consumers read it here instead. Inlined until `@sentry/conventions` ships it -// (https://github.com/getsentry/sentry-conventions/pull/572). +// Inlined until `@sentry/conventions` ships it (https://github.com/getsentry/sentry-conventions/pull/572). export const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type'; export const PROCESSING_TYPE_PARSE = 'parse'; export const PROCESSING_TYPE_VALIDATE = 'validate'; -// graphql-js `subscribe()` runs a subscription operation, so it is an execute too; the operation -// itself is told apart by `graphql.operation.type`. +// `subscribe()` runs an operation too; `graphql.operation.type` is what marks it as a subscription. export const PROCESSING_TYPE_EXECUTE = 'execute'; export const PROCESSING_TYPE_RESOLVE = 'resolve'; -// Field-level resolver-span attributes; not in `@sentry/conventions`. These match the keys the -// vendored OTel instrumentation emitted, so upgrading users see no attribute rename. +// Field-level resolver-span attributes; not in `@sentry/conventions`. export const GRAPHQL_FIELD_NAME = 'graphql.field.name'; export const GRAPHQL_FIELD_PATH = 'graphql.field.path'; export const GRAPHQL_FIELD_TYPE = 'graphql.field.type'; diff --git a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts index 8603f48ebd08..88171a6f5909 100644 --- a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts @@ -157,8 +157,7 @@ function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void { const client = getClient(); return startInactiveSpan({ - // No operation type is available here, so with span streaming the span takes the static - // fallback and `graphql.processing.type` is what tells it apart from the other phases. + // No operation type here, so streaming falls back; the phase lives on the attribute instead. name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, @@ -206,8 +205,7 @@ function setupOperationChannel( tracingChannel(channelName), data => { const client = getClient(); - // The operation name is supplied by the client, so with span streaming only the operation type - // may reach the span name. + // The operation name comes from the client, so only the operation type may reach the name. const streamedName = data.operationType ? `GraphQL ${data.operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; const span = startInactiveSpan({ diff --git a/packages/server-utils/src/integrations/graphql/spans.ts b/packages/server-utils/src/integrations/graphql/spans.ts index c22b0395862d..4a0a01fd8d30 100644 --- a/packages/server-utils/src/integrations/graphql/spans.ts +++ b/packages/server-utils/src/integrations/graphql/spans.ts @@ -49,8 +49,7 @@ export function startParseSpan(): Span { const client = getClient(); return startInactiveSpan({ - // No operation type is available here, so with span streaming the span takes the static fallback - // and `graphql.processing.type` is what tells it apart from the other phases. + // No operation type here, so streaming falls back; the phase lives on the attribute instead. name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE }, }); @@ -177,8 +176,7 @@ export function startExecuteSpan( const operationName = operation?.name?.value ?? args.operationName ?? undefined; const client = getClient(); - // The operation name is supplied by the client, so with span streaming only the operation type may - // reach the span name. + // The operation name comes from the client, so only the operation type may reach the name. const streamedName = operationType ? `GraphQL ${operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; const span = startInactiveSpan({ diff --git a/packages/server-utils/src/integrations/graphql/utils.ts b/packages/server-utils/src/integrations/graphql/utils.ts index 7b8efaf12d7d..f84bc9361a9d 100644 --- a/packages/server-utils/src/integrations/graphql/utils.ts +++ b/packages/server-utils/src/integrations/graphql/utils.ts @@ -45,8 +45,7 @@ export function renameRootSpanWithOperation(span: Span, operationType: string, o } rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, operations); - // The operation name comes from the client, so appending it would make the root span name high - // cardinality. With span streaming the `sentry.graphql.operation` attribute carries it instead. + // The operation name comes from the client, so `sentry.graphql.operation` carries it instead. const client = getClient(); if (client && hasSpanStreamingEnabled(client)) { return; From 47731ab9061597f14212e482aba55c159e36d6c9 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 25 Aug 2026 11:18:04 +0200 Subject: [PATCH 6/9] Drop the explanatory comments on the span name branches Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- packages/server-utils/src/integrations/graphql/constants.ts | 1 - .../src/integrations/graphql/graphql-dc-subscriber.ts | 3 --- packages/server-utils/src/integrations/graphql/resolvers.ts | 1 - packages/server-utils/src/integrations/graphql/spans.ts | 2 -- packages/server-utils/src/integrations/graphql/utils.ts | 1 - 5 files changed, 8 deletions(-) diff --git a/packages/server-utils/src/integrations/graphql/constants.ts b/packages/server-utils/src/integrations/graphql/constants.ts index 15bb63087d56..38c292ef7a9b 100644 --- a/packages/server-utils/src/integrations/graphql/constants.ts +++ b/packages/server-utils/src/integrations/graphql/constants.ts @@ -14,7 +14,6 @@ export const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type'; export const PROCESSING_TYPE_PARSE = 'parse'; export const PROCESSING_TYPE_VALIDATE = 'validate'; -// `subscribe()` runs an operation too; `graphql.operation.type` is what marks it as a subscription. export const PROCESSING_TYPE_EXECUTE = 'execute'; export const PROCESSING_TYPE_RESOLVE = 'resolve'; diff --git a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts index 88171a6f5909..57381b1479d1 100644 --- a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts @@ -157,7 +157,6 @@ function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void { const client = getClient(); return startInactiveSpan({ - // No operation type here, so streaming falls back; the phase lives on the attribute instead. name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, @@ -205,7 +204,6 @@ function setupOperationChannel( tracingChannel(channelName), data => { const client = getClient(); - // The operation name comes from the client, so only the operation type may reach the name. const streamedName = data.operationType ? `GraphQL ${data.operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; const span = startInactiveSpan({ @@ -251,7 +249,6 @@ function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignor const client = getClient(); return startInactiveSpan({ - // The field path is unbounded, so with span streaming it stays on `graphql.field.path` only. name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK diff --git a/packages/server-utils/src/integrations/graphql/resolvers.ts b/packages/server-utils/src/integrations/graphql/resolvers.ts index a2d00a7f68e9..e64320825a79 100644 --- a/packages/server-utils/src/integrations/graphql/resolvers.ts +++ b/packages/server-utils/src/integrations/graphql/resolvers.ts @@ -195,7 +195,6 @@ function createResolverSpan(info: GraphQLResolveInfo, path: string[], parentSpan const client = getClient(); return startInactiveSpan({ - // The field path is unbounded, so with span streaming it stays on `graphql.field.path` only. name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : `${SPAN_NAME_RESOLVE} ${path.join('.')}`, attributes, diff --git a/packages/server-utils/src/integrations/graphql/spans.ts b/packages/server-utils/src/integrations/graphql/spans.ts index 4a0a01fd8d30..ea170e7f7e0d 100644 --- a/packages/server-utils/src/integrations/graphql/spans.ts +++ b/packages/server-utils/src/integrations/graphql/spans.ts @@ -49,7 +49,6 @@ export function startParseSpan(): Span { const client = getClient(); return startInactiveSpan({ - // No operation type here, so streaming falls back; the phase lives on the attribute instead. name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE }, }); @@ -176,7 +175,6 @@ export function startExecuteSpan( const operationName = operation?.name?.value ?? args.operationName ?? undefined; const client = getClient(); - // The operation name comes from the client, so only the operation type may reach the name. const streamedName = operationType ? `GraphQL ${operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; const span = startInactiveSpan({ diff --git a/packages/server-utils/src/integrations/graphql/utils.ts b/packages/server-utils/src/integrations/graphql/utils.ts index f84bc9361a9d..bf5a78d104a3 100644 --- a/packages/server-utils/src/integrations/graphql/utils.ts +++ b/packages/server-utils/src/integrations/graphql/utils.ts @@ -45,7 +45,6 @@ export function renameRootSpanWithOperation(span: Span, operationType: string, o } rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, operations); - // The operation name comes from the client, so `sentry.graphql.operation` carries it instead. const client = getClient(); if (client && hasSpanStreamingEnabled(client)) { return; From 232c4d11f2ab011284a8ade5bdbaa6f7594b2fad Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 25 Aug 2026 11:22:40 +0200 Subject: [PATCH 7/9] Cover the processing type in the integration suites instead of a unit test Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- .../tracing/apollo-graphql/resolvers/test.ts | 24 +- .../graphql/graphql-dc-subscriber.test.ts | 256 ------------------ 2 files changed, 20 insertions(+), 260 deletions(-) delete mode 100644 packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts index bd67ed84bf72..6021abe5aabe 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts @@ -22,15 +22,23 @@ describe('GraphQL/Apollo Tests > resolve spans', () => { origin: 'auto.graphql.diagnostic_channel', data: expect.objectContaining({ 'graphql.operation.type': 'query', + 'graphql.processing.type': 'execute', 'graphql.document': '{hello}', 'sentry.origin': 'auto.graphql.diagnostic_channel', }), }), - expect.objectContaining({ description: 'graphql.parse' }), - expect.objectContaining({ description: 'graphql.validate' }), + expect.objectContaining({ + description: 'graphql.parse', + data: expect.objectContaining({ 'graphql.processing.type': 'parse' }), + }), + expect.objectContaining({ + description: 'graphql.validate', + data: expect.objectContaining({ 'graphql.processing.type': 'validate' }), + }), expect.objectContaining({ description: 'graphql.resolve hello', data: expect.objectContaining({ + 'graphql.processing.type': 'resolve', 'graphql.field.name': 'hello', 'graphql.field.path': 'hello', 'graphql.field.type': 'String', @@ -61,15 +69,23 @@ describe('GraphQL/Apollo Tests > resolve spans', () => { origin: 'auto.graphql.diagnostic_channel', data: expect.objectContaining({ 'graphql.operation.type': 'query', + 'graphql.processing.type': 'execute', 'graphql.document': '{hello}', 'sentry.origin': 'auto.graphql.diagnostic_channel', }), }), - expect.objectContaining({ description: 'graphql.parse' }), - expect.objectContaining({ description: 'graphql.validate' }), + expect.objectContaining({ + description: 'graphql.parse', + data: expect.objectContaining({ 'graphql.processing.type': 'parse' }), + }), + expect.objectContaining({ + description: 'graphql.validate', + data: expect.objectContaining({ 'graphql.processing.type': 'validate' }), + }), expect.objectContaining({ description: 'graphql.resolve hello', data: expect.objectContaining({ + 'graphql.processing.type': 'resolve', 'graphql.field.name': 'hello', 'graphql.field.path': 'hello', 'graphql.field.type': 'String', diff --git a/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts b/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts deleted file mode 100644 index c0579c14678a..000000000000 --- a/packages/server-utils/test/integrations/graphql/graphql-dc-subscriber.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { tracingChannel } from 'node:diagnostics_channel'; -import type { Scope, Span } from '@sentry/core'; -import { - _INTERNAL_setSpanForScope, - Client, - createTransport, - getActiveSpan, - getAsyncContextStrategy, - getDefaultCurrentScope, - getDefaultIsolationScope, - getMainCarrier, - initAndBind, - resolvedSyncPromise, - setAsyncContextStrategy, - spanToJSON, - startSpan, -} from '@sentry/core'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { - GRAPHQL_DC_CHANNEL_EXECUTE, - GRAPHQL_DC_CHANNEL_PARSE, - GRAPHQL_DC_CHANNEL_RESOLVE, - GRAPHQL_DC_CHANNEL_SUBSCRIBE, - GRAPHQL_DC_CHANNEL_VALIDATE, - type GraphqlTracingChannelFactory, - subscribeGraphqlDiagnosticChannels, -} from '../../../src/integrations/graphql/graphql-dc-subscriber'; - -interface TestStore { - scope: Scope; - isolationScope: Scope; -} - -class TestClient extends Client { - public eventFromException(): PromiseLike { - return resolvedSyncPromise({}); - } - public eventFromMessage(): PromiseLike { - return resolvedSyncPromise({}); - } -} - -function initTestClient(traceLifecycle: 'static' | 'stream'): void { - initAndBind(TestClient, { - dsn: 'https://username@domain/123', - integrations: [], - sendClientReports: false, - stackParser: () => [], - traceLifecycle, - tracesSampleRate: 1, - transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), - }); -} - -function installTestAsyncContextStrategy(): void { - const asyncStorage = new AsyncLocalStorage(); - - function getScopes(): TestStore { - return ( - asyncStorage.getStore() || { - scope: getDefaultCurrentScope(), - isolationScope: getDefaultIsolationScope(), - } - ); - } - - setAsyncContextStrategy({ - withScope: callback => { - const scope = getScopes().scope.clone(); - const isolationScope = getScopes().isolationScope; - return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); - }, - withSetScope: (scope, callback) => { - const isolationScope = getScopes().isolationScope; - return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); - }, - withIsolationScope: callback => { - const scope = getScopes().scope; - const isolationScope = getScopes().isolationScope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); - }, - withSetIsolationScope: (isolationScope, callback) => { - const scope = getScopes().scope; - return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); - }, - getCurrentScope: () => getScopes().scope, - getIsolationScope: () => getScopes().isolationScope, - getTracingChannelBinding: () => ({ - asyncLocalStorage: asyncStorage, - getStoreWithActiveSpan: span => { - const scope = getScopes().scope.clone(); - const isolationScope = getScopes().isolationScope; - _INTERNAL_setSpanForScope(scope, span); - return { scope, isolationScope }; - }, - }), - }); -} - -/** - * Publishes one channel operation inside an enclosing span (the subscriber only creates a span when - * one is active) and returns the span it bound, plus the enclosing span's final name and operations. - */ -async function traceOperation( - channelName: string, - data: Record, -): Promise<{ - spanName: string | undefined; - processingType: unknown; - enclosingSpanName: string | undefined; - enclosingOperations: unknown; -}> { - const channel = tracingChannel(channelName); - let span: Span | undefined; - let enclosingSpanName: string | undefined; - let enclosingOperations: unknown; - - await startSpan({ name: 'GET /graphql' }, async enclosing => { - await channel.tracePromise(async () => { - span = getActiveSpan(); - }, data); - - const enclosingJson = spanToJSON(enclosing); - enclosingSpanName = enclosingJson.name; - enclosingOperations = enclosingJson.attributes['sentry.graphql.operation']; - }); - - const spanJson = span && spanToJSON(span); - - return { - spanName: spanJson?.name, - processingType: spanJson?.attributes['graphql.processing.type'], - enclosingSpanName, - enclosingOperations, - }; -} - -const factory = tracingChannel as GraphqlTracingChannelFactory; - -describe('subscribeGraphqlDiagnosticChannels', () => { - // The subscriber captures the async-context strategy's ALS when it binds, so the strategy must be - // installed before we subscribe, and both stay fixed for the file. Only the client changes per test. - beforeAll(() => { - installTestAsyncContextStrategy(); - subscribeGraphqlDiagnosticChannels(factory, { ignoreResolveSpans: false }); - }); - - afterAll(() => { - setAsyncContextStrategy(undefined); - }); - - afterEach(() => { - // Keep the async-context strategy the subscriber bound to in `beforeAll`; wiping it would strand - // the ALS it captured, so no further spans would be created. - const acs = getAsyncContextStrategy(getMainCarrier()); - getMainCarrier().__SENTRY__ = undefined; - setAsyncContextStrategy(acs); - }); - - describe('with span streaming', () => { - it.each([ - [GRAPHQL_DC_CHANNEL_PARSE, {}], - [GRAPHQL_DC_CHANNEL_VALIDATE, {}], - [ - GRAPHQL_DC_CHANNEL_RESOLVE, - { fieldName: 'name', parentType: 'User', fieldType: 'String', fieldPath: 'user.0.name' }, - ], - [GRAPHQL_DC_CHANNEL_EXECUTE, { operationName: 'GetUser' }], - ])('names the %s span with the static fallback when no operation type is available', async (channel, data) => { - initTestClient('stream'); - - const { spanName } = await traceOperation(channel, data); - - expect(spanName).toBe('GraphQL Operation'); - }); - - it.each([ - [GRAPHQL_DC_CHANNEL_EXECUTE, 'query', 'GraphQL query'], - [GRAPHQL_DC_CHANNEL_EXECUTE, 'mutation', 'GraphQL mutation'], - [GRAPHQL_DC_CHANNEL_SUBSCRIBE, 'subscription', 'GraphQL subscription'], - ])('names a %s span after the operation type, dropping the operation name', async (channel, type, expected) => { - initTestClient('stream'); - - const { spanName } = await traceOperation(channel, { operationType: type, operationName: 'GetUser' }); - - expect(spanName).toBe(expected); - }); - - it('records the operation on the root span without renaming it', async () => { - initTestClient('stream'); - - const { enclosingSpanName, enclosingOperations } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { - operationType: 'query', - operationName: 'GetUser', - }); - - expect(enclosingSpanName).toBe('GET /graphql'); - expect(enclosingOperations).toBe('query GetUser'); - }); - }); - - describe.each(['stream', 'static'] as const)('graphql.processing.type (%s)', traceLifecycle => { - it.each([ - [GRAPHQL_DC_CHANNEL_PARSE, {}, 'parse'], - [GRAPHQL_DC_CHANNEL_VALIDATE, {}, 'validate'], - [GRAPHQL_DC_CHANNEL_EXECUTE, { operationType: 'query' }, 'execute'], - // graphql-js `subscribe()` runs an operation, so it is an execute; `graphql.operation.type` - // is what marks it as a subscription. - [GRAPHQL_DC_CHANNEL_SUBSCRIBE, { operationType: 'subscription' }, 'execute'], - [ - GRAPHQL_DC_CHANNEL_RESOLVE, - { fieldName: 'name', parentType: 'User', fieldType: 'String', fieldPath: 'user.0.name' }, - 'resolve', - ], - ])('marks the %s span', async (channel, data, expected) => { - initTestClient(traceLifecycle); - - const { processingType } = await traceOperation(channel, data); - - expect(processingType).toBe(expected); - }); - }); - - describe('without span streaming', () => { - it.each([ - [GRAPHQL_DC_CHANNEL_PARSE, {}, 'graphql.parse'], - [GRAPHQL_DC_CHANNEL_VALIDATE, {}, 'graphql.validate'], - [ - GRAPHQL_DC_CHANNEL_RESOLVE, - { fieldName: 'name', parentType: 'User', fieldType: 'String', fieldPath: 'user.0.name' }, - 'graphql.resolve user.0.name', - ], - [GRAPHQL_DC_CHANNEL_EXECUTE, { operationType: 'query', operationName: 'GetUser' }, 'query GetUser'], - [GRAPHQL_DC_CHANNEL_EXECUTE, {}, 'graphql.execute'], - ])('keeps the %s span name', async (channel, data, expected) => { - initTestClient('static'); - - const { spanName } = await traceOperation(channel, data); - - expect(spanName).toBe(expected); - }); - - it('renames the root span with the operation', async () => { - initTestClient('static'); - - const { enclosingSpanName, enclosingOperations } = await traceOperation(GRAPHQL_DC_CHANNEL_EXECUTE, { - operationType: 'query', - operationName: 'GetUser', - }); - - expect(enclosingSpanName).toBe('GET /graphql (query GetUser)'); - expect(enclosingOperations).toBe('query GetUser'); - }); - }); -}); From 426abf59c5350cff7e1251079dc7677ac0ddb83d Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 25 Aug 2026 13:37:08 +0200 Subject: [PATCH 8/9] Name graphql phase spans after the processing type Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- MIGRATION.md | 8 ++++---- .../suites/tracing/apollo-graphql/span-streaming/test.ts | 8 +++++--- .../src/integrations/graphql/graphql-dc-subscriber.ts | 9 ++++----- .../server-utils/src/integrations/graphql/resolvers.ts | 5 +++-- packages/server-utils/src/integrations/graphql/spans.ts | 7 +++---- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 3c08f6553c59..4cbd21ec4857 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -622,10 +622,10 @@ If you [opt out of span streaming](#opting-out-of-span-streaming), span names re The following span names were adjusted: -| Span op | Before | After | -| ---------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | -| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | `GraphQL ` (`GraphQL query`), or `GraphQL Operation` for parse, validate and resolve spans | +| Span op | Before | After | +| ---------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none | +| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) | Some consequences to be aware of: diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts index 0b220d622f96..10bce9d943ea 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts @@ -33,10 +33,12 @@ describe('GraphQL/Apollo Tests > span streaming', () => { // reach the span name. const resolveSpans = spans.filter(span => span.attributes['graphql.field.path']); expect(resolveSpans.map(span => span.attributes['graphql.field.path']?.value)).toEqual(['hello', 'login']); + expect(resolveSpans.map(span => span.name)).toEqual(['GraphQL resolve', 'GraphQL resolve']); - // Parse, validate and resolve spans have no operation type to name them after. - const fallbackSpans = spans.filter(span => !executeSpans.includes(span)); - expect(fallbackSpans.every(span => span.name === 'GraphQL Operation')).toBe(true); + // Parse and validate spans have no operation type, so they are named after the phase. + const phaseSpans = spans.filter(span => !executeSpans.includes(span) && !resolveSpans.includes(span)); + expect(phaseSpans.length).toBeGreaterThan(0); + expect(phaseSpans.every(span => ['GraphQL parse', 'GraphQL validate'].includes(span.name))).toBe(true); expect(spans.some(span => span.name.includes('GetHello') || span.name.includes('TestMutation'))).toBe( false, diff --git a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts index 57381b1479d1..d62b1cdbf632 100644 --- a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts @@ -3,7 +3,6 @@ import { GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE } from import { GRAPHQL } from '@sentry/conventions/op'; import { getClient, - GRAPHQL_SPAN_NAME_FALLBACK, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -157,7 +156,7 @@ function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void { const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, + name: client && hasSpanStreamingEnabled(client) ? `GraphQL ${PROCESSING_TYPE_PARSE}` : SPAN_NAME_PARSE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, @@ -174,7 +173,7 @@ function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): voi const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_VALIDATE, + name: client && hasSpanStreamingEnabled(client) ? `GraphQL ${PROCESSING_TYPE_VALIDATE}` : SPAN_NAME_VALIDATE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, @@ -204,7 +203,7 @@ function setupOperationChannel( tracingChannel(channelName), data => { const client = getClient(); - const streamedName = data.operationType ? `GraphQL ${data.operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; + const streamedName = `GraphQL ${data.operationType || PROCESSING_TYPE_EXECUTE}`; const span = startInactiveSpan({ name: @@ -251,7 +250,7 @@ function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignor return startInactiveSpan({ name: client && hasSpanStreamingEnabled(client) - ? GRAPHQL_SPAN_NAME_FALLBACK + ? `GraphQL ${PROCESSING_TYPE_RESOLVE}` : `${SPAN_NAME_RESOLVE} ${data.fieldPath}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, diff --git a/packages/server-utils/src/integrations/graphql/resolvers.ts b/packages/server-utils/src/integrations/graphql/resolvers.ts index e64320825a79..8e028d939ee8 100644 --- a/packages/server-utils/src/integrations/graphql/resolvers.ts +++ b/packages/server-utils/src/integrations/graphql/resolvers.ts @@ -10,7 +10,6 @@ import { GRAPHQL } from '@sentry/conventions/op'; import type { Span, SpanAttributes } from '@sentry/core'; import { getClient, - GRAPHQL_SPAN_NAME_FALLBACK, hasSpanStreamingEnabled, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -196,7 +195,9 @@ function createResolverSpan(info: GraphQLResolveInfo, path: string[], parentSpan return startInactiveSpan({ name: - client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : `${SPAN_NAME_RESOLVE} ${path.join('.')}`, + client && hasSpanStreamingEnabled(client) + ? `GraphQL ${PROCESSING_TYPE_RESOLVE}` + : `${SPAN_NAME_RESOLVE} ${path.join('.')}`, attributes, parentSpan, }); diff --git a/packages/server-utils/src/integrations/graphql/spans.ts b/packages/server-utils/src/integrations/graphql/spans.ts index ea170e7f7e0d..16f27d8a5a9c 100644 --- a/packages/server-utils/src/integrations/graphql/spans.ts +++ b/packages/server-utils/src/integrations/graphql/spans.ts @@ -10,7 +10,6 @@ import { GRAPHQL } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; import { getClient, - GRAPHQL_SPAN_NAME_FALLBACK, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -49,7 +48,7 @@ export function startParseSpan(): Span { const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE, + name: client && hasSpanStreamingEnabled(client) ? `GraphQL ${PROCESSING_TYPE_PARSE}` : SPAN_NAME_PARSE, attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE }, }); } @@ -59,7 +58,7 @@ export function startValidateSpan(documentAST: unknown): Span { const client = getClient(); return startInactiveSpan({ - name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_VALIDATE, + name: client && hasSpanStreamingEnabled(client) ? `GraphQL ${PROCESSING_TYPE_VALIDATE}` : SPAN_NAME_VALIDATE, attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_VALIDATE, @@ -175,7 +174,7 @@ export function startExecuteSpan( const operationName = operation?.name?.value ?? args.operationName ?? undefined; const client = getClient(); - const streamedName = operationType ? `GraphQL ${operationType}` : GRAPHQL_SPAN_NAME_FALLBACK; + const streamedName = `GraphQL ${operationType || PROCESSING_TYPE_EXECUTE}`; const span = startInactiveSpan({ name: From 393a67b388d088a9be5e09d91a6515e06261a565 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 25 Aug 2026 13:53:07 +0200 Subject: [PATCH 9/9] Rely on the default trace lifecycle in the streamed graphql suite Claude-Session: https://claude.ai/code/session_013bjBXkGkJo8eL8hkz48byi --- .../suites/tracing/apollo-graphql/span-streaming/instrument.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs index 9346d06c93e9..aed7c6310653 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'stream', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0,