diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 1098efdb425a..ba804e364d0d 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -145,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", @@ -154,7 +155,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/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)', diff --git a/packages/node/src/integrations/tracing/graphql/index.ts b/packages/node/src/integrations/tracing/graphql/index.ts index d49a290b5e42..51bfc959a250 100644 --- a/packages/node/src/integrations/tracing/graphql/index.ts +++ b/packages/node/src/integrations/tracing/graphql/index.ts @@ -1,10 +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 { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemetry'; +import { defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; interface GraphqlOptions { /** @@ -40,59 +37,7 @@ const INTEGRATION_NAME = 'Graphql'; export const instrumentGraphql = generateInstrumentOnce( INTEGRATION_NAME, GraphQLInstrumentation, - (_options: GraphqlOptions) => { - const options = getOptionsWithDefaults(_options); - - 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 }); - } - - 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 = {}) => { @@ -132,21 +77,3 @@ function getOptionsWithDefaults(options?: GraphqlOptions): GraphqlOptions { ...options, }; } - -// copy from packages/opentelemetry/utils -function getGraphqlOperationNamesFromAttribute(attr: AttributeValue): 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/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..c86164402020 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts @@ -18,10 +18,14 @@ * - 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) + * - 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 */ -/* eslint-disable */ -import { context, trace } from '@opentelemetry/api'; +/* oxlint-disable max-lines */ + import { isWrapped, InstrumentationBase, @@ -51,35 +55,35 @@ 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, SpanAttributeValue } from '@sentry/core'; import { - addInputVariableAttributes, - addSpanSource, - endSpan, - getOperation, - isPromise, - wrapFieldResolver, - wrapFields, -} from './utils'; - -import { SDK_VERSION } from '@sentry/core'; -import * as api from '@opentelemetry/api'; -import { GraphQLInstrumentationConfig, GraphQLInstrumentationParsedConfig } from './types'; + 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'; +const ORIGIN = 'auto.graphql.otel.graphql'; + const DEFAULT_CONFIG: GraphQLInstrumentationParsedConfig = { - mergeItems: false, - depth: -1, - allowValues: false, ignoreResolveSpans: false, }; @@ -209,7 +213,7 @@ export class GraphQLInstrumentation extends InstrumentationBase { + return withActiveSpan(span, () => { return safeExecuteInTheMiddle>( () => { return (original as executeFunctionWithObj).apply(this, [processedArgs]); @@ -223,53 +227,80 @@ export class GraphQLInstrumentation extends InstrumentationBase) { - const config = this.getConfig(); + private _handleExecutionResult(span: Span, err?: Error, result?: PromiseOrValue) { if (result === undefined || err) { endSpan(span, err); return; } if (isPromise(result)) { - (result as Promise).then( + result.then( resultData => { - 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 as ExecutionResult); + this._updateSpanFromResult(span, result); + endSpan(span); } } - private _executeResponseHook(span: api.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, + )})`, ); } @@ -299,10 +330,9 @@ export class GraphQLInstrumentation extends InstrumentationBase { + return withActiveSpan(span, () => { return safeExecuteInTheMiddle( () => { return original.call(obj, source, options); @@ -313,7 +343,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 +409,7 @@ export class GraphQLInstrumentation extends InstrumentationBase, ): OtelExecutionArgs { if (!contextValue) { + // oxlint-disable-next-line no-param-reassign contextValue = {}; } @@ -426,16 +448,12 @@ export class GraphQLInstrumentation extends InstrumentationBase this.getConfig(), - fieldResolverForExecute, - isUsingDefaultResolver, - ); + // oxlint-disable-next-line no-param-reassign + 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 { @@ -450,3 +468,21 @@ export class GraphQLInstrumentation extends InstrumentationBase 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..26aef326e501 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/types.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/types.ts @@ -17,33 +17,10 @@ * - 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'; - -export interface GraphQLInstrumentationExecutionResponseHook { - (span: api.Span, data: any): void; -} +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; 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. * @@ -64,33 +41,12 @@ export interface GraphQLInstrumentationConfig extends InstrumentationConfig { ignoreTrivialResolveSpans?: boolean; /** - * Place all resolve spans under the same parent instead of producing a nested tree structure. + * If this is enabled, a `http.server` root span containing the execute span will automatically be renamed + * to include the operation name. * * @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. - * - * @param data - A GraphQL `ExecutionResult` object. For the exact type definitions, see the following: - * - {@linkcode https://github.com/graphql/graphql-js/blob/v14.7.0/src/execution/execute.js#L115 graphql@14} - * - {@linkcode https://github.com/graphql/graphql-js/blob/15.x.x/src/execution/execute.d.ts#L31 graphql@15} - * - {@linkcode https://github.com/graphql/graphql-js/blob/16.x.x/src/execution/execute.ts#L127 graphql@16} - * - * @default undefined - */ - responseHook?: GraphQLInstrumentationExecutionResponseHook; + useOperationNameForRootSpan?: boolean; } // Utility type to make specific properties required @@ -99,5 +55,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..eced9d1b8655 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,12 +33,13 @@ 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'; -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); @@ -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(); } @@ -158,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); } } @@ -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)) { diff --git a/packages/node/test/integrations/tracing/graphql.test.ts b/packages/node/test/integrations/tracing/graphql.test.ts deleted file mode 100644 index 4bd45dea109f..000000000000 --- a/packages/node/test/integrations/tracing/graphql.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { GraphQLInstrumentation } from '../../../src/integrations/tracing/graphql/vendored/instrumentation'; -import { INSTRUMENTED } from '@sentry/node-core'; -import { beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; -import { graphqlIntegration, instrumentGraphql } from '../../../src/integrations/tracing/graphql'; - -vi.mock('../../../src/integrations/tracing/graphql/vendored/instrumentation'); - -describe('GraphQL', () => { - 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), - }); - }); -});