diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-update-txn-name/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-update-txn-name/test.ts index 6226ff75dbb9..7d2d949898c2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-update-txn-name/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-update-txn-name/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { Event } from '@sentry/core'; +import { type Event, SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -10,27 +10,34 @@ import { import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; -sentryTest('sets the source to custom when updating the transaction name', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } +sentryTest( + 'sets the source to custom when updating the transaction name with `span.updateName`', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } - const url = await getLocalTestUrl({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); - const eventData = await getFirstSentryEnvelopeRequest(page, url); + const eventData = await getFirstSentryEnvelopeRequest(page, url); - const traceContextData = eventData.contexts?.trace?.data; + const traceContextData = eventData.contexts?.trace?.data; - expect(traceContextData).toMatchObject({ - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', - }); + expect(traceContextData).toBeDefined(); - expect(eventData.transaction).toBe('new name'); + expect(eventData.transaction).toBe('new name'); - expect(eventData.contexts?.trace?.op).toBe('pageload'); - expect(eventData.spans?.length).toBeGreaterThan(0); - expect(eventData.transaction_info?.source).toEqual('custom'); -}); + expect(traceContextData).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }); + + expect(traceContextData![SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); + + expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(eventData.spans?.length).toBeGreaterThan(0); + expect(eventData.transaction_info?.source).toEqual('custom'); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/init.js new file mode 100644 index 000000000000..1f0b64911a75 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/subject.js new file mode 100644 index 000000000000..7f0ad0fea340 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/subject.js @@ -0,0 +1,4 @@ +const activeSpan = Sentry.getActiveSpan(); +const rootSpan = activeSpan && Sentry.getRootSpan(activeSpan); + +Sentry.updateSpanName(rootSpan, 'new name'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/test.ts new file mode 100644 index 000000000000..69094b38e4dd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-updateSpanName/test.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { type Event, SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME } from '@sentry/core'; + +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/browser'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'sets the source to custom when updating the transaction name with Sentry.updateSpanName', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + const traceContextData = eventData.contexts?.trace?.data; + + expect(traceContextData).toBeDefined(); + + expect(traceContextData).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + }); + + expect(traceContextData![SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); + + expect(eventData.transaction).toBe('new name'); + + expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(eventData.spans?.length).toBeGreaterThan(0); + expect(eventData.transaction_info?.source).toEqual('custom'); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts b/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts index 7ce5f7195a5b..34e15d1be573 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/dsc-txn-name-update/test.ts @@ -181,5 +181,9 @@ async function captureErrorAndGetEnvelopeTraceHeader(page: Page): Promise { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + rootSpan.updateName('new-name'); + res.send({ response: 'response 1' }); +}); + +app.get('/test/:id/span-updateName-source', (_req, res) => { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + rootSpan.updateName('new-name'); + rootSpan.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'custom'); + res.send({ response: 'response 2' }); +}); + +app.get('/test/:id/updateSpanName', (_req, res) => { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + Sentry.updateSpanName(rootSpan, 'new-name'); + res.send({ response: 'response 3' }); +}); + +app.get('/test/:id/updateSpanNameAndSource', (_req, res) => { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + Sentry.updateSpanName(rootSpan, 'new-name'); + rootSpan.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component'); + res.send({ response: 'response 4' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/updateName/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/updateName/test.ts new file mode 100644 index 000000000000..c6345713fd7e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/tracing/updateName/test.ts @@ -0,0 +1,94 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('express tracing', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + // This test documents the unfortunate behaviour of using `span.updateName` on the server-side. + // For http.server root spans (which is the root span on the server 99% of the time), Otel's http instrumentation + // calls `span.updateName` and overwrites whatever the name was set to before (by us or by users). + test("calling just `span.updateName` doesn't update the final name in express (missing source)", done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'GET /test/:id/span-updateName', + transaction_info: { + source: 'route', + }, + }, + }) + .start(done) + .makeRequest('get', '/test/123/span-updateName'); + }); + + // Also calling `updateName` AND setting a source doesn't change anything - Otel has no concept of source, this is sentry-internal. + // Therefore, only the source is updated but the name is still overwritten by Otel. + test("calling `span.updateName` and setting attribute source doesn't update the final name in express but it updates the source", done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'GET /test/:id/span-updateName-source', + transaction_info: { + source: 'custom', + }, + }, + }) + .start(done) + .makeRequest('get', '/test/123/span-updateName-source'); + }); + + // This test documents the correct way to update the span name (and implicitly the source) in Node: + test('calling `Sentry.updateSpanName` updates the final name and source in express', done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: txnEvent => { + expect(txnEvent).toMatchObject({ + transaction: 'new-name', + transaction_info: { + source: 'custom', + }, + contexts: { + trace: { + op: 'http.server', + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }); + // ensure we delete the internal attribute once we're done with it + expect(txnEvent.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); + }, + }) + .start(done) + .makeRequest('get', '/test/123/updateSpanName'); + }); + }); + + // This test documents the correct way to update the span name (and implicitly the source) in Node: + test('calling `Sentry.updateSpanName` and setting source subsequently updates the final name and sets correct source', done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: txnEvent => { + expect(txnEvent).toMatchObject({ + transaction: 'new-name', + transaction_info: { + source: 'component', + }, + contexts: { + trace: { + op: 'http.server', + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component' }, + }, + }, + }); + // ensure we delete the internal attribute once we're done with it + expect(txnEvent.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); + }, + }) + .start(done) + .makeRequest('get', '/test/123/updateSpanNameAndSource'); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage/test.ts index 86b3bf6d9d22..0370b123cab2 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage/test.ts @@ -1,11 +1,42 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); }); -test('should send a manually started root span', done => { +test('sends a manually started root span with source custom', done => { createRunner(__dirname, 'scenario.ts') - .expect({ transaction: { transaction: 'test_span' } }) + .expect({ + transaction: { + transaction: 'test_span', + transaction_info: { source: 'custom' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }, + }) + .start(done); +}); + +test("doesn't change the name for manually started spans even if attributes triggering inference are set", done => { + createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'test_span', + transaction_info: { source: 'custom' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }, + }) .start(done); }); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/scenario.ts new file mode 100644 index 000000000000..ea30608c1c5c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/scenario.ts @@ -0,0 +1,16 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + span.updateName('new name'); + }, +); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/test.ts new file mode 100644 index 000000000000..676071926b91 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateName-method/test.ts @@ -0,0 +1,24 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name when calling `span.updateName`', done => { + createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'new name', + transaction_info: { source: 'url' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }, + }, + }, + }, + }) + .start(done); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function/scenario.ts new file mode 100644 index 000000000000..ecf7670fa23d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function/scenario.ts @@ -0,0 +1,16 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +Sentry.startSpan( + { name: 'test_span', attributes: { [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }, + (span: Sentry.Span) => { + Sentry.updateSpanName(span, 'new name'); + }, +); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function/test.ts new file mode 100644 index 000000000000..c5b325fc0ea2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/updateSpanName-function/test.ts @@ -0,0 +1,24 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('updates the span name and source when calling `updateSpanName`', done => { + createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + transaction: 'new name', + transaction_info: { source: 'custom' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }, + }) + .start(done); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 7eca9de9a41a..ad7481816b4d 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -138,6 +138,7 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + updateSpanName, withActiveSpan, withIsolationScope, withMonitor, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 3f167b62a7e3..5146fe423b45 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -121,6 +121,7 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + updateSpanName, // eslint-disable-next-line deprecation/deprecation addOpenTelemetryInstrumentation, zodErrorsIntegration, diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 492f9da23b38..295e6daa36cc 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -62,6 +62,7 @@ export { spanToJSON, spanToTraceHeader, spanToBaggageHeader, + updateSpanName, } from '@sentry/core'; export { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 1ba5f2de4786..dcae6e98aa8d 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -141,6 +141,7 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + updateSpanName, // eslint-disable-next-line deprecation/deprecation addOpenTelemetryInstrumentation, zodErrorsIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index f3c80b8ddf32..fb8c34694282 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -89,6 +89,7 @@ export { spanToJSON, spanToTraceHeader, spanToBaggageHeader, + updateSpanName, } from '@sentry/core'; export { withSentry } from './handler'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77259d2434d4..5baf88f38e1c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -81,6 +81,7 @@ export { getActiveSpan, addChildSpanToSpan, spanTimeInputToSeconds, + updateSpanName, } from './utils/spanUtils'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 2896bd81f93f..b799f5321a0e 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -29,6 +29,15 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; +/** + * A custom span name set by users guaranteed to be taken over any automatically + * inferred name. This attribute is removed before the span is sent. + * + * @internal only meant for internal SDK usage + * @hidden + */ +export const SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME = 'sentry.custom_span_name'; + /** * The id of the profile that this span occurred in. */ diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 126702dfad2b..9965261970f2 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -5,6 +5,7 @@ import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary'; import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_PROFILE_ID, + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -355,6 +356,14 @@ export class SentrySpan implements Span { const source = this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] as TransactionSource | undefined; + // remove internal root span attributes we don't need to send. + /* eslint-disable @typescript-eslint/no-dynamic-delete */ + delete this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + spans.forEach(span => { + span.data && delete span.data[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + }); + // eslint-enabled-next-line @typescript-eslint/no-dynamic-delete + const transaction: TransactionEvent = { contexts: { trace: spanToTransactionTraceContext(this), diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index a2ee74fd7cfa..cf0c3086bf88 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -234,6 +234,16 @@ export interface Span { /** * Update the name of the span. + * + * **Important:** You most likely want to use `Sentry.updateSpanName(span, name)` instead! + * + * This method will update the current span name but cannot guarantee that the new name will be + * the final name of the span. Instrumentation might still overwrite the name with an automatically + * computed name, for example in `http.server` or `db` spans. + * + * You can ensure that your name is kept and not overwritten by calling `Sentry.updateSpanName(span, name)` + * + * @param name the new name of the span */ updateName(name: string): this; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 594a297f9395..09ba9d729449 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -3,7 +3,12 @@ import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import { getMetricSummaryJsonForSpan, updateMetricSummaryOnSpan } from '../metrics/metric-summary'; import type { MetricType } from '../metrics/types'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; import type { @@ -310,3 +315,27 @@ export function showSpanDropWarning(): void { hasShownSpanDropWarning = true; } } + +/** + * Updates the name of the given span and ensures that the span name is not + * overwritten by the Sentry SDK. + * + * Use this function instead of `span.updateName()` if you want to make sure that + * your name is kept. For some spans, for example root `http.server` spans the + * Sentry SDK would otherwise overwrite the span name with a high-quality name + * it infers when the span ends. + * + * Use this function in server code or when your span is started on the server + * and on the client (browser). If you only update a span name on the client, + * you can also use `span.updateName()` the SDK does not overwrite the name. + * + * @param span - The span to update the name of. + * @param name - The name to set on the span. + */ +export function updateSpanName(span: Span, name: string): void { + span.updateName(name); + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: name, + }); +} diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index f7187695a025..aa6d4bf4cb2f 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -1,6 +1,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET, @@ -14,8 +15,14 @@ import { } from '../../../src'; import type { Span, SpanAttributes, SpanStatus, SpanTimeInput } from '../../../src/types-hoist'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; -import { spanToTraceContext } from '../../../src/utils/spanUtils'; -import { getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../../../src/utils/spanUtils'; +import { + getRootSpan, + spanIsSampled, + spanTimeInputToSeconds, + spanToJSON, + spanToTraceContext, + updateSpanName, +} from '../../../src/utils/spanUtils'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; function createMockedOtelSpan({ @@ -332,3 +339,13 @@ describe('getRootSpan', () => { }); }); }); + +describe('updateSpanName', () => { + it('updates the span name and source', () => { + const span = new SentrySpan({ name: 'old-name', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }); + updateSpanName(span, 'new-name'); + const spanJSON = spanToJSON(span); + expect(spanJSON.description).toBe('new-name'); + expect(spanJSON.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toBe('custom'); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 892f6ce681c0..cea4effad4bd 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -89,6 +89,7 @@ export { spanToJSON, spanToTraceHeader, spanToBaggageHeader, + updateSpanName, } from '@sentry/core'; export { DenoClient } from './client'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 6f89769c2a37..95d30ec8b072 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -118,6 +118,7 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + updateSpanName, // eslint-disable-next-line deprecation/deprecation addOpenTelemetryInstrumentation, zodErrorsIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index fa16ac4e6b3d..215d53bdbde3 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -142,6 +142,7 @@ export { spanToTraceHeader, spanToBaggageHeader, trpcMiddleware, + updateSpanName, zodErrorsIntegration, profiler, } from '@sentry/core'; diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index a8d5affa4646..bff6518eb27d 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,6 +12,7 @@ import type { TransactionSource, } from '@sentry/core'; import { + SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, @@ -392,6 +393,7 @@ function removeSentryAttributes(data: Record): Record = {}; @@ -174,12 +198,21 @@ export function descriptionForHttpMethod( const origin = attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] || 'manual'; const isManualSpan = !`${origin}`.startsWith('auto'); - const useInferredDescription = isClientOrServerKind || !isManualSpan; + // If users (or in very rare occasions we) set the source to custom, we don't overwrite the name + const alreadyHasCustomSource = attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom'; + const customSpanName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + + const useInferredDescription = + !alreadyHasCustomSource && customSpanName == null && (isClientOrServerKind || !isManualSpan); + + const { description, source } = useInferredDescription + ? { description: inferredDescription, source: inferredSource } + : getUserUpdatedNameAndSource(name, attributes); return { op: opParts.join('.'), - description: useInferredDescription ? description : name, - source: useInferredDescription ? source : 'custom', + description, + source, data, }; } @@ -244,3 +277,36 @@ export function getSanitizedUrl( return { urlPath: undefined, url, query, fragment, hasRoute: false }; } + +/** + * Because Otel instrumentation sometimes mutates span names via `span.updateName`, the only way + * to ensure that a user-set span name is preserved is to store it as a tmp attribute on the span. + * We delete this attribute once we're done with it when preparing the event envelope. + * + * This temp attribute always takes precedence over the original name. + * + * We also need to take care of setting the correct source. Users can always update the source + * after updating the name, so we need to respect that. + * + * @internal exported only for testing + */ +export function getUserUpdatedNameAndSource( + originalName: string, + attributes: Attributes, + fallbackSource: TransactionSource = 'custom', +): { + description: string; + source: TransactionSource; +} { + const source = (attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] as TransactionSource) || fallbackSource; + const description = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; + + if (description && typeof description === 'string') { + return { + description, + source, + }; + } + + return { description: originalName, source }; +} diff --git a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts index c44645c62888..d43dfcd9f587 100644 --- a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts +++ b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts @@ -15,7 +15,13 @@ import { SEMATTRS_RPC_SERVICE, } from '@opentelemetry/semantic-conventions'; -import { descriptionForHttpMethod, getSanitizedUrl, parseSpanDescription } from '../../src/utils/parseSpanDescription'; +import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + descriptionForHttpMethod, + getSanitizedUrl, + getUserUpdatedNameAndSource, + parseSpanDescription, +} from '../../src/utils/parseSpanDescription'; describe('parseSpanDescription', () => { it.each([ @@ -81,6 +87,53 @@ describe('parseSpanDescription', () => { source: 'task', }, ], + [ + 'works with db system and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'db', + source: 'custom', + }, + ], + [ + 'works with db system and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'custom name', + op: 'db', + source: 'custom', + }, + ], + [ + 'works with db system and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'custom name', + op: 'db', + source: 'component', + }, + ], [ 'works with db system without statement', { @@ -107,6 +160,50 @@ describe('parseSpanDescription', () => { source: 'route', }, ], + [ + 'works with rpc service and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'rpc', + source: 'custom', + }, + ], + [ + 'works with rpc service and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'rpc', + source: 'custom', + }, + ], + [ + 'works with rpc service and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'rpc', + source: 'component', + }, + ], [ 'works with messaging system', { @@ -120,6 +217,50 @@ describe('parseSpanDescription', () => { source: 'route', }, ], + [ + 'works with messaging system and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'message', + source: 'custom', + }, + ], + [ + 'works with messaging system and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'message', + source: 'custom', + }, + ], + [ + 'works with messaging system and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'message', + source: 'component', + }, + ], [ 'works with faas trigger', { @@ -133,6 +274,50 @@ describe('parseSpanDescription', () => { source: 'route', }, ], + [ + 'works with faas trigger and custom source', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'test-faas-trigger', + source: 'custom', + }, + ], + [ + 'works with faas trigger and custom source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'test-faas-trigger', + source: 'custom', + }, + ], + [ + 'works with faas trigger and component source and custom name', + { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + undefined, + { + description: 'custom name', + op: 'test-faas-trigger', + source: 'component', + }, + ], ])('%s', (_, attributes, name, kind, expected) => { const actual = parseSpanDescription({ attributes, kind, name } as unknown as Span); expect(actual).toEqual(expected); @@ -172,6 +357,26 @@ describe('descriptionForHttpMethod', () => { source: 'url', }, ], + [ + 'works with prefetch request', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + 'sentry.http.prefetch': true, + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client.prefetch', + description: 'GET https://www.example.com/my-path', + data: { + url: 'https://www.example.com/my-path', + }, + source: 'url', + }, + ], [ 'works with basic server POST', 'POST', @@ -230,6 +435,71 @@ describe('descriptionForHttpMethod', () => { source: 'custom', }, ], + [ + "doesn't overwrite span name with source custom", + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', + [SEMATTRS_HTTP_TARGET]: '/my-path/123', + [ATTR_HTTP_ROUTE]: '/my-path/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'test name', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'custom', + }, + ], + [ + 'takes user-passed span name (with source custom)', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', + [SEMATTRS_HTTP_TARGET]: '/my-path/123', + [ATTR_HTTP_ROUTE]: '/my-path/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'custom name', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'custom', + }, + ], + [ + 'takes user-passed span name (with source component)', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', + [SEMATTRS_HTTP_TARGET]: '/my-path/123', + [ATTR_HTTP_ROUTE]: '/my-path/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'custom name', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'component', + }, + ], ])('%s', (_, httpMethod, attributes, name, kind, expected) => { const actual = descriptionForHttpMethod({ attributes, kind, name }, httpMethod); expect(actual).toEqual(expected); @@ -383,3 +653,38 @@ describe('getSanitizedUrl', () => { expect(actual).toEqual(expected); }); }); + +describe('getUserUpdatedNameAndSource', () => { + it('returns param name if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not set', () => { + expect(getUserUpdatedNameAndSource('base name', {})).toEqual({ description: 'base name', source: 'custom' }); + }); + + it('returns param name with custom fallback source if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not set', () => { + expect(getUserUpdatedNameAndSource('base name', {}, 'route')).toEqual({ + description: 'base name', + source: 'route', + }); + }); + + it('returns param name if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not a string', () => { + expect(getUserUpdatedNameAndSource('base name', { [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 123 })).toEqual({ + description: 'base name', + source: 'custom', + }); + }); + + it.each(['custom', 'task', 'url', 'route'])( + 'returns `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute if is a string and source is %s', + source => { + expect( + getUserUpdatedNameAndSource('base name', { + [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + }), + ).toEqual({ + description: 'custom name', + source, + }); + }, + ); +}); diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index f6a5f5060dd9..4bb6539dbd33 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -134,6 +134,7 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + updateSpanName, withActiveSpan, withIsolationScope, withMonitor, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 450420a2b586..4c1f192b0c36 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -126,6 +126,7 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + updateSpanName, withActiveSpan, withIsolationScope, withMonitor, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index bb88e121244f..4996dcc0e7ca 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -128,6 +128,7 @@ export { startSpanManual, tediousIntegration, trpcMiddleware, + updateSpanName, withActiveSpan, withIsolationScope, withMonitor,