diff --git a/MIGRATION.md b/MIGRATION.md index 6edbb8ad61b1..26a1cf412035 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -613,21 +613,28 @@ 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 processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) | 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, as long as the option stays enabled (the default). Disabling it skips both, as before. + 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/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/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..aed7c6310653 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + 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..10bce9d943ea --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/span-streaming/test.ts @@ -0,0 +1,95 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +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' && + item.attributes['sentry.segment.name']?.value === 'Test Transaction', + ); +} + +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 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, + ); + }, + }) + .start() + .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({ + 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(); + // Both operations are recorded here rather than in the name. + expect(segmentSpan?.attributes['sentry.graphql.operation']?.value).toEqual([ + 'query GetHello', + 'mutation TestMutation', + ]); + }, + }) + .start() + .completed(); + }); + }); +}); 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 a990c825094a..38c292ef7a9b 100644 --- a/packages/server-utils/src/integrations/graphql/constants.ts +++ b/packages/server-utils/src/integrations/graphql/constants.ts @@ -1,18 +1,22 @@ -/* - * 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 (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'; +export const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe'; export const SPAN_NAME_RESOLVE = 'graphql.resolve'; +// 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'; +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'; 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..68f7c832fe14 100644 --- a/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/graphql/graphql-dc-subscriber.ts @@ -2,12 +2,31 @@ 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, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, 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'; @@ -20,21 +39,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'; - -// 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 }; @@ -99,8 +103,12 @@ export interface GraphQLOptions { ignoreTrivialResolveSpans?: boolean; /** - * Rename the enclosing root span to include the operation name(s), e.g. - * `GET /graphql` -> `GET /graphql (query GetUser)`. Defaults to `true`. + * Record the operation name(s) on the enclosing root span as `sentry.graphql.operation`, and rename + * that span to include them, e.g. `GET /graphql` -> `GET /graphql (query GetUser)`. Defaults to + * `true`; when disabled, neither happens. + * + * With span streaming only the attribute is recorded, since the operation name is supplied by the + * client and would make the root span name high cardinality. */ useOperationNameForRootSpan?: boolean; } @@ -145,26 +153,32 @@ 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) ? `GraphQL ${PROCESSING_TYPE_PARSE}` : SPAN_NAME_PARSE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE, }, - }), - ); + }); + }); } 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) ? `GraphQL ${PROCESSING_TYPE_VALIDATE}` : 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), }, }); @@ -189,11 +203,18 @@ function setupOperationChannel( bindTracingChannelToSpan( tracingChannel(channelName), data => { + const client = getClient(); + const streamedName = `GraphQL ${data.operationType || PROCESSING_TYPE_EXECUTE}`; + 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, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_EXECUTE, [GRAPHQL_OPERATION_TYPE]: data.operationType, [GRAPHQL_OPERATION_NAME]: data.operationName || undefined, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(data.document), @@ -225,11 +246,17 @@ function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignor return undefined; } + const client = getClient(); + return startInactiveSpan({ - name: `${SPAN_NAME_RESOLVE} ${data.fieldPath}`, + name: + client && hasSpanStreamingEnabled(client) + ? `GraphQL ${PROCESSING_TYPE_RESOLVE}` + : `${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 feac1cd136e8..8e028d939ee8 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, @@ -23,7 +25,9 @@ import { GRAPHQL_FIELD_TYPE, GRAPHQL_PARENT_NAME, GRAPHQL_PATCHED_SYMBOL, + GRAPHQL_PROCESSING_TYPE, ORIGIN, + PROCESSING_TYPE_RESOLVE, SPAN_NAME_RESOLVE, } from './constants'; import type { @@ -180,13 +184,23 @@ 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(), [GRAPHQL_PARENT_NAME]: info.parentType.name, }; - return startInactiveSpan({ name: `${SPAN_NAME_RESOLVE} ${path.join('.')}`, attributes, parentSpan }); + const client = getClient(); + + return startInactiveSpan({ + name: + client && hasSpanStreamingEnabled(client) + ? `GraphQL ${PROCESSING_TYPE_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..16f27d8a5a9c 100644 --- a/packages/server-utils/src/integrations/graphql/spans.ts +++ b/packages/server-utils/src/integrations/graphql/spans.ts @@ -9,6 +9,8 @@ import { GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE } from import { GRAPHQL } from '@sentry/conventions/op'; import type { Span } from '@sentry/core'; import { + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -16,7 +18,17 @@ 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, + GRAPHQL_PROCESSING_TYPE, + ORIGIN, + PROCESSING_TYPE_EXECUTE, + PROCESSING_TYPE_PARSE, + PROCESSING_TYPE_VALIDATE, + SPAN_NAME_EXECUTE, + SPAN_NAME_PARSE, + SPAN_NAME_VALIDATE, +} from './constants'; import { getOperation, wrapFields, wrapFieldResolver } from './resolvers'; import type { DocumentNode, @@ -33,14 +45,25 @@ 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) ? `GraphQL ${PROCESSING_TYPE_PARSE}` : SPAN_NAME_PARSE, + attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE }, + }); } /** `documentAST` is the 2nd argument to `validate(schema, documentAST, …)`. */ export function startValidateSpan(documentAST: unknown): Span { + const client = getClient(); + return startInactiveSpan({ - name: SPAN_NAME_VALIDATE, - attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(documentAST as GraphqlDocumentNode) }, + name: client && hasSpanStreamingEnabled(client) ? `GraphQL ${PROCESSING_TYPE_VALIDATE}` : SPAN_NAME_VALIDATE, + attributes: { + ...BASE_ATTRIBUTES, + [GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_VALIDATE, + [GRAPHQL_DOCUMENT]: collectGraphqlDocument(documentAST as GraphqlDocumentNode), + }, }); } @@ -150,10 +173,17 @@ export function startExecuteSpan( const operationType = operation?.operation; const operationName = operation?.name?.value ?? args.operationName ?? undefined; + const client = getClient(); + const streamedName = `GraphQL ${operationType || PROCESSING_TYPE_EXECUTE}`; + 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_PROCESSING_TYPE]: PROCESSING_TYPE_EXECUTE, [GRAPHQL_OPERATION_TYPE]: operationType, [GRAPHQL_OPERATION_NAME]: operationName || undefined, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(document), diff --git a/packages/server-utils/src/integrations/graphql/utils.ts b/packages/server-utils/src/integrations/graphql/utils.ts index c92f10d6da93..bf5a78d104a3 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,11 @@ export function renameRootSpanWithOperation(span: Span, operationType: string, o } rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, operations); + 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;