diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/apollo-server.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/apollo-server.js new file mode 100644 index 000000000000..8c1817564196 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/apollo-server.js @@ -0,0 +1,33 @@ +const { ApolloServer, gql } = require('apollo-server'); +const Sentry = require('@sentry/node'); + +module.exports = () => { + return Sentry.startSpan({ name: 'Test Server Start' }, () => { + return new ApolloServer({ + typeDefs: gql`type Query { + hello: String + world: String + } + type Mutation { + login(email: String): String + }`, + resolvers: { + Query: { + hello: () => { + return 'Hello!'; + }, + world: () => { + return 'World!'; + }, + }, + Mutation: { + login: async (_, { email }) => { + return `${email}--token`; + }, + }, + }, + introspection: false, + debug: false, + }); + }); +}; diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js index 9cecf2302315..6defe777d464 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js @@ -12,7 +12,8 @@ Sentry.init({ setInterval(() => {}, 1000); async function run() { - const { ApolloServer, gql } = require('apollo-server'); + const { gql } = require('apollo-server'); + const server = require('./apollo-server')(); await Sentry.startSpan( { @@ -20,29 +21,6 @@ async function run() { op: 'transaction', }, async span => { - const server = new ApolloServer({ - typeDefs: gql` - type Query { - hello: String - } - type Mutation { - login(email: String): String - } - `, - resolvers: { - Query: { - hello: () => { - return 'Hello world!'; - }, - }, - Mutation: { - login: async (_, { email }) => { - return `${email}--token`; - }, - }, - }, - }); - // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation await server.executeOperation({ query: gql`mutation Mutation($email: String){ diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js index f0c140fd4b24..b9a05c4b1c3c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js @@ -12,7 +12,7 @@ Sentry.init({ setInterval(() => {}, 1000); async function run() { - const { ApolloServer, gql } = require('apollo-server'); + const server = require('./apollo-server')(); await Sentry.startSpan( { @@ -20,21 +20,6 @@ async function run() { op: 'transaction', }, async span => { - const typeDefs = gql`type Query { hello: String }`; - - const resolvers = { - Query: { - hello: () => { - return 'Hello world!'; - }, - }, - }; - - const server = new ApolloServer({ - typeDefs, - resolvers, - }); - // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation await server.executeOperation({ query: '{hello}', 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 5bf91f7653c1..46e05acf940e 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 @@ -1,7 +1,12 @@ import { createRunner } from '../../../utils/runner'; +// Graphql Instrumentation emits some spans by default on server start +const EXPECTED_START_SERVER_TRANSACTION = { + transaction: 'Test Server Start', +}; + describe('GraphQL/Apollo Tests', () => { - test('CJS - should instrument GraphQL queries used from Apollo Server.', done => { + test('should instrument GraphQL queries used from Apollo Server.', done => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ @@ -18,10 +23,13 @@ describe('GraphQL/Apollo Tests', () => { ]), }; - createRunner(__dirname, 'scenario-query.js').expect({ transaction: EXPECTED_TRANSACTION }).start(done); + createRunner(__dirname, 'scenario-query.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); }); - test('CJS - should instrument GraphQL mutations used from Apollo Server.', done => { + test('should instrument GraphQL mutations used from Apollo Server.', done => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ @@ -39,6 +47,9 @@ describe('GraphQL/Apollo Tests', () => { ]), }; - createRunner(__dirname, 'scenario-mutation.js').expect({ transaction: EXPECTED_TRANSACTION }).start(done); + createRunner(__dirname, 'scenario-mutation.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-invalid-root-span.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-invalid-root-span.js new file mode 100644 index 000000000000..840a5551b98a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-invalid-root-span.js @@ -0,0 +1,34 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })], + transport: loggingTransport, +}); + +const tracer = client.tracer; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + const server = require('../apollo-server')(); + + await tracer.startActiveSpan('test span name', async span => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ + query: 'query GetHello {hello}', + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations-many.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations-many.js new file mode 100644 index 000000000000..992ff5337b46 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations-many.js @@ -0,0 +1,43 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })], + transport: loggingTransport, +}); + +const tracer = client.tracer; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + const server = require('../apollo-server')(); + + await tracer.startActiveSpan( + 'test span name', + { + kind: 1, + attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' }, + }, + async span => { + for (let i = 1; i < 10; i++) { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ + query: `query GetHello${i} {hello}`, + }); + } + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations.js new file mode 100644 index 000000000000..d9eeca63ae10 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations.js @@ -0,0 +1,45 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })], + transport: loggingTransport, +}); + +const tracer = client.tracer; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + const server = require('../apollo-server')(); + + await tracer.startActiveSpan( + 'test span name', + { + kind: 1, + attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' }, + }, + async span => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ + query: 'query GetWorld {world}', + }); + + await server.executeOperation({ + query: 'query GetHello {hello}', + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-mutation.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-mutation.js new file mode 100644 index 000000000000..8ee9154c0e51 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-mutation.js @@ -0,0 +1,45 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })], + transport: loggingTransport, +}); + +const tracer = client.tracer; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + const { gql } = require('apollo-server'); + const server = require('../apollo-server')(); + + await tracer.startActiveSpan( + 'test span name', + { + kind: 1, + attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' }, + }, + async span => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ + query: gql`mutation TestMutation($email: String){ + login(email: $email) + }`, + variables: { email: 'test@email.com' }, + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-no-operation-name.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-no-operation-name.js new file mode 100644 index 000000000000..14879bc0e79d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-no-operation-name.js @@ -0,0 +1,41 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })], + transport: loggingTransport, +}); + +const tracer = client.tracer; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + const server = require('../apollo-server')(); + + await tracer.startActiveSpan( + 'test span name', + { + kind: 1, + attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' }, + }, + async span => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ + query: 'query {hello}', + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-query.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-query.js new file mode 100644 index 000000000000..4dc3357ab17f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-query.js @@ -0,0 +1,41 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })], + transport: loggingTransport, +}); + +const tracer = client.tracer; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +async function run() { + const server = require('../apollo-server')(); + + await tracer.startActiveSpan( + 'test span name', + { + kind: 1, + attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' }, + }, + async span => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ + query: 'query GetHello {hello}', + }); + + setTimeout(() => { + span.end(); + server.stop(); + }, 500); + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); 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 new file mode 100644 index 000000000000..234cc4009b38 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts @@ -0,0 +1,152 @@ +import { createRunner } from '../../../../utils/runner'; + +// Graphql Instrumentation emits some spans by default on server start +const EXPECTED_START_SERVER_TRANSACTION = { + transaction: 'Test Server Start', +}; + +describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { + test('useOperationNameForRootSpan works with single query operation', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /test-graphql (query GetHello)', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.name': 'GetHello', + 'graphql.operation.type': 'query', + 'graphql.source': 'query GetHello {hello}', + 'sentry.origin': 'auto.graphql.otel.graphql', + }, + description: 'query GetHello', + status: 'ok', + origin: 'auto.graphql.otel.graphql', + }), + ]), + }; + + createRunner(__dirname, 'scenario-query.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); + }); + + test('useOperationNameForRootSpan works with single mutation operation', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /test-graphql (mutation TestMutation)', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.name': 'TestMutation', + 'graphql.operation.type': 'mutation', + 'graphql.source': `mutation TestMutation($email: String) { + login(email: $email) +}`, + 'sentry.origin': 'auto.graphql.otel.graphql', + }, + description: 'mutation TestMutation', + status: 'ok', + origin: 'auto.graphql.otel.graphql', + }), + ]), + }; + + createRunner(__dirname, 'scenario-mutation.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); + }); + + test('useOperationNameForRootSpan ignores an invalid root span', done => { + const EXPECTED_TRANSACTION = { + transaction: 'test span name', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.name': 'GetHello', + 'graphql.operation.type': 'query', + 'graphql.source': 'query GetHello {hello}', + 'sentry.origin': 'auto.graphql.otel.graphql', + }, + description: 'query GetHello', + status: 'ok', + origin: 'auto.graphql.otel.graphql', + }), + ]), + }; + + createRunner(__dirname, 'scenario-invalid-root-span.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); + }); + + test('useOperationNameForRootSpan works with single query operation without name', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /test-graphql (query)', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.type': 'query', + 'graphql.source': 'query {hello}', + 'sentry.origin': 'auto.graphql.otel.graphql', + }, + description: 'query', + status: 'ok', + origin: 'auto.graphql.otel.graphql', + }), + ]), + }; + + createRunner(__dirname, 'scenario-no-operation-name.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); + }); + + test('useOperationNameForRootSpan works with multiple query operations', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /test-graphql (query GetHello, query GetWorld)', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.name': 'GetHello', + 'graphql.operation.type': 'query', + 'graphql.source': 'query GetHello {hello}', + 'sentry.origin': 'auto.graphql.otel.graphql', + }, + description: 'query GetHello', + status: 'ok', + origin: 'auto.graphql.otel.graphql', + }), + expect.objectContaining({ + data: { + 'graphql.operation.name': 'GetWorld', + 'graphql.operation.type': 'query', + 'graphql.source': 'query GetWorld {world}', + 'sentry.origin': 'auto.graphql.otel.graphql', + }, + description: 'query GetWorld', + status: 'ok', + origin: 'auto.graphql.otel.graphql', + }), + ]), + }; + + createRunner(__dirname, 'scenario-multiple-operations.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); + }); + + test('useOperationNameForRootSpan works with more than 5 query operations', done => { + const EXPECTED_TRANSACTION = { + transaction: + 'GET /test-graphql (query GetHello1, query GetHello2, query GetHello3, query GetHello4, query GetHello5, +4)', + }; + + createRunner(__dirname, 'scenario-multiple-operations-many.js') + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start(done); + }); +}); diff --git a/packages/node/src/integrations/tracing/graphql.ts b/packages/node/src/integrations/tracing/graphql.ts index 097ee3ba43f8..914653ac745c 100644 --- a/packages/node/src/integrations/tracing/graphql.ts +++ b/packages/node/src/integrations/tracing/graphql.ts @@ -1,12 +1,17 @@ import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, getRootSpan, spanToJSON } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemetry'; import type { IntegrationFn } from '@sentry/types'; import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; interface GraphqlOptions { - /** Do not create spans for resolvers. */ + /** + * Do not create spans for resolvers. + * + * Defaults to true. + */ ignoreResolveSpans?: boolean; /** @@ -16,8 +21,18 @@ interface GraphqlOptions { * use the default resolver which just looks for a property with that name on the object. * If the property is not a function, it's not very interesting to trace. * This option can reduce noise and number of spans created. + * + * Defaults to true. + */ + ignoreTrivialResolveSpans?: boolean; + + /** + * If this is enabled, a http.server root span containing this span will automatically be renamed to include the operation name. + * Set this to `false` if you do not want this behavior, and want to keep the default http.server span name. + * + * Defaults to true. */ - ignoreTrivalResolveSpans?: boolean; + useOperationNameForRootSpan?: boolean; } const INTEGRATION_NAME = 'Graphql'; @@ -28,6 +43,7 @@ export const instrumentGraphql = generateInstrumentOnce( const options = { ignoreResolveSpans: true, ignoreTrivialResolveSpans: true, + useOperationNameForRootSpan: true, ..._options, }; @@ -35,6 +51,35 @@ export const instrumentGraphql = generateInstrumentOnce( ...options, responseHook(span) { addOriginToSpan(span, 'auto.graphql.otel.graphql'); + + 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); + + // We guard to only do this on http.server spans + + 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.push(newOperation); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, existingOperations); + } else if (existingOperations) { + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, [existingOperations, newOperation]); + } else { + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, newOperation); + } + } }, }); }, diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index ef57ab0fff3d..98460b575c8d 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1,3 +1,5 @@ +export { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from './semanticAttributes'; + export { getRequestSpanData } from './utils/getRequestSpanData'; export type { OpenTelemetryClient } from './types'; diff --git a/packages/opentelemetry/src/semanticAttributes.ts b/packages/opentelemetry/src/semanticAttributes.ts index 80a80f87a666..2e14c71bf5e9 100644 --- a/packages/opentelemetry/src/semanticAttributes.ts +++ b/packages/opentelemetry/src/semanticAttributes.ts @@ -1,2 +1,5 @@ /** If this attribute is true, it means that the parent is a remote span. */ export const SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE = 'sentry.parentIsRemote'; + +// These are not standardized yet, but used by the graphql instrumentation +export const SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION = 'sentry.graphql.operation'; diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts index a9d99aa91b8a..6d1c9936899b 100644 --- a/packages/opentelemetry/src/utils/parseSpanDescription.ts +++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts @@ -15,6 +15,7 @@ import type { SpanAttributes, TransactionSource } from '@sentry/types'; import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '@sentry/utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '../semanticAttributes'; import type { AbstractSpan } from '../types'; import { getSpanKind } from './getSpanKind'; import { spanHasAttributes, spanHasName } from './spanTypes'; @@ -136,8 +137,16 @@ export function descriptionForHttpMethod( return { op: opParts.join('.'), description: name, source: 'custom' }; } - // Ex. description="GET /api/users". - const description = `${httpMethod} ${urlPath}`; + const graphqlOperationsAttribute = attributes[SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION]; + + // Ex. GET /api/users + const baseDescription = `${httpMethod} ${urlPath}`; + + // When the http span has a graphql operation, append it to the description + // We add these in the graphqlIntegration + const description = graphqlOperationsAttribute + ? `${baseDescription} (${getGraphqlOperationNamesFromAttribute(graphqlOperationsAttribute)})` + : baseDescription; // If `httpPath` is a root path, then we can categorize the transaction source as route. const source: TransactionSource = hasRoute || urlPath === '/' ? 'route' : 'url'; @@ -162,6 +171,22 @@ export function descriptionForHttpMethod( }; } +function getGraphqlOperationNamesFromAttribute(attr: AttributeValue): string { + if (Array.isArray(attr)) { + 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}`; +} + /** Exported for tests only */ export function getSanitizedUrl( attributes: Attributes, diff --git a/packages/opentelemetry/src/utils/spanTypes.ts b/packages/opentelemetry/src/utils/spanTypes.ts index f92d411200a1..39c62219d2ad 100644 --- a/packages/opentelemetry/src/utils/spanTypes.ts +++ b/packages/opentelemetry/src/utils/spanTypes.ts @@ -22,7 +22,7 @@ export function spanHasAttributes( */ export function spanHasKind(span: SpanType): span is SpanType & { kind: SpanKind } { const castSpan = span as ReadableSpan; - return !!castSpan.kind; + return typeof castSpan.kind === 'number'; } /** diff --git a/packages/opentelemetry/test/utils/spanTypes.test.ts b/packages/opentelemetry/test/utils/spanTypes.test.ts index 99152204adfa..af07e5c45af5 100644 --- a/packages/opentelemetry/test/utils/spanTypes.test.ts +++ b/packages/opentelemetry/test/utils/spanTypes.test.ts @@ -24,7 +24,9 @@ describe('spanTypes', () => { it.each([ [{}, false], [{ kind: null }, false], - [{ kind: 'TEST_KIND' }, true], + [{ kind: 0 }, true], + [{ kind: 5 }, true], + [{ kind: 'TEST_KIND' }, false], ])('works with %p', (span, expected) => { const castSpan = span as unknown as Span; const actual = spanHasKind(castSpan);