From 4cc4cb7ebb2a1452ac8233e1e5991ac33ca67f9b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 15 Jun 2026 12:03:40 -0400 Subject: [PATCH 1/4] ref(node): Streamline graphql instrumentation Migrate the vendored graphql span lifecycle from the OpenTelemetry tracer to the @sentry/core span API (startInactiveSpan + withActiveSpan), bake the auto.graphql.otel.graphql origin into the execute span (previously set via a Sentry responseHook), and drop the OTel recordException calls (no-ops in the Sentry pipeline) in favour of setStatus. Remove the upstream-only config the SDK never exposed through GraphqlOptions (allowValues, depth, mergeItems, flatResolveSpans) along with the now-dead variable-attribute code and the allowValues opt-out (source literals are now always redacted, which is the de-facto default). Drop the blanket eslint-disable in favour of a scoped oxlint exception, and remove @opentelemetry/api from the vendored source (span types now come from @sentry/core). --- .oxlintrc.base.json | 15 ++- .../src/integrations/tracing/graphql/index.ts | 14 +- .../tracing/graphql/vendored/enum.ts | 1 - .../graphql/vendored/enums/AttributeNames.ts | 3 - .../graphql/vendored/instrumentation.ts | 76 ++++------- .../graphql/vendored/internal-types.ts | 7 +- .../tracing/graphql/vendored/symbols.ts | 1 - .../tracing/graphql/vendored/types.ts | 42 +----- .../tracing/graphql/vendored/utils.ts | 127 +++++------------- 9 files changed, 86 insertions(+), 200 deletions(-) diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 1098efdb425a..cf198146ba9d 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -130,6 +130,20 @@ "no-param-reassign": "off" } }, + { + "files": ["**/integrations/tracing/graphql/vendored/**/*.ts"], + "rules": { + "typescript/consistent-type-imports": "off", + "typescript/no-unnecessary-type-assertion": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-explicit-any": "off", + "typescript/no-this-alias": "off", + "typescript/prefer-for-of": "off", + "max-lines": "off", + "complexity": "off", + "no-param-reassign": "off" + } + }, { "files": ["**/integrations/tracing/redis/vendored/**/*.ts"], "rules": { @@ -154,7 +168,6 @@ "**/integrations/tracing/mongoose/vendored/**/*.ts", "**/integrations/tracing/amqplib/vendored/**/*.ts", "**/integrations/tracing/prisma/vendored/**/*.ts", - "**/integrations/tracing/graphql/vendored/**/*.ts", "**/integrations/tracing/postgres/vendored/**/*.ts", "**/integrations/tracing/fastify/vendored/**/*.ts" ], diff --git a/packages/node/src/integrations/tracing/graphql/index.ts b/packages/node/src/integrations/tracing/graphql/index.ts index d49a290b5e42..d488bae044c2 100644 --- a/packages/node/src/integrations/tracing/graphql/index.ts +++ b/packages/node/src/integrations/tracing/graphql/index.ts @@ -1,9 +1,7 @@ -import type { AttributeValue } from '@opentelemetry/api'; -import { SpanStatusCode } from '@opentelemetry/api'; import { GraphQLInstrumentation } from './vendored/instrumentation'; -import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, getRootSpan, spanToJSON } from '@sentry/core'; -import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; +import type { IntegrationFn, SpanAttributeValue } from '@sentry/core'; +import { defineIntegration, getRootSpan, SPAN_STATUS_ERROR, spanToJSON } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemetry'; interface GraphqlOptions { @@ -46,13 +44,11 @@ export const instrumentGraphql = generateInstrumentOnce( return { ...options, responseHook(span, result) { - addOriginToSpan(span, 'auto.graphql.otel.graphql'); - // We want to ensure spans are marked as errored if there are errors in the result // We only do that if the span is not already marked with a status const resultWithMaybeError = result as { errors?: { message: string }[] }; if (resultWithMaybeError.errors?.length && !spanToJSON(span).status) { - span.setStatus({ code: SpanStatusCode.ERROR }); + span.setStatus({ code: SPAN_STATUS_ERROR }); } const attributes = spanToJSON(span).data; @@ -134,7 +130,7 @@ function getOptionsWithDefaults(options?: GraphqlOptions): GraphqlOptions { } // copy from packages/opentelemetry/utils -function getGraphqlOperationNamesFromAttribute(attr: AttributeValue): string { +function getGraphqlOperationNamesFromAttribute(attr: SpanAttributeValue): string { if (Array.isArray(attr)) { // oxlint-disable-next-line typescript/require-array-sort-compare const sorted = attr.slice().sort(); diff --git a/packages/node/src/integrations/tracing/graphql/vendored/enum.ts b/packages/node/src/integrations/tracing/graphql/vendored/enum.ts index 9b12216dc5a8..8a3790e26753 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/enum.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/enum.ts @@ -17,7 +17,6 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-graphql * - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0 */ -/* eslint-disable */ export enum AllowedOperationTypes { QUERY = 'query', diff --git a/packages/node/src/integrations/tracing/graphql/vendored/enums/AttributeNames.ts b/packages/node/src/integrations/tracing/graphql/vendored/enums/AttributeNames.ts index 51ee1b973edd..5dca402073a9 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/enums/AttributeNames.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/enums/AttributeNames.ts @@ -17,7 +17,6 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-graphql * - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0 */ -/* eslint-disable */ export enum AttributeNames { SOURCE = 'graphql.source', @@ -27,6 +26,4 @@ export enum AttributeNames { PARENT_NAME = 'graphql.parent.name', OPERATION_TYPE = 'graphql.operation.type', OPERATION_NAME = 'graphql.operation.name', - VARIABLES = 'graphql.variables.', - ERROR_VALIDATION_NAME = 'graphql.validation.error', } diff --git a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts index bdb0cc2366d4..3cb7ba2363cf 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts @@ -18,10 +18,10 @@ * - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0 * - Types from `graphql` package inlined as simplified interfaces * - Minor TypeScript strictness adjustments + * - Span lifecycle migrated from the OpenTelemetry tracer to the @sentry/core span API + * - `auto.graphql.otel.graphql` origin baked into the execute span (previously set via a Sentry responseHook) */ -/* eslint-disable */ -import { context, trace } from '@opentelemetry/api'; import { isWrapped, InstrumentationBase, @@ -60,26 +60,17 @@ import { ObjectWithGraphQLData, OPERATION_NOT_SUPPORTED, } from './internal-types'; -import { - addInputVariableAttributes, - addSpanSource, - endSpan, - getOperation, - isPromise, - wrapFieldResolver, - wrapFields, -} from './utils'; - -import { SDK_VERSION } from '@sentry/core'; -import * as api from '@opentelemetry/api'; +import { addSpanSource, endSpan, getOperation, isPromise, wrapFieldResolver, wrapFields } from './utils'; + +import type { Span } from '@sentry/core'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, withActiveSpan } from '@sentry/core'; import { GraphQLInstrumentationConfig, GraphQLInstrumentationParsedConfig } from './types'; const PACKAGE_NAME = '@sentry/instrumentation-graphql'; +const ORIGIN = 'auto.graphql.otel.graphql'; + const DEFAULT_CONFIG: GraphQLInstrumentationParsedConfig = { - mergeItems: false, - depth: -1, - allowValues: false, ignoreResolveSpans: false, }; @@ -209,7 +200,7 @@ export class GraphQLInstrumentation extends InstrumentationBase { + return withActiveSpan(span, () => { return safeExecuteInTheMiddle>( () => { return (original as executeFunctionWithObj).apply(this, [processedArgs]); @@ -223,7 +214,7 @@ export class GraphQLInstrumentation extends InstrumentationBase) { + private _handleExecutionResult(span: Span, err?: Error, result?: PromiseOrValue) { const config = this.getConfig(); if (result === undefined || err) { endSpan(span, err); @@ -252,7 +243,7 @@ export class GraphQLInstrumentation extends InstrumentationBase { + return withActiveSpan(span, () => { return safeExecuteInTheMiddle( () => { return original.call(obj, source, options); @@ -313,7 +303,7 @@ export class GraphQLInstrumentation extends InstrumentationBase { - const span = this.tracer.startSpan(SpanNames.VALIDATE, {}); + const span = startInactiveSpan({ name: SpanNames.VALIDATE }); - return context.with(trace.setSpan(context.active(), span), () => { + return withActiveSpan(span, () => { return safeExecuteInTheMiddle>( () => { return original.call(obj, schema, documentAST, rules, options, typeInfo); }, - (err, errors) => { + (err, _errors) => { if (!documentAST.loc) { span.updateName(SpanNames.SCHEMA_VALIDATE); } - if (errors && errors.length) { - span.recordException({ - name: AttributeNames.ERROR_VALIDATION_NAME, - message: JSON.stringify(errors), - }); - } endSpan(span, err); }, ); }); } - private _createExecuteSpan(operation: DefinitionNode | undefined, processedArgs: ExecutionArgs): api.Span { - const config = this.getConfig(); - - const span = this.tracer.startSpan(SpanNames.EXECUTE, {}); + private _createExecuteSpan(operation: DefinitionNode | undefined, processedArgs: ExecutionArgs): Span { + const span = startInactiveSpan({ + name: SpanNames.EXECUTE, + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN }, + }); if (operation) { const { operation: operationType, name: nameNode } = operation as OperationDefinitionNode; @@ -384,11 +369,7 @@ export class GraphQLInstrumentation extends InstrumentationBase this.getConfig(), - fieldResolverForExecute, - isUsingDefaultResolver, - ); + fieldResolver = wrapFieldResolver(() => this.getConfig(), fieldResolverForExecute, isUsingDefaultResolver); if (schema) { - wrapFields(schema.getQueryType() as any, this.tracer, () => this.getConfig()); - wrapFields(schema.getMutationType() as any, this.tracer, () => this.getConfig()); + wrapFields(schema.getQueryType() as any, () => this.getConfig()); + wrapFields(schema.getMutationType() as any, () => this.getConfig()); } return { diff --git a/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts b/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts index 41950d3270a7..00691b168fec 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts @@ -19,9 +19,8 @@ * - Types from `graphql` package inlined as simplified interfaces * - Minor TypeScript strictness adjustments */ -/* eslint-disable */ -import type * as api from '@opentelemetry/api'; +import type { Span } from '@sentry/core'; import type { DocumentNode, ExecutionArgs, @@ -91,12 +90,12 @@ export type validateType = ( ) => ReadonlyArray; export interface GraphQLField { - span: api.Span; + span: Span; } interface OtelGraphQLData { source?: any; - span: api.Span; + span: Span; fields: { [key: string]: GraphQLField }; } diff --git a/packages/node/src/integrations/tracing/graphql/vendored/symbols.ts b/packages/node/src/integrations/tracing/graphql/vendored/symbols.ts index 92e1442ec678..e8abe7864add 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/symbols.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/symbols.ts @@ -17,7 +17,6 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-graphql * - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0 */ -/* eslint-disable */ export const OTEL_PATCHED_SYMBOL = Symbol.for('opentelemetry.patched'); diff --git a/packages/node/src/integrations/tracing/graphql/vendored/types.ts b/packages/node/src/integrations/tracing/graphql/vendored/types.ts index 5ea84d02c831..f71cd9d3c2e4 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/types.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/types.ts @@ -17,33 +17,15 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-graphql * - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0 */ -/* eslint-disable */ -import { InstrumentationConfig } from '@opentelemetry/instrumentation'; -import type * as api from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import type { Span } from '@sentry/core'; export interface GraphQLInstrumentationExecutionResponseHook { - (span: api.Span, data: any): void; + (span: Span, data: any): void; } export interface GraphQLInstrumentationConfig extends InstrumentationConfig { - /** - * When set to true it will not remove attributes values from schema source. - * By default all values that can be sensitive are removed and replaced - * with "*" - * - * @default false - */ - allowValues?: boolean; - - /** - * The maximum depth of fields/resolvers to instrument. - * When set to 0 it will not instrument fields and resolvers - * - * @default undefined - */ - depth?: number; - /** * Do not create spans for resolvers. * @@ -63,22 +45,6 @@ export interface GraphQLInstrumentationConfig extends InstrumentationConfig { */ ignoreTrivialResolveSpans?: boolean; - /** - * Place all resolve spans under the same parent instead of producing a nested tree structure. - * - * @default false - */ - flatResolveSpans?: boolean; - - /** - * Whether to merge list items into a single element. - * - * @example `users.*.name` instead of `users.0.name`, `users.1.name` - * - * @default false - */ - mergeItems?: boolean; - /** * Hook that allows adding custom span attributes based on the data * returned from "execute" GraphQL action. @@ -99,5 +65,5 @@ type RequireSpecificKeys = T & { [P in K]-?: T[P] }; // Merged and parsed config of default instrumentation config and GraphQL export type GraphQLInstrumentationParsedConfig = RequireSpecificKeys< GraphQLInstrumentationConfig, - 'mergeItems' | 'depth' | 'allowValues' | 'ignoreResolveSpans' + 'ignoreResolveSpans' >; diff --git a/packages/node/src/integrations/tracing/graphql/vendored/utils.ts b/packages/node/src/integrations/tracing/graphql/vendored/utils.ts index 3efe92f0b813..040a17b4cb7a 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/utils.ts @@ -18,8 +18,8 @@ * - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0 * - Types from `graphql` package inlined as simplified interfaces * - Minor TypeScript strictness adjustments + * - Span lifecycle migrated from the OpenTelemetry tracer to the @sentry/core span API */ -/* eslint-disable */ import type { DocumentNode, @@ -33,7 +33,8 @@ import type { Maybe, Token, } from './graphql-types'; -import * as api from '@opentelemetry/api'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { SPAN_STATUS_ERROR, startInactiveSpan, withActiveSpan } from '@sentry/core'; import { AllowedOperationTypes, SpanNames, TokenKind } from './enum'; import { AttributeNames } from './enums/AttributeNames'; import { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_PATCHED_SYMBOL } from './symbols'; @@ -52,41 +53,12 @@ const isObjectLike = (value: unknown): value is { [key: string]: unknown } => { return typeof value == 'object' && value !== null; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function addInputVariableAttribute(span: api.Span, key: string, variable: any) { - if (Array.isArray(variable)) { - variable.forEach((value, idx) => { - addInputVariableAttribute(span, `${key}.${idx}`, value); - }); - } else if (variable instanceof Object) { - Object.entries(variable).forEach(([nestedKey, value]) => { - addInputVariableAttribute(span, `${key}.${nestedKey}`, value); - }); - } else { - span.setAttribute(`${AttributeNames.VARIABLES}${String(key)}`, variable); - } -} - -export function addInputVariableAttributes(span: api.Span, variableValues: { [key: string]: any }) { - Object.entries(variableValues).forEach(([key, value]) => { - addInputVariableAttribute(span, key, value); - }); -} - -export function addSpanSource( - span: api.Span, - loc?: Location, - allowValues?: boolean, - start?: number, - end?: number, -): void { - const source = getSourceFromLocation(loc, allowValues, start, end); +export function addSpanSource(span: Span, loc?: Location, start?: number, end?: number): void { + const source = getSourceFromLocation(loc, start, end); span.setAttribute(AttributeNames.SOURCE, source); } function createFieldIfNotExists( - tracer: api.Tracer, - getConfig: () => GraphQLInstrumentationParsedConfig, contextValue: any, info: GraphQLResolveInfo, path: string[], @@ -99,11 +71,10 @@ function createFieldIfNotExists( return { field, spanAdded: false }; } - const config = getConfig(); - const parentSpan = config.flatResolveSpans ? getRootSpan(contextValue) : getParentFieldSpan(contextValue, path); + const parentSpan = getParentFieldSpan(contextValue, path); field = { - span: createResolverSpan(tracer, getConfig, contextValue, info, path, parentSpan), + span: createResolverSpan(contextValue, info, path, parentSpan), }; addField(contextValue, path, field); @@ -111,42 +82,33 @@ function createFieldIfNotExists( return { field, spanAdded: true }; } -function createResolverSpan( - tracer: api.Tracer, - getConfig: () => GraphQLInstrumentationParsedConfig, - contextValue: any, - info: GraphQLResolveInfo, - path: string[], - parentSpan?: api.Span, -): api.Span { - const attributes: api.SpanAttributes = { +function createResolverSpan(contextValue: any, info: GraphQLResolveInfo, path: string[], parentSpan?: Span): Span { + const attributes: SpanAttributes = { [AttributeNames.FIELD_NAME]: info.fieldName, [AttributeNames.FIELD_PATH]: path.join('.'), [AttributeNames.FIELD_TYPE]: info.returnType.toString(), [AttributeNames.PARENT_NAME]: info.parentType.name, }; - const span = tracer.startSpan( - `${SpanNames.RESOLVE} ${attributes[AttributeNames.FIELD_PATH]}`, - { - attributes, - }, - parentSpan ? api.trace.setSpan(api.context.active(), parentSpan) : undefined, - ); + const span = startInactiveSpan({ + name: `${SpanNames.RESOLVE} ${attributes[AttributeNames.FIELD_PATH]}`, + attributes, + parentSpan, + }); const document = contextValue[OTEL_GRAPHQL_DATA_SYMBOL].source; const fieldNode = info.fieldNodes.find(fieldNode => fieldNode.kind === 'Field'); if (fieldNode) { - addSpanSource(span, document.loc, getConfig().allowValues, fieldNode.loc?.start, fieldNode.loc?.end); + addSpanSource(span, document.loc, fieldNode.loc?.start, fieldNode.loc?.end); } return span; } -export function endSpan(span: api.Span, error?: Error): void { +export function endSpan(span: Span, error?: Error): void { if (error) { - span.recordException(error); + span.setStatus({ code: SPAN_STATUS_ERROR, message: error.message }); } span.end(); } @@ -175,7 +137,7 @@ function getField(contextValue: any, path: string[]): GraphQLField { return contextValue[OTEL_GRAPHQL_DATA_SYMBOL].fields[path.join('.')]; } -function getParentFieldSpan(contextValue: any, path: string[]): api.Span { +function getParentFieldSpan(contextValue: any, path: string[]): Span { for (let i = path.length - 1; i > 0; i--) { const field = getField(contextValue, path.slice(0, i)); @@ -187,20 +149,15 @@ function getParentFieldSpan(contextValue: any, path: string[]): api.Span { return getRootSpan(contextValue); } -function getRootSpan(contextValue: any): api.Span { +function getRootSpan(contextValue: any): Span { return contextValue[OTEL_GRAPHQL_DATA_SYMBOL].span; } -function pathToArray(mergeItems: boolean, path: GraphQLPath): string[] { +function pathToArray(path: GraphQLPath): string[] { const flattened: string[] = []; let curr: GraphQLPath | undefined = path; while (curr) { - let key = curr.key; - - if (mergeItems && typeof key === 'number') { - key = '*'; - } - flattened.push(String(key)); + flattened.push(String(curr.key)); curr = curr.prev; } return flattened.reverse(); @@ -224,12 +181,7 @@ function repeatChar(char: string, to: number): string { const KindsToBeRemoved: string[] = [TokenKind.FLOAT, TokenKind.STRING, TokenKind.INT, TokenKind.BLOCK_STRING]; -export function getSourceFromLocation( - loc?: Location, - allowValues = false, - inputStart?: number, - inputEnd?: number, -): string { +export function getSourceFromLocation(loc?: Location, inputStart?: number, inputEnd?: number): string { let source = ''; if (loc?.startToken) { @@ -251,8 +203,7 @@ export function getSourceFromLocation( } let value = next.value || next.kind; let space = ''; - if (!allowValues && KindsToBeRemoved.indexOf(next.kind) >= 0) { - // value = repeatChar('*', value.length); + if (KindsToBeRemoved.indexOf(next.kind) >= 0) { value = '*'; } if (next.kind === TokenKind.STRING) { @@ -282,7 +233,6 @@ export function getSourceFromLocation( export function wrapFields( type: Maybe, - tracer: api.Tracer, getConfig: () => GraphQLInstrumentationParsedConfig, ): void { if (!type || (type as any)[OTEL_PATCHED_SYMBOL]) { @@ -300,13 +250,13 @@ export function wrapFields( } if (field.resolve) { - field.resolve = wrapFieldResolver(tracer, getConfig, field.resolve); + field.resolve = wrapFieldResolver(getConfig, field.resolve); } if (field.type) { const unwrappedTypes = unwrapType(field.type); for (const unwrappedType of unwrappedTypes) { - wrapFields(unwrappedType as any, tracer, getConfig); + wrapFields(unwrappedType as any, getConfig); } } }); @@ -339,19 +289,18 @@ function isGraphQLObjectType(type: GraphQLType): type is GraphQLObjectType { return 'getFields' in type && typeof type.getFields === 'function'; } -const handleResolveSpanError = (resolveSpan: api.Span, err: any, shouldEndSpan: boolean) => { +const handleResolveSpanError = (resolveSpan: Span, err: any, shouldEndSpan: boolean) => { if (!shouldEndSpan) { return; } - resolveSpan.recordException(err); resolveSpan.setStatus({ - code: api.SpanStatusCode.ERROR, + code: SPAN_STATUS_ERROR, message: err.message, }); resolveSpan.end(); }; -const handleResolveSpanSuccess = (resolveSpan: api.Span, shouldEndSpan: boolean) => { +const handleResolveSpanSuccess = (resolveSpan: Span, shouldEndSpan: boolean) => { if (!shouldEndSpan) { return; } @@ -359,7 +308,6 @@ const handleResolveSpanSuccess = (resolveSpan: api.Span, shouldEndSpan: boolean) }; export function wrapFieldResolver( - tracer: api.Tracer, getConfig: () => GraphQLInstrumentationParsedConfig, fieldResolver: Maybe & OtelPatched>, isDefaultResolver = false, @@ -398,20 +346,13 @@ export function wrapFieldResolver( if (!contextValue[OTEL_GRAPHQL_DATA_SYMBOL]) { return fieldResolver.call(this, source, args, contextValue, info); } - const path = pathToArray(config.mergeItems, info && info.path); - const depth = path.filter((item: any) => typeof item === 'string').length; - - let span: api.Span; - let shouldEndSpan = false; - if (config.depth >= 0 && config.depth < depth) { - span = getParentFieldSpan(contextValue, path); - } else { - const { field, spanAdded } = createFieldIfNotExists(tracer, getConfig, contextValue, info, path); - span = field.span; - shouldEndSpan = spanAdded; - } + const path = pathToArray(info?.path); + + const { field, spanAdded } = createFieldIfNotExists(contextValue, info, path); + const span = field.span; + const shouldEndSpan = spanAdded; - return api.context.with(api.trace.setSpan(api.context.active(), span), () => { + return withActiveSpan(span, () => { try { const res = fieldResolver.call(this, source, args, contextValue, info); if (isPromise(res)) { From 7dcd8c829933536504b081bf4a205374a276a76d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 15 Jun 2026 12:16:43 -0400 Subject: [PATCH 2/4] test(node-integration-tests): Cover graphql resolve spans and source redaction Add real Apollo integration coverage for two paths the existing apollo-graphql suite never exercised: - resolve spans: a scenario with `ignoreResolveSpans: false` asserting the parse/validate/resolve span tree (the resolver lifecycle migrated to the @sentry/core span API). - source redaction: an inline string literal must be redacted to `"*"` in `graphql.source`, locking in the now-hardcoded default after dropping the `allowValues` option. --- .../apollo-graphql/resolvers/instrument.mjs | 10 ++++ .../resolvers/scenario-query.mjs | 26 ++++++++++ .../tracing/apollo-graphql/resolvers/test.ts | 52 +++++++++++++++++++ .../apollo-graphql/scenario-redaction.mjs | 31 +++++++++++ .../suites/tracing/apollo-graphql/test.ts | 35 +++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/scenario-query.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-redaction.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/instrument.mjs new file mode 100644 index 000000000000..aed7c6310653 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/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/resolvers/scenario-query.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/scenario-query.mjs new file mode 100644 index 000000000000..dcb0cb6ef9bc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/scenario-query.mjs @@ -0,0 +1,26 @@ +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: '{hello}', + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }, + ); +} + +run(); 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 new file mode 100644 index 000000000000..3a97e4fc33af --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts @@ -0,0 +1,52 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +// Server start transaction (Apollo Server v5 no longer runs introspection query on start) +const EXPECTED_START_SERVER_TRANSACTION = { + transaction: 'Test Server Start', +}; + +describe('GraphQL/Apollo Tests > resolve spans', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // With `ignoreResolveSpans: false`, the instrumentation emits a span for the execute step as well as + // for `parse`, `validate` and each (non-trivial) field resolver. + const EXPECTED_TRANSACTION = { + // `useOperationNameForRootSpan` defaults to true, so the root span name gets the operation appended. + transaction: 'Test Transaction (query)', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'query', + origin: 'auto.graphql.otel.graphql', + data: expect.objectContaining({ + 'graphql.operation.type': 'query', + 'graphql.source': '{hello}', + 'sentry.origin': 'auto.graphql.otel.graphql', + }), + }), + expect.objectContaining({ description: 'graphql.parse' }), + expect.objectContaining({ description: 'graphql.validate' }), + expect.objectContaining({ + description: 'graphql.resolve hello', + data: expect.objectContaining({ + 'graphql.field.name': 'hello', + 'graphql.field.path': 'hello', + 'graphql.field.type': 'String', + 'graphql.parent.name': 'Query', + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-query.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('emits parse, validate and resolve spans when ignoreResolveSpans is false', async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-redaction.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-redaction.mjs new file mode 100644 index 000000000000..95bb6aab6766 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-redaction.mjs @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node'; +import gql from 'graphql-tag'; + +async function run() { + const { createApolloServer } = await import('./apollo-server.mjs'); + const server = createApolloServer(); + + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async span => { + // Inline string literal (not a variable) so we can assert it gets redacted out of `graphql.source`. + await server.executeOperation({ + query: gql` + mutation { + login(email: "secret@example.com") + } + `, + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts index 65acb67a94ac..6bacfb66091f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts @@ -80,6 +80,41 @@ describe('GraphQL/Apollo Tests', () => { ); }); + describe('redaction', () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction (mutation)', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'mutation', + status: 'ok', + origin: 'auto.graphql.otel.graphql', + data: expect.objectContaining({ + 'graphql.operation.type': 'mutation', + // The inline email literal must be redacted to `"*"`, so the raw value can never reach `graphql.source`. + 'graphql.source': expect.stringContaining('login(email: "*")'), + 'sentry.origin': 'auto.graphql.otel.graphql', + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-redaction.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('redacts inline literal values from graphql.source.', async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + }, + { copyPaths: ['apollo-server.mjs'] }, + ); + }); + describe('error', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction (mutation Mutation)', From 6327f24670192e446ad4c5149c32fb2026a777a3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 15 Jun 2026 13:49:28 -0400 Subject: [PATCH 3/4] chore: type fixes --- .oxlintrc.base.json | 15 +----------- .../graphql/vendored/instrumentation.ts | 24 +++++++++++-------- .../graphql/vendored/internal-types.ts | 2 +- .../tracing/graphql/vendored/utils.ts | 10 ++++---- 4 files changed, 21 insertions(+), 30 deletions(-) diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index cf198146ba9d..ba804e364d0d 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -130,20 +130,6 @@ "no-param-reassign": "off" } }, - { - "files": ["**/integrations/tracing/graphql/vendored/**/*.ts"], - "rules": { - "typescript/consistent-type-imports": "off", - "typescript/no-unnecessary-type-assertion": "off", - "typescript/no-unsafe-member-access": "off", - "typescript/no-explicit-any": "off", - "typescript/no-this-alias": "off", - "typescript/prefer-for-of": "off", - "max-lines": "off", - "complexity": "off", - "no-param-reassign": "off" - } - }, { "files": ["**/integrations/tracing/redis/vendored/**/*.ts"], "rules": { @@ -159,6 +145,7 @@ "**/integrations/fs/vendored/**/*.ts", "**/integrations/tracing/knex/vendored/**/*.ts", "**/integrations/tracing/mongo/vendored/**/*.ts", + "**/integrations/tracing/graphql/vendored/**/*.ts", "**/integrations/tracing/koa/vendored/**/*.ts", "**/integrations/tracing/mysql2/vendored/**/*.ts", "**/integration/aws/vendored/**/*.ts", diff --git a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts index 3cb7ba2363cf..66f59832b8fc 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts @@ -22,6 +22,8 @@ * - `auto.graphql.otel.graphql` origin baked into the execute span (previously set via a Sentry responseHook) */ +/* oxlint-disable max-lines */ + import { isWrapped, InstrumentationBase, @@ -51,20 +53,20 @@ import { AttributeNames } from './enums/AttributeNames'; import { OTEL_GRAPHQL_DATA_SYMBOL } from './symbols'; import { - executeFunctionWithObj, - executeArgumentsArray, - executeType, - parseType, - validateType, - OtelExecutionArgs, - ObjectWithGraphQLData, + type executeFunctionWithObj, + type executeArgumentsArray, + type executeType, + type parseType, + type validateType, + type OtelExecutionArgs, + type ObjectWithGraphQLData, OPERATION_NOT_SUPPORTED, } from './internal-types'; import { addSpanSource, endSpan, getOperation, isPromise, wrapFieldResolver, wrapFields } from './utils'; import type { Span } from '@sentry/core'; import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, withActiveSpan } from '@sentry/core'; -import { GraphQLInstrumentationConfig, GraphQLInstrumentationParsedConfig } from './types'; +import type { GraphQLInstrumentationConfig, GraphQLInstrumentationParsedConfig } from './types'; const PACKAGE_NAME = '@sentry/instrumentation-graphql'; @@ -222,7 +224,7 @@ export class GraphQLInstrumentation extends InstrumentationBase).then( + result.then( resultData => { if (typeof config.responseHook !== 'function') { endSpan(span); @@ -239,7 +241,7 @@ export class GraphQLInstrumentation extends InstrumentationBase, ): OtelExecutionArgs { if (!contextValue) { + // oxlint-disable-next-line no-param-reassign contextValue = {}; } @@ -407,6 +410,7 @@ export class GraphQLInstrumentation extends InstrumentationBase this.getConfig(), fieldResolverForExecute, isUsingDefaultResolver); if (schema) { diff --git a/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts b/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts index 00691b168fec..76fda6062338 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/internal-types.ts @@ -36,7 +36,7 @@ import type { TypeInfo, ValidationRule, } from './graphql-types'; -import { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_PATCHED_SYMBOL } from './symbols'; +import type { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_PATCHED_SYMBOL } from './symbols'; export type { Maybe } from './graphql-types'; diff --git a/packages/node/src/integrations/tracing/graphql/vendored/utils.ts b/packages/node/src/integrations/tracing/graphql/vendored/utils.ts index 040a17b4cb7a..eced9d1b8655 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/utils.ts @@ -38,8 +38,8 @@ import { SPAN_STATUS_ERROR, startInactiveSpan, withActiveSpan } from '@sentry/co import { AllowedOperationTypes, SpanNames, TokenKind } from './enum'; import { AttributeNames } from './enums/AttributeNames'; import { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_PATCHED_SYMBOL } from './symbols'; -import { GraphQLField, GraphQLPath, ObjectWithGraphQLData, OtelPatched } from './internal-types'; -import { GraphQLInstrumentationParsedConfig } from './types'; +import type { GraphQLField, GraphQLPath, ObjectWithGraphQLData, OtelPatched } from './internal-types'; +import type { GraphQLInstrumentationParsedConfig } from './types'; const OPERATION_VALUES = Object.values(AllowedOperationTypes); @@ -120,10 +120,10 @@ export function getOperation(document: DocumentNode, operationName?: Maybe OPERATION_VALUES.indexOf((definition as any)?.operation) !== -1) - .find(definition => operationName === (definition as any)?.name?.value); + .filter(definition => OPERATION_VALUES.indexOf(definition?.operation) !== -1) + .find(definition => operationName === definition?.name?.value); } else { - return document.definitions.find(definition => OPERATION_VALUES.indexOf((definition as any)?.operation) !== -1); + return document.definitions.find(definition => OPERATION_VALUES.indexOf(definition?.operation) !== -1); } } From 2b354fa8eebb9d7f858ef72b2057104762c2a682 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 15 Jun 2026 15:15:01 -0400 Subject: [PATCH 4/4] ref(node): Inline graphql responseHook logic into instrumentation Move the Sentry-specific responseHook behavior (error status + root span renaming via useOperationNameForRootSpan) directly into the vendored graphql instrumentation, dropping the generic responseHook config indirection. The behavior is now applied when the execution result is handled. Also remove the graphql unit test, which only asserted default option assignment against a mock. The defaults and the operation-name formatting (including the >5 truncation) are already covered end-to-end by the apollo-graphql integration suite. --- .../src/integrations/tracing/graphql/index.ts | 75 +----------- .../graphql/vendored/instrumentation.ts | 110 +++++++++++++----- .../tracing/graphql/vendored/types.ts | 18 +-- .../test/integrations/tracing/graphql.test.ts | 47 -------- 4 files changed, 90 insertions(+), 160 deletions(-) delete mode 100644 packages/node/test/integrations/tracing/graphql.test.ts diff --git a/packages/node/src/integrations/tracing/graphql/index.ts b/packages/node/src/integrations/tracing/graphql/index.ts index d488bae044c2..51bfc959a250 100644 --- a/packages/node/src/integrations/tracing/graphql/index.ts +++ b/packages/node/src/integrations/tracing/graphql/index.ts @@ -1,8 +1,7 @@ import { GraphQLInstrumentation } from './vendored/instrumentation'; -import type { IntegrationFn, SpanAttributeValue } from '@sentry/core'; -import { defineIntegration, getRootSpan, SPAN_STATUS_ERROR, spanToJSON } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemetry'; interface GraphqlOptions { /** @@ -38,57 +37,7 @@ const INTEGRATION_NAME = 'Graphql'; export const instrumentGraphql = generateInstrumentOnce( INTEGRATION_NAME, GraphQLInstrumentation, - (_options: GraphqlOptions) => { - const options = getOptionsWithDefaults(_options); - - return { - ...options, - responseHook(span, result) { - // We want to ensure spans are marked as errored if there are errors in the result - // We only do that if the span is not already marked with a status - const resultWithMaybeError = result as { errors?: { message: string }[] }; - if (resultWithMaybeError.errors?.length && !spanToJSON(span).status) { - span.setStatus({ code: SPAN_STATUS_ERROR }); - } - - const attributes = spanToJSON(span).data; - - // If operation.name is not set, we fall back to use operation.type only - const operationType = attributes['graphql.operation.type']; - const operationName = attributes['graphql.operation.name']; - - if (options.useOperationNameForRootSpan && operationType) { - const rootSpan = getRootSpan(span); - const rootSpanAttributes = spanToJSON(rootSpan).data; - - const existingOperations = rootSpanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION] || []; - - const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; - - // We keep track of each operation on the root span - // This can either be a string, or an array of strings (if there are multiple operations) - if (Array.isArray(existingOperations)) { - (existingOperations as string[]).push(newOperation); - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, existingOperations); - } else if (typeof existingOperations === 'string') { - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, [existingOperations, newOperation]); - } else { - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, newOperation); - } - - if (!spanToJSON(rootSpan).data['original-description']) { - rootSpan.setAttribute('original-description', spanToJSON(rootSpan).description); - } - // Important for e.g. @sentry/aws-serverless because this would otherwise overwrite the name again - rootSpan.updateName( - `${spanToJSON(rootSpan).data['original-description']} (${getGraphqlOperationNamesFromAttribute( - existingOperations, - )})`, - ); - } - }, - }; - }, + (_options: GraphqlOptions) => getOptionsWithDefaults(_options), ); const _graphqlIntegration = ((options: GraphqlOptions = {}) => { @@ -128,21 +77,3 @@ function getOptionsWithDefaults(options?: GraphqlOptions): GraphqlOptions { ...options, }; } - -// copy from packages/opentelemetry/utils -function getGraphqlOperationNamesFromAttribute(attr: SpanAttributeValue): string { - if (Array.isArray(attr)) { - // oxlint-disable-next-line typescript/require-array-sort-compare - const sorted = attr.slice().sort(); - - // Up to 5 items, we just add all of them - if (sorted.length <= 5) { - return sorted.join(', '); - } else { - // Else, we add the first 5 and the diff of other operations - return `${sorted.slice(0, 5).join(', ')}, +${sorted.length - 5}`; - } - } - - return `${attr}`; -} diff --git a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts index 66f59832b8fc..c86164402020 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts @@ -20,6 +20,8 @@ * - Minor TypeScript strictness adjustments * - Span lifecycle migrated from the OpenTelemetry tracer to the @sentry/core span API * - `auto.graphql.otel.graphql` origin baked into the execute span (previously set via a Sentry responseHook) + * - The generic `responseHook` config was removed; its Sentry-specific logic (error status + root span renaming + * via `useOperationNameForRootSpan`) is now applied directly when the execution result is handled */ /* oxlint-disable max-lines */ @@ -64,8 +66,17 @@ import { } from './internal-types'; import { addSpanSource, endSpan, getOperation, isPromise, wrapFieldResolver, wrapFields } from './utils'; -import type { Span } from '@sentry/core'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, withActiveSpan } from '@sentry/core'; +import type { Span, SpanAttributeValue } from '@sentry/core'; +import { + getRootSpan, + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + spanToJSON, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemetry'; import type { GraphQLInstrumentationConfig, GraphQLInstrumentationParsedConfig } from './types'; const PACKAGE_NAME = '@sentry/instrumentation-graphql'; @@ -217,7 +228,6 @@ export class GraphQLInstrumentation extends InstrumentationBase) { - const config = this.getConfig(); if (result === undefined || err) { endSpan(span, err); return; @@ -226,43 +236,71 @@ export class GraphQLInstrumentation extends InstrumentationBase { - if (typeof config.responseHook !== 'function') { - endSpan(span); - return; - } - this._executeResponseHook(span, resultData); + this._updateSpanFromResult(span, resultData); + endSpan(span); }, error => { endSpan(span, error); }, ); } else { - if (typeof config.responseHook !== 'function') { - endSpan(span); - return; - } - this._executeResponseHook(span, result); + this._updateSpanFromResult(span, result); + endSpan(span); } } - private _executeResponseHook(span: Span, result: ExecutionResult) { - const { responseHook } = this.getConfig(); - if (!responseHook) { + /** + * Applies Sentry-specific span mutations based on the GraphQL execution result: + * - Marks the execute span as errored if the result contains errors (and no status was set yet) + * - Optionally renames the containing root span to include the GraphQL operation name(s) + */ + private _updateSpanFromResult(span: Span, result: ExecutionResult): void { + // We want to ensure spans are marked as errored if there are errors in the result + // We only do that if the span is not already marked with a status + if (result.errors?.length && !spanToJSON(span).status) { + span.setStatus({ code: SPAN_STATUS_ERROR }); + } + + if (!this.getConfig().useOperationNameForRootSpan) { return; } - safeExecuteInTheMiddle( - () => { - responseHook(span, result); - }, - err => { - if (err) { - this._diag.error('Error running response hook', err); - } + const attributes = spanToJSON(span).data; - endSpan(span, undefined); - }, - true, + // If operation.name is not set, we fall back to use operation.type only + const operationType = attributes[AttributeNames.OPERATION_TYPE]; + const operationName = attributes[AttributeNames.OPERATION_NAME]; + + if (!operationType) { + return; + } + + const rootSpan = getRootSpan(span); + const rootSpanAttributes = spanToJSON(rootSpan).data; + + const existingOperations = rootSpanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION] || []; + + const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; + + // We keep track of each operation on the root span + // This can either be a string, or an array of strings (if there are multiple operations) + if (Array.isArray(existingOperations)) { + (existingOperations as string[]).push(newOperation); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, existingOperations); + } else if (typeof existingOperations === 'string') { + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, [existingOperations, newOperation]); + } else { + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, newOperation); + } + + if (!spanToJSON(rootSpan).data['original-description']) { + rootSpan.setAttribute('original-description', spanToJSON(rootSpan).description); + } + // Important for e.g. @sentry/aws-serverless because this would otherwise overwrite the name again + rootSpan.updateName( + `${spanToJSON(rootSpan).data['original-description']} (${getGraphqlOperationNamesFromAttribute( + existingOperations, + )})`, ); } @@ -430,3 +468,21 @@ export class GraphQLInstrumentation extends InstrumentationBase { - beforeEach(() => { - vi.clearAllMocks(); - delete INSTRUMENTED.Graphql; - - (GraphQLInstrumentation as unknown as MockInstance).mockImplementation(() => { - return { - setTracerProvider: () => undefined, - setMeterProvider: () => undefined, - getConfig: () => ({}), - setConfig: () => ({}), - enable: () => undefined, - }; - }); - }); - - it('defaults are correct for instrumentGraphql', () => { - instrumentGraphql({ ignoreTrivialResolveSpans: false }); - - expect(GraphQLInstrumentation).toHaveBeenCalledTimes(1); - expect(GraphQLInstrumentation).toHaveBeenCalledWith({ - ignoreResolveSpans: true, - ignoreTrivialResolveSpans: false, - useOperationNameForRootSpan: true, - responseHook: expect.any(Function), - }); - }); - - it('defaults are correct for _graphqlIntegration', () => { - graphqlIntegration({ ignoreTrivialResolveSpans: false }).setupOnce!(); - - expect(GraphQLInstrumentation).toHaveBeenCalledTimes(1); - expect(GraphQLInstrumentation).toHaveBeenCalledWith({ - ignoreResolveSpans: true, - ignoreTrivialResolveSpans: false, - useOperationNameForRootSpan: true, - responseHook: expect.any(Function), - }); - }); -});