From 56c4346668c474338321eb9d7c47a724f1d85f7d Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 7 May 2025 13:32:49 +0200 Subject: [PATCH 01/28] feat(react-router): Create a transaction filter for react router --- packages/react-router/src/server/sdk.ts | 30 ++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index d1e6b32b1d96..0f87fa40a102 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,4 +1,4 @@ -import { applySdkMetadata, logger, setTag } from '@sentry/core'; +import { type EventProcessor, applySdkMetadata, getGlobalScope, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -20,5 +20,33 @@ export function init(options: NodeOptions): NodeClient | undefined { setTag('runtime', 'node'); DEBUG_BUILD && logger.log('SDK successfully initialized'); + + getGlobalScope().addEventProcessor(lowQualityTransactionsFilter(options)); + return client; } + +/** + * Filters out noisy transactions such as requests to node_modules + * + * @param options The NodeOptions passed to the SDK + * @returns An EventProcessor that filters low-quality transactions + */ +export function lowQualityTransactionsFilter(options: NodeOptions): EventProcessor { + return Object.assign( + (event => { + if (event.type !== 'transaction' || !event.transaction) { + return event; + } + + if (event.transaction.match(/\/node_modules\//)) { + options.debug && + logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); + return null; + } + + return event; + }) satisfies EventProcessor, + { id: 'ReactRouterLowQualityTransactionsFilter' }, + ); +} From 354066608b21f1ab0a7543689786629a6ba11f10 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 7 May 2025 14:55:04 +0200 Subject: [PATCH 02/28] Update react router sdk tests --- packages/react-router/src/server/sdk.ts | 11 +- packages/react-router/test/server/sdk.test.ts | 110 +++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index 0f87fa40a102..1380f9af3235 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -26,8 +26,14 @@ export function init(options: NodeOptions): NodeClient | undefined { return client; } +const matchedRegexes = [ + /GET \/node_modules\//, + /GET \/favicon\.ico/, + /GET \/@id\//, +]; + /** - * Filters out noisy transactions such as requests to node_modules + * Filters out noisy transactions such as requests to node_modules, favicon.ico, @id/ * * @param options The NodeOptions passed to the SDK * @returns An EventProcessor that filters low-quality transactions @@ -35,11 +41,12 @@ export function init(options: NodeOptions): NodeClient | undefined { export function lowQualityTransactionsFilter(options: NodeOptions): EventProcessor { return Object.assign( (event => { + if (event.type !== 'transaction' || !event.transaction) { return event; } - if (event.transaction.match(/\/node_modules\//)) { + if (matchedRegexes.some(regex => event.transaction?.match(regex))) { options.debug && logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); return null; diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index 55c12935fe66..cb4423b5e2c0 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -1,7 +1,12 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getGlobalScope } from '@sentry/core'; +import type { Event, EventType } from '@sentry/core'; import * as SentryNode from '@sentry/node'; +import type { NodeClient } from '@sentry/node'; import { SDK_VERSION } from '@sentry/node'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { init as reactRouterInit } from '../../src/server/sdk'; + +import { init as reactRouterInit, lowQualityTransactionsFilter } from '../../src/server/sdk'; const nodeInit = vi.spyOn(SentryNode, 'init'); @@ -39,7 +44,106 @@ describe('React Router server SDK', () => { }); it('returns client from init', () => { - expect(reactRouterInit({})).not.toBeUndefined(); + const client = reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }) as NodeClient; + expect(client).not.toBeUndefined(); + }); + + it('registers the low quality transactions filter', async () => { + const addEventProcessor = vi.spyOn(getGlobalScope(), 'addEventProcessor'); + addEventProcessor.mockClear(); + + reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }) as NodeClient; + + expect(addEventProcessor).toHaveBeenCalledTimes(1); + const processor = addEventProcessor.mock.calls[0]![0]; + expect(processor?.id).toEqual('ReactRouterLowQualityTransactionsFilter'); + }); + + describe('transaction filtering', () => { + const beforeSendEvent = vi.fn(event => event); + let client: NodeClient; + + beforeEach(() => { + vi.clearAllMocks(); + beforeSendEvent.mockClear(); + SentryNode.getGlobalScope().clear(); + + client = reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }) as NodeClient; + + client.on('beforeSendEvent', beforeSendEvent); + }); + + describe('filters out low quality transactions', () => { + it.each([ + 'GET /node_modules/react/index.js', + 'GET /favicon.ico', + 'GET /@id/package', + ])('%s', async (transaction) => { + client.captureEvent({ type: 'transaction', transaction }); + + await client.flush(); + + expect(beforeSendEvent).not.toHaveBeenCalled(); + }); + }); + + describe('allows high quality transactions', () => { + it.each([ + 'GET /', + 'GET /users', + 'POST /api/data', + 'GET /projects/123', + ])('%s', async (transaction) => { + client.captureEvent({ type: 'transaction', transaction }); + + await client.flush(); + + expect(beforeSendEvent).toHaveBeenCalledWith( + expect.objectContaining({ transaction }), + expect.any(Object) + ); + }); + }); + }); + }); + + describe('lowQualityTransactionsFilter', () => { + describe('filters out low quality transactions', () => { + it.each([ + ['node_modules request', 'GET /node_modules/react/index.js'], + ['favicon.ico request', 'GET /favicon.ico'], + ['@id request', 'GET /@id/package'] + ])('%s', (description, transaction) => { + const filter = lowQualityTransactionsFilter({}); + const event = { + type: 'transaction' as EventType, + transaction, + } as Event; + + expect(filter(event, {})).toBeNull(); + }); + }); + + describe('does not filter good transactions', () => { + it.each([ + ['normal page request', 'GET /users'], + ['API request', 'POST /api/users'], + ['app route', 'GET /projects/123'] + ])('%s', (description, transaction) => { + const filter = lowQualityTransactionsFilter({}); + const event = { + type: 'transaction' as EventType, + transaction, + } as Event; + + expect(filter(event, {})).toBe(event); + }); }); }); }); From aa9e709d2fdd82e2a83a7b7010e03ed58de26f39 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 7 May 2025 17:02:14 +0200 Subject: [PATCH 03/28] Fix linter issues --- packages/react-router/src/server/sdk.ts | 10 ++---- packages/react-router/test/server/sdk.test.ts | 33 +++++++------------ 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index 1380f9af3235..995369a4663d 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -26,11 +26,7 @@ export function init(options: NodeOptions): NodeClient | undefined { return client; } -const matchedRegexes = [ - /GET \/node_modules\//, - /GET \/favicon\.ico/, - /GET \/@id\//, -]; +const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//]; /** * Filters out noisy transactions such as requests to node_modules, favicon.ico, @id/ @@ -41,14 +37,12 @@ const matchedRegexes = [ export function lowQualityTransactionsFilter(options: NodeOptions): EventProcessor { return Object.assign( (event => { - if (event.type !== 'transaction' || !event.transaction) { return event; } if (matchedRegexes.some(regex => event.transaction?.match(regex))) { - options.debug && - logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); + options.debug && logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); return null; } diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index cb4423b5e2c0..e8dbbf426077 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -80,34 +80,25 @@ describe('React Router server SDK', () => { }); describe('filters out low quality transactions', () => { - it.each([ - 'GET /node_modules/react/index.js', - 'GET /favicon.ico', - 'GET /@id/package', - ])('%s', async (transaction) => { - client.captureEvent({ type: 'transaction', transaction }); + it.each(['GET /node_modules/react/index.js', 'GET /favicon.ico', 'GET /@id/package'])( + '%s', + async transaction => { + client.captureEvent({ type: 'transaction', transaction }); - await client.flush(); + await client.flush(); - expect(beforeSendEvent).not.toHaveBeenCalled(); - }); + expect(beforeSendEvent).not.toHaveBeenCalled(); + }, + ); }); describe('allows high quality transactions', () => { - it.each([ - 'GET /', - 'GET /users', - 'POST /api/data', - 'GET /projects/123', - ])('%s', async (transaction) => { + it.each(['GET /', 'GET /users', 'POST /api/data', 'GET /projects/123'])('%s', async transaction => { client.captureEvent({ type: 'transaction', transaction }); await client.flush(); - expect(beforeSendEvent).toHaveBeenCalledWith( - expect.objectContaining({ transaction }), - expect.any(Object) - ); + expect(beforeSendEvent).toHaveBeenCalledWith(expect.objectContaining({ transaction }), expect.any(Object)); }); }); }); @@ -118,7 +109,7 @@ describe('React Router server SDK', () => { it.each([ ['node_modules request', 'GET /node_modules/react/index.js'], ['favicon.ico request', 'GET /favicon.ico'], - ['@id request', 'GET /@id/package'] + ['@id request', 'GET /@id/package'], ])('%s', (description, transaction) => { const filter = lowQualityTransactionsFilter({}); const event = { @@ -134,7 +125,7 @@ describe('React Router server SDK', () => { it.each([ ['normal page request', 'GET /users'], ['API request', 'POST /api/users'], - ['app route', 'GET /projects/123'] + ['app route', 'GET /projects/123'], ])('%s', (description, transaction) => { const filter = lowQualityTransactionsFilter({}); const event = { From e4fe07f8837b0348b5ca2be84d3b46cdc0389f4a Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 7 May 2025 17:15:45 +0200 Subject: [PATCH 04/28] fix imports for linter --- packages/react-router/test/server/sdk.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index e8dbbf426077..50f1cbce022d 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -1,11 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { getGlobalScope } from '@sentry/core'; import type { Event, EventType } from '@sentry/core'; -import * as SentryNode from '@sentry/node'; +import { getGlobalScope } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; +import * as SentryNode from '@sentry/node'; import { SDK_VERSION } from '@sentry/node'; - +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { init as reactRouterInit, lowQualityTransactionsFilter } from '../../src/server/sdk'; const nodeInit = vi.spyOn(SentryNode, 'init'); From 79ec0d632454055c5c2bb432a7e4c8f2c7cf12e3 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 8 May 2025 11:53:30 +0200 Subject: [PATCH 05/28] Update low quality transaction filter to an integration in react-router --- ...lowQualityTransactionsFilterIntegration.ts | 36 +++++++ packages/react-router/src/server/sdk.ts | 42 +++----- ...alityTransactionsFilterIntegration.test.ts | 69 ++++++++++++++ packages/react-router/test/server/sdk.test.ts | 95 ++++--------------- 4 files changed, 133 insertions(+), 109 deletions(-) create mode 100644 packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts create mode 100644 packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts diff --git a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts new file mode 100644 index 000000000000..69372b598cef --- /dev/null +++ b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts @@ -0,0 +1,36 @@ +import { type Client,type Event, type EventHint, defineIntegration, logger } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node'; + +/** + * Integration that filters out noisy http transactions such as requests to node_modules, favicon.ico, @id/ + * + */ + +function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { + name: string; + processEvent: (event: Event, hint: EventHint, client: Client) => Event | null; +} { + const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//]; + + return { + name: 'LowQualityTransactionsFilter', + + processEvent(event: Event, _hint: EventHint, _client: Client): Event | null { + if (event.type !== 'transaction' || !event.transaction) { + return event; + } + + if (matchedRegexes.some(regex => event.transaction?.match(regex))) { + options.debug && logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); + return null; + } + + return event; + }, + }; +} + + +export const lowQualityTransactionsFilterIntegration = defineIntegration( + (options: NodeOptions) => _lowQualityTransactionsFilterIntegration(options), +); diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index 995369a4663d..fc0fb6cb34b4 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,7 +1,16 @@ -import { type EventProcessor, applySdkMetadata, getGlobalScope, logger, setTag } from '@sentry/core'; +import type { Integration} from '@sentry/core'; +import { applySdkMetadata, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { init as initNodeSdk } from '@sentry/node'; +import { getDefaultIntegrations as getNodeDefaultIntegrations,init as initNodeSdk} from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; +import { lowQualityTransactionsFilterIntegration } from './lowQualityTransactionsFilterIntegration'; + +function getDefaultIntegrations(options: NodeOptions): Integration[] { + return [ + ... getNodeDefaultIntegrations(options), + lowQualityTransactionsFilterIntegration(options), + ]; +} /** * Initializes the server side of the React Router SDK @@ -9,6 +18,7 @@ import { DEBUG_BUILD } from '../common/debug-build'; export function init(options: NodeOptions): NodeClient | undefined { const opts = { ...options, + defaultIntegrations: getDefaultIntegrations(options), }; DEBUG_BUILD && logger.log('Initializing SDK...'); @@ -21,33 +31,5 @@ export function init(options: NodeOptions): NodeClient | undefined { DEBUG_BUILD && logger.log('SDK successfully initialized'); - getGlobalScope().addEventProcessor(lowQualityTransactionsFilter(options)); - return client; } - -const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//]; - -/** - * Filters out noisy transactions such as requests to node_modules, favicon.ico, @id/ - * - * @param options The NodeOptions passed to the SDK - * @returns An EventProcessor that filters low-quality transactions - */ -export function lowQualityTransactionsFilter(options: NodeOptions): EventProcessor { - return Object.assign( - (event => { - if (event.type !== 'transaction' || !event.transaction) { - return event; - } - - if (matchedRegexes.some(regex => event.transaction?.match(regex))) { - options.debug && logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); - return null; - } - - return event; - }) satisfies EventProcessor, - { id: 'ReactRouterLowQualityTransactionsFilter' }, - ); -} diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts new file mode 100644 index 000000000000..0e0c86b4b68a --- /dev/null +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -0,0 +1,69 @@ +import type { Event, EventType, Integration } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import * as SentryNode from '@sentry/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { lowQualityTransactionsFilterIntegration } from '../../src/server/lowQualityTransactionsFilterIntegration'; + +const loggerLog = vi.spyOn(SentryCore.logger, 'log').mockImplementation(() => {}); + +describe('Low Quality Transactions Filter Integration', () => { + afterEach(() => { + vi.clearAllMocks(); + SentryNode.getGlobalScope().clear(); + }); + + describe('integration functionality', () => { + describe('filters out low quality transactions', () => { + it.each([ + ['node_modules requests', 'GET /node_modules/some-package/index.js'], + ['favicon.ico requests', 'GET /favicon.ico'], + ['@id/ requests', 'GET /@id/some-id'] + ])('%s', (description, transaction) => { + const integration = lowQualityTransactionsFilterIntegration({ debug: true }) as Integration; + const event = { + type: 'transaction' as EventType, + transaction, + } as Event; + + const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + + expect(result).toBeNull(); + + expect(loggerLog).toHaveBeenCalledWith( + '[ReactRouter] Filtered node_modules transaction:', + transaction + ); + }); + }); + + describe('allows high quality transactions', () => { + it.each([ + ['normal page requests', 'GET /api/users'], + ['API endpoints', 'POST /data'], + ['app routes', 'GET /projects/123'] + ])('%s', (description, transaction) => { + const integration = lowQualityTransactionsFilterIntegration({}) as Integration; + const event = { + type: 'transaction' as EventType, + transaction, + } as Event; + + const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + + expect(result).toEqual(event); + }); + }); + + it('does not affect non-transaction events', () => { + const integration = lowQualityTransactionsFilterIntegration({}) as Integration; + const event = { + type: 'error' as EventType, + transaction: 'GET /node_modules/some-package/index.js', + } as Event; + + const result = integration.processEvent!(event, {}, {} as SentryCore.Client); + + expect(result).toEqual(event); + }); + }); +}); diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index 50f1cbce022d..0c6abd109d5f 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -1,10 +1,10 @@ -import type { Event, EventType } from '@sentry/core'; -import { getGlobalScope } from '@sentry/core'; +import type { Integration } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { SDK_VERSION } from '@sentry/node'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { init as reactRouterInit, lowQualityTransactionsFilter } from '../../src/server/sdk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as LowQualityModule from '../../src/server/lowQualityTransactionsFilterIntegration'; +import { init as reactRouterInit } from '../../src/server/sdk'; const nodeInit = vi.spyOn(SentryNode, 'init'); @@ -48,91 +48,28 @@ describe('React Router server SDK', () => { expect(client).not.toBeUndefined(); }); - it('registers the low quality transactions filter', async () => { - const addEventProcessor = vi.spyOn(getGlobalScope(), 'addEventProcessor'); - addEventProcessor.mockClear(); + it('adds the low quality transactions filter integration by default', () => { + const filterSpy = vi.spyOn(LowQualityModule, 'lowQualityTransactionsFilterIntegration'); reactRouterInit({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - }) as NodeClient; - - expect(addEventProcessor).toHaveBeenCalledTimes(1); - const processor = addEventProcessor.mock.calls[0]![0]; - expect(processor?.id).toEqual('ReactRouterLowQualityTransactionsFilter'); - }); - - describe('transaction filtering', () => { - const beforeSendEvent = vi.fn(event => event); - let client: NodeClient; - - beforeEach(() => { - vi.clearAllMocks(); - beforeSendEvent.mockClear(); - SentryNode.getGlobalScope().clear(); - - client = reactRouterInit({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }) as NodeClient; - - client.on('beforeSendEvent', beforeSendEvent); }); - describe('filters out low quality transactions', () => { - it.each(['GET /node_modules/react/index.js', 'GET /favicon.ico', 'GET /@id/package'])( - '%s', - async transaction => { - client.captureEvent({ type: 'transaction', transaction }); - - await client.flush(); - - expect(beforeSendEvent).not.toHaveBeenCalled(); - }, - ); - }); + expect(filterSpy).toHaveBeenCalled(); - describe('allows high quality transactions', () => { - it.each(['GET /', 'GET /users', 'POST /api/data', 'GET /projects/123'])('%s', async transaction => { - client.captureEvent({ type: 'transaction', transaction }); - - await client.flush(); - - expect(beforeSendEvent).toHaveBeenCalledWith(expect.objectContaining({ transaction }), expect.any(Object)); - }); - }); - }); - }); + expect(nodeInit).toHaveBeenCalledTimes(1); + const initOptions = nodeInit.mock.calls[0]?.[0]; - describe('lowQualityTransactionsFilter', () => { - describe('filters out low quality transactions', () => { - it.each([ - ['node_modules request', 'GET /node_modules/react/index.js'], - ['favicon.ico request', 'GET /favicon.ico'], - ['@id request', 'GET /@id/package'], - ])('%s', (description, transaction) => { - const filter = lowQualityTransactionsFilter({}); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; + expect(initOptions).toBeDefined(); - expect(filter(event, {})).toBeNull(); - }); - }); + const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; + expect(Array.isArray(defaultIntegrations)).toBe(true); - describe('does not filter good transactions', () => { - it.each([ - ['normal page request', 'GET /users'], - ['API request', 'POST /api/users'], - ['app route', 'GET /projects/123'], - ])('%s', (description, transaction) => { - const filter = lowQualityTransactionsFilter({}); - const event = { - type: 'transaction' as EventType, - transaction, - } as Event; + const filterIntegration = defaultIntegrations.find( + integration => integration.name === 'LowQualityTransactionsFilter' + ); - expect(filter(event, {})).toBe(event); - }); + expect(filterIntegration).toBeDefined(); }); }); }); From f8623907a272d7f0cec3a285c053c1ac648c6095 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 8 May 2025 12:00:56 +0200 Subject: [PATCH 06/28] Fix linter issues --- .../server/lowQualityTransactionsFilterIntegration.ts | 7 +++---- packages/react-router/src/server/sdk.ts | 9 +++------ .../lowQualityTransactionsFilterIntegration.test.ts | 9 +++------ packages/react-router/test/server/sdk.test.ts | 2 +- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts index 69372b598cef..ecf6695f5524 100644 --- a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts +++ b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts @@ -1,4 +1,4 @@ -import { type Client,type Event, type EventHint, defineIntegration, logger } from '@sentry/core'; +import { type Client, type Event, type EventHint, defineIntegration, logger } from '@sentry/core'; import type { NodeOptions } from '@sentry/node'; /** @@ -30,7 +30,6 @@ function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { }; } - -export const lowQualityTransactionsFilterIntegration = defineIntegration( - (options: NodeOptions) => _lowQualityTransactionsFilterIntegration(options), +export const lowQualityTransactionsFilterIntegration = defineIntegration((options: NodeOptions) => + _lowQualityTransactionsFilterIntegration(options), ); diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index fc0fb6cb34b4..c980078ac7b5 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,15 +1,12 @@ -import type { Integration} from '@sentry/core'; +import type { Integration } from '@sentry/core'; import { applySdkMetadata, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations as getNodeDefaultIntegrations,init as initNodeSdk} from '@sentry/node'; +import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { lowQualityTransactionsFilterIntegration } from './lowQualityTransactionsFilterIntegration'; function getDefaultIntegrations(options: NodeOptions): Integration[] { - return [ - ... getNodeDefaultIntegrations(options), - lowQualityTransactionsFilterIntegration(options), - ]; + return [...getNodeDefaultIntegrations(options), lowQualityTransactionsFilterIntegration(options)]; } /** diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts index 0e0c86b4b68a..58ddf3e215d6 100644 --- a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -17,7 +17,7 @@ describe('Low Quality Transactions Filter Integration', () => { it.each([ ['node_modules requests', 'GET /node_modules/some-package/index.js'], ['favicon.ico requests', 'GET /favicon.ico'], - ['@id/ requests', 'GET /@id/some-id'] + ['@id/ requests', 'GET /@id/some-id'], ])('%s', (description, transaction) => { const integration = lowQualityTransactionsFilterIntegration({ debug: true }) as Integration; const event = { @@ -29,10 +29,7 @@ describe('Low Quality Transactions Filter Integration', () => { expect(result).toBeNull(); - expect(loggerLog).toHaveBeenCalledWith( - '[ReactRouter] Filtered node_modules transaction:', - transaction - ); + expect(loggerLog).toHaveBeenCalledWith('[ReactRouter] Filtered node_modules transaction:', transaction); }); }); @@ -40,7 +37,7 @@ describe('Low Quality Transactions Filter Integration', () => { it.each([ ['normal page requests', 'GET /api/users'], ['API endpoints', 'POST /data'], - ['app routes', 'GET /projects/123'] + ['app routes', 'GET /projects/123'], ])('%s', (description, transaction) => { const integration = lowQualityTransactionsFilterIntegration({}) as Integration; const event = { diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index 0c6abd109d5f..57b51d16c042 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -66,7 +66,7 @@ describe('React Router server SDK', () => { expect(Array.isArray(defaultIntegrations)).toBe(true); const filterIntegration = defaultIntegrations.find( - integration => integration.name === 'LowQualityTransactionsFilter' + integration => integration.name === 'LowQualityTransactionsFilter', ); expect(filterIntegration).toBeDefined(); From befe970695c06b210de86652db00cd8800e89cfa Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 8 May 2025 10:25:33 -0400 Subject: [PATCH 07/28] feat: Export `consoleLoggingIntegration` from vercel edge sdk (#16228) fixes https://github.com/getsentry/sentry-javascript/issues/16229 ref: https://github.com/getsentry/sentry-javascript/discussions/15916#discussioncomment-13067000 --- packages/vercel-edge/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 1fb7cd0135ac..c98cf8ed253d 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -88,6 +88,7 @@ export { spanToTraceHeader, spanToBaggageHeader, wrapMcpServerWithSentry, + consoleLoggingIntegration, } from '@sentry/core'; export { VercelEdgeClient } from './client'; From f2afa298ef1451e4ee6965666a02697dca946207 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 8 May 2025 10:25:47 -0400 Subject: [PATCH 08/28] feat(cloudflare): Add support for email, queue, and tail handler (#16233) Cloudflare workers have a variety of handlers you can define for different functionality: https://developers.cloudflare.com/workers/runtime-apis/handlers/ Right now our instrumentation wraps the `fetch` and `scheduled` handlers. This PR extends our instrumentation to also wrap the `queue`, `email`, and `tail` handler. We only create spans for queue and email, but not for tail because its meant as a debugging/analytics endpoint. We can introduce spans in `tail` handler if there is demand in the future. We need to add this wrapping, otherwise users cannot use the SDK in these handlers because of how the cloudflare isolation model works (fetch handler is completed isolated from email and queue handlers, so we need to wrap every handler individually). --- packages/cloudflare/src/handler.ts | 119 +++- packages/cloudflare/test/handler.test.ts | 702 ++++++++++++++++++++++- 2 files changed, 803 insertions(+), 18 deletions(-) diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 7e1667d6dc56..62956cff62cf 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,6 +1,7 @@ import { captureException, flush, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan, @@ -66,7 +67,7 @@ export function withSentry>) { + const [emailMessage, env, context] = args; + return withIsolationScope(isolationScope => { + const options = getFinalOptions(optionsCallback(env), env); + + const client = init(options); + isolationScope.setClient(client); + + addCloudResourceContext(isolationScope); + + return startSpan( + { + op: 'faas.email', + name: `Handle Email ${emailMessage.to}`, + attributes: { + 'faas.trigger': 'email', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.email', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + async () => { + try { + return await (target.apply(thisArg, args) as ReturnType); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context.waitUntil(flush(2000)); + } + }, + ); + }); + }, + }); + + markAsInstrumented(handler.email); + } + + if ('queue' in handler && typeof handler.queue === 'function' && !isInstrumented(handler.queue)) { + handler.queue = new Proxy(handler.queue, { + apply(target, thisArg, args: Parameters>) { + const [batch, env, context] = args; + + return withIsolationScope(isolationScope => { + const options = getFinalOptions(optionsCallback(env), env); + + const client = init(options); + isolationScope.setClient(client); + + addCloudResourceContext(isolationScope); + + return startSpan( + { + op: 'faas.queue', + name: `process ${batch.queue}`, + attributes: { + 'faas.trigger': 'pubsub', + 'messaging.destination.name': batch.queue, + 'messaging.system': 'cloudflare', + 'messaging.batch.message_count': batch.messages.length, + 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts, 0), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.queue', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + async () => { + try { + return await (target.apply(thisArg, args) as ReturnType); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context.waitUntil(flush(2000)); + } + }, + ); + }); + }, + }); + + markAsInstrumented(handler.queue); + } + + if ('tail' in handler && typeof handler.tail === 'function' && !isInstrumented(handler.tail)) { + handler.tail = new Proxy(handler.tail, { + apply(target, thisArg, args: Parameters>) { + const [, env, context] = args; + + return withIsolationScope(async isolationScope => { + const options = getFinalOptions(optionsCallback(env), env); + + const client = init(options); + isolationScope.setClient(client); + + addCloudResourceContext(isolationScope); + + try { + return await (target.apply(thisArg, args) as ReturnType); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context.waitUntil(flush(2000)); + } + }); + }, + }); + + markAsInstrumented(handler.tail); + } + // This is here because Miniflare sometimes cannot get instrumented - // } catch (e) { // Do not console anything here, we don't want to spam the console with errors } diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index 602df308c3df..6ae688f316f9 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -1,7 +1,7 @@ // Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. -import type { ScheduledController } from '@cloudflare/workers-types'; +import type { ForwardableEmailMessage, MessageBatch, ScheduledController, TraceItem } from '@cloudflare/workers-types'; import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -29,7 +29,7 @@ describe('withSentry', () => { const optionsCallback = vi.fn().mockReturnValue({}); const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); expect(optionsCallback).toHaveBeenCalledTimes(1); expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); @@ -44,7 +44,7 @@ describe('withSentry', () => { } satisfies ExportedHandler; const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - const result = await wrappedHandler.fetch( + const result = await wrappedHandler.fetch?.( new Request('https://example.com'), MOCK_ENV, createMockExecutionContext(), @@ -74,7 +74,7 @@ describe('withSentry', () => { ); try { - await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); } catch { // ignore } @@ -104,7 +104,7 @@ describe('withSentry', () => { ); try { - await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); } catch { // ignore } @@ -124,7 +124,7 @@ describe('withSentry', () => { const optionsCallback = vi.fn().mockReturnValue({}); const wrappedHandler = withSentry(optionsCallback, handler); - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); expect(optionsCallback).toHaveBeenCalledTimes(1); expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); @@ -149,7 +149,7 @@ describe('withSentry', () => { }), handler, ); - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); expect(sentryEvent.release).toBe('1.1.1'); }); @@ -174,7 +174,7 @@ describe('withSentry', () => { }), handler, ); - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); expect(sentryEvent.release).toEqual('2.0.0'); }); @@ -188,7 +188,7 @@ describe('withSentry', () => { const context = createMockExecutionContext(); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, context); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); // eslint-disable-next-line @typescript-eslint/unbound-method expect(context.waitUntil).toHaveBeenCalledTimes(1); @@ -205,7 +205,7 @@ describe('withSentry', () => { } satisfies ExportedHandler; const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); expect(initAndBindSpy).toHaveBeenCalledTimes(1); expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); @@ -231,7 +231,7 @@ describe('withSentry', () => { }), handler, ); - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); }); @@ -252,7 +252,7 @@ describe('withSentry', () => { const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); try { - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); } catch { // ignore } @@ -275,7 +275,7 @@ describe('withSentry', () => { let thrownError: Error | undefined; try { - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); } catch (e: any) { thrownError = e; } @@ -305,13 +305,13 @@ describe('withSentry', () => { handler, ); - await wrappedHandler.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); expect(sentryEvent.transaction).toEqual('Scheduled Cron 0 0 0 * * *'); expect(sentryEvent.spans).toHaveLength(0); expect(sentryEvent.contexts?.trace).toEqual({ data: { - 'sentry.origin': 'auto.faas.cloudflare', + 'sentry.origin': 'auto.faas.cloudflare.scheduled', 'sentry.op': 'faas.cron', 'faas.cron': '0 0 0 * * *', 'faas.time': expect.any(String), @@ -320,13 +320,617 @@ describe('withSentry', () => { 'sentry.source': 'task', }, op: 'faas.cron', - origin: 'auto.faas.cloudflare', + origin: 'auto.faas.cloudflare.scheduled', span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); }); }); }); + + describe('email handler', () => { + test('executes options callback with env', async () => { + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('merges options from env and callback', async () => { + const handler = { + email(_message, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + email(_message, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + email(_message, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + const handler = { + email(_message, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + try { + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'cloudflare' }, + }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + email(_message, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + + let thrownError: Error | undefined; + try { + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + + describe('tracing instrumentation', () => { + test('creates a span that wraps email invocation', async () => { + const handler = { + email(_message, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + beforeSendTransaction(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + const emailMessage = createMockEmailMessage(); + await wrappedHandler.email?.(emailMessage, MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.transaction).toEqual(`Handle Email ${emailMessage.to}`); + expect(sentryEvent.spans).toHaveLength(0); + expect(sentryEvent.contexts?.trace).toEqual({ + data: { + 'sentry.origin': 'auto.faas.cloudflare.email', + 'sentry.op': 'faas.email', + 'faas.trigger': 'email', + 'sentry.sample_rate': 1, + 'sentry.source': 'task', + }, + op: 'faas.email', + origin: 'auto.faas.cloudflare.email', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + }); + }); + }); + + describe('queue handler', () => { + test('executes options callback with env', async () => { + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('merges options from env and callback', async () => { + const handler = { + queue(_batch, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + queue(_batch, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + queue(_batch, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + const handler = { + queue(_batch, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + try { + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'cloudflare' }, + }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + queue(_batch, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + + let thrownError: Error | undefined; + try { + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + + describe('tracing instrumentation', () => { + test('creates a span that wraps queue invocation with correct attributes', async () => { + const handler = { + queue(_batch, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + beforeSendTransaction(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + const batch = createMockQueueBatch(); + await wrappedHandler.queue?.(batch, MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.transaction).toEqual(`process ${batch.queue}`); + expect(sentryEvent.spans).toHaveLength(0); + expect(sentryEvent.contexts?.trace).toEqual({ + data: { + 'sentry.origin': 'auto.faas.cloudflare.queue', + 'sentry.op': 'queue.process', + 'faas.trigger': 'pubsub', + 'messaging.destination.name': batch.queue, + 'messaging.system': 'cloudflare', + 'messaging.batch.message_count': batch.messages.length, + 'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts, 0), + 'sentry.sample_rate': 1, + 'sentry.source': 'task', + }, + op: 'queue.process', + origin: 'auto.faas.cloudflare.queue', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + }); + }); + }); + + describe('tail handler', () => { + test('executes options callback with env', async () => { + const handler = { + tail(_event, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const optionsCallback = vi.fn().mockReturnValue({}); + + const wrappedHandler = withSentry(optionsCallback, handler); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(optionsCallback).toHaveBeenCalledTimes(1); + expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); + }); + + test('merges options from env and callback', async () => { + const handler = { + tail(_event, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + tail(_event, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toEqual('2.0.0'); + }); + + test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { + const handler = { + tail(_event, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const context = createMockExecutionContext(); + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, context); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + }); + + test('creates a cloudflare client and sets it on the handler', async () => { + const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); + const handler = { + tail(_event, _env, _context) { + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(initAndBindSpy).toHaveBeenCalledTimes(1); + expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); + }); + + describe('scope instrumentation', () => { + test('adds cloud resource context', async () => { + const handler = { + tail(_event, _env, _context) { + SentryCore.captureMessage('cloud_resource'); + return; + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' }); + }); + }); + + describe('error instrumentation', () => { + test('captures errors thrown by the handler', async () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + const error = new Error('test'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + const handler = { + tail(_event, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + try { + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'cloudflare' }, + }); + }); + + test('re-throws the error after capturing', async () => { + const error = new Error('test'); + const handler = { + tail(_event, _env, _context) { + throw error; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + + let thrownError: Error | undefined; + try { + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, createMockExecutionContext()); + } catch (e: any) { + thrownError = e; + } + + expect(thrownError).toBe(error); + }); + }); + }); }); function createMockExecutionContext(): ExecutionContext { @@ -343,3 +947,69 @@ function createMockScheduledController(): ScheduledController { noRetry: vi.fn(), }; } + +function createMockEmailMessage(): ForwardableEmailMessage { + return { + from: 'sender@example.com', + to: 'recipient@example.com', + raw: new ReadableStream(), + rawSize: 1024, + headers: new Headers(), + setReject: vi.fn(), + forward: vi.fn(), + reply: vi.fn(), + }; +} + +function createMockQueueBatch(): MessageBatch { + return { + queue: 'test-queue', + messages: [ + { + id: '1', + timestamp: new Date(), + body: 'test message 1', + attempts: 1, + retry: vi.fn(), + ack: vi.fn(), + }, + { + id: '2', + timestamp: new Date(), + body: 'test message 2', + attempts: 2, + retry: vi.fn(), + ack: vi.fn(), + }, + ], + retryAll: vi.fn(), + ackAll: vi.fn(), + }; +} + +function createMockTailEvent(): TraceItem[] { + return [ + { + event: { + consumedEvents: [ + { + scriptName: 'test-script', + }, + ], + }, + eventTimestamp: Date.now(), + logs: [ + { + timestamp: Date.now(), + level: 'info', + message: 'Test log message', + }, + ], + exceptions: [], + diagnosticsChannelEvents: [], + scriptName: 'test-script', + outcome: 'ok', + truncated: false, + }, + ]; +} From db2f60402969610467396ca1557a01d094e04ed2 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 8 May 2025 18:05:41 +0100 Subject: [PATCH 09/28] fix(remix): Remove vendored types (#16218) Removed the vendored types and started importing them from Remix v2 packages. Also added `@remix-run/server-runtime` as `devDependency` and `peerDependency` --- packages/remix/package.json | 2 + packages/remix/src/server/errors.ts | 10 +- packages/remix/src/server/instrumentServer.ts | 40 +-- packages/remix/src/utils/utils.ts | 12 +- packages/remix/src/utils/vendor/response.ts | 39 +-- packages/remix/src/utils/vendor/types.ts | 235 ------------------ 6 files changed, 40 insertions(+), 298 deletions(-) delete mode 100644 packages/remix/src/utils/vendor/types.ts diff --git a/packages/remix/package.json b/packages/remix/package.json index 4e214c8962b5..52ff6e499983 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -78,12 +78,14 @@ "devDependencies": { "@remix-run/node": "^2.15.2", "@remix-run/react": "^2.15.2", + "@remix-run/server-runtime": "2.15.2", "@types/express": "^4.17.14", "vite": "^5.4.11" }, "peerDependencies": { "@remix-run/node": "2.x", "@remix-run/react": "2.x", + "@remix-run/server-runtime": "2.x", "react": "18.x" }, "scripts": { diff --git a/packages/remix/src/server/errors.ts b/packages/remix/src/server/errors.ts index 32e76a9db260..90359212300d 100644 --- a/packages/remix/src/server/errors.ts +++ b/packages/remix/src/server/errors.ts @@ -1,9 +1,12 @@ import type { + ActionFunction, ActionFunctionArgs, EntryContext, HandleDocumentRequestFunction, + LoaderFunction, LoaderFunctionArgs, } from '@remix-run/node'; +import { isRouteErrorResponse } from '@remix-run/router'; import type { RequestEventData, Span } from '@sentry/core'; import { addExceptionMechanism, @@ -17,8 +20,9 @@ import { import { DEBUG_BUILD } from '../utils/debug-build'; import type { RemixOptions } from '../utils/remixOptions'; import { storeFormDataKeys } from '../utils/utils'; -import { extractData, isResponse, isRouteErrorResponse } from '../utils/vendor/response'; -import type { DataFunction, RemixRequest } from '../utils/vendor/types'; +import { extractData, isResponse } from '../utils/vendor/response'; + +type DataFunction = LoaderFunction | ActionFunction; /** * Captures an exception happened in the Remix server. @@ -87,7 +91,7 @@ export function errorHandleDocumentRequestFunction( this: unknown, origDocumentRequestFunction: HandleDocumentRequestFunction, requestContext: { - request: RemixRequest; + request: Request; responseStatusCode: number; responseHeaders: Headers; context: EntryContext; diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index 8cd54e989530..3417188cc7d5 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -1,4 +1,18 @@ /* eslint-disable max-lines */ +import type { AgnosticRouteObject } from '@remix-run/router'; +import { isDeferredData, isRouteErrorResponse } from '@remix-run/router'; +import type { + ActionFunction, + ActionFunctionArgs, + AppLoadContext, + CreateRequestHandlerFunction, + EntryContext, + HandleDocumentRequestFunction, + LoaderFunction, + LoaderFunctionArgs, + RequestHandler, + ServerBuild, +} from '@remix-run/server-runtime'; import type { RequestEventData, Span, TransactionSource, WrappedFunction } from '@sentry/core'; import { continueTrace, @@ -22,23 +36,15 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../utils/debug-build'; import { createRoutes, getTransactionName } from '../utils/utils'; -import { extractData, isDeferredData, isResponse, isRouteErrorResponse, json } from '../utils/vendor/response'; -import type { - AppData, - AppLoadContext, - CreateRequestHandlerFunction, - DataFunction, - DataFunctionArgs, - EntryContext, - HandleDocumentRequestFunction, - RemixRequest, - RequestHandler, - ServerBuild, - ServerRoute, - ServerRouteManifest, -} from '../utils/vendor/types'; +import { extractData, isResponse, json } from '../utils/vendor/response'; import { captureRemixServerException, errorHandleDataFunction, errorHandleDocumentRequestFunction } from './errors'; +type AppData = unknown; +type RemixRequest = Parameters[0]; +type ServerRouteManifest = ServerBuild['routes']; +type DataFunction = LoaderFunction | ActionFunction; +type DataFunctionArgs = LoaderFunctionArgs | ActionFunctionArgs; + const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); function isRedirectResponse(response: Response): boolean { return redirectStatusCodes.has(response.status); @@ -261,7 +267,7 @@ function wrapRequestHandler( return origRequestHandler.call(this, request, loadContext); } - let resolvedRoutes: ServerRoute[] | undefined; + let resolvedRoutes: AgnosticRouteObject[] | undefined; if (options?.instrumentTracing) { if (typeof build === 'function') { @@ -428,7 +434,7 @@ export const makeWrappedCreateRequestHandler = (options?: { instrumentTracing?: function (origCreateRequestHandler: CreateRequestHandlerFunction): CreateRequestHandlerFunction { return function ( this: unknown, - build: ServerBuild | (() => Promise), + build: ServerBuild | (() => ServerBuild | Promise), ...args: unknown[] ): RequestHandler { const newBuild = instrumentBuild(build, options); diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index 57b26e07ce56..a1d878ac1314 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -1,9 +1,11 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'; +import type { ActionFunctionArgs, LoaderFunctionArgs, ServerBuild } from '@remix-run/node'; +import type { AgnosticRouteObject } from '@remix-run/router'; import type { Span, TransactionSource } from '@sentry/core'; import { logger } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import { getRequestMatch, matchServerRoutes } from './vendor/response'; -import type { ServerRoute, ServerRouteManifest } from './vendor/types'; + +type ServerRouteManifest = ServerBuild['routes']; /** * @@ -29,7 +31,7 @@ export async function storeFormDataKeys(args: LoaderFunctionArgs | ActionFunctio /** * Get transaction name from routes and url */ -export function getTransactionName(routes: ServerRoute[], url: URL): [string, TransactionSource] { +export function getTransactionName(routes: AgnosticRouteObject[], url: URL): [string, TransactionSource] { const matches = matchServerRoutes(routes, url.pathname); const match = matches && getRequestMatch(url, matches); return match === null ? [url.pathname, 'url'] : [match.route.id || 'no-route-id', 'route']; @@ -41,11 +43,11 @@ export function getTransactionName(routes: ServerRoute[], url: URL): [string, Tr * @param manifest * @param parentId */ -export function createRoutes(manifest: ServerRouteManifest, parentId?: string): ServerRoute[] { +export function createRoutes(manifest: ServerRouteManifest, parentId?: string): AgnosticRouteObject[] { return Object.entries(manifest) .filter(([, route]) => route.parentId === parentId) .map(([id, route]) => ({ ...route, children: createRoutes(manifest, id), - })); + })) as AgnosticRouteObject[]; } diff --git a/packages/remix/src/utils/vendor/response.ts b/packages/remix/src/utils/vendor/response.ts index 4b7197f65982..dcdf70348967 100644 --- a/packages/remix/src/utils/vendor/response.ts +++ b/packages/remix/src/utils/vendor/response.ts @@ -8,8 +8,6 @@ import type { AgnosticRouteMatch, AgnosticRouteObject } from '@remix-run/router'; import { matchRoutes } from '@remix-run/router'; -import type { DeferredData, ErrorResponse, ServerRoute } from './types'; - /** * Based on Remix Implementation * @@ -76,7 +74,7 @@ export const json: JsonFunction = (data, init = {}) => { * Changed so that `matchRoutes` function is passed in. */ export function matchServerRoutes( - routes: ServerRoute[], + routes: AgnosticRouteObject[], pathname: string, ): AgnosticRouteMatch[] | null { const matches = matchRoutes(routes, pathname); @@ -126,38 +124,3 @@ export function getRequestMatch( return match; } - -/** - * https://github.com/remix-run/remix/blob/3e589152bc717d04e2054c31bea5a1056080d4b9/packages/remix-server-runtime/responses.ts#L75-L85 - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isDeferredData(value: any): value is DeferredData { - const deferred: DeferredData = value; - return ( - deferred && - typeof deferred === 'object' && - typeof deferred.data === 'object' && - typeof deferred.subscribe === 'function' && - typeof deferred.cancel === 'function' && - typeof deferred.resolveData === 'function' - ); -} - -/** - * https://github.com/remix-run/react-router/blob/f9b3dbd9cbf513366c456b33d95227f42f36da63/packages/router/utils.ts#L1574 - * - * Check if the given error is an ErrorResponse generated from a 4xx/5xx - * Response thrown from an action/loader - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isRouteErrorResponse(value: any): value is ErrorResponse { - const error: ErrorResponse = value; - - return ( - error != null && - typeof error.status === 'number' && - typeof error.statusText === 'string' && - typeof error.internal === 'boolean' && - 'data' in error - ); -} diff --git a/packages/remix/src/utils/vendor/types.ts b/packages/remix/src/utils/vendor/types.ts deleted file mode 100644 index 015207bd94a2..000000000000 --- a/packages/remix/src/utils/vendor/types.ts +++ /dev/null @@ -1,235 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ -// Types vendored from @remix-run/server-runtime@1.6.0: -// https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts -// Copyright 2021 Remix Software Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -import type * as Express from 'express'; -import type { Agent } from 'https'; -import type { ComponentType } from 'react'; - -type Dev = { - command?: string; - scheme?: string; - host?: string; - port?: number; - restart?: boolean; - tlsKey?: string; - tlsCert?: string; -}; - -export interface FutureConfig { - unstable_dev: boolean | Dev; - /** @deprecated Use the `postcss` config option instead */ - unstable_postcss: boolean; - /** @deprecated Use the `tailwind` config option instead */ - unstable_tailwind: boolean; - v2_errorBoundary: boolean; - v2_headers: boolean; - v2_meta: boolean; - v2_normalizeFormMethod: boolean; - v2_routeConvention: boolean; -} - -export interface RemixConfig { - [key: string]: any; - future: FutureConfig; -} - -export interface ErrorResponse { - status: number; - statusText: string; - data: any; - error?: Error; - internal: boolean; -} - -export type RemixRequestState = { - method: string; - redirect: RequestRedirect; - headers: Headers; - parsedURL: URL; - signal: AbortSignal | null; - size: number | null; -}; - -export type RemixRequest = Request & - Record & { - agent?: Agent | ((parsedURL: URL) => Agent) | undefined; - }; - -export type AppLoadContext = Record & { __sentry_express_wrapped__?: boolean }; -export type AppData = any; -export type RequestHandler = (request: RemixRequest, loadContext?: AppLoadContext) => Promise; -export type CreateRequestHandlerFunction = (this: unknown, build: ServerBuild, ...args: any[]) => RequestHandler; -export type ServerRouteManifest = RouteManifest>; -export type Params = { - readonly [key in Key]: string | undefined; -}; - -export type ExpressRequest = Express.Request; -export type ExpressResponse = Express.Response; -export type ExpressNextFunction = Express.NextFunction; - -export interface Route { - index: false | undefined; - caseSensitive?: boolean; - id: string; - parentId?: string; - path?: string; -} - -export interface EntryRoute extends Route { - hasAction: boolean; - hasLoader: boolean; - hasCatchBoundary: boolean; - hasErrorBoundary: boolean; - imports?: string[]; - module: string; -} - -export interface RouteData { - [routeId: string]: AppData; -} - -export type DeferredData = { - data: Record; - init?: ResponseInit; - deferredKeys: string[]; - subscribe(fn: (aborted: boolean, settledKey?: string) => void): () => boolean; - cancel(): void; - resolveData(signal: AbortSignal): Promise; -}; - -export interface MetaFunction { - (args: { data: AppData; parentsData: RouteData; params: Params; location: Location }): HtmlMetaDescriptor; -} - -export interface HtmlMetaDescriptor { - [name: string]: null | string | undefined | Record | Array | string>; - charset?: 'utf-8'; - charSet?: 'utf-8'; - title?: string; -} - -export type CatchBoundaryComponent = ComponentType<{}>; -export type RouteComponent = ComponentType<{}>; -export type ErrorBoundaryComponent = ComponentType<{ error: Error }>; -export type RouteHandle = any; -export interface LinksFunction { - (): any[]; -} -export interface EntryRouteModule { - CatchBoundary?: CatchBoundaryComponent; - ErrorBoundary?: ErrorBoundaryComponent; - default: RouteComponent; - handle?: RouteHandle; - links?: LinksFunction; - meta?: MetaFunction | HtmlMetaDescriptor; -} - -export interface ActionFunction { - (args: DataFunctionArgs): Promise | Response | Promise | AppData; -} - -export interface LoaderFunction { - (args: DataFunctionArgs): Promise | Response | Promise | AppData; -} - -export interface HeadersFunction { - (args: { loaderHeaders: Headers; parentHeaders: Headers; actionHeaders: Headers }): Headers | HeadersInit; -} - -export interface ServerRouteModule extends EntryRouteModule { - action?: ActionFunction; - headers?: HeadersFunction | { [name: string]: string }; - loader?: LoaderFunction; -} - -export interface ServerRoute extends Route { - children: ServerRoute[]; - module: ServerRouteModule; -} - -export interface RouteManifest { - [routeId: string]: Route; -} - -export interface ServerBuild { - entry: { - module: ServerEntryModule; - }; - routes: ServerRouteManifest; - assets: AssetsManifest; - publicPath?: string; - assetsBuildDirectory?: string; - future?: FutureConfig; -} - -export interface HandleDocumentRequestFunction { - ( - request: RemixRequest, - responseStatusCode: number, - responseHeaders: Headers, - context: EntryContext, - loadContext?: AppLoadContext, - ): Promise | Response; -} - -export interface HandleDataRequestFunction { - (response: Response, args: DataFunctionArgs): Promise | Response; -} - -interface ServerEntryModule { - default: HandleDocumentRequestFunction; - handleDataRequest?: HandleDataRequestFunction; -} - -export interface DataFunctionArgs { - request: RemixRequest; - context: AppLoadContext; - params: Params; -} - -export interface DataFunction { - (args: DataFunctionArgs): Promise | Response | Promise | AppData; -} - -// Taken from Remix Implementation -// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/routeMatching.ts#L6-L10 -export interface RouteMatch { - params: Params; - pathname: string; - route: Route; -} - -export interface EntryContext { - [name: string]: any; -} - -export interface AssetsManifest { - entry: { - imports: string[]; - module: string; - }; - routes: RouteManifest; - url: string; - version: string; -} - -export type ExpressRequestHandler = (req: any, res: any, next: any) => Promise; - -export type ExpressCreateRequestHandler = (this: unknown, options: any) => ExpressRequestHandler; - -export interface ExpressCreateRequestHandlerOptions { - build: ServerBuild; - getLoadContext?: GetLoadContextFunction; - mode?: string; -} - -export type GetLoadContextFunction = (req: any, res: any) => AppLoadContext; From 5dd37a6e14f0fb4193f2247e090358d944e70a64 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 8 May 2025 16:42:58 -0400 Subject: [PATCH 10/28] feat: Support Node 24 (#16236) resolves https://github.com/getsentry/sentry-javascript/issues/16058 Profiling binary bump: https://github.com/getsentry/sentry-javascript-profiling-node-binaries/pull/12 --- .github/workflows/build.yml | 6 ++--- packages/profiling-node/package.json | 2 +- packages/profiling-node/src/integration.ts | 4 ++-- scripts/ci-unit-tests.ts | 8 ++++++- yarn.lock | 28 +++++++++++----------- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 978e728e1f56..8996e5f5cf5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -475,7 +475,7 @@ jobs: strategy: fail-fast: false matrix: - node: [18, 20, 22] + node: [18, 20, 22, 24] steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) uses: actions/checkout@v4 @@ -713,12 +713,12 @@ jobs: strategy: fail-fast: false matrix: - node: ['18.20.5', 20, 22] + node: ['18.20.5', 20, 22, 24] typescript: - false include: # Only check typescript for latest version (to streamline CI) - - node: 22 + - node: 24 typescript: '3.8' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index 2a2fb0d31c67..738b429b00dd 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -62,7 +62,7 @@ "test:watch": "vitest --watch" }, "dependencies": { - "@sentry-internal/node-cpu-profiler": "^2.0.0", + "@sentry-internal/node-cpu-profiler": "^2.2.0", "@sentry/core": "9.17.0", "@sentry/node": "9.17.0" }, diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index fc94694c383e..67ad0c0ed2e3 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -632,12 +632,12 @@ class ContinuousProfiler { /** Exported only for tests. */ export const _nodeProfilingIntegration = ((): ProfilingIntegration => { - if (![16, 18, 20, 22].includes(NODE_MAJOR)) { + if (![16, 18, 20, 22, 24].includes(NODE_MAJOR)) { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( `[Sentry Profiling] You are using a Node.js version that does not have prebuilt binaries (${NODE_VERSION}).`, - 'The @sentry/profiling-node package only has prebuilt support for the following LTS versions of Node.js: 16, 18, 20, 22.', + 'The @sentry/profiling-node package only has prebuilt support for the following LTS versions of Node.js: 16, 18, 20, 22, 24.', 'To use the @sentry/profiling-node package with this version of Node.js, you will need to compile the native addon from source.', 'See: https://github.com/getsentry/sentry-javascript/tree/develop/packages/profiling-node#building-the-package-from-source', ); diff --git a/scripts/ci-unit-tests.ts b/scripts/ci-unit-tests.ts index cd53df2dfd68..a86ac0f6358f 100644 --- a/scripts/ci-unit-tests.ts +++ b/scripts/ci-unit-tests.ts @@ -4,7 +4,7 @@ import * as path from 'path'; const UNIT_TEST_ENV = process.env.UNIT_TEST_ENV as 'node' | 'browser' | undefined; const RUN_AFFECTED = process.argv.includes('--affected'); -const NODE_VERSION = process.env.NODE_VERSION as '18' | '20' | '22'; +const NODE_VERSION = process.env.NODE_VERSION as '18' | '20' | '22' | '24'; // These packages are tested separately in CI, so no need to run them here const DEFAULT_SKIP_PACKAGES = ['@sentry/bun', '@sentry/deno']; @@ -29,6 +29,8 @@ const BROWSER_TEST_PACKAGES = [ // Packages that cannot run in Node 18 const SKIP_NODE_18_PACKAGES = ['@sentry/react-router']; +const SKIP_NODE_24_PACKAGES = ['@sentry/google-cloud-serverless']; + function getAllPackages(): string[] { const { workspaces }: { workspaces: string[] } = JSON.parse( fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'), @@ -63,6 +65,10 @@ function runTests(): void { if (NODE_VERSION === '18') { SKIP_NODE_18_PACKAGES.forEach(pkg => ignores.add(pkg)); } + + if (NODE_VERSION === '24') { + SKIP_NODE_24_PACKAGES.forEach(pkg => ignores.add(pkg)); + } } if (RUN_AFFECTED) { diff --git a/yarn.lock b/yarn.lock index 5952c9d32733..644150b45d72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6394,13 +6394,13 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" -"@sentry-internal/node-cpu-profiler@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.0.0.tgz#76a0d363055876b91663769daee2d4b12321ba3b" - integrity sha512-0pZId+HY/AbNs1+CoCi8wogBWTrRv+DYeOgbevhekzMr5HYsA6PRY21NtHBXMbu0WcswFwaveDKR+sOW1EDHAA== +"@sentry-internal/node-cpu-profiler@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/node-cpu-profiler/-/node-cpu-profiler-2.2.0.tgz#0640d4aebb4d36031658ccff83dc22b76f437ede" + integrity sha512-oLHVYurqZfADPh5hvmQYS5qx8t0UZzT2u6+/68VXsFruQEOnYJTODKgU3BVLmemRs3WE6kCJjPeFdHVYOQGSzQ== dependencies: - detect-libc "^2.0.2" - node-abi "^3.61.0" + detect-libc "^2.0.3" + node-abi "^3.73.0" "@sentry-internal/rrdom@2.34.0": version "2.34.0" @@ -13212,10 +13212,10 @@ detect-libc@^1.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== -detect-libc@^2.0.0, detect-libc@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d" - integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== +detect-libc@^2.0.0, detect-libc@^2.0.2, detect-libc@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8" + integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== detect-newline@3.1.0: version "3.1.0" @@ -21606,10 +21606,10 @@ nock@^13.5.5: json-stringify-safe "^5.0.1" propagate "^2.0.0" -node-abi@^3.3.0, node-abi@^3.61.0: - version "3.61.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.61.0.tgz#9248f8b8e35dbae2fafeecd6240c5a017ea23f3f" - integrity sha512-dYDO1rxzvMXjEMi37PBeFuYgwh3QZpsw/jt+qOmnRSwiV4z4c+OLoRlTa3V8ID4TrkSQpzCVc9OI2sstFaINfQ== +node-abi@^3.3.0, node-abi@^3.73.0: + version "3.75.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.75.0.tgz#2f929a91a90a0d02b325c43731314802357ed764" + integrity sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg== dependencies: semver "^7.3.5" From c59c7c97c8f51d887fbe51bdfce4def6b934f97b Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 9 May 2025 01:53:41 +0100 Subject: [PATCH 11/28] fix(test): Make Remix integration tests Node 24 compatible (#16241) Resolves: #16239 Looks like importing types without `type` fails on Node 24. --- .github/workflows/build.yml | 2 +- .../test/integration/test/client/capture-exception.test.ts | 2 +- .../remix/test/integration/test/client/capture-message.test.ts | 2 +- packages/remix/test/integration/test/client/click-error.test.ts | 2 +- .../remix/test/integration/test/client/errorboundary.test.ts | 2 +- .../remix/test/integration/test/client/manualtracing.test.ts | 2 +- packages/remix/test/integration/test/client/meta-tags.test.ts | 2 +- packages/remix/test/integration/test/client/pageload.test.ts | 2 +- packages/remix/test/integration/test/client/root-loader.test.ts | 2 +- .../test/integration/test/server/instrumentation/loader.test.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8996e5f5cf5f..7cfcdb449ea6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -760,7 +760,7 @@ jobs: strategy: fail-fast: false matrix: - node: [18, 20, 22] + node: [18, 20, 22, 24] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v4 diff --git a/packages/remix/test/integration/test/client/capture-exception.test.ts b/packages/remix/test/integration/test/client/capture-exception.test.ts index 68aaa9e0a018..3f0aaaa4d83d 100644 --- a/packages/remix/test/integration/test/client/capture-exception.test.ts +++ b/packages/remix/test/integration/test/client/capture-exception.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; test('should report a manually captured error.', async ({ page }) => { diff --git a/packages/remix/test/integration/test/client/capture-message.test.ts b/packages/remix/test/integration/test/client/capture-message.test.ts index ab1a9083d132..09a1720f94fa 100644 --- a/packages/remix/test/integration/test/client/capture-message.test.ts +++ b/packages/remix/test/integration/test/client/capture-message.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; test('should report a manually captured message.', async ({ page }) => { diff --git a/packages/remix/test/integration/test/client/click-error.test.ts b/packages/remix/test/integration/test/client/click-error.test.ts index a6385c0e0963..c8c70105708f 100644 --- a/packages/remix/test/integration/test/client/click-error.test.ts +++ b/packages/remix/test/integration/test/client/click-error.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; test('should report a manually captured message on click with the correct stacktrace.', async ({ page }) => { diff --git a/packages/remix/test/integration/test/client/errorboundary.test.ts b/packages/remix/test/integration/test/client/errorboundary.test.ts index dc7f0378184f..cb30c5c15c89 100644 --- a/packages/remix/test/integration/test/client/errorboundary.test.ts +++ b/packages/remix/test/integration/test/client/errorboundary.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; test('should capture React component errors.', async ({ page }) => { diff --git a/packages/remix/test/integration/test/client/manualtracing.test.ts b/packages/remix/test/integration/test/client/manualtracing.test.ts index ff2bcac3ec1c..cc9b5b086e0b 100644 --- a/packages/remix/test/integration/test/client/manualtracing.test.ts +++ b/packages/remix/test/integration/test/client/manualtracing.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; test('should report a manually created / finished transaction.', async ({ page }) => { diff --git a/packages/remix/test/integration/test/client/meta-tags.test.ts b/packages/remix/test/integration/test/client/meta-tags.test.ts index 5e54226c65d6..94a5ecfa1bd4 100644 --- a/packages/remix/test/integration/test/client/meta-tags.test.ts +++ b/packages/remix/test/integration/test/client/meta-tags.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { getFirstSentryEnvelopeRequest } from './utils/helpers'; test('should inject `sentry-trace` and `baggage` meta tags inside the root page.', async ({ page }) => { diff --git a/packages/remix/test/integration/test/client/pageload.test.ts b/packages/remix/test/integration/test/client/pageload.test.ts index 55e97e23635f..967eb3952623 100644 --- a/packages/remix/test/integration/test/client/pageload.test.ts +++ b/packages/remix/test/integration/test/client/pageload.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { getFirstSentryEnvelopeRequest } from './utils/helpers'; test('should add `pageload` transaction on load.', async ({ page }) => { diff --git a/packages/remix/test/integration/test/client/root-loader.test.ts b/packages/remix/test/integration/test/client/root-loader.test.ts index 431195e8eab7..e9273fbd6caa 100644 --- a/packages/remix/test/integration/test/client/root-loader.test.ts +++ b/packages/remix/test/integration/test/client/root-loader.test.ts @@ -1,4 +1,4 @@ -import { Page, expect, test } from '@playwright/test'; +import { type Page, expect, test } from '@playwright/test'; async function getRouteData(page: Page): Promise { return page.evaluate('window.__remixContext.state.loaderData').catch(err => { diff --git a/packages/remix/test/integration/test/server/instrumentation/loader.test.ts b/packages/remix/test/integration/test/server/instrumentation/loader.test.ts index a1e257681fa0..eef2a9683813 100644 --- a/packages/remix/test/integration/test/server/instrumentation/loader.test.ts +++ b/packages/remix/test/integration/test/server/instrumentation/loader.test.ts @@ -1,4 +1,4 @@ -import { Event } from '@sentry/core'; +import type { Event } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from '../utils/helpers'; From b3242e0a9074812df43e6121996e111eb7988f4d Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 9 May 2025 03:01:48 -0400 Subject: [PATCH 12/28] chore(ci): re-enable Node 24 tests for google-cloud-serverless (#16242) resolves https://github.com/getsentry/sentry-javascript/issues/16238 The error with the google cloud serverless tests in Node 24 was only happening in Node `24.0.0`, which was fixed in [`24.0.1`](https://github.com/nodejs/node/releases/tag/v24.0.1), so we can safely re-enable everything. --- scripts/ci-unit-tests.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scripts/ci-unit-tests.ts b/scripts/ci-unit-tests.ts index a86ac0f6358f..2802bde62fa6 100644 --- a/scripts/ci-unit-tests.ts +++ b/scripts/ci-unit-tests.ts @@ -29,8 +29,6 @@ const BROWSER_TEST_PACKAGES = [ // Packages that cannot run in Node 18 const SKIP_NODE_18_PACKAGES = ['@sentry/react-router']; -const SKIP_NODE_24_PACKAGES = ['@sentry/google-cloud-serverless']; - function getAllPackages(): string[] { const { workspaces }: { workspaces: string[] } = JSON.parse( fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'), @@ -65,10 +63,6 @@ function runTests(): void { if (NODE_VERSION === '18') { SKIP_NODE_18_PACKAGES.forEach(pkg => ignores.add(pkg)); } - - if (NODE_VERSION === '24') { - SKIP_NODE_24_PACKAGES.forEach(pkg => ignores.add(pkg)); - } } if (RUN_AFFECTED) { From 79480cff1f08079a8c09e10e2049c9a0f2c151ec Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 9 May 2025 09:28:07 +0200 Subject: [PATCH 13/28] feat(nextjs): Add more attributes for generation functions (#16214) Necessary for Next.js insights page --- .../nextjs-app-dir/tests/server-components.test.ts | 8 ++++---- .../nextjs/src/common/wrapGenerationFunctionWithSentry.ts | 2 ++ .../nextjs/src/common/wrapServerComponentWithSentry.ts | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index f65cf7dbc1c1..4f564f2f462d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -80,8 +80,8 @@ test('Should set a "not_found" status on a server component span when notFound() op: 'function.nextjs', status: 'not_found', data: expect.objectContaining({ - 'sentry.nextjs.function.type': 'Page', - 'sentry.nextjs.function.route': '/server-component/not-found', + 'sentry.nextjs.ssr.function.type': 'Page', + 'sentry.nextjs.ssr.function.route': '/server-component/not-found', }), }), ); @@ -112,8 +112,8 @@ test('Should capture an error and transaction for a app router page', async ({ p op: 'function.nextjs', status: 'internal_error', data: expect.objectContaining({ - 'sentry.nextjs.function.type': 'Page', - 'sentry.nextjs.function.route': '/server-component/faulty', + 'sentry.nextjs.ssr.function.type': 'Page', + 'sentry.nextjs.ssr.function.route': '/server-component/faulty', }), }), ); diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 5e7cad749edb..801c0e9b0dab 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -102,6 +102,8 @@ export function wrapGenerationFunctionWithSentry a attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + 'sentry.nextjs.ssr.function.type': generationFunctionIdentifier, + 'sentry.nextjs.ssr.function.route': componentRoute, }, }, span => { diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 95dd72d3d9e2..7319ddee9837 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -89,8 +89,8 @@ export function wrapServerComponentWithSentry any> attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - 'sentry.nextjs.function.type': componentType, - 'sentry.nextjs.function.route': componentRoute, + 'sentry.nextjs.ssr.function.type': componentType, + 'sentry.nextjs.ssr.function.route': componentRoute, }, }, span => { From caf789ae86d5c4dcae4a11017ca402935c3e150a Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Fri, 9 May 2025 15:34:57 +0200 Subject: [PATCH 14/28] Resolve undefined typescript issue --- .../src/server/lowQualityTransactionsFilterIntegration.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts index ecf6695f5524..705359eab62c 100644 --- a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts +++ b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts @@ -20,7 +20,9 @@ function _lowQualityTransactionsFilterIntegration(options: NodeOptions): { return event; } - if (matchedRegexes.some(regex => event.transaction?.match(regex))) { + const transaction = event.transaction; + + if (matchedRegexes.some(regex => transaction.match(regex))) { options.debug && logger.log('[ReactRouter] Filtered node_modules transaction:', event.transaction); return null; } From 8f7599a2938da7b3380187cc5f5247f6dec08e8e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 9 May 2025 17:34:16 -0400 Subject: [PATCH 15/28] build: Pin node unit tests to ^24.0.1 (#16248) Node [`24.0.1`](https://github.com/nodejs/node/releases/tag/v24.0.1) fixed a bug with our google cloud serverless tests that we were running into in https://github.com/getsentry/sentry-javascript/issues/16238 For some reason CI doesn't want to use it? So pinning to `^24.0.1`. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cfcdb449ea6..ae4095e304ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -475,7 +475,7 @@ jobs: strategy: fail-fast: false matrix: - node: [18, 20, 22, 24] + node: [18, 20, 22, '^24.0.1'] steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) uses: actions/checkout@v4 From 5a12eed83ce6d5ef407316c19f0d89cef90c304f Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 12:34:06 +0200 Subject: [PATCH 16/28] test: Use `%j` instead of `%p` placeholder for vitest (#16258) See https://vitest.dev/api/#test-each, `%p` does not exist there. --- packages/browser/test/tracing/request.test.ts | 8 ++++---- packages/core/test/lib/utils/merge.test.ts | 2 +- packages/core/test/lib/utils/parseSampleRate.test.ts | 2 +- packages/node/test/integrations/http.test.ts | 2 +- packages/opentelemetry/test/utils/spanTypes.test.ts | 8 ++++---- .../test/unit/coreHandlers/handleBreadcrumbs.test.ts | 2 +- .../test/unit/coreHandlers/handleClick.test.ts | 2 +- packages/replay-internal/test/unit/util/isSampled.test.ts | 2 +- packages/vue/test/router.test.ts | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index d2e7f03f9287..298a2693d096 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -183,7 +183,7 @@ describe('shouldAttachHeaders', () => { ['https://my-origin.com?my-query', 'my-query', true], ['https://not-my-origin.com?my-query', 'my-query', true], ])( - 'for url %p and tracePropagationTarget %p on page "https://my-origin.com/api/my-route" should return %p', + 'for url %j and tracePropagationTarget %j on page "https://my-origin.com/api/my-route" should return %j', (url, matcher, result) => { expect(shouldAttachHeaders(url, [matcher])).toBe(result); }, @@ -234,7 +234,7 @@ describe('shouldAttachHeaders', () => { 'https://not-my-origin.com/api', 'https://my-origin.com?my-query', 'https://not-my-origin.com?my-query', - ])('should return false for everything if tracePropagationTargets are empty (%p)', url => { + ])('should return false for everything if tracePropagationTargets are empty (%j)', url => { expect(shouldAttachHeaders(url, [])).toBe(false); }); @@ -266,7 +266,7 @@ describe('shouldAttachHeaders', () => { ['http://localhost:3000', false], ['https://somewhere.com/test/localhost/123', false], ['https://somewhere.com/test?url=https://my-origin.com', false], - ])('for URL %p should return %p', (url, expectedResult) => { + ])('for URL %j should return %j', (url, expectedResult) => { expect(shouldAttachHeaders(url, undefined)).toBe(expectedResult); }); }); @@ -327,7 +327,7 @@ describe('shouldAttachHeaders', () => { ['https://not-my-origin.com/api', 'api', true], ['https://my-origin.com?my-query', 'my-query', true], ['https://not-my-origin.com?my-query', 'my-query', true], - ])('for url %p and tracePropagationTarget %p should return %p', (url, matcher, result) => { + ])('for url %j and tracePropagationTarget %j should return %j', (url, matcher, result) => { expect(shouldAttachHeaders(url, [matcher])).toBe(result); }); }); diff --git a/packages/core/test/lib/utils/merge.test.ts b/packages/core/test/lib/utils/merge.test.ts index 8cd86121f199..95d16cef2581 100644 --- a/packages/core/test/lib/utils/merge.test.ts +++ b/packages/core/test/lib/utils/merge.test.ts @@ -69,7 +69,7 @@ describe('merge', () => { a0a: 'a0a', }, ], - ])('works with %p and %p', (oldData, newData, expected) => { + ])('works with %j and %j', (oldData, newData, expected) => { const actual = merge(oldData, newData as any); expect(actual).toEqual(expected); }); diff --git a/packages/core/test/lib/utils/parseSampleRate.test.ts b/packages/core/test/lib/utils/parseSampleRate.test.ts index fae94a6cc354..7f0a3f399fa0 100644 --- a/packages/core/test/lib/utils/parseSampleRate.test.ts +++ b/packages/core/test/lib/utils/parseSampleRate.test.ts @@ -17,7 +17,7 @@ describe('parseSampleRate', () => { ['1.5', undefined], ['0.555', 0.555], ['0', 0], - ])('works with %p', (input, sampleRate) => { + ])('works with %j', (input, sampleRate) => { const actual = parseSampleRate(input); expect(actual).toBe(sampleRate); }); diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index ad59c1709646..6d4bea24661f 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -11,7 +11,7 @@ describe('httpIntegration', () => { [{ spans: false }, { skipOpenTelemetrySetup: true }, false], [{}, { skipOpenTelemetrySetup: true }, false], [{}, { skipOpenTelemetrySetup: false }, true], - ])('returns the correct value for options=%p and clientOptions=%p', (options, clientOptions, expected) => { + ])('returns the correct value for options=%j and clientOptions=%j', (options, clientOptions, expected) => { const actual = _shouldInstrumentSpans(options, clientOptions); expect(actual).toBe(expected); }); diff --git a/packages/opentelemetry/test/utils/spanTypes.test.ts b/packages/opentelemetry/test/utils/spanTypes.test.ts index 88b1d1064e9d..36f88e20c03e 100644 --- a/packages/opentelemetry/test/utils/spanTypes.test.ts +++ b/packages/opentelemetry/test/utils/spanTypes.test.ts @@ -8,7 +8,7 @@ describe('spanTypes', () => { [{}, false], [{ attributes: null }, false], [{ attributes: {} }, true], - ])('works with %p', (span, expected) => { + ])('works with %j', (span, expected) => { const castSpan = span as unknown as Span; const actual = spanHasAttributes(castSpan); @@ -27,7 +27,7 @@ describe('spanTypes', () => { [{ kind: 0 }, true], [{ kind: 5 }, true], [{ kind: 'TEST_KIND' }, false], - ])('works with %p', (span, expected) => { + ])('works with %j', (span, expected) => { const castSpan = span as unknown as Span; const actual = spanHasKind(castSpan); @@ -44,7 +44,7 @@ describe('spanTypes', () => { [{}, false], [{ parentSpanId: null }, false], [{ parentSpanId: 'TEST_PARENT_ID' }, true], - ])('works with %p', (span, expected) => { + ])('works with %j', (span, expected) => { const castSpan = span as unknown as Span; const actual = spanHasParentId(castSpan); @@ -61,7 +61,7 @@ describe('spanTypes', () => { [{}, false], [{ events: null }, false], [{ events: [] }, true], - ])('works with %p', (span, expected) => { + ])('works with %j', (span, expected) => { const castSpan = span as unknown as Span; const actual = spanHasEvents(castSpan); diff --git a/packages/replay-internal/test/unit/coreHandlers/handleBreadcrumbs.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleBreadcrumbs.test.ts index 5ac831f7fb20..f820f5774b01 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleBreadcrumbs.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleBreadcrumbs.test.ts @@ -5,7 +5,7 @@ import { normalizeBreadcrumb, normalizeConsoleBreadcrumb } from '../../../src/co describe('Unit | coreHandlers | handleBreadcrumbs', () => { describe('normalizeBreadcrumb', () => { it.each([undefined, 'ui.click', 'ui.scroll', 'fetch', 'xhr', 'sentry.event', 'sentry.transaction'])( - 'returns null if breadcrumb has category=%p', + 'returns null if breadcrumb has category=%j', category => { const actual = normalizeBreadcrumb({ category }); expect(actual).toBeNull(); diff --git a/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts index 2816c369423f..66beddf6ab19 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleClick.test.ts @@ -509,7 +509,7 @@ describe('Unit | coreHandlers | handleClick', () => { ['a', { target: '_blank' }, true], ['a', { download: '' }, true], ['a', { href: 'xx' }, false], - ])('it works with <%s> & %p', (tagName, attributes, expected) => { + ])('it works with <%s> & %j', (tagName, attributes, expected) => { const node = document.createElement(tagName); Object.entries(attributes).forEach(([key, value]) => { node.setAttribute(key, value); diff --git a/packages/replay-internal/test/unit/util/isSampled.test.ts b/packages/replay-internal/test/unit/util/isSampled.test.ts index dfc8bbf44a45..e64328e9f635 100644 --- a/packages/replay-internal/test/unit/util/isSampled.test.ts +++ b/packages/replay-internal/test/unit/util/isSampled.test.ts @@ -18,7 +18,7 @@ describe('Unit | util | isSampled', () => { const mockRandom = vi.spyOn(Math, 'random'); test.each(cases)( - 'given sample rate of %p and RNG returns %p, result should be %p', + 'given sample rate of %j and RNG returns %j, result should be %j', (sampleRate: number, mockRandomValue: number, expectedResult: boolean) => { mockRandom.mockImplementationOnce(() => mockRandomValue); const result = isSampled(sampleRate); diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 8cb789fc9d27..681fdf7849e6 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -328,7 +328,7 @@ describe('instrumentVueRouter()', () => { [false, 0], [true, 1], ])( - 'should return instrumentation that considers the instrumentPageLoad = %p', + 'should return instrumentation that considers the instrumentPageLoad = %j', (instrumentPageLoad, expectedCallsAmount) => { const mockRootSpan = { ...MOCK_SPAN, @@ -368,7 +368,7 @@ describe('instrumentVueRouter()', () => { [false, 0], [true, 1], ])( - 'should return instrumentation that considers the instrumentNavigation = %p', + 'should return instrumentation that considers the instrumentNavigation = %j', (instrumentNavigation, expectedCallsAmount) => { const mockStartSpan = vi.fn().mockReturnValue(MOCK_SPAN); instrumentVueRouter( From 8f5f2f35e9983f6571ebc5b99ae55f39f3776417 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 12:35:43 +0200 Subject: [PATCH 17/28] ref(bun): Ensure bun is latest for local tests (#16244) I noticed that my bun version locally was old, leading to weird test issues. Now, our install script will ensure this is the latest version. On CI, we already test against the latest version. (Noticed this because bun 1.0.2 which I had installed reports itself as Node 18.5.0 which lead to ESM warnings) --- .../suites/esm/warn-esm/test.ts | 3 +- packages/bun/scripts/install-bun.js | 80 ++++++++++++------- packages/node/src/sdk/initOtel.ts | 2 +- 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/esm/warn-esm/test.ts b/dev-packages/node-integration-tests/suites/esm/warn-esm/test.ts index 41b3ce8f46f0..18eebdab6e85 100644 --- a/dev-packages/node-integration-tests/suites/esm/warn-esm/test.ts +++ b/dev-packages/node-integration-tests/suites/esm/warn-esm/test.ts @@ -5,8 +5,7 @@ afterAll(() => { cleanupChildProcesses(); }); -const esmWarning = - '[Sentry] You are using Node.js in ESM mode ("import syntax"). The Sentry Node.js SDK is not compatible with ESM in Node.js versions before 18.19.0 or before 20.6.0. Please either build your application with CommonJS ("require() syntax"), or upgrade your Node.js version.'; +const esmWarning = `[Sentry] You are using Node.js v${process.versions.node} in ESM mode ("import syntax"). The Sentry Node.js SDK is not compatible with ESM in Node.js versions before 18.19.0 or before 20.6.0. Please either build your application with CommonJS ("require() syntax"), or upgrade your Node.js version.`; test("warns if using ESM on Node.js versions that don't support `register()`", async () => { const nodeMajorVersion = Number(process.versions.node.split('.')[0]); diff --git a/packages/bun/scripts/install-bun.js b/packages/bun/scripts/install-bun.js index a0ecfad6083c..2f885c4f2b7d 100644 --- a/packages/bun/scripts/install-bun.js +++ b/packages/bun/scripts/install-bun.js @@ -10,40 +10,58 @@ const https = require('https'); const installScriptUrl = 'https://bun.sh/install'; // Check if bun is installed -exec('bun -version', error => { +exec('bun -version', (error, version) => { if (error) { console.error('bun is not installed. Installing...'); - // Download and execute the installation script - https - .get(installScriptUrl, res => { - if (res.statusCode !== 200) { - console.error(`Failed to download the installation script (HTTP ${res.statusCode})`); - process.exit(1); - } - - res.setEncoding('utf8'); - let scriptData = ''; - - res.on('data', chunk => { - scriptData += chunk; - }); + installLatestBun(); + } else { + const versionBefore = version.trim(); - res.on('end', () => { - // Execute the downloaded script - exec(scriptData, installError => { - if (installError) { - console.error('Failed to install bun:', installError); - process.exit(1); - } - console.log('bun has been successfully installed.'); - }); - }); - }) - .on('error', e => { - console.error('Failed to download the installation script:', e); + exec('bun upgrade', (error, stdout, stderr) => { + if (error) { + console.error('Failed to upgrade bun:', error); process.exit(1); - }); - } else { - // Bun is installed + } + + const out = [stdout, stderr].join('\n'); + + if (out.includes("You're already on the latest version of Bun")) { + return; + } + + console.log(out); + }); } }); + +function installLatestBun() { + https + .get(installScriptUrl, res => { + if (res.statusCode !== 200) { + console.error(`Failed to download the installation script (HTTP ${res.statusCode})`); + process.exit(1); + } + + res.setEncoding('utf8'); + let scriptData = ''; + + res.on('data', chunk => { + scriptData += chunk; + }); + + res.on('end', () => { + // Execute the downloaded script + exec(scriptData, installError => { + if (installError) { + console.error('Failed to install bun:', installError); + process.exit(1); + } + console.log('bun has been successfully installed.'); + }); + }); + }) + .on('error', e => { + console.error('Failed to download the installation script:', e); + process.exit(1); + }); +} diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 5f6d8ec2a999..26b9cfa71f72 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -59,7 +59,7 @@ export function maybeInitializeEsmLoader(): void { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( - '[Sentry] You are using Node.js in ESM mode ("import syntax"). The Sentry Node.js SDK is not compatible with ESM in Node.js versions before 18.19.0 or before 20.6.0. Please either build your application with CommonJS ("require() syntax"), or upgrade your Node.js version.', + `[Sentry] You are using Node.js v${process.versions.node} in ESM mode ("import syntax"). The Sentry Node.js SDK is not compatible with ESM in Node.js versions before 18.19.0 or before 20.6.0. Please either build your application with CommonJS ("require() syntax"), or upgrade your Node.js version.`, ); }); } From 5ed56271b95e4fb0d950d97b33371971d5b68533 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 13:26:16 +0200 Subject: [PATCH 18/28] fix(core): Gracefully handle invalid baggage entries (#16257) Fixes https://github.com/getsentry/sentry-javascript/issues/16251 --- packages/core/src/utils-hoist/baggage.ts | 12 ++++++- .../core/test/utils-hoist/baggage.test.ts | 35 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils-hoist/baggage.ts b/packages/core/src/utils-hoist/baggage.ts index 9b97f0a92eea..e3204d8b2105 100644 --- a/packages/core/src/utils-hoist/baggage.ts +++ b/packages/core/src/utils-hoist/baggage.ts @@ -113,7 +113,17 @@ export function parseBaggageHeader( function baggageHeaderToObject(baggageHeader: string): Record { return baggageHeader .split(',') - .map(baggageEntry => baggageEntry.split('=').map(keyOrValue => decodeURIComponent(keyOrValue.trim()))) + .map(baggageEntry => + baggageEntry.split('=').map(keyOrValue => { + try { + return decodeURIComponent(keyOrValue.trim()); + } catch { + // We ignore errors here, e.g. if the value cannot be URL decoded. + // This will then be skipped in the next step + return; + } + }), + ) .reduce>((acc, [key, value]) => { if (key && value) { acc[key] = value; diff --git a/packages/core/test/utils-hoist/baggage.test.ts b/packages/core/test/utils-hoist/baggage.test.ts index d24b11c9f62e..c05ac0d5dd96 100644 --- a/packages/core/test/utils-hoist/baggage.test.ts +++ b/packages/core/test/utils-hoist/baggage.test.ts @@ -1,7 +1,8 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader, + parseBaggageHeader, } from '../../src/utils-hoist/baggage'; test.each([ @@ -27,7 +28,7 @@ test.each([ { environment: 'production', release: '1.0.1' }, ], [42, undefined], -])('baggageHeaderToDynamicSamplingContext(%p) should return %p', (input, expectedOutput) => { +])('baggageHeaderToDynamicSamplingContext(%j) should return %j', (input, expectedOutput) => { expect(baggageHeaderToDynamicSamplingContext(input)).toStrictEqual(expectedOutput); }); @@ -40,6 +41,34 @@ test.each([ { release: 'abcdf', environment: '1234', someRandomKey: 'foo' }, 'sentry-release=abcdf,sentry-environment=1234,sentry-someRandomKey=foo', ], -])('dynamicSamplingContextToSentryBaggageHeader(%p) should return %p', (input, expectedOutput) => { +])('dynamicSamplingContextToSentryBaggageHeader(%j) should return %j', (input, expectedOutput) => { expect(dynamicSamplingContextToSentryBaggageHeader(input)).toStrictEqual(expectedOutput); }); + +describe('parseBaggageHeader', () => { + test.each([ + [undefined, undefined], + [1, undefined], + [true, undefined], + [false, undefined], + [null, undefined], + [NaN, undefined], + [Infinity, undefined], + [0, undefined], + ['', undefined], + ['foo', {}], + [ + 'sentry-environment=production,sentry-release=10.0.2,foo=bar', + { 'sentry-environment': 'production', 'sentry-release': '10.0.2', foo: 'bar' }, + ], + [ + ['sentry-environment=production,sentry-release=10.0.2,foo=bar', 'foo2=bar2'], + { 'sentry-environment': 'production', 'sentry-release': '10.0.2', foo: 'bar', foo2: 'bar2' }, + ], + // ignores malformed baggage entries + ['foo=bar,foo2=%3G', { foo: 'bar' }], + ])('parseBaggageHeader(%j) should return %j', (input, expectedOutput) => { + const actual = parseBaggageHeader(input); + expect(actual).toStrictEqual(expectedOutput); + }); +}); From f1121ce8d16490a42d173de7fa6174ce9da8f967 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 12 May 2025 13:36:52 +0200 Subject: [PATCH 19/28] fix(node): Use sentry forked `@fastify/otel` dependency with pinned Otel v1 deps (#16256) The `@fastify/otel` instrumentation uses OpenTelemetry v2, but for the time being Sentry only supports OpenTelemetry v1. We forked the library at https://github.com/getsentry/fastify-otel/tree/otel-v1 and downgraded its dependencies to OpenTelemetry v1. For the downgrade work in the fork see: https://github.com/getsentry/fastify-otel/commit/7893f7044bcdd37acfea10f098571f54740c504b **Note**: This also bumps the instrumentation from `0.6.0` to `0.8.0`. Resolves: #16245 --------- Co-authored-by: Francesco Gringl-Novy --- packages/node/package.json | 2 +- yarn.lock | 41 ++++++++------------------------------ 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index b0d4126673ad..117d53d86814 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -65,7 +65,7 @@ "access": "public" }, "dependencies": { - "@fastify/otel": "0.6.0", + "@fastify/otel": "getsentry/fastify-otel#otel-v1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", diff --git a/yarn.lock b/yarn.lock index 644150b45d72..65c36ff15a0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3912,14 +3912,14 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== -"@fastify/otel@0.6.0": - version "0.6.0" - resolved "https://registry.yarnpkg.com/@fastify/otel/-/otel-0.6.0.tgz#f86dfa6711804d0087288d7fadc097b41feea5b1" - integrity sha512-lL+36KwGcFiAMcsPOLLsR+GV8ZpQuz5RLVstlgqmecTdQLTXVOe9Z8uwpMg9ktPcV++Ugp3dzzpBKNFWWWelYg== +"@fastify/otel@git+https://github.com/getsentry/fastify-otel.git#otel-v1": + version "0.8.0" + resolved "git+https://github.com/getsentry/fastify-otel.git#39826f0b6bb23e82fc83819d96c5440a504ab5bc" dependencies: - "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.200.0" + "@opentelemetry/core" "^1.30.1" + "@opentelemetry/instrumentation" "^0.57.2" "@opentelemetry/semantic-conventions" "^1.28.0" + minimatch "^10.0.1" "@gar/promisify@^1.1.3": version "1.1.3" @@ -5420,13 +5420,6 @@ dependencies: "@octokit/openapi-types" "^18.0.0" -"@opentelemetry/api-logs@0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz#f9015fd844920c13968715b3cdccf5a4d4ff907e" - integrity sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q== - dependencies: - "@opentelemetry/api" "^1.3.0" - "@opentelemetry/api-logs@0.52.1": version "0.52.1" resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz#52906375da4d64c206b0c4cb8ffa209214654ecc" @@ -5458,13 +5451,6 @@ dependencies: "@opentelemetry/semantic-conventions" "1.28.0" -"@opentelemetry/core@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.0.0.tgz#37e9f0e9ddec4479b267aca6f32d88757c941b3a" - integrity sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ== - dependencies: - "@opentelemetry/semantic-conventions" "^1.29.0" - "@opentelemetry/instrumentation-amqplib@^0.46.1": version "0.46.1" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz#7101678488d0e942162ca85c9ac6e93e1f3e0008" @@ -5695,17 +5681,6 @@ semver "^7.5.2" shimmer "^1.2.1" -"@opentelemetry/instrumentation@^0.200.0": - version "0.200.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.200.0.tgz#29d1d4f70cbf0cb1ca9f2f78966379b0be96bddc" - integrity sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg== - dependencies: - "@opentelemetry/api-logs" "0.200.0" - "@types/shimmer" "^1.2.0" - import-in-the-middle "^1.8.1" - require-in-the-middle "^7.1.1" - shimmer "^1.2.1" - "@opentelemetry/instrumentation@^0.52.1": version "0.52.1" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz#2e7e46a38bd7afbf03cf688c862b0b43418b7f48" @@ -5750,7 +5725,7 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6" integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== -"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0": +"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.30.0": version "1.32.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.32.0.tgz#a15e8f78f32388a7e4655e7f539570e40958ca3f" integrity sha512-s0OpmpQFSfMrmedAn9Lhg4KWJELHCU6uU9dtIJ28N8UGhf9Y55im5X8fEzwhwDwiSqN+ZPSNrDJF7ivf/AuRPQ== @@ -20832,7 +20807,7 @@ minimatch@5.1.0, minimatch@^5.0.1, minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimatch@^10.0.0: +minimatch@^10.0.0, minimatch@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== From 02f280c7bab8c35439bb7f6e1d5a8d071dd84679 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 14:08:30 +0200 Subject: [PATCH 20/28] deps(node): Bump `import-in-the-middle` to `1.13.1` (#16260) See https://github.com/nodejs/import-in-the-middle/releases/tag/import-in-the-middle-v1.13.1 ### Bug Fixes * handling of circular dependencies ([#181](https://github.com/nodejs/import-in-the-middle/issues/181)) ([b58092e](https://github.com/nodejs/import-in-the-middle/commit/b58092ec9becf4a14f541da4cf5bfb190f2a9a9b)) * importing JSON files ([#182](https://github.com/nodejs/import-in-the-middle/issues/182)) ([8c52014](https://github.com/nodejs/import-in-the-middle/commit/8c52014658fcf698cc340d032b441d9e7a65be36)) * warning from use of context.importAssertions ([#179](https://github.com/nodejs/import-in-the-middle/issues/179)) ([8e56cf1](https://github.com/nodejs/import-in-the-middle/commit/8e56cf1e89752e6c8768d648c10c12fb3178e2ae)) This version is already allowed by our range, but in order to ensure everybody gets this, bumping it here. --- .../e2e-tests/test-applications/nextjs-turbo/package.json | 6 +----- packages/node/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index 99679ba13deb..36beb12cd227 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -35,11 +35,7 @@ "@sentry/opentelemetry": "latest || *", "@sentry/react": "latest || *", "@sentry-internal/replay": "latest || *", - "@sentry/vercel-edge": "latest || *", - "import-in-the-middle": "1.12.0" - }, - "overrides": { - "import-in-the-middle": "1.12.0" + "@sentry/vercel-edge": "latest || *" }, "volta": { "extends": "../../package.json" diff --git a/packages/node/package.json b/packages/node/package.json index 117d53d86814..df166ebb7090 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -98,7 +98,7 @@ "@prisma/instrumentation": "6.7.0", "@sentry/core": "9.17.0", "@sentry/opentelemetry": "9.17.0", - "import-in-the-middle": "^1.13.0" + "import-in-the-middle": "^1.13.1" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/yarn.lock b/yarn.lock index 65c36ff15a0d..4aa628eb3130 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17660,10 +17660,10 @@ import-fresh@^3.0.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-in-the-middle@^1.13.0, import-in-the-middle@^1.8.1: - version "1.13.0" - resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.13.0.tgz#e592583c3f53ff29c6079c0af31feab592ac6b2a" - integrity sha512-YG86SYDtrL/Yu8JgfWb7kjQ0myLeT1whw6fs/ZHFkXFcbk9zJU9lOCsSJHpvaPumU11nN3US7NW6x1YTk+HrUA== +import-in-the-middle@^1.13.1, import-in-the-middle@^1.8.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.13.1.tgz#789651f9e93dd902a5a306f499ab51eb72b03a12" + integrity sha512-k2V9wNm9B+ysuelDTHjI9d5KPc4l8zAZTGqj+pcynvWkypZd857ryzN8jNC7Pg2YZXNMJcHRPpaDyCBbNyVRpA== dependencies: acorn "^8.14.0" acorn-import-attributes "^1.9.5" From 9d659a56578819d38d2338fe5e3a1938af4a38ea Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 14:34:48 +0200 Subject: [PATCH 21/28] fix(node): Ensure traces are propagated without spans in Node 22+ (#16221) Today, if the `httpIntegration` is used without spans (e.g. in a custom OTEL setup or when setting `httpIntegration({ spans: false })` manually), outgoing requests will not have traces propagated. This PR fixes this by using the `http.client.request.created` diagnostics channel to add the trace headers in this scenario. However, sadly this channel was only added in Node 22, so it does not work on versions before that. I suppose this is still worth adding because it is better than what we have today (which is that it does not work at all). We may think about making this work for Node <22, but this would require us monkey patching http again, which we really do not want to do... Also note that as of now this should not really affect the vast majority of cases, as unless you specifically opt out of spans today this will always work as we always add the otel http instrumentation by default. And in custom otel setups, users will usually have this set up anyhow. (Also, fetch works in all environments as expected). If we want to, in a follow up, avoid adding the otel spans instrumentation if performance is disabled, we need to think about this... an option could be to always adding the instrumentation on node <22, and only skip it on Node 22+. But this can be looked at separately, this PR at least makes things better than before on Node 22+... This supersedes https://github.com/getsentry/sentry-javascript/pull/15735 --------- Co-authored-by: Abhijeet Prasad Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- .../http-no-tracing-no-spans/instrument.mjs | 17 ++ .../http-no-tracing-no-spans/scenario.mjs | 43 ++++ .../requests/http-no-tracing-no-spans/test.ts | 204 ++++++++++++++++++ .../http/SentryHttpInstrumentation.ts | 73 +++++++ packages/node/src/integrations/http/index.ts | 3 + 5 files changed, 340 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/instrument.mjs new file mode 100644 index 000000000000..9f713557b30a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/instrument.mjs @@ -0,0 +1,17 @@ +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', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [Sentry.httpIntegration({ spans: false })], + transport: loggingTransport, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/scenario.mjs new file mode 100644 index 000000000000..2ee57c8651e0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/scenario.mjs @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/node'; +import * as http from 'http'; + +async function run() { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpGet(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + + Sentry.captureException(new Error('foo')); +} + +run(); + +function makeHttpRequest(url) { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} + +function makeHttpGet(url) { + return new Promise(resolve => { + http.get(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts new file mode 100644 index 000000000000..41f688178d1c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -0,0 +1,204 @@ +import { describe, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +describe('outgoing http requests with tracing & spans disabled', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + conditionalTest({ min: 22 })('node >=22', () => { + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .ensureNoErrorOutput() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); + + closeTestServer(); + }); + }); + + // On older node versions, outgoing requests do not get trace-headers injected, sadly + // This is because the necessary diagnostics channel hook is not available yet + conditionalTest({ max: 21 })('node <22', () => { + test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { + expect.assertions(9); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + // This is not instrumented, sadly + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v1', headers => { + // This is not instrumented, sadly + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .ensureNoErrorOutput() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); + + closeTestServer(); + }); + }); + }); +}); diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 4e044879d2aa..6b8f615479e4 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -18,13 +18,17 @@ import { getCurrentScope, getIsolationScope, getSanitizedUrlString, + getTraceData, httpRequestToRequestData, logger, + LRUMap, parseUrl, stripUrlQueryAndFragment, withIsolationScope, } from '@sentry/core'; +import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../../debug-build'; +import { mergeBaggageHeaders } from '../../utils/baggage'; import { getRequestUrl } from '../../utils/getRequestUrl'; const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; @@ -49,6 +53,15 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ extractIncomingTraceFromHeader?: boolean; + /** + * Whether to propagate Sentry trace headers in outgoing requests. + * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled) + * then this instrumentation can take over. + * + * @default `false` + */ + propagateTraceInOutgoingRequests?: boolean; + /** * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. * For the scope of this instrumentation, this callback only controls breadcrumb creation. @@ -102,8 +115,12 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024; * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts */ export class SentryHttpInstrumentation extends InstrumentationBase { + private _propagationDecisionMap: LRUMap; + public constructor(config: SentryHttpInstrumentationOptions = {}) { super(INSTRUMENTATION_NAME, VERSION, config); + + this._propagationDecisionMap = new LRUMap(100); } /** @inheritdoc */ @@ -127,6 +144,11 @@ export class SentryHttpInstrumentation extends InstrumentationBase { + const data = _data as { request: http.ClientRequest }; + this._onOutgoingRequestCreated(data.request); + }) satisfies ChannelListener; + /** * You may be wondering why we register these diagnostics-channel listeners * in such a convoluted way (as InstrumentationNodeModuleDefinition...)˝, @@ -153,12 +175,20 @@ export class SentryHttpInstrumentation extends InstrumentationBase { unsubscribe('http.server.request.start', onHttpServerRequestStart); unsubscribe('http.client.response.finish', onHttpClientResponseFinish); unsubscribe('http.client.request.error', onHttpClientRequestError); + unsubscribe('http.client.request.created', onHttpClientRequestCreated); }, ), new InstrumentationNodeModuleDefinition( @@ -209,6 +239,49 @@ export class SentryHttpInstrumentation extends InstrumentationBase // If spans are not instrumented, it means the HttpInstrumentation has not been added // In that case, we want to handle incoming trace extraction ourselves extractIncomingTraceFromHeader: !instrumentSpans, + // If spans are not instrumented, it means the HttpInstrumentation has not been added + // In that case, we want to handle trace propagation ourselves + propagateTraceInOutgoingRequests: !instrumentSpans, }); // This is the "regular" OTEL instrumentation that emits spans From d273046d2066e058c5fb54c3ba9a8e6632971a3e Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 12 May 2025 14:54:44 +0200 Subject: [PATCH 22/28] feat(opentelemetry): Widen peer dependencies to support Otel v2 (#16246) In preparation for supporting OpenTelemetry v2 we widened the peer dependency range of `@sentry/opentelemetry` to allow v2 dependencies. **Note**: `@sentry/node` still requires the v1 packages of OpenTelemetry, this is just preparatory work to allow us to bump this down the road. Parts of `@sentry/opentelemetry` (and `@sentry/core`) have been reworked to be compatible with both OpenTelemetry v1 and v2. Unit tests in `@sentry/opentelemetry` are running with v1 dependencies while a new package was added to `dev-packages/` with a copy of all unit tests from `@sentry/opentelemetry` to run with v2 dependencies, adjusted to breaking changes introduced with OpenTelemetry v2. --------- Co-authored-by: Onur Temizkan --- .size-limit.js | 2 +- .../opentelemetry-v2-tests/.eslintrc.js | 6 + dev-packages/opentelemetry-v2-tests/README.md | 19 + .../opentelemetry-v2-tests/package.json | 24 + .../test/asyncContextStrategy.test.ts | 442 ++++ .../test/custom/client.test.ts | 19 + .../test/helpers/TestClient.ts | 48 + .../test/helpers/initOtel.ts | 79 + .../test/helpers/isSpan.ts | 12 + .../test/helpers/mockSdkInit.ts | 81 + .../test/integration/breadcrumbs.test.ts | 357 +++ .../test/integration/scope.test.ts | 387 ++++ .../test/integration/transactions.test.ts | 676 ++++++ .../test/propagator.test.ts | 670 ++++++ .../test/sampler.test.ts | 141 ++ .../test/spanExporter.test.ts | 169 ++ .../opentelemetry-v2-tests/test/trace.test.ts | 1935 +++++++++++++++++ .../opentelemetry-v2-tests/test/tsconfig.json | 3 + .../test/utils/getActiveSpan.test.ts | 155 ++ .../test/utils/getRequestSpanData.test.ts | 80 + .../test/utils/getSpanKind.test.ts | 11 + .../test/utils/getTraceData.test.ts | 94 + .../test/utils/groupSpansWithParents.test.ts | 174 ++ .../test/utils/mapStatus.test.ts | 130 ++ .../test/utils/parseSpanDescription.test.ts | 690 ++++++ .../test/utils/setupCheck.test.ts | 44 + .../test/utils/setupEventContextTrace.test.ts | 108 + .../test/utils/spanToJSON.test.ts | 78 + .../test/utils/spanTypes.test.ts | 80 + .../opentelemetry-v2-tests/tsconfig.json | 8 + .../opentelemetry-v2-tests/tsconfig.test.json | 12 + .../opentelemetry-v2-tests/vite.config.ts | 11 + package.json | 3 +- packages/core/src/utils/spanUtils.ts | 13 +- packages/opentelemetry/package.json | 10 +- packages/opentelemetry/src/custom/client.ts | 7 +- packages/opentelemetry/src/spanExporter.ts | 6 +- .../src/utils/getParentSpanId.ts | 16 + .../src/utils/groupSpansWithParents.ts | 3 +- packages/opentelemetry/src/utils/spanTypes.ts | 3 +- yarn.lock | 65 +- 41 files changed, 6844 insertions(+), 27 deletions(-) create mode 100644 dev-packages/opentelemetry-v2-tests/.eslintrc.js create mode 100644 dev-packages/opentelemetry-v2-tests/README.md create mode 100644 dev-packages/opentelemetry-v2-tests/package.json create mode 100644 dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/propagator.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/sampler.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/trace.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/tsconfig.json create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts create mode 100644 dev-packages/opentelemetry-v2-tests/tsconfig.json create mode 100644 dev-packages/opentelemetry-v2-tests/tsconfig.test.json create mode 100644 dev-packages/opentelemetry-v2-tests/vite.config.ts create mode 100644 packages/opentelemetry/src/utils/getParentSpanId.ts diff --git a/.size-limit.js b/.size-limit.js index 6128fee06b3d..bbe29ceded7c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -54,7 +54,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '70 KB', + limit: '70.1 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); const TerserPlugin = require('terser-webpack-plugin'); diff --git a/dev-packages/opentelemetry-v2-tests/.eslintrc.js b/dev-packages/opentelemetry-v2-tests/.eslintrc.js new file mode 100644 index 000000000000..fdb9952bae52 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], +}; diff --git a/dev-packages/opentelemetry-v2-tests/README.md b/dev-packages/opentelemetry-v2-tests/README.md new file mode 100644 index 000000000000..e5ae255c830c --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/README.md @@ -0,0 +1,19 @@ +# OpenTelemetry v2 Tests + +This package contains tests for `@sentry/opentelemetry` when using OpenTelemetry v2. It is used to ensure compatibility with OpenTelemetry v2 APIs. + +## Running Tests + +To run the tests: + +```bash +yarn test +``` + +## Structure + +The tests are copied from `packages/opentelemetry/test` with adjusted imports to work with OpenTelemetry v2 dependencies. The main differences are: + +1. Uses OpenTelemetry v2 as devDependencies +2. Imports from `@sentry/opentelemetry` instead of relative paths +3. Tests the same functionality but with v2 APIs diff --git a/dev-packages/opentelemetry-v2-tests/package.json b/dev-packages/opentelemetry-v2-tests/package.json new file mode 100644 index 000000000000..494c85fd666e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sentry-internal/opentelemetry-v2-tests", + "version": "1.0.0", + "private": true, + "description": "Tests for @sentry/opentelemetry with OpenTelemetry v2", + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest --watch" + }, + "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.200.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts b/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts new file mode 100644 index 000000000000..0df183362633 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts @@ -0,0 +1,442 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { Scope } from '@sentry/core'; +import { + getCurrentScope, + getIsolationScope, + Scope as ScopeClass, + setAsyncContextStrategy, + withIsolationScope, + withScope, +} from '@sentry/core'; +import { afterAll, afterEach, beforeEach, describe, expect, it, test } from 'vitest'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../../../packages/opentelemetry/src/asyncContextStrategy'; +import { setupOtel } from './helpers/initOtel'; +import { cleanupOtel } from './helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; + +describe('asyncContextStrategy', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + + const options = getDefaultTestClientOptions(); + const client = new TestClient(options); + [provider] = setupOtel(client); + setOpenTelemetryContextAsyncContextStrategy(); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + afterAll(() => { + // clear the strategy + setAsyncContextStrategy(undefined); + }); + + test('scope inheritance', () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + withIsolationScope(() => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b', 'b'); + isolationScope1.setExtra('bb', 'bb'); + + withScope(() => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + }); + + test('async scope inheritance', async () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + async function asyncSetExtra(scope: Scope, key: string, value: string): Promise { + await new Promise(resolve => setTimeout(resolve, 1)); + scope.setExtra(key, value); + } + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + await withIsolationScope(async () => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + await asyncSetExtra(scope1, 'b', 'b'); + await asyncSetExtra(isolationScope1, 'bb', 'bb'); + + await withScope(async () => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + await asyncSetExtra(scope2, 'c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + }); + + test('concurrent scope contexts', () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + withIsolationScope(() => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b', 'b'); + isolationScope1.setExtra('bb', 'bb'); + + withScope(() => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + + withIsolationScope(() => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b2', 'b'); + isolationScope1.setExtra('bb2', 'bb'); + + withScope(() => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c2', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b2: 'b', + c2: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb2: 'bb', + }); + }); + }); + }); + + test('concurrent async scope contexts', async () => { + const initialScope = getCurrentScope(); + const initialIsolationScope = getIsolationScope(); + + async function asyncSetExtra(scope: Scope, key: string, value: string): Promise { + await new Promise(resolve => setTimeout(resolve, 1)); + scope.setExtra(key, value); + } + + initialScope.setExtra('a', 'a'); + initialIsolationScope.setExtra('aa', 'aa'); + + await withIsolationScope(async () => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + await asyncSetExtra(scope1, 'b', 'b'); + await asyncSetExtra(isolationScope1, 'bb', 'bb'); + + await withScope(async () => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + await asyncSetExtra(scope2, 'c', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b: 'b', + c: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb: 'bb', + }); + }); + }); + + await withIsolationScope(async () => { + const scope1 = getCurrentScope(); + const isolationScope1 = getIsolationScope(); + + expect(scope1).not.toBe(initialScope); + expect(isolationScope1).not.toBe(initialIsolationScope); + + expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); + + scope1.setExtra('b2', 'b'); + isolationScope1.setExtra('bb2', 'bb'); + + await withScope(async () => { + const scope2 = getCurrentScope(); + const isolationScope2 = getIsolationScope(); + + expect(scope2).not.toBe(scope1); + expect(isolationScope2).toBe(isolationScope1); + + expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); + + scope2.setExtra('c2', 'c'); + + expect(scope2.getScopeData().extra).toEqual({ + a: 'a', + b2: 'b', + c2: 'c', + }); + + expect(isolationScope2.getScopeData().extra).toEqual({ + aa: 'aa', + bb2: 'bb', + }); + }); + }); + }); + + describe('withScope()', () => { + it('will make the passed scope the active scope within the callback', () => + new Promise(done => { + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + done(); + }); + })); + + it('will pass a scope that is different from the current active isolation scope', () => + new Promise(done => { + withScope(scope => { + expect(getIsolationScope()).not.toBe(scope); + done(); + }); + })); + + it('will always make the inner most passed scope the current scope when nesting calls', () => + new Promise(done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); + done(); + }); + }); + })); + + it('forks the scope when not passing any scope', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withScope(scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('forks the scope when passing undefined', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + withScope(undefined, scope => { + expect(getCurrentScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('sets the passed in scope as active scope', () => + new Promise(done => { + const initialScope = getCurrentScope(); + initialScope.setTag('aa', 'aa'); + + const customScope = new ScopeClass(); + + withScope(customScope, scope => { + expect(getCurrentScope()).toBe(customScope); + expect(scope).toBe(customScope); + done(); + }); + })); + }); + + describe('withIsolationScope()', () => { + it('will make the passed isolation scope the active isolation scope within the callback', () => + new Promise(done => { + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + done(); + }); + })); + + it('will pass an isolation scope that is different from the current active scope', () => + new Promise(done => { + withIsolationScope(scope => { + expect(getCurrentScope()).not.toBe(scope); + done(); + }); + })); + + it('will always make the inner most passed scope the current scope when nesting calls', () => + new Promise(done => { + withIsolationScope(_scope1 => { + withIsolationScope(scope2 => { + expect(getIsolationScope()).toBe(scope2); + done(); + }); + }); + })); + + it('forks the isolation scope when not passing any isolation scope', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(scope => { + expect(getIsolationScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('forks the isolation scope when passing undefined', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + withIsolationScope(undefined, scope => { + expect(getIsolationScope()).toBe(scope); + scope.setTag('bb', 'bb'); + expect(scope).not.toBe(initialScope); + expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + done(); + }); + })); + + it('sets the passed in isolation scope as active isolation scope', () => + new Promise(done => { + const initialScope = getIsolationScope(); + initialScope.setTag('aa', 'aa'); + + const customScope = new ScopeClass(); + + withIsolationScope(customScope, scope => { + expect(getIsolationScope()).toBe(customScope); + expect(scope).toBe(customScope); + done(); + }); + })); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts b/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts new file mode 100644 index 000000000000..b39f45d4919e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts @@ -0,0 +1,19 @@ +import { ProxyTracer } from '@opentelemetry/api'; +import { describe, expect, it } from 'vitest'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('OpenTelemetryClient', () => { + it('exposes a tracer', () => { + const options = getDefaultTestClientOptions(); + const client = new TestClient(options); + + const tracer = client.tracer; + expect(tracer).toBeDefined(); + expect(tracer).toBeInstanceOf(ProxyTracer); + + // Ensure we always get the same tracer instance + const tracer2 = client.tracer; + + expect(tracer2).toBe(tracer); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts new file mode 100644 index 000000000000..f67cc361d73e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts @@ -0,0 +1,48 @@ +import type { ClientOptions, Event, Options, SeverityLevel } from '@sentry/core'; +import { Client, createTransport, getCurrentScope, resolvedSyncPromise } from '@sentry/core'; +import { wrapClientClass } from '../../../../packages/opentelemetry/src/custom/client'; +import type { OpenTelemetryClient } from '../../../../packages/opentelemetry/src/types'; + +class BaseTestClient extends Client { + public constructor(options: ClientOptions) { + super(options); + } + + public eventFromException(exception: any): PromiseLike { + return resolvedSyncPromise({ + exception: { + values: [ + { + type: exception.name, + value: exception.message, + }, + ], + }, + }); + } + + public eventFromMessage(message: string, level: SeverityLevel = 'info'): PromiseLike { + return resolvedSyncPromise({ message, level }); + } +} + +export const TestClient = wrapClientClass(BaseTestClient); + +export type TestClientInterface = Client & OpenTelemetryClient; + +export function init(options: Partial = {}): void { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ...options })); + + // The client is on the current scope, from where it generally is inherited + getCurrentScope().setClient(client); + client.init(); +} + +export function getDefaultTestClientOptions(options: Partial = {}): ClientOptions { + return { + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), + stackParser: () => [], + ...options, + } as ClientOptions; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts new file mode 100644 index 000000000000..50d35295ba60 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts @@ -0,0 +1,79 @@ +import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + SEMRESATTRS_SERVICE_NAMESPACE, +} from '@opentelemetry/semantic-conventions'; +import { getClient, logger, SDK_VERSION } from '@sentry/core'; +import { wrapContextManagerClass } from '../../../../packages/opentelemetry/src/contextManager'; +import { DEBUG_BUILD } from '../../../../packages/opentelemetry/src/debug-build'; +import { SentryPropagator } from '../../../../packages/opentelemetry/src/propagator'; +import { SentrySampler } from '../../../../packages/opentelemetry/src/sampler'; +import { setupEventContextTrace } from '../../../../packages/opentelemetry/src/setupEventContextTrace'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; +import { enhanceDscWithOpenTelemetryRootSpanName } from '../../../../packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName'; +import type { TestClientInterface } from './TestClient'; + +/** + * Initialize OpenTelemetry for Node. + */ +export function initOtel(): void { + const client = getClient(); + + if (!client) { + DEBUG_BUILD && + logger.warn( + 'No client available, skipping OpenTelemetry setup. This probably means that `Sentry.init()` was not called before `initOtel()`.', + ); + return; + } + + if (client.getOptions().debug) { + const otelLogger = new Proxy(logger as typeof logger & { verbose: (typeof logger)['debug'] }, { + get(target, prop, receiver) { + const actualProp = prop === 'verbose' ? 'debug' : prop; + return Reflect.get(target, actualProp, receiver); + }, + }); + + diag.setLogger(otelLogger, DiagLogLevel.DEBUG); + } + + setupEventContextTrace(client); + enhanceDscWithOpenTelemetryRootSpanName(client); + + const [provider, spanProcessor] = setupOtel(client); + client.traceProvider = provider; + client.spanProcessor = spanProcessor; +} + +/** Just exported for tests. */ +export function setupOtel(client: TestClientInterface): [BasicTracerProvider, SentrySpanProcessor] { + const spanProcessor = new SentrySpanProcessor(); + // Create and configure NodeTracerProvider + const provider = new BasicTracerProvider({ + sampler: new SentrySampler(client), + resource: defaultResource().merge( + resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'opentelemetry-test', + // eslint-disable-next-line deprecation/deprecation + [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_VERSION]: SDK_VERSION, + }), + ), + forceFlushTimeoutMillis: 500, + spanProcessors: [spanProcessor], + }); + + // We use a custom context manager to keep context in sync with sentry scope + const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); + + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new SentryPropagator()); + context.setGlobalContextManager(new SentryContextManager()); + + return [provider, spanProcessor]; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts new file mode 100644 index 000000000000..3146551e3da7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts @@ -0,0 +1,12 @@ +import type { Span } from '@opentelemetry/api'; +import { INVALID_TRACEID, INVALID_SPANID, type SpanContext } from '@opentelemetry/api'; + +export const isSpan = (value: unknown): value is Span => { + return ( + typeof value === 'object' && + value !== null && + 'spanContext' in value && + (value.spanContext as () => SpanContext)().traceId !== INVALID_TRACEID && + (value.spanContext as () => SpanContext)().spanId !== INVALID_SPANID + ); +}; diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts new file mode 100644 index 000000000000..eb112d017a1c --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts @@ -0,0 +1,81 @@ +import { context, propagation, ProxyTracerProvider, trace } from '@opentelemetry/api'; +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import type { ClientOptions, Options } from '@sentry/core'; +import { flush, getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { setOpenTelemetryContextAsyncContextStrategy } from '../../../../packages/opentelemetry/src/asyncContextStrategy'; +import type { OpenTelemetryClient } from '../../../../packages/opentelemetry/src/types'; +import { clearOpenTelemetrySetupCheck } from '../../../../packages/opentelemetry/src/utils/setupCheck'; +import { initOtel } from './initOtel'; +import { init as initTestClient } from './TestClient'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +/** + * Initialize Sentry for Node. + */ +function init(options: Partial | undefined = {}): void { + setOpenTelemetryContextAsyncContextStrategy(); + initTestClient(options); + initOtel(); +} + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + delete (global as any).__SENTRY__; +} + +export function mockSdkInit(options?: Partial) { + resetGlobals(); + + init({ dsn: PUBLIC_DSN, ...options }); +} + +export async function cleanupOtel(_provider?: BasicTracerProvider): Promise { + clearOpenTelemetrySetupCheck(); + + const provider = getProvider(_provider); + + if (provider) { + await provider.forceFlush(); + await provider.shutdown(); + } + + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); + + await flush(); +} + +export function getSpanProcessor(): SentrySpanProcessor | undefined { + const client = getClient(); + if (!client) { + return undefined; + } + + const spanProcessor = client.spanProcessor; + if (spanProcessor instanceof SentrySpanProcessor) { + return spanProcessor; + } + + return undefined; +} + +export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { + let provider = _provider || getClient()?.traceProvider || trace.getTracerProvider(); + + if (provider instanceof ProxyTracerProvider) { + provider = provider.getDelegate(); + } + + if (!(provider instanceof BasicTracerProvider)) { + return undefined; + } + + return provider; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts new file mode 100644 index 000000000000..800c2dbbeba1 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts @@ -0,0 +1,357 @@ +import { addBreadcrumb, captureException, getClient, withIsolationScope, withScope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startSpan } from '../../../../packages/opentelemetry/src/trace'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; + +describe('Integration | breadcrumbs', () => { + const beforeSendTransaction = vi.fn(() => null); + + afterEach(async () => { + await cleanupOtel(); + }); + + describe('without tracing', () => { + it('correctly adds & retrieves breadcrumbs', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const client = getClient() as TestClientInterface; + + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + + const error = new Error('test'); + captureException(error); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('handles parallel isolation scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + addBreadcrumb({ timestamp: 123456, message: 'test0' }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test2' }); + captureException(error); + }); + + withIsolationScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test3' }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test0', timestamp: 123456 }, + { message: 'test2', timestamp: 123456 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + }); + + it('correctly adds & retrieves breadcrumbs', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + startSpan({ name: 'test' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); + }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + captureException(error); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('correctly adds & retrieves breadcrumbs for the current isolation scope only', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + withIsolationScope(() => { + startSpan({ name: 'test1' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); + + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); + }); + }); + }); + + withIsolationScope(() => { + startSpan({ name: 'test2' }, () => { + addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + captureException(error); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test2-a', timestamp: 123456 }, + { message: 'test2-b', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('ignores scopes inside of root span', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + captureException(error); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(2); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('handles deep nesting of scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + startSpan({ name: 'test1' }, () => { + withScope(() => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + }); + startSpan({ name: 'inner1' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + + startSpan({ name: 'inner2' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test3' }); + + startSpan({ name: 'inner3' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test4' }); + + captureException(error); + + startSpan({ name: 'inner4' }, () => { + addBreadcrumb({ timestamp: 123457, message: 'test5' }); + }); + + addBreadcrumb({ timestamp: 123457, message: 'test6' }); + }); + }); + }); + + addBreadcrumb({ timestamp: 123456, message: 'test99' }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123457 }, + { message: 'test4', timestamp: 123457 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); + + it('correctly adds & retrieves breadcrumbs in async isolation scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); + + mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); + + const client = getClient() as TestClientInterface; + + const error = new Error('test'); + + const promise1 = withIsolationScope(() => { + return startSpan({ name: 'test' }, async () => { + addBreadcrumb({ timestamp: 123456, message: 'test1' }); + + await startSpan({ name: 'inner1' }, async () => { + addBreadcrumb({ timestamp: 123457, message: 'test2' }); + }); + + await startSpan({ name: 'inner2' }, async () => { + addBreadcrumb({ timestamp: 123455, message: 'test3' }); + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + captureException(error); + }); + }); + + const promise2 = withIsolationScope(() => { + return startSpan({ name: 'test-b' }, async () => { + addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); + + await startSpan({ name: 'inner1' }, async () => { + addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); + }); + + await startSpan({ name: 'inner2' }, async () => { + addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); + }); + }); + }); + + await Promise.all([promise1, promise2]); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeBreadcrumb).toHaveBeenCalledTimes(6); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test1', timestamp: 123456 }, + { message: 'test2', timestamp: 123457 }, + { message: 'test3', timestamp: 123455 }, + ], + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts new file mode 100644 index 000000000000..3e237b749d5e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts @@ -0,0 +1,387 @@ +import { + captureException, + getCapturedScopesOnSpan, + getClient, + getCurrentScope, + getIsolationScope, + setTag, + withIsolationScope, + withScope, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startSpan } from '../../../../packages/opentelemetry/src/trace'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; + +describe('Integration | Scope', () => { + afterEach(async () => { + await cleanupOtel(); + }); + + describe.each([ + ['with tracing', true], + ['without tracing', false], + ])('%s', (_name, tracingEnabled) => { + it('correctly syncs OTEL context & Sentry hub/scope', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ + tracesSampleRate: tracingEnabled ? 1 : 0, + beforeSend, + beforeSendTransaction, + }); + + const client = getClient() as TestClientInterface; + + const rootScope = getCurrentScope(); + + const error = new Error('test error'); + let spanId: string | undefined; + let traceId: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + withScope(scope1 => { + scope1.setTag('tag2', 'val2'); + + withScope(scope2b => { + scope2b.setTag('tag3-b', 'val3-b'); + }); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3'); + + startSpan({ name: 'outer' }, span => { + expect(getCapturedScopesOnSpan(span).scope).toBe(tracingEnabled ? scope2 : undefined); + + spanId = span.spanContext().spanId; + traceId = span.spanContext().traceId; + + setTag('tag4', 'val4'); + + captureException(error); + }); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + + if (spanId) { + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: { + trace: { + span_id: spanId, + trace_id: traceId, + }, + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + } + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + tag4: 'val4', + }, + }), + { + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + // Note: Scope for transaction is taken at `start` time, not `finish` time + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + }, + span_id: spanId, + status: 'ok', + trace_id: traceId, + origin: 'manual', + }, + }), + spans: [], + start_timestamp: expect.any(Number), + tags: { + tag1: 'val1', + tag2: 'val2', + tag3: 'val3', + tag4: 'val4', + }, + timestamp: expect.any(Number), + transaction_info: { source: 'custom' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + } + }); + + it('isolates parallel scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + const rootScope = getCurrentScope(); + + const error1 = new Error('test error 1'); + const error2 = new Error('test error 2'); + let spanId1: string | undefined; + let spanId2: string | undefined; + let traceId1: string | undefined; + let traceId2: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + const initialIsolationScope = getIsolationScope(); + + withScope(scope1 => { + scope1.setTag('tag2', 'val2a'); + + expect(getIsolationScope()).toBe(initialIsolationScope); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3a'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).toBe(initialIsolationScope); + + spanId1 = span.spanContext().spanId; + traceId1 = span.spanContext().traceId; + + setTag('tag4', 'val4a'); + + captureException(error1); + }); + }); + }); + + withScope(scope1 => { + scope1.setTag('tag2', 'val2b'); + + expect(getIsolationScope()).toBe(initialIsolationScope); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3b'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).toBe(initialIsolationScope); + + spanId2 = span.spanContext().spanId; + traceId2 = span.spanContext().traceId; + + setTag('tag4', 'val4b'); + + captureException(error2); + }); + }); + }); + + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(2); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: spanId1 + ? { + span_id: spanId1, + trace_id: traceId1, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3a', + tag4: 'val4a', + }, + }), + { + event_id: expect.any(String), + originalException: error1, + syntheticException: expect.any(Error), + }, + ); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: spanId2 + ? { + span_id: spanId2, + trace_id: traceId2, + } + : expect.any(Object), + }), + tags: { + tag1: 'val1', + tag2: 'val2b', + tag3: 'val3b', + tag4: 'val4b', + }, + }), + { + event_id: expect.any(String), + originalException: error2, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + } + }); + + it('isolates parallel isolation scopes', async () => { + const beforeSend = vi.fn(() => null); + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + const rootScope = getCurrentScope(); + + const error1 = new Error('test error 1'); + const error2 = new Error('test error 2'); + let spanId1: string | undefined; + let spanId2: string | undefined; + let traceId1: string | undefined; + let traceId2: string | undefined; + + rootScope.setTag('tag1', 'val1'); + + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('isolationTag1', 'val1'); + + withIsolationScope(scope1 => { + scope1.setTag('tag2', 'val2a'); + + expect(getIsolationScope()).not.toBe(initialIsolationScope); + getIsolationScope().setTag('isolationTag2', 'val2'); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3a'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).not.toBe(initialIsolationScope); + + spanId1 = span.spanContext().spanId; + traceId1 = span.spanContext().traceId; + + setTag('tag4', 'val4a'); + + captureException(error1); + }); + }); + }); + + withIsolationScope(scope1 => { + scope1.setTag('tag2', 'val2b'); + + expect(getIsolationScope()).not.toBe(initialIsolationScope); + getIsolationScope().setTag('isolationTag2', 'val2b'); + + withScope(scope2 => { + scope2.setTag('tag3', 'val3b'); + + startSpan({ name: 'outer' }, span => { + expect(getIsolationScope()).not.toBe(initialIsolationScope); + + spanId2 = span.spanContext().spanId; + traceId2 = span.spanContext().traceId; + + setTag('tag4', 'val4b'); + + captureException(error2); + }); + }); + }); + + await client.flush(); + + expect(spanId1).toBeDefined(); + expect(spanId2).toBeDefined(); + expect(traceId1).toBeDefined(); + expect(traceId2).toBeDefined(); + + expect(beforeSend).toHaveBeenCalledTimes(2); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: spanId1, + trace_id: traceId1, + }, + }), + tags: { + tag1: 'val1', + tag2: 'val2a', + tag3: 'val3a', + tag4: 'val4a', + isolationTag1: 'val1', + isolationTag2: 'val2', + }, + }), + { + event_id: expect.any(String), + originalException: error1, + syntheticException: expect.any(Error), + }, + ); + + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: spanId2, + trace_id: traceId2, + }, + }), + tags: { + tag1: 'val1', + tag2: 'val2b', + tag3: 'val3b', + tag4: 'val4b', + isolationTag1: 'val1', + isolationTag2: 'val2b', + }, + }), + { + event_id: expect.any(String), + originalException: error2, + syntheticException: expect.any(Error), + }, + ); + + if (tracingEnabled) { + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + } + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts new file mode 100644 index 000000000000..fc2702b4e390 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts @@ -0,0 +1,676 @@ +import type { SpanContext } from '@opentelemetry/api'; +import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; +import { TraceState } from '@opentelemetry/core'; +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { Event, TransactionEvent } from '@sentry/core'; +import { + addBreadcrumb, + getClient, + logger, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setTag, + startSpanManual, + withIsolationScope, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SENTRY_TRACE_STATE_DSC } from '../../../../packages/opentelemetry/src/constants'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; +import { startInactiveSpan, startSpan } from '../../../../packages/opentelemetry/src/trace'; +import { makeTraceState } from '../../../../packages/opentelemetry/src/utils/makeTraceState'; +import { cleanupOtel, getProvider, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; + +describe('Integration | Transactions', () => { + afterEach(async () => { + vi.restoreAllMocks(); + vi.useRealTimers(); + await cleanupOtel(); + }); + + it('correctly creates transaction & spans', async () => { + const transactions: TransactionEvent[] = []; + const beforeSendTransaction = vi.fn(event => { + transactions.push(event); + return null; + }); + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction, + release: '8.0.0', + }); + + const client = getClient() as TestClientInterface; + + addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + setTag('outer.tag', 'test value'); + + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + }, + }, + span => { + addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + setTag('test.tag', 'test value'); + + startSpan({ name: 'inner span 2' }, innerSpan => { + addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }, + ); + + await client.flush(); + + expect(transactions).toHaveLength(1); + const transaction = transactions[0]!; + + expect(transaction.breadcrumbs).toEqual([ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ]); + + expect(transaction.contexts?.otel).toEqual({ + resource: { + 'service.name': 'opentelemetry-test', + 'service.namespace': 'sentry', + 'service.version': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }); + + expect(transaction.contexts?.trace).toEqual({ + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + 'sentry.sample_rate': 1, + 'test.outer': 'test value', + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.test', + }); + + expect(transaction.sdkProcessingMetadata?.sampleRate).toEqual(1); + expect(transaction.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ + environment: 'production', + public_key: expect.any(String), + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + transaction: 'test name', + release: '8.0.0', + sample_rand: expect.any(String), + }); + + expect(transaction.environment).toEqual('production'); + expect(transaction.event_id).toEqual(expect.any(String)); + expect(transaction.start_timestamp).toEqual(expect.any(Number)); + expect(transaction.timestamp).toEqual(expect.any(Number)); + expect(transaction.transaction).toEqual('test name'); + + expect(transaction.tags).toEqual({ + 'outer.tag': 'test value', + 'test.tag': 'test value', + }); + expect(transaction.transaction_info).toEqual({ source: 'task' }); + expect(transaction.type).toEqual('transaction'); + + expect(transaction.spans).toHaveLength(2); + const spans = transaction.spans || []; + + // note: Currently, spans do not have any context/span added to them + // This is the same behavior as for the "regular" SDKs + expect(spans).toEqual([ + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + { + data: { + 'test.inner': 'test value', + 'sentry.origin': 'manual', + }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]); + }); + + it('correctly creates concurrent transaction & spans', async () => { + const beforeSendTransaction = vi.fn(() => null); + + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + + addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); + + withIsolationScope(() => { + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + span => { + addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value', + }); + + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + setTag('test.tag', 'test value'); + + startSpan({ name: 'inner span 2' }, innerSpan => { + addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value', + }); + }); + }, + ); + }); + + withIsolationScope(() => { + startSpan({ op: 'test op b', name: 'test name b' }, span => { + addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); + + span.setAttributes({ + 'test.outer': 'test value b', + }); + + const subSpan = startInactiveSpan({ name: 'inner span 1b' }); + subSpan.end(); + + setTag('test.tag', 'test value b'); + + startSpan({ name: 'inner span 2b' }, innerSpan => { + addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); + + innerSpan.setAttributes({ + 'test.inner': 'test value b', + }); + }); + }); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(2); + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2', timestamp: 123456 }, + { message: 'test breadcrumb 3', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + 'test.outer': 'test value', + 'sentry.sample_rate': 1, + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.test', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value', + }, + timestamp: expect.any(Number), + transaction: 'test name', + transaction_info: { source: 'task' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + breadcrumbs: [ + { message: 'test breadcrumb 1', timestamp: 123456 }, + { message: 'test breadcrumb 2b', timestamp: 123456 }, + { message: 'test breadcrumb 3b', timestamp: 123456 }, + ], + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op b', + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + 'test.outer': 'test value b', + 'sentry.sample_rate': 1, + }, + op: 'test op b', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }, + }), + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + tags: { + 'test.tag': 'test value b', + }, + timestamp: expect.any(Number), + transaction: 'test name b', + transaction_info: { source: 'custom' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + }); + + it('correctly creates transaction & spans with a trace header data', async () => { + const beforeSendTransaction = vi.fn(() => null); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const traceState = makeTraceState({ + dsc: undefined, + sampled: true, + }); + + const spanContext: SpanContext = { + traceId, + spanId: parentSpanId, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + traceState, + }; + + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const client = getClient() as TestClientInterface; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + () => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + startSpan({ name: 'inner span 2' }, () => {}); + }, + ); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(beforeSendTransaction).toHaveBeenLastCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + data: { + 'sentry.op': 'test op', + 'sentry.origin': 'auto.test', + 'sentry.source': 'task', + }, + op: 'test op', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + parent_span_id: parentSpanId, + status: 'ok', + trace_id: traceId, + origin: 'auto.test', + }, + }), + // spans are circular (they have a reference to the transaction), which leads to jest choking on this + // instead we compare them in detail below + spans: [expect.any(Object), expect.any(Object)], + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'test name', + transaction_info: { source: 'task' }, + type: 'transaction', + }), + { + event_id: expect.any(String), + }, + ); + + // Checking the spans here, as they are circular to the transaction... + const runArgs = beforeSendTransaction.mock.calls[0] as unknown as [TransactionEvent, unknown]; + const spans = runArgs[0].spans || []; + + // note: Currently, spans do not have any context/span added to them + // This is the same behavior as for the "regular" SDKs + expect(spans).toEqual([ + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 1', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: traceId, + }, + { + data: { + 'sentry.origin': 'manual', + }, + description: 'inner span 2', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: traceId, + }, + ]); + }); + + it('cleans up spans that are not flushed for over 5 mins', async () => { + const beforeSendTransaction = vi.fn(() => null); + + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); + + const spanProcessor = getSpanProcessor(); + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + void startSpan({ name: 'test name' }, async () => { + startInactiveSpan({ name: 'inner span 1' }).end(); + startInactiveSpan({ name: 'inner span 2' }).end(); + + // Pretend this is pending for 10 minutes + await new Promise(resolve => setTimeout(resolve, 10 * 60 * 1000)); + }); + + // Child-spans have been added to the exporter, but they are pending since they are waiting for their parent + const finishedSpans1 = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans1.push(...bucket.spans); + } + }); + expect(finishedSpans1.length).toBe(2); + expect(beforeSendTransaction).toHaveBeenCalledTimes(0); + + // Now wait for 5 mins + vi.advanceTimersByTime(5 * 60 * 1_000 + 1); + + // Adding another span will trigger the cleanup + startSpan({ name: 'other span' }, () => {}); + + vi.advanceTimersByTime(1); + + // Old spans have been cleared away + const finishedSpans2 = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans2.push(...bucket.spans); + } + }); + expect(finishedSpans2.length).toBe(0); + + // Called once for the 'other span' + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + + expect(logs).toEqual( + expect.arrayContaining([ + 'SpanExporter dropped 2 spans because they were pending for more than 300 seconds.', + 'SpanExporter exported 1 spans, 0 spans are waiting for their parent spans to finish', + ]), + ); + }); + + it('includes child spans that are finished in the same tick but after their parent span', async () => { + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + const transactions: Event[] = []; + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); + + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + + span.end(); + subSpan2.end(); + }); + + vi.advanceTimersByTime(1); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.spans).toHaveLength(2); + + // No spans are pending + const finishedSpans = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans.push(...bucket.spans); + } + }); + expect(finishedSpans.length).toBe(0); + }); + + it('discards child spans that are finished after their parent span', async () => { + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + const transactions: Event[] = []; + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); + + const provider = getProvider(); + const spanProcessor = getSpanProcessor(); + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + startSpanManual({ name: 'test name' }, async span => { + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + subSpan.end(); + + const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); + + span.end(); + + setTimeout(() => { + subSpan2.end(); + }, 1); + }); + + vi.advanceTimersByTime(2); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.spans).toHaveLength(1); + + // subSpan2 is pending (and will eventually be cleaned up) + const finishedSpans: any = []; + exporter['_finishedSpanBuckets'].forEach(bucket => { + if (bucket) { + finishedSpans.push(...bucket.spans); + } + }); + expect(finishedSpans.length).toBe(1); + expect(finishedSpans[0]?.name).toBe('inner span 2'); + }); + + it('uses & inherits DSC on span trace state', async () => { + const transactionEvents: Event[] = []; + const beforeSendTransaction = vi.fn(event => { + transactionEvents.push(event); + return null; + }); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const dscString = `sentry-transaction=other-transaction,sentry-environment=other,sentry-release=8.0.0,sentry-public_key=public,sentry-trace_id=${traceId},sentry-sampled=true`; + + const spanContext: SpanContext = { + traceId, + spanId: parentSpanId, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + traceState: new TraceState().set(SENTRY_TRACE_STATE_DSC, dscString), + }; + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction, + release: '7.0.0', + }); + + const client = getClient() as TestClientInterface; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + startSpan( + { + op: 'test op', + name: 'test name', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + }, + }, + span => { + expect(span.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); + + const subSpan = startInactiveSpan({ name: 'inner span 1' }); + + expect(subSpan.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); + + subSpan.end(); + + startSpan({ name: 'inner span 2' }, subSpan => { + expect(subSpan.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); + }); + }, + ); + }); + + await client.flush(); + + expect(transactionEvents).toHaveLength(1); + expect(transactionEvents[0]?.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ + environment: 'other', + public_key: 'public', + release: '8.0.0', + sampled: 'true', + trace_id: traceId, + transaction: 'other-transaction', + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts b/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts new file mode 100644 index 000000000000..8e3f85b38250 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts @@ -0,0 +1,670 @@ +import { + context, + defaultTextMapGetter, + defaultTextMapSetter, + propagation, + ROOT_CONTEXT, + trace, + TraceFlags, +} from '@opentelemetry/api'; +import { suppressTracing } from '@opentelemetry/core'; +import { getCurrentScope, withScope } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + SENTRY_BAGGAGE_HEADER, + SENTRY_SCOPES_CONTEXT_KEY, + SENTRY_TRACE_HEADER, +} from '../../../packages/opentelemetry/src/constants'; +import { SentryPropagator } from '../../../packages/opentelemetry/src/propagator'; +import { getSamplingDecision } from '../../../packages/opentelemetry/src/utils/getSamplingDecision'; +import { makeTraceState } from '../../../packages/opentelemetry/src/utils/makeTraceState'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +describe('SentryPropagator', () => { + const propagator = new SentryPropagator(); + let carrier: { [key: string]: unknown }; + + beforeEach(() => { + carrier = {}; + mockSdkInit({ + environment: 'production', + release: '1.0.0', + tracesSampleRate: 1, + dsn: 'https://abc@domain/123', + }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('returns fields set', () => { + expect(propagator.fields()).toEqual([SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER]); + }); + + describe('inject', () => { + describe('without active local span', () => { + it('uses scope propagation context without DSC if no span is found', () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + parentSpanId: '6e0c63257de34c93', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ].sort(), + ); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); + }); + }); + + it('uses scope propagation context with DSC if no span is found', () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + parentSpanId: '6e0c63257de34c93', + sampled: true, + sampleRand: Math.random(), + dsc: { + transaction: 'sampled-transaction', + sampled: 'false', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sampled=false', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ].sort(), + ); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); + }); + }); + + it('uses propagation data from current scope if no scope & span is found', () => { + const scope = getCurrentScope(); + const traceId = scope.getPropagationContext().traceId; + + const ctx = trace.deleteSpan(ROOT_CONTEXT).deleteValue(SENTRY_SCOPES_CONTEXT_KEY); + propagator.inject(ctx, carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual([ + 'sentry-environment=production', + 'sentry-public_key=abc', + 'sentry-release=1.0.0', + `sentry-trace_id=${traceId}`, + ]); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(traceId); + }); + }); + + describe('with active span', () => { + it.each([ + [ + 'continues a remote trace without dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-transaction=test', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + true, + ], + [ + 'continues a remote trace with dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + traceState: makeTraceState({ + dsc: { + transaction: 'sampled-transaction', + sampled: 'true', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }), + }, + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sampled=true', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + true, + ], + [ + 'continues an unsampled remote trace without dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-transaction=test', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + undefined, + ], + [ + 'continues an unsampled remote trace with sampled trace state & without dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ + sampled: false, + }), + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-sampled=false', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', + false, + ], + [ + 'continues an unsampled remote trace with dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ + dsc: { + transaction: 'sampled-transaction', + sampled: 'false', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }), + }, + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sampled=false', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', + false, + ], + [ + 'continues an unsampled remote trace with dsc & sampled trace state', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ + sampled: false, + dsc: { + transaction: 'sampled-transaction', + trace_id: 'dsc_trace_id', + public_key: 'dsc_public_key', + environment: 'dsc_environment', + release: 'dsc_release', + sample_rate: '0.5', + replay_id: 'dsc_replay_id', + }, + }), + }, + [ + 'sentry-environment=dsc_environment', + 'sentry-release=dsc_release', + 'sentry-public_key=dsc_public_key', + 'sentry-trace_id=dsc_trace_id', + 'sentry-transaction=sampled-transaction', + 'sentry-sample_rate=0.5', + 'sentry-replay_id=dsc_replay_id', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', + false, + ], + [ + 'starts a new trace without existing dsc', + { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }, + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ], + 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', + true, + ], + ])('%s', (_name, spanContext, baggage, sentryTrace, samplingDecision) => { + expect(getSamplingDecision(spanContext)).toBe(samplingDecision); + + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + trace.getTracer('test').startActiveSpan('test', span => { + propagator.inject(context.active(), carrier, defaultTextMapSetter); + baggage.forEach(baggageItem => { + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(baggageItem); + }); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(sentryTrace.replace('{{spanId}}', span.spanContext().spanId)); + }); + }); + }); + + it('uses local span over propagation context', () => { + context.with( + trace.setSpanContext(ROOT_CONTEXT, { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }), + () => { + trace.getTracer('test').startActiveSpan('test', span => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'TRACE_ID', + parentSpanId: 'PARENT_SPAN_ID', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=true', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-transaction=test', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + ].forEach(item => { + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(item); + }); + expect(carrier[SENTRY_TRACE_HEADER]).toBe( + `d4cda95b652f4a1592b449d5929fda1b-${span.spanContext().spanId}-1`, + ); + }); + }); + }, + ); + }); + + it('uses remote span with deferred sampling decision over propagation context', () => { + const carrier: Record = {}; + context.with( + trace.setSpanContext(ROOT_CONTEXT, { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + }), + () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'TRACE_ID', + parentSpanId: 'PARENT_SPAN_ID', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ].sort(), + ); + // Used spanId is a random ID, not from the remote span + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}/); + expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'); + }); + }, + ); + }); + + it('uses remote span over propagation context', () => { + const carrier: Record = {}; + context.with( + trace.setSpanContext(ROOT_CONTEXT, { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + isRemote: true, + traceState: makeTraceState({ sampled: false }), + }), + () => { + withScope(scope => { + scope.setPropagationContext({ + traceId: 'TRACE_ID', + parentSpanId: 'PARENT_SPAN_ID', + sampled: true, + sampleRand: Math.random(), + }); + + propagator.inject(context.active(), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-public_key=abc', + 'sentry-sampled=false', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + ].sort(), + ); + // Used spanId is a random ID, not from the remote span + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-0/); + expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'); + }); + }, + ); + }); + }); + + it('should include existing baggage', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should include existing baggage header', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const carrier = { + other: 'header', + baggage: 'foo=bar,other=yes', + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage(); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'other=yes', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should include existing baggage array header', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const carrier = { + other: 'header', + baggage: ['foo=bar,other=yes', 'other2=no'], + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage(); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'other=yes', + 'other2=no', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should overwrite existing sentry baggage header', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const carrier = { + baggage: 'foo=bar,other=yes,sentry-release=9.9.9,sentry-other=yes', + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage(); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'other=yes', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-other=yes', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + }); + + it('should create baggage without propagation context', () => { + const scope = getCurrentScope(); + const traceId = scope.getPropagationContext().traceId; + + const context = ROOT_CONTEXT; + const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe( + `foo=bar,sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=${traceId}`, + ); + }); + + it('should NOT set baggage and sentry-trace header if instrumentation is suppressed', () => { + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + + const context = suppressTracing(trace.setSpanContext(ROOT_CONTEXT, spanContext)); + propagator.inject(context, carrier, defaultTextMapSetter); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); + }); + }); + + describe('extract', () => { + it('sets data from sentry trace header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({}), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + }); + + it('sets data from negative sampled sentry trace header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({ sampled: false }), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(false); + }); + + it('sets data from not sampled sentry trace header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({}), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(undefined); + }); + + it('handles undefined sentry trace header', () => { + const sentryTraceHeader = undefined; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual(undefined); + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: expect.stringMatching(/[a-f0-9]{32}/), + sampleRand: expect.any(Number), + }); + }); + + it('sets data from baggage header on span context', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + const baggage = + 'sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-transaction=dsc-transaction,sentry-sample_rand=0.123'; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + carrier[SENTRY_BAGGAGE_HEADER] = baggage; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({ + dsc: { + environment: 'production', + release: '1.0.0', + public_key: 'abc', + trace_id: 'd4cda95b652f4a1592b449d5929fda1b', + transaction: 'dsc-transaction', + sample_rand: '0.123', + }, + }), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + }); + + it('handles empty dsc baggage header', () => { + const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; + const baggage = ''; + carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; + carrier[SENTRY_BAGGAGE_HEADER] = baggage; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual({ + isRemote: true, + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceState: makeTraceState({}), + }); + expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + }); + + it('handles when sentry-trace is an empty array', () => { + carrier[SENTRY_TRACE_HEADER] = []; + const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); + expect(trace.getSpanContext(context)).toEqual(undefined); + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: expect.stringMatching(/[a-f0-9]{32}/), + sampleRand: expect.any(Number), + }); + }); + }); +}); + +function baggageToArray(baggage: unknown): string[] { + return typeof baggage === 'string' ? baggage.split(',').sort() : []; +} diff --git a/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts b/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts new file mode 100644 index 000000000000..86cf7b135f97 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts @@ -0,0 +1,141 @@ +import { context, SpanKind, trace } from '@opentelemetry/api'; +import { TraceState } from '@opentelemetry/core'; +import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; +import { ATTR_HTTP_REQUEST_METHOD } from '@opentelemetry/semantic-conventions'; +import { generateSpanId, generateTraceId } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../../../packages/opentelemetry/src/constants'; +import { SentrySampler } from '../../../packages/opentelemetry/src/sampler'; +import { cleanupOtel } from './helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; + +describe('SentrySampler', () => { + afterEach(async () => { + await cleanupOtel(); + }); + + it('works with tracesSampleRate=0', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual( + expect.objectContaining({ + decision: SamplingDecision.NOT_RECORD, + attributes: { 'sentry.sample_rate': 0 }, + }), + ); + expect(actual.traceState?.get('sentry.sampled_not_recording')).toBe('1'); + expect(actual.traceState?.get('sentry.sample_rand')).toEqual(expect.any(String)); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction'); + + spyOnDroppedEvent.mockReset(); + }); + + it('works with tracesSampleRate=0 & for a child span', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + spanId: generateSpanId(), + traceId, + traceFlags: 0, + traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), + }); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual({ + decision: SamplingDecision.NOT_RECORD, + traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), + }); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); + + it('works with tracesSampleRate=1', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual( + expect.objectContaining({ + decision: SamplingDecision.RECORD_AND_SAMPLED, + attributes: { 'sentry.sample_rate': 1 }, + }), + ); + expect(actual.traceState?.constructor.name).toBe('TraceState'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); + + it('works with traceSampleRate=undefined', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: undefined })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.INTERNAL; + const spanAttributes = {}; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual({ + decision: SamplingDecision.NOT_RECORD, + traceState: new TraceState(), + }); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); + + it('ignores local http client root spans', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'test'; + const spanKind = SpanKind.CLIENT; + const spanAttributes = { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + }; + const links = undefined; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); + expect(actual).toEqual({ + decision: SamplingDecision.NOT_RECORD, + traceState: new TraceState(), + }); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + + spyOnDroppedEvent.mockReset(); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts b/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts new file mode 100644 index 000000000000..5a1782c89e7b --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts @@ -0,0 +1,169 @@ +import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan, startSpanManual } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createTransactionForOtelSpan } from '../../../packages/opentelemetry/src/spanExporter'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +describe('createTransactionForOtelSpan', () => { + beforeEach(() => { + mockSdkInit({ + tracesSampleRate: 1, + }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('works with a basic span', () => { + const span = startInactiveSpan({ name: 'test', startTime: 1733821670000 }); + span.end(1733821672000); + + const event = createTransactionForOtelSpan(span as any); + // we do not care about this here + delete event.sdkProcessingMetadata; + + expect(event).toEqual({ + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + origin: 'manual', + status: 'ok', + }, + otel: { + resource: { + 'service.name': 'opentelemetry-test', + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + 'service.namespace': 'sentry', + 'service.version': SDK_VERSION, + }, + }, + }, + spans: [], + start_timestamp: 1733821670, + timestamp: 1733821672, + transaction: 'test', + type: 'transaction', + transaction_info: { source: 'custom' }, + }); + }); + + it('works with a http.server span', () => { + const span = startInactiveSpan({ + name: 'test', + startTime: 1733821670000, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + }, + }); + span.end(1733821672000); + + const event = createTransactionForOtelSpan(span as any); + // we do not care about this here + delete event.sdkProcessingMetadata; + + expect(event).toEqual({ + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + 'sentry.op': 'http.server', + 'http.response.status_code': 200, + }, + origin: 'manual', + status: 'ok', + op: 'http.server', + }, + otel: { + resource: { + 'service.name': 'opentelemetry-test', + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + 'service.namespace': 'sentry', + 'service.version': SDK_VERSION, + }, + }, + response: { + status_code: 200, + }, + }, + spans: [], + start_timestamp: 1733821670, + timestamp: 1733821672, + transaction: 'test', + type: 'transaction', + transaction_info: { source: 'custom' }, + }); + }); + + it('adds span link to the trace context when adding with addLink()', () => { + const span = startInactiveSpan({ name: 'parent1' }); + span.end(); + + startSpanManual({ name: 'rootSpan' }, rootSpan => { + rootSpan.addLink({ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }); + rootSpan.end(); + + const prevTraceId = span.spanContext().traceId; + const prevSpanId = span.spanContext().spanId; + const event = createTransactionForOtelSpan(rootSpan as any); + + expect(event.contexts?.trace).toEqual( + expect.objectContaining({ + links: [ + expect.objectContaining({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + trace_id: expect.stringMatching(prevTraceId), + span_id: expect.stringMatching(prevSpanId), + }), + ], + }), + ); + }); + }); + + it('adds span link to the trace context when linked in span options', () => { + const span = startInactiveSpan({ name: 'parent1' }); + + const prevTraceId = span.spanContext().traceId; + const prevSpanId = span.spanContext().spanId; + + const linkedSpan = startInactiveSpan({ + name: 'parent2', + links: [{ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }], + }); + + span.end(); + linkedSpan.end(); + + const event = createTransactionForOtelSpan(linkedSpan as any); + + expect(event.contexts?.trace).toEqual( + expect.objectContaining({ + links: [ + expect.objectContaining({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + trace_id: expect.stringMatching(prevTraceId), + span_id: expect.stringMatching(prevSpanId), + }), + ], + }), + ); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/trace.test.ts b/dev-packages/opentelemetry-v2-tests/test/trace.test.ts new file mode 100644 index 000000000000..84be427a1fb3 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/trace.test.ts @@ -0,0 +1,1935 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span, TimeInput } from '@opentelemetry/api'; +import { context, ROOT_CONTEXT, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_METHOD } from '@opentelemetry/semantic-conventions'; +import type { Event, Scope } from '@sentry/core'; +import { + getClient, + getCurrentScope, + getDynamicSamplingContextFromClient, + getDynamicSamplingContextFromSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanIsSampled, + spanToJSON, + suppressTracing, + withScope, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + continueTrace, + startInactiveSpan, + startSpan, + startSpanManual, +} from '../../../packages/opentelemetry/src/trace'; +import type { AbstractSpan } from '../../../packages/opentelemetry/src/types'; +import { getActiveSpan } from '../../../packages/opentelemetry/src/utils/getActiveSpan'; +import { getSamplingDecision } from '../../../packages/opentelemetry/src/utils/getSamplingDecision'; +import { getSpanKind } from '../../../packages/opentelemetry/src/utils/getSpanKind'; +import { makeTraceState } from '../../../packages/opentelemetry/src/utils/makeTraceState'; +import { spanHasAttributes, spanHasName } from '../../../packages/opentelemetry/src/utils/spanTypes'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; +import { isSpan } from './helpers/isSpan'; +import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; + +describe('trace', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + describe('startSpan', () => { + it('works with a sync callback', () => { + const spans: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + const res = startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + spans.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + spans.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + + return 'test value'; + }); + + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans).toHaveLength(2); + const [outerSpan, innerSpan] = spans as [Span, Span]; + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getSpanName(innerSpan)).toEqual('inner'); + + expect(getSpanEndTime(outerSpan)).not.toEqual([0, 0]); + expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); + }); + + it('works with an async callback', async () => { + const spans: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + const res = await startSpan({ name: 'outer' }, async outerSpan => { + expect(outerSpan).toBeDefined(); + spans.push(outerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + await startSpan({ name: 'inner' }, async innerSpan => { + expect(innerSpan).toBeDefined(); + spans.push(innerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + + return 'test value'; + }); + + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans).toHaveLength(2); + const [outerSpan, innerSpan] = spans as [Span, Span]; + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getSpanName(innerSpan)).toEqual('inner'); + + expect(getSpanEndTime(outerSpan)).not.toEqual([0, 0]); + expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); + }); + + it('works with multiple parallel calls', () => { + const spans1: Span[] = []; + const spans2: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + spans1.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + spans1.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + }); + + startSpan({ name: 'outer2' }, outerSpan => { + expect(outerSpan).toBeDefined(); + spans2.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer2'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpan({ name: 'inner2' }, innerSpan => { + expect(innerSpan).toBeDefined(); + spans2.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner2'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + }); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans1).toHaveLength(2); + expect(spans2).toHaveLength(2); + }); + + it('works with multiple parallel async calls', async () => { + const spans1: Span[] = []; + const spans2: Span[] = []; + + expect(getActiveSpan()).toEqual(undefined); + + const promise1 = startSpan({ name: 'outer' }, async outerSpan => { + expect(outerSpan).toBeDefined(); + spans1.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + expect(getRootSpan(outerSpan)).toEqual(outerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + await startSpan({ name: 'inner' }, async innerSpan => { + expect(innerSpan).toBeDefined(); + spans1.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + expect(getRootSpan(innerSpan)).toEqual(outerSpan); + }); + }); + + const promise2 = startSpan({ name: 'outer2' }, async outerSpan => { + expect(outerSpan).toBeDefined(); + spans2.push(outerSpan); + + expect(getSpanName(outerSpan)).toEqual('outer2'); + expect(getActiveSpan()).toEqual(outerSpan); + expect(getRootSpan(outerSpan)).toEqual(outerSpan); + + await new Promise(resolve => setTimeout(resolve, 10)); + + await startSpan({ name: 'inner2' }, async innerSpan => { + expect(innerSpan).toBeDefined(); + spans2.push(innerSpan); + + expect(getSpanName(innerSpan)).toEqual('inner2'); + expect(getActiveSpan()).toEqual(innerSpan); + expect(getRootSpan(innerSpan)).toEqual(outerSpan); + }); + }); + + await Promise.all([promise1, promise2]); + + expect(getActiveSpan()).toEqual(undefined); + expect(spans1).toHaveLength(2); + expect(spans2).toHaveLength(2); + }); + + it('allows to pass context arguments', () => { + startSpan( + { + name: 'outer', + }, + span => { + expect(span).toBeDefined(); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }); + }, + ); + + startSpan( + { + name: 'outer', + op: 'my-op', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + }, + }, + span => { + expect(span).toBeDefined(); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'my-op', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }); + }, + ); + }); + + it('allows to pass base SpanOptions', () => { + const date = [5000, 0] as TimeInput; + + startSpan( + { + name: 'outer', + kind: SpanKind.CLIENT, + attributes: { + test1: 'test 1', + test2: 2, + }, + startTime: date, + }, + span => { + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('outer'); + expect(getSpanStartTime(span)).toEqual(date); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + test1: 'test 1', + test2: 2, + }); + expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); + }, + ); + }); + + it('allows to pass a startTime in seconds', () => { + const startTime = 1708504860.961; + const start = startSpan({ name: 'outer', startTime: startTime }, span => { + return getSpanStartTime(span); + }); + + expect(start).toEqual([1708504860, 961000000]); + }); + + it('allows to pass a scope', () => { + const initialScope = getCurrentScope(); + + let manualScope: Scope; + let parentSpan: Span; + + // "hack" to create a manual scope with a parent span + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + manualScope = getCurrentScope(); + manualScope.setTag('manual', 'tag'); + }); + + expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag' }); + expect(getCurrentScope()).not.toBe(manualScope!); + + getCurrentScope().setTag('outer', 'tag'); + + startSpan({ name: 'GET users/[id]', scope: manualScope! }, span => { + // the current scope in the callback is a fork of the manual scope + expect(getCurrentScope()).not.toBe(initialScope); + expect(getCurrentScope()).not.toBe(manualScope); + expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag' }); + + // getActiveSpan returns the correct span + expect(getActiveSpan()).toBe(span); + + // span hierarchy is correct + expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); + + // scope data modifications are isolated between original and forked manual scope + getCurrentScope().setTag('inner', 'tag'); + manualScope!.setTag('manual-scope-inner', 'tag'); + + expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag', inner: 'tag' }); + expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' }); + }); + + // manualScope modifications remain set outside the callback + expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' }); + + // current scope is reset back to initial scope + expect(getCurrentScope()).toBe(initialScope); + expect(getCurrentScope().getScopeData().tags).toEqual({ outer: 'tag' }); + + // although the manual span is still running, it's no longer active due to being outside of the callback + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass a parentSpan', () => { + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + }); + + startSpan({ name: 'GET users/[id]', parentSpan: parentSpan! }, span => { + expect(getActiveSpan()).toBe(span); + expect(spanToJSON(span).parent_span_id).toBe(parentSpan.spanContext().spanId); + }); + + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass parentSpan=null', () => { + startSpan({ name: 'GET users/[id' }, () => { + startSpan({ name: 'child', parentSpan: null }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + }); + }); + }); + + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpan({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpan( + { + name: '/users/:id', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }, + rawSpan2 => { + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }, + ); + }); + + it('allows to force a transaction with forceTransaction=true', async () => { + const client = getClient()!; + const transactionEvents: Event[] = []; + + client.getOptions().beforeSendTransaction = event => { + transactionEvents.push({ + ...event, + sdkProcessingMetadata: { + dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, + }, + }); + return event; + }; + + startSpan({ name: 'outer transaction' }, () => { + startSpan({ name: 'inner span' }, () => { + startSpan({ name: 'inner transaction', forceTransaction: true }, () => { + startSpan({ name: 'inner span 2' }, () => { + // all good + }); + }); + }); + }); + + await client.flush(); + + const normalizedTransactionEvents = transactionEvents.map(event => { + return { + ...event, + spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), + }; + }); + + expect(normalizedTransactionEvents).toHaveLength(2); + + const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); + const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); + + const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; + // The inner transaction should be a child of the last span of the outer transaction + const innerParentSpanId = outerTransaction?.spans?.[0]?.id; + const innerSpanId = innerTransaction?.contexts?.trace?.span_id; + + expect(outerTraceId).toBeDefined(); + expect(innerParentSpanId).toBeDefined(); + expect(innerSpanId).toBeDefined(); + // inner span ID should _not_ be the parent span ID, but the id of the new span + expect(innerSpanId).not.toEqual(innerParentSpanId); + + expect(outerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + status: 'ok', + }); + expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); + expect(outerTransaction?.transaction).toEqual('outer transaction'); + expect(outerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + + expect(innerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'manual', + }, + parent_span_id: innerParentSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: outerTraceId, + origin: 'manual', + status: 'ok', + }); + expect(innerTransaction?.spans).toEqual([{ name: 'inner span 2', id: expect.any(String) }]); + expect(innerTransaction?.transaction).toEqual('inner transaction'); + expect(innerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + }); + + // TODO: propagation scope is not picked up by spans... + + describe('onlyIfParent', () => { + it('does not create a span if there is no parent', () => { + const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + expect(isSpan(span)).toBe(false); + }); + + it('creates a span if there is a parent', () => { + const span = startSpan({ name: 'parent span' }, () => { + const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + return span; + }); + + expect(isSpan(span)).toBe(true); + }); + }); + }); + + describe('startInactiveSpan', () => { + it('works at the root', () => { + const span = startInactiveSpan({ name: 'test' }); + + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('test'); + expect(getSpanEndTime(span)).toEqual([0, 0]); + expect(getActiveSpan()).toBeUndefined(); + + span.end(); + + expect(getSpanEndTime(span)).not.toEqual([0, 0]); + expect(getActiveSpan()).toBeUndefined(); + }); + + it('works as a child span', () => { + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(getActiveSpan()).toEqual(outerSpan); + + const innerSpan = startInactiveSpan({ name: 'test' }); + + expect(innerSpan).toBeDefined(); + expect(getSpanName(innerSpan)).toEqual('test'); + expect(getSpanEndTime(innerSpan)).toEqual([0, 0]); + expect(getActiveSpan()).toEqual(outerSpan); + + innerSpan.end(); + + expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); + expect(getActiveSpan()).toEqual(outerSpan); + }); + }); + + it('allows to pass context arguments', () => { + const span = startInactiveSpan({ + name: 'outer', + }); + + expect(span).toBeDefined(); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }); + + const span2 = startInactiveSpan({ + name: 'outer', + op: 'my-op', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + }, + }); + + expect(span2).toBeDefined(); + expect(getSpanAttributes(span2)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'my-op', + }); + }); + + it('allows to pass base SpanOptions', () => { + const date = [5000, 0] as TimeInput; + + const span = startInactiveSpan({ + name: 'outer', + kind: SpanKind.CLIENT, + attributes: { + test1: 'test 1', + test2: 2, + }, + startTime: date, + }); + + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('outer'); + expect(getSpanStartTime(span)).toEqual(date); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + test1: 'test 1', + test2: 2, + }); + expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); + }); + + it('allows to pass a startTime in seconds', () => { + const startTime = 1708504860.961; + const span = startInactiveSpan({ name: 'outer', startTime: startTime }); + + expect(getSpanStartTime(span)).toEqual([1708504860, 961000000]); + }); + + it('allows to pass a scope', () => { + const initialScope = getCurrentScope(); + + let manualScope: Scope; + + const parentSpan = startSpanManual({ name: 'detached' }, span => { + manualScope = getCurrentScope(); + manualScope.setTag('manual', 'tag'); + return span; + }); + + getCurrentScope().setTag('outer', 'tag'); + + const span = startInactiveSpan({ name: 'GET users/[id]', scope: manualScope! }); + expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); + + expect(getCurrentScope()).toBe(initialScope); + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass a parentSpan', () => { + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + }); + + const span = startInactiveSpan({ name: 'GET users/[id]', parentSpan: parentSpan! }); + + expect(getActiveSpan()).toBe(undefined); + expect(spanToJSON(span).parent_span_id).toBe(parentSpan!.spanContext().spanId); + + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass parentSpan=null', () => { + startSpan({ name: 'outer' }, () => { + const span = startInactiveSpan({ name: 'test span', parentSpan: null }); + expect(spanToJSON(span).parent_span_id).toBe(undefined); + span.end(); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const rawSpan2 = startInactiveSpan({ + name: 'GET users/[id]', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }); + + const span1JSON = spanToJSON(rawSpan1); + const span2JSON = spanToJSON(rawSpan2); + const span2LinkJSON = span2JSON.links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + + // sampling decision is inherited + expect(span2LinkJSON?.sampled).toBe(Boolean(spanToJSON(rawSpan1).data['sentry.sample_rate'])); + }); + + it('allows to force a transaction with forceTransaction=true', async () => { + const client = getClient()!; + const transactionEvents: Event[] = []; + + client.getOptions().beforeSendTransaction = event => { + transactionEvents.push({ + ...event, + sdkProcessingMetadata: { + dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, + }, + }); + return event; + }; + + startSpan({ name: 'outer transaction' }, () => { + startSpan({ name: 'inner span' }, () => { + const innerTransaction = startInactiveSpan({ name: 'inner transaction', forceTransaction: true }); + innerTransaction.end(); + }); + }); + + await client.flush(); + + const normalizedTransactionEvents = transactionEvents.map(event => { + return { + ...event, + spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), + }; + }); + + expect(normalizedTransactionEvents).toHaveLength(2); + + const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); + const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); + + const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; + // The inner transaction should be a child of the last span of the outer transaction + const innerParentSpanId = outerTransaction?.spans?.[0]?.id; + const innerSpanId = innerTransaction?.contexts?.trace?.span_id; + + expect(outerTraceId).toBeDefined(); + expect(innerParentSpanId).toBeDefined(); + expect(innerSpanId).toBeDefined(); + // inner span ID should _not_ be the parent span ID, but the id of the new span + expect(innerSpanId).not.toEqual(innerParentSpanId); + + expect(outerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + status: 'ok', + }); + expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); + expect(outerTransaction?.transaction).toEqual('outer transaction'); + expect(outerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + + expect(innerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'manual', + }, + parent_span_id: innerParentSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: outerTraceId, + origin: 'manual', + status: 'ok', + }); + expect(innerTransaction?.spans).toEqual([]); + expect(innerTransaction?.transaction).toEqual('inner transaction'); + expect(innerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + }); + + describe('onlyIfParent', () => { + it('does not create a span if there is no parent', () => { + const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); + + expect(isSpan(span)).toBe(false); + }); + + it('creates a span if there is a parent', () => { + const span = startSpan({ name: 'parent span' }, () => { + const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); + + return span; + }); + + expect(isSpan(span)).toBe(true); + }); + }); + + it('includes the scope at the time the span was started when finished', async () => { + const beforeSendTransaction = vi.fn(event => event); + + const client = getClient()!; + + client.getOptions().beforeSendTransaction = beforeSendTransaction; + + let span: Span; + + const scope = getCurrentScope(); + scope.setTag('outer', 'foo'); + + withScope(scope => { + scope.setTag('scope', 1); + span = startInactiveSpan({ name: 'my-span' }); + scope.setTag('scope_after_span', 2); + }); + + withScope(scope => { + scope.setTag('scope', 2); + span.end(); + }); + + await client.flush(); + + expect(beforeSendTransaction).toHaveBeenCalledTimes(1); + expect(beforeSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ + outer: 'foo', + scope: 1, + scope_after_span: 2, + }), + }), + expect.anything(), + ); + }); + }); + + describe('startSpanManual', () => { + it('does not automatically finish the span', () => { + expect(getActiveSpan()).toEqual(undefined); + + let _outerSpan: Span | undefined; + let _innerSpan: Span | undefined; + + const res = startSpanManual({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + _outerSpan = outerSpan; + + expect(getSpanName(outerSpan)).toEqual('outer'); + expect(getActiveSpan()).toEqual(outerSpan); + + startSpanManual({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + _innerSpan = innerSpan; + + expect(getSpanName(innerSpan)).toEqual('inner'); + expect(getActiveSpan()).toEqual(innerSpan); + }); + + expect(getSpanEndTime(_innerSpan!)).toEqual([0, 0]); + + _innerSpan!.end(); + + expect(getSpanEndTime(_innerSpan!)).not.toEqual([0, 0]); + + return 'test value'; + }); + + expect(getSpanEndTime(_outerSpan!)).toEqual([0, 0]); + + _outerSpan!.end(); + + expect(getSpanEndTime(_outerSpan!)).not.toEqual([0, 0]); + + expect(res).toEqual('test value'); + + expect(getActiveSpan()).toEqual(undefined); + }); + + it('allows to pass base SpanOptions', () => { + const date = [5000, 0] as TimeInput; + + startSpanManual( + { + name: 'outer', + kind: SpanKind.CLIENT, + attributes: { + test1: 'test 1', + test2: 2, + }, + startTime: date, + }, + span => { + expect(span).toBeDefined(); + expect(getSpanName(span)).toEqual('outer'); + expect(getSpanStartTime(span)).toEqual(date); + expect(getSpanAttributes(span)).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + test1: 'test 1', + test2: 2, + }); + expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); + }, + ); + }); + + it('allows to pass a startTime in seconds', () => { + const startTime = 1708504860.961; + const start = startSpanManual({ name: 'outer', startTime: startTime }, span => { + const start = getSpanStartTime(span); + span.end(); + return start; + }); + + expect(start).toEqual([1708504860, 961000000]); + }); + + it('allows to pass a scope', () => { + const initialScope = getCurrentScope(); + + let manualScope: Scope; + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + manualScope = getCurrentScope(); + manualScope.setTag('manual', 'tag'); + }); + + getCurrentScope().setTag('outer', 'tag'); + + startSpanManual({ name: 'GET users/[id]', scope: manualScope! }, span => { + expect(getCurrentScope()).not.toBe(initialScope); + + expect(getCurrentScope()).toEqual(manualScope); + expect(getActiveSpan()).toBe(span); + + expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); + + span.end(); + }); + + expect(getCurrentScope()).toBe(initialScope); + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass a parentSpan', () => { + let parentSpan: Span; + + startSpanManual({ name: 'detached' }, span => { + parentSpan = span; + }); + + startSpanManual({ name: 'GET users/[id]', parentSpan: parentSpan! }, span => { + expect(getActiveSpan()).toBe(span); + expect(spanToJSON(span).parent_span_id).toBe(parentSpan.spanContext().spanId); + + span.end(); + }); + + expect(getActiveSpan()).toBe(undefined); + }); + + it('allows to pass parentSpan=null', () => { + startSpan({ name: 'outer' }, () => { + startSpanManual({ name: 'GET users/[id]', parentSpan: null }, span => { + expect(spanToJSON(span).parent_span_id).toBe(undefined); + span.end(); + }); + }); + }); + + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpanManual({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpanManual( + { + name: '/users/:id', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }, + rawSpan2 => { + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }, + ); + }); + + it('allows to force a transaction with forceTransaction=true', async () => { + const client = getClient()!; + const transactionEvents: Event[] = []; + + client.getOptions().beforeSendTransaction = event => { + transactionEvents.push({ + ...event, + sdkProcessingMetadata: { + dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, + }, + }); + return event; + }; + + startSpanManual({ name: 'outer transaction' }, span => { + startSpanManual({ name: 'inner span' }, span => { + startSpanManual({ name: 'inner transaction', forceTransaction: true }, span => { + startSpanManual({ name: 'inner span 2' }, span => { + // all good + span.end(); + }); + span.end(); + }); + span.end(); + }); + span.end(); + }); + + await client.flush(); + + const normalizedTransactionEvents = transactionEvents.map(event => { + return { + ...event, + spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), + }; + }); + + expect(normalizedTransactionEvents).toHaveLength(2); + + const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); + const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); + + const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; + // The inner transaction should be a child of the last span of the outer transaction + const innerParentSpanId = outerTransaction?.spans?.[0]?.id; + const innerSpanId = innerTransaction?.contexts?.trace?.span_id; + + expect(outerTraceId).toBeDefined(); + expect(innerParentSpanId).toBeDefined(); + expect(innerSpanId).toBeDefined(); + // inner span ID should _not_ be the parent span ID, but the id of the new span + expect(innerSpanId).not.toEqual(innerParentSpanId); + + expect(outerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.origin': 'manual', + }, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + status: 'ok', + }); + expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); + expect(outerTransaction?.transaction).toEqual('outer transaction'); + expect(outerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + + expect(innerTransaction?.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'custom', + 'sentry.origin': 'manual', + }, + parent_span_id: innerParentSpanId, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: outerTraceId, + origin: 'manual', + status: 'ok', + }); + expect(innerTransaction?.spans).toEqual([{ name: 'inner span 2', id: expect.any(String) }]); + expect(innerTransaction?.transaction).toEqual('inner transaction'); + expect(innerTransaction?.sdkProcessingMetadata).toEqual({ + dynamicSamplingContext: { + environment: 'production', + public_key: 'username', + trace_id: outerTraceId, + sample_rate: '1', + transaction: 'outer transaction', + sampled: 'true', + sample_rand: expect.any(String), + }, + }); + }); + + describe('onlyIfParent', () => { + it('does not create a span if there is no parent', () => { + const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + expect(isSpan(span)).toBe(false); + }); + + it('creates a span if there is a parent', () => { + const span = startSpan({ name: 'parent span' }, () => { + const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { + return span; + }); + + return span; + }); + + expect(isSpan(span)).toBe(true); + }); + }); + }); + + describe('propagation', () => { + it('starts new trace, if there is no parent', () => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + const traceId = spanToJSON(span).trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + expect(spanToJSON(span).parent_span_id).toBe(undefined); + expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + trace_id: traceId, + environment: 'production', + public_key: 'username', + sample_rate: '1', + sampled: 'true', + transaction: 'test span', + sample_rand: expect.any(String), + }); + }); + }); + + // Note: This _should_ never happen, when we have an incoming trace, we should always have a parent span + it('starts new trace, ignoring parentSpanId, if there is no parent', () => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + propagationContext.parentSpanId = '1121201211212012'; + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + const traceId = spanToJSON(span).trace_id; + expect(traceId).toMatch(/[a-f0-9]{32}/); + expect(spanToJSON(span).parent_span_id).toBe(undefined); + expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + environment: 'production', + public_key: 'username', + trace_id: traceId, + sample_rate: '1', + sampled: 'true', + transaction: 'test span', + sample_rand: expect.any(String), + }); + }); + }); + + it('picks up the trace context from the parent without DSC', () => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + + startSpan({ name: 'parent span' }, parentSpan => { + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + expect(spanToJSON(span).trace_id).toEqual(parentSpan.spanContext().traceId); + expect(spanToJSON(span).parent_span_id).toEqual(parentSpan.spanContext().spanId); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + ...getDynamicSamplingContextFromClient(propagationContext.traceId, getClient()!), + trace_id: parentSpan.spanContext().traceId, + transaction: 'parent span', + sampled: 'true', + sample_rate: '1', + sample_rand: expect.any(String), + }); + }); + }); + }); + + it('picks up the trace context from the parent with DSC', () => { + withScope(() => { + const ctx = trace.setSpanContext(ROOT_CONTEXT, { + traceId: '12312012123120121231201212312012', + spanId: '1121201211212012', + isRemote: false, + traceFlags: TraceFlags.SAMPLED, + traceState: makeTraceState({ + dsc: { + release: '1.0', + environment: 'production', + }, + }), + }); + + context.with(ctx, () => { + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + expect(spanToJSON(span).trace_id).toEqual('12312012123120121231201212312012'); + expect(spanToJSON(span).parent_span_id).toEqual('1121201211212012'); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + release: '1.0', + environment: 'production', + }); + }); + }); + }); + + it('picks up the trace context from a remote parent', () => { + withScope(() => { + const ctx = trace.setSpanContext(ROOT_CONTEXT, { + traceId: '12312012123120121231201212312012', + spanId: '1121201211212012', + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + traceState: makeTraceState({ + dsc: { + release: '1.0', + environment: 'production', + }, + }), + }); + + context.with(ctx, () => { + const span = startInactiveSpan({ name: 'test span' }); + + expect(span).toBeDefined(); + expect(spanToJSON(span).trace_id).toEqual('12312012123120121231201212312012'); + expect(spanToJSON(span).parent_span_id).toEqual('1121201211212012'); + expect(getDynamicSamplingContextFromSpan(span)).toEqual({ + release: '1.0', + environment: 'production', + }); + }); + }); + }); + }); +}); + +describe('trace (tracing disabled)', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 0 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('startSpan calls callback without span', () => { + const val = startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + + return 'test value'; + }); + + expect(val).toEqual('test value'); + }); + + it('startInactiveSpan returns a NonRecordinSpan', () => { + const span = startInactiveSpan({ name: 'test' }); + + expect(span).toBeDefined(); + expect(span.isRecording()).toBe(false); + }); +}); + +describe('trace (sampling)', () => { + afterEach(async () => { + await cleanupOtel(); + vi.clearAllMocks(); + }); + + it('samples with a tracesSampleRate, when Math.random() > tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(false); + }); + }); + }); + + it('samples with a tracesSampleRate, when Math.random() < tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.4); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(true); + // All fields are empty for NonRecordingSpan + expect(getSpanName(outerSpan)).toBe('outer'); + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(true); + expect(getSpanName(innerSpan)).toBe('inner'); + }); + }); + }); + + it('positive parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 1 }); + + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(true); + expect(getSpanName(outerSpan)).toBe('outer'); + + // Now let's mutate the tracesSampleRate so that the next entry _should_ not be sampled + // but it will because of parent sampling + const client = getClient(); + client!.getOptions().tracesSampleRate = 0.5; + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(true); + expect(getSpanName(innerSpan)).toBe('inner'); + }); + }); + }); + + it('negative parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + // This will def. be unsampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + + // Now let's mutate the tracesSampleRate so that the next entry _should_ be sampled + // but it will remain unsampled because of parent sampling + const client = getClient(); + client!.getOptions().tracesSampleRate = 1; + + startSpan({ name: 'inner' }, innerSpan => { + expect(innerSpan).toBeDefined(); + expect(innerSpan.isRecording()).toBe(false); + }); + }); + }); + + it('positive remote parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: true, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + }; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(true); + expect(getSpanName(outerSpan)).toBe('outer'); + }); + }); + }); + + it('negative remote parent sampling takes precedence over tracesSampleRate', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + mockSdkInit({ tracesSampleRate: 0.5 }); + + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: false, + isRemote: true, + traceFlags: TraceFlags.NONE, + }; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(outerSpan.isRecording()).toBe(false); + }); + }); + }); + + it('samples with a tracesSampler returning a boolean', () => { + let tracesSamplerResponse: boolean = true; + + const tracesSampler = vi.fn(() => { + return tracesSamplerResponse; + }); + + mockSdkInit({ tracesSampler }); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + }); + + expect(tracesSampler).toBeCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer', + attributes: {}, + inheritOrSampleWith: expect.any(Function), + }); + + // Now return `false`, it should not sample + tracesSamplerResponse = false; + + startSpan({ name: 'outer2' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + + startSpan({ name: 'inner2' }, innerSpan => { + expect(innerSpan.isRecording()).toBe(false); + }); + }); + + expect(tracesSampler).toHaveBeenCalledTimes(2); + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + parentSampled: undefined, + name: 'outer', + attributes: {}, + }), + ); + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + parentSampled: undefined, + name: 'outer2', + attributes: {}, + }), + ); + + // Only root spans should go through the sampler + expect(tracesSampler).not.toHaveBeenLastCalledWith({ + name: 'inner2', + }); + }); + + it('samples with a tracesSampler returning a number', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.6); + + let tracesSamplerResponse: number = 1; + + const tracesSampler = vi.fn(() => { + return tracesSamplerResponse; + }); + + mockSdkInit({ tracesSampler }); + + startSpan( + { + name: 'outer', + op: 'test.op', + attributes: { attr1: 'yes', attr2: 1 }, + }, + outerSpan => { + expect(outerSpan).toBeDefined(); + }, + ); + + expect(tracesSampler).toHaveBeenCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer', + attributes: { + attr1: 'yes', + attr2: 1, + 'sentry.op': 'test.op', + }, + inheritOrSampleWith: expect.any(Function), + }); + + // Now return `0`, it should not sample + tracesSamplerResponse = 0; + + startSpan({ name: 'outer2' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + + startSpan({ name: 'inner2' }, innerSpan => { + expect(innerSpan.isRecording()).toBe(false); + }); + }); + + expect(tracesSampler).toHaveBeenCalledTimes(2); + expect(tracesSampler).toHaveBeenCalledWith( + expect.objectContaining({ + parentSampled: undefined, + name: 'outer2', + attributes: {}, + }), + ); + + // Only root spans should be passed to tracesSampler + expect(tracesSampler).not.toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'inner2', + }), + ); + + // Now return `0.4`, it should not sample + tracesSamplerResponse = 0.4; + + startSpan({ name: 'outer3' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + }); + + expect(tracesSampler).toHaveBeenCalledTimes(3); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer3', + attributes: {}, + inheritOrSampleWith: expect.any(Function), + }); + }); + + it('samples with a tracesSampler even if parent is remotely sampled', () => { + const tracesSampler = vi.fn(() => { + return false; + }); + + mockSdkInit({ tracesSampler }); + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + const parentSpanId = '6e0c63257de34c92'; + + const spanContext = { + traceId, + spanId: parentSpanId, + sampled: true, + isRemote: true, + traceFlags: TraceFlags.SAMPLED, + }; + + // We simulate the correct context we'd normally get from the SentryPropagator + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + // This will def. be sampled because of the tracesSampleRate + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan.isRecording()).toBe(false); + }); + }); + + expect(tracesSampler).toBeCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: true, + name: 'outer', + attributes: {}, + inheritOrSampleWith: expect.any(Function), + }); + }); + + it('ignores parent span context if it is invalid', () => { + mockSdkInit({ tracesSampleRate: 1 }); + const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; + + const spanContext = { + traceId, + spanId: 'INVALID', + traceFlags: TraceFlags.SAMPLED, + }; + + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(span.spanContext().spanId).not.toBe('INVALID'); + expect(span.spanContext().spanId).toMatch(/[a-f0-9]{16}/); + expect(span.spanContext().traceId).not.toBe(traceId); + expect(span.spanContext().traceId).toMatch(/[a-f0-9]{32}/); + }); + }); + }); +}); + +describe('HTTP methods (sampling)', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('does sample when HTTP method is other than OPTIONS or HEAD', () => { + const spanGET = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'GET' } }, span => { + return span; + }); + expect(spanIsSampled(spanGET)).toBe(true); + expect(getSamplingDecision(spanGET.spanContext())).toBe(true); + + const spanPOST = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'POST' } }, span => { + return span; + }); + expect(spanIsSampled(spanPOST)).toBe(true); + expect(getSamplingDecision(spanPOST.spanContext())).toBe(true); + + const spanPUT = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'PUT' } }, span => { + return span; + }); + expect(spanIsSampled(spanPUT)).toBe(true); + expect(getSamplingDecision(spanPUT.spanContext())).toBe(true); + + const spanDELETE = startSpanManual( + { name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'DELETE' } }, + span => { + return span; + }, + ); + expect(spanIsSampled(spanDELETE)).toBe(true); + expect(getSamplingDecision(spanDELETE.spanContext())).toBe(true); + }); + + it('does not sample when HTTP method is OPTIONS', () => { + const span = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'OPTIONS' } }, span => { + return span; + }); + expect(spanIsSampled(span)).toBe(false); + expect(getSamplingDecision(span.spanContext())).toBe(false); + }); + + it('does not sample when HTTP method is HEAD', () => { + const span = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'HEAD' } }, span => { + return span; + }); + expect(spanIsSampled(span)).toBe(false); + expect(getSamplingDecision(span.spanContext())).toBe(false); + }); +}); + +describe('continueTrace', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('works without trace & baggage data', () => { + const scope = continueTrace({ sentryTrace: undefined, baggage: undefined }, () => { + const span = getActiveSpan()!; + expect(span).toBeUndefined(); + return getCurrentScope(); + }); + + expect(scope.getPropagationContext()).toEqual({ + traceId: expect.any(String), + sampleRand: expect.any(Number), + }); + + expect(scope.getScopeData().sdkProcessingMetadata).toEqual({}); + }); + + it('works with trace data', () => { + continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-0', + baggage: undefined, + }, + () => { + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span)).toEqual({ + span_id: '1121201211212012', + trace_id: '12312012123120121231201212312012', + data: {}, + start_timestamp: 0, + }); + expect(getSamplingDecision(span.spanContext())).toBe(false); + expect(spanIsSampled(span)).toBe(false); + }, + ); + }); + + it('works with trace & baggage data', () => { + continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-version=1.0,sentry-environment=production', + }, + () => { + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span)).toEqual({ + span_id: '1121201211212012', + trace_id: '12312012123120121231201212312012', + data: {}, + start_timestamp: 0, + }); + expect(getSamplingDecision(span.spanContext())).toBe(true); + expect(spanIsSampled(span)).toBe(true); + }, + ); + }); + + it('works with trace & 3rd party baggage data', () => { + continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-version=1.0,sentry-environment=production,dogs=great,cats=boring', + }, + () => { + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span)).toEqual({ + span_id: '1121201211212012', + trace_id: '12312012123120121231201212312012', + data: {}, + start_timestamp: 0, + }); + expect(getSamplingDecision(span.spanContext())).toBe(true); + expect(spanIsSampled(span)).toBe(true); + }, + ); + }); + + it('returns response of callback', () => { + const result = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-0', + baggage: undefined, + }, + () => { + return 'aha'; + }, + ); + + expect(result).toEqual('aha'); + }); +}); + +describe('suppressTracing', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('works for a root span', () => { + const span = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(span.isRecording()).toBe(false); + expect(spanIsSampled(span)).toBe(false); + }); + + it('works for a child span', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child1 = startInactiveSpan({ name: 'inner1' }); + + expect(child1.isRecording()).toBe(true); + expect(spanIsSampled(child1)).toBe(true); + + const child2 = suppressTracing(() => { + return startInactiveSpan({ name: 'span' }); + }); + + expect(child2.isRecording()).toBe(false); + expect(spanIsSampled(child2)).toBe(false); + }); + }); + + it('works for a child span with forceTransaction=true', () => { + startSpan({ name: 'outer' }, span => { + expect(span.isRecording()).toBe(true); + expect(spanIsSampled(span)).toBe(true); + + const child = suppressTracing(() => { + return startInactiveSpan({ name: 'span', forceTransaction: true }); + }); + + expect(child.isRecording()).toBe(false); + expect(spanIsSampled(child)).toBe(false); + }); + }); +}); + +function getSpanName(span: AbstractSpan): string | undefined { + return spanHasName(span) ? span.name : undefined; +} + +function getSpanEndTime(span: AbstractSpan): [number, number] | undefined { + return (span as ReadableSpan).endTime; +} + +function getSpanStartTime(span: AbstractSpan): [number, number] | undefined { + return (span as ReadableSpan).startTime; +} + +function getSpanAttributes(span: AbstractSpan): Record | undefined { + return spanHasAttributes(span) ? span.attributes : undefined; +} + +function getSpanParentSpanId(span: AbstractSpan): string | undefined { + return getParentSpanId(span as ReadableSpan); +} diff --git a/dev-packages/opentelemetry-v2-tests/test/tsconfig.json b/dev-packages/opentelemetry-v2-tests/test/tsconfig.json new file mode 100644 index 000000000000..38ca0b13bcdd --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts new file mode 100644 index 000000000000..c91e49ea5f84 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts @@ -0,0 +1,155 @@ +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { getRootSpan } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getActiveSpan } from '../../../../packages/opentelemetry/src/utils/getActiveSpan'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('getActiveSpan', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions()); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + it('returns undefined if no span is active', () => { + const span = getActiveSpan(); + expect(span).toBeUndefined(); + }); + + it('returns undefined if no provider is active', async () => { + await provider?.forceFlush(); + await provider?.shutdown(); + provider = undefined; + + const span = getActiveSpan(); + expect(span).toBeUndefined(); + }); + + it('returns currently active span', () => { + const tracer = trace.getTracer('test'); + + expect(getActiveSpan()).toBeUndefined(); + + tracer.startActiveSpan('test', span => { + expect(getActiveSpan()).toBe(span); + + const inner1 = tracer.startSpan('inner1'); + + expect(getActiveSpan()).toBe(span); + + inner1.end(); + + tracer.startActiveSpan('inner2', inner2 => { + expect(getActiveSpan()).toBe(inner2); + + inner2.end(); + }); + + expect(getActiveSpan()).toBe(span); + + span.end(); + }); + + expect(getActiveSpan()).toBeUndefined(); + }); + + it('returns currently active span in concurrent spans', () => { + const tracer = trace.getTracer('test'); + + expect(getActiveSpan()).toBeUndefined(); + + tracer.startActiveSpan('test1', span => { + expect(getActiveSpan()).toBe(span); + + tracer.startActiveSpan('inner1', inner1 => { + expect(getActiveSpan()).toBe(inner1); + inner1.end(); + }); + + span.end(); + }); + + tracer.startActiveSpan('test2', span => { + expect(getActiveSpan()).toBe(span); + + tracer.startActiveSpan('inner2', inner => { + expect(getActiveSpan()).toBe(inner); + inner.end(); + }); + + span.end(); + }); + + expect(getActiveSpan()).toBeUndefined(); + }); +}); + +describe('getRootSpan', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(async () => { + await provider?.forceFlush(); + await provider?.shutdown(); + }); + + it('returns currently active root span', () => { + const tracer = trace.getTracer('test'); + + tracer.startActiveSpan('test', span => { + expect(getRootSpan(span)).toBe(span); + + const inner1 = tracer.startSpan('inner1'); + + expect(getRootSpan(inner1)).toBe(span); + + inner1.end(); + + tracer.startActiveSpan('inner2', inner2 => { + expect(getRootSpan(inner2)).toBe(span); + + inner2.end(); + }); + + span.end(); + }); + }); + + it('returns currently active root span in concurrent spans', () => { + const tracer = trace.getTracer('test'); + + tracer.startActiveSpan('test1', span => { + expect(getRootSpan(span)).toBe(span); + + tracer.startActiveSpan('inner1', inner1 => { + expect(getRootSpan(inner1)).toBe(span); + inner1.end(); + }); + + span.end(); + }); + + tracer.startActiveSpan('test2', span => { + expect(getRootSpan(span)).toBe(span); + + tracer.startActiveSpan('inner2', inner => { + expect(getRootSpan(inner)).toBe(span); + inner.end(); + }); + + span.end(); + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts new file mode 100644 index 000000000000..3f0914b6afb7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts @@ -0,0 +1,80 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getRequestSpanData } from '../../../../packages/opentelemetry/src/utils/getRequestSpanData'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('getRequestSpanData', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + function createSpan(name: string): Span { + return trace.getTracer('test').startSpan(name); + } + + it('works with basic span', () => { + const span = createSpan('test-span'); + const data = getRequestSpanData(span); + + expect(data).toEqual({}); + }); + + it('works with http span', () => { + const span = createSpan('test-span'); + span.setAttributes({ + [SEMATTRS_HTTP_URL]: 'http://example.com?foo=bar#baz', + [SEMATTRS_HTTP_METHOD]: 'GET', + }); + + const data = getRequestSpanData(span); + + expect(data).toEqual({ + url: 'http://example.com', + 'http.method': 'GET', + 'http.query': '?foo=bar', + 'http.fragment': '#baz', + }); + }); + + it('works without method', () => { + const span = createSpan('test-span'); + span.setAttributes({ + [SEMATTRS_HTTP_URL]: 'http://example.com', + }); + + const data = getRequestSpanData(span); + + expect(data).toEqual({ + url: 'http://example.com', + 'http.method': 'GET', + }); + }); + + it('works with incorrect URL', () => { + const span = createSpan('test-span'); + span.setAttributes({ + [SEMATTRS_HTTP_URL]: 'malformed-url-here', + [SEMATTRS_HTTP_METHOD]: 'GET', + }); + + const data = getRequestSpanData(span); + + expect(data).toEqual({ + url: 'malformed-url-here', + 'http.method': 'GET', + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts new file mode 100644 index 000000000000..16dacdafe8ee --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts @@ -0,0 +1,11 @@ +import type { Span } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; +import { describe, expect, it } from 'vitest'; +import { getSpanKind } from '../../../../packages/opentelemetry/src/utils/getSpanKind'; + +describe('getSpanKind', () => { + it('works', () => { + expect(getSpanKind({} as Span)).toBe(SpanKind.INTERNAL); + expect(getSpanKind({ kind: SpanKind.CLIENT } as unknown as Span)).toBe(SpanKind.CLIENT); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts new file mode 100644 index 000000000000..136b6251523d --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts @@ -0,0 +1,94 @@ +import { context, trace } from '@opentelemetry/api'; +import { getCurrentScope, setAsyncContextStrategy } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTraceData } from '../../../../packages/opentelemetry/src/utils/getTraceData'; +import { makeTraceState } from '../../../../packages/opentelemetry/src/utils/makeTraceState'; +import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; + +describe('getTraceData', () => { + beforeEach(() => { + setAsyncContextStrategy(undefined); + mockSdkInit(); + }); + + afterEach(async () => { + await cleanupOtel(); + vi.clearAllMocks(); + }); + + it('returns the tracing data from the span, if a span is available', () => { + const ctx = trace.setSpanContext(context.active(), { + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: 1, + }); + + context.with(ctx, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + }); + }); + }); + + it('allows to pass a span directly', () => { + const ctx = trace.setSpanContext(context.active(), { + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: 1, + }); + + const span = trace.getSpan(ctx)!; + + const data = getTraceData({ span }); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + }); + }); + + it('returns propagationContext DSC data if no span is available', () => { + getCurrentScope().setPropagationContext({ + traceId: '12345678901234567890123456789012', + sampleRand: Math.random(), + sampled: true, + dsc: { + environment: 'staging', + public_key: 'key', + trace_id: '12345678901234567890123456789012', + }, + }); + + const traceData = getTraceData(); + + expect(traceData['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); + expect(traceData.baggage).toEqual( + 'sentry-environment=staging,sentry-public_key=key,sentry-trace_id=12345678901234567890123456789012', + ); + }); + + it('works with an span with frozen DSC in traceState', () => { + const ctx = trace.setSpanContext(context.active(), { + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + traceFlags: 1, + traceState: makeTraceState({ + dsc: { environment: 'test-dev', public_key: '456', trace_id: '12345678901234567890123456789088' }, + }), + }); + + context.with(ctx, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=test-dev,sentry-public_key=456,sentry-trace_id=12345678901234567890123456789088', + }); + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts new file mode 100644 index 000000000000..87d7daa4a43a --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts @@ -0,0 +1,174 @@ +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { withActiveSpan } from '../../../../packages/opentelemetry/src/trace'; +import { groupSpansWithParents } from '../../../../packages/opentelemetry/src/utils/groupSpansWithParents'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('groupSpansWithParents', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + it('works with no spans', () => { + const actual = groupSpansWithParents([]); + expect(actual).toEqual([]); + }); + + it('works with a single root span & in-order spans', () => { + const tracer = trace.getTracer('test'); + const rootSpan = tracer.startSpan('root') as unknown as ReadableSpan; + const parentSpan1 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent1') as unknown as ReadableSpan, + ); + const parentSpan2 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent2') as unknown as ReadableSpan, + ); + const child1 = withActiveSpan( + parentSpan1 as unknown as Span, + () => tracer.startSpan('child1') as unknown as ReadableSpan, + ); + + const actual = groupSpansWithParents([rootSpan, parentSpan1, parentSpan2, child1]); + expect(actual).toHaveLength(4); + + // Ensure parent & span is correctly set + const rootRef = actual.find(ref => ref.span === rootSpan); + const parent1Ref = actual.find(ref => ref.span === parentSpan1); + const parent2Ref = actual.find(ref => ref.span === parentSpan2); + const child1Ref = actual.find(ref => ref.span === child1); + + expect(rootRef).toBeDefined(); + expect(parent1Ref).toBeDefined(); + expect(parent2Ref).toBeDefined(); + expect(child1Ref).toBeDefined(); + + expect(rootRef?.parentNode).toBeUndefined(); + expect(rootRef?.children).toEqual([parent1Ref, parent2Ref]); + + expect(parent1Ref?.span).toBe(parentSpan1); + expect(parent2Ref?.span).toBe(parentSpan2); + + expect(parent1Ref?.parentNode).toBe(rootRef); + expect(parent2Ref?.parentNode).toBe(rootRef); + + expect(parent1Ref?.children).toEqual([child1Ref]); + expect(parent2Ref?.children).toEqual([]); + + expect(child1Ref?.parentNode).toBe(parent1Ref); + expect(child1Ref?.children).toEqual([]); + }); + + it('works with a spans with missing root span', () => { + const tracer = trace.getTracer('test'); + + // We create this root span here, but we do not pass it to `groupSpansWithParents` below + const rootSpan = tracer.startSpan('root') as unknown as ReadableSpan; + const parentSpan1 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent1') as unknown as ReadableSpan, + ); + const parentSpan2 = withActiveSpan( + rootSpan as unknown as Span, + () => tracer.startSpan('parent2') as unknown as ReadableSpan, + ); + const child1 = withActiveSpan( + parentSpan1 as unknown as Span, + () => tracer.startSpan('child1') as unknown as ReadableSpan, + ); + + const actual = groupSpansWithParents([parentSpan1, parentSpan2, child1]); + expect(actual).toHaveLength(4); + + // Ensure parent & span is correctly set + const rootRef = actual.find(ref => ref.id === rootSpan.spanContext().spanId); + const parent1Ref = actual.find(ref => ref.span === parentSpan1); + const parent2Ref = actual.find(ref => ref.span === parentSpan2); + const child1Ref = actual.find(ref => ref.span === child1); + + expect(rootRef).toBeDefined(); + expect(parent1Ref).toBeDefined(); + expect(parent2Ref).toBeDefined(); + expect(child1Ref).toBeDefined(); + + expect(rootRef?.parentNode).toBeUndefined(); + expect(rootRef?.span).toBeUndefined(); + expect(rootRef?.children).toEqual([parent1Ref, parent2Ref]); + + expect(parent1Ref?.span).toBe(parentSpan1); + expect(parent2Ref?.span).toBe(parentSpan2); + + expect(parent1Ref?.parentNode).toBe(rootRef); + expect(parent2Ref?.parentNode).toBe(rootRef); + + expect(parent1Ref?.children).toEqual([child1Ref]); + expect(parent2Ref?.children).toEqual([]); + + expect(child1Ref?.parentNode).toBe(parent1Ref); + expect(child1Ref?.children).toEqual([]); + }); + + it('works with multiple root spans & out-of-order spans', () => { + const tracer = trace.getTracer('test'); + const rootSpan1 = tracer.startSpan('root1') as unknown as ReadableSpan; + const rootSpan2 = tracer.startSpan('root2') as unknown as ReadableSpan; + const parentSpan1 = withActiveSpan( + rootSpan1 as unknown as Span, + () => tracer.startSpan('parent1') as unknown as ReadableSpan, + ); + const parentSpan2 = withActiveSpan( + rootSpan2 as unknown as Span, + () => tracer.startSpan('parent2') as unknown as ReadableSpan, + ); + const childSpan1 = withActiveSpan( + parentSpan1 as unknown as Span, + () => tracer.startSpan('child1') as unknown as ReadableSpan, + ); + + const actual = groupSpansWithParents([childSpan1, parentSpan1, parentSpan2, rootSpan2, rootSpan1]); + expect(actual).toHaveLength(5); + + // Ensure parent & span is correctly set + const root1Ref = actual.find(ref => ref.span === rootSpan1); + const root2Ref = actual.find(ref => ref.span === rootSpan2); + const parent1Ref = actual.find(ref => ref.span === parentSpan1); + const parent2Ref = actual.find(ref => ref.span === parentSpan2); + const child1Ref = actual.find(ref => ref.span === childSpan1); + + expect(root1Ref).toBeDefined(); + expect(root2Ref).toBeDefined(); + expect(parent1Ref).toBeDefined(); + expect(parent2Ref).toBeDefined(); + expect(child1Ref).toBeDefined(); + + expect(root1Ref?.parentNode).toBeUndefined(); + expect(root1Ref?.children).toEqual([parent1Ref]); + + expect(root2Ref?.parentNode).toBeUndefined(); + expect(root2Ref?.children).toEqual([parent2Ref]); + + expect(parent1Ref?.span).toBe(parentSpan1); + expect(parent2Ref?.span).toBe(parentSpan2); + + expect(parent1Ref?.parentNode).toBe(root1Ref); + expect(parent2Ref?.parentNode).toBe(root2Ref); + + expect(parent1Ref?.children).toEqual([child1Ref]); + expect(parent2Ref?.children).toEqual([]); + + expect(child1Ref?.parentNode).toBe(parent1Ref); + expect(child1Ref?.children).toEqual([]); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts new file mode 100644 index 000000000000..b479da0d61ad --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts @@ -0,0 +1,130 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_RPC_GRPC_STATUS_CODE } from '@opentelemetry/semantic-conventions'; +import type { SpanStatus } from '@sentry/core'; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mapStatus } from '../../../../packages/opentelemetry/src/utils/mapStatus'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('mapStatus', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + function createSpan(name: string): Span { + return trace.getTracer('test').startSpan(name); + } + + const statusTestTable: [undefined | number | string, undefined | string, SpanStatus][] = [ + // http codes + [400, undefined, { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], + [401, undefined, { code: SPAN_STATUS_ERROR, message: 'unauthenticated' }], + [403, undefined, { code: SPAN_STATUS_ERROR, message: 'permission_denied' }], + [404, undefined, { code: SPAN_STATUS_ERROR, message: 'not_found' }], + [409, undefined, { code: SPAN_STATUS_ERROR, message: 'already_exists' }], + [429, undefined, { code: SPAN_STATUS_ERROR, message: 'resource_exhausted' }], + [499, undefined, { code: SPAN_STATUS_ERROR, message: 'cancelled' }], + [500, undefined, { code: SPAN_STATUS_ERROR, message: 'internal_error' }], + [501, undefined, { code: SPAN_STATUS_ERROR, message: 'unimplemented' }], + [503, undefined, { code: SPAN_STATUS_ERROR, message: 'unavailable' }], + [504, undefined, { code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }], + [999, undefined, { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], + + // grpc codes + [undefined, '1', { code: SPAN_STATUS_ERROR, message: 'cancelled' }], + [undefined, '2', { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], + [undefined, '3', { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], + [undefined, '4', { code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }], + [undefined, '5', { code: SPAN_STATUS_ERROR, message: 'not_found' }], + [undefined, '6', { code: SPAN_STATUS_ERROR, message: 'already_exists' }], + [undefined, '7', { code: SPAN_STATUS_ERROR, message: 'permission_denied' }], + [undefined, '8', { code: SPAN_STATUS_ERROR, message: 'resource_exhausted' }], + [undefined, '9', { code: SPAN_STATUS_ERROR, message: 'failed_precondition' }], + [undefined, '10', { code: SPAN_STATUS_ERROR, message: 'aborted' }], + [undefined, '11', { code: SPAN_STATUS_ERROR, message: 'out_of_range' }], + [undefined, '12', { code: SPAN_STATUS_ERROR, message: 'unimplemented' }], + [undefined, '13', { code: SPAN_STATUS_ERROR, message: 'internal_error' }], + [undefined, '14', { code: SPAN_STATUS_ERROR, message: 'unavailable' }], + [undefined, '15', { code: SPAN_STATUS_ERROR, message: 'data_loss' }], + [undefined, '16', { code: SPAN_STATUS_ERROR, message: 'unauthenticated' }], + [undefined, '999', { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], + + // http takes precedence over grpc + [400, '2', { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], + ]; + + it.each(statusTestTable)('works with httpCode=%s, grpcCode=%s', (httpCode, grpcCode, expected) => { + const span = createSpan('test-span'); + span.setStatus({ code: 0 }); // UNSET + + if (httpCode) { + span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, httpCode); + } + + if (grpcCode) { + span.setAttribute(SEMATTRS_RPC_GRPC_STATUS_CODE, grpcCode); + } + + const actual = mapStatus(span); + expect(actual).toEqual(expected); + }); + + it('works with string SEMATTRS_HTTP_STATUS_CODE', () => { + const span = createSpan('test-span'); + + span.setStatus({ code: 0 }); // UNSET + span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, '400'); + + const actual = mapStatus(span); + expect(actual).toEqual({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); + }); + + it('returns ok span status when is UNSET present on span', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 0 }); // UNSET + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_OK }); + }); + + it('returns ok span status when already present on span', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 1 }); // OK + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_OK }); + }); + + it('returns error status when span already has error status', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 2, message: 'invalid_argument' }); // ERROR + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); + }); + + it('returns error status when span already has error status without message', () => { + const span = createSpan('test-span'); + span.setStatus({ code: 2 }); // ERROR + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'unknown_error' }); + }); + + it('infers error status form attributes when span already has error status without message', () => { + const span = createSpan('test-span'); + span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, 500); + span.setStatus({ code: 2 }); // ERROR + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + }); + + it('returns unknown error status when code is unknown', () => { + const span = createSpan('test-span'); + span.setStatus({ code: -1 as 0 }); + expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'unknown_error' }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts new file mode 100644 index 000000000000..56d50a3b2fbc --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts @@ -0,0 +1,690 @@ +/* eslint-disable deprecation/deprecation */ +import type { Span } from '@opentelemetry/api'; +import { SpanKind } from '@opentelemetry/api'; +import { + ATTR_HTTP_ROUTE, + SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_SYSTEM, + SEMATTRS_FAAS_TRIGGER, + SEMATTRS_HTTP_HOST, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_STATUS_CODE, + SEMATTRS_HTTP_TARGET, + SEMATTRS_HTTP_URL, + SEMATTRS_MESSAGING_SYSTEM, + SEMATTRS_RPC_SERVICE, +} from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { + descriptionForHttpMethod, + getSanitizedUrl, + getUserUpdatedNameAndSource, + parseSpanDescription, +} from '../../../../packages/opentelemetry/src/utils/parseSpanDescription'; + +describe('parseSpanDescription', () => { + it.each([ + [ + 'works without attributes & name', + undefined, + undefined, + undefined, + { + description: '', + op: undefined, + source: 'custom', + }, + ], + [ + 'works with empty attributes', + {}, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: undefined, + source: 'custom', + }, + ], + [ + 'works with deprecated http method', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'http.client', + source: 'custom', + }, + ], + [ + 'works with http method', + { + 'http.request.method': 'GET', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'http.client', + source: 'custom', + }, + ], + [ + 'works with db system', + { + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'SELECT * from users', + op: 'db', + 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', + { + [SEMATTRS_DB_SYSTEM]: 'mysql', + }, + 'test name', + SpanKind.CLIENT, + { + description: 'test name', + op: 'db', + source: 'task', + }, + ], + [ + 'works with rpc service', + { + [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'rpc', + 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', + { + [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'message', + 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', + { + [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', + }, + 'test name', + undefined, + { + description: 'test name', + op: 'test-faas-trigger', + 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); + }); +}); + +describe('descriptionForHttpMethod', () => { + it.each([ + [ + 'works without attributes', + 'GET', + {}, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'test name', + source: 'custom', + }, + ], + [ + 'works with basic client GET', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'GET https://www.example.com/my-path', + data: { + url: 'https://www.example.com/my-path', + }, + 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', + { + [SEMATTRS_HTTP_METHOD]: 'POST', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + }, + 'test name', + SpanKind.SERVER, + { + op: 'http.server', + description: 'POST /my-path', + data: { + url: 'https://www.example.com/my-path', + }, + source: 'url', + }, + ], + [ + 'works with client GET with route', + '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', + }, + 'test name', + SpanKind.CLIENT, + { + op: 'http.client', + description: 'GET /my-path/:id', + data: { + url: 'https://www.example.com/my-path/123', + }, + source: 'route', + }, + ], + [ + 'works with basic client GET with SpanKind.INTERNAL', + 'GET', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', + [SEMATTRS_HTTP_TARGET]: '/my-path', + }, + 'test name', + SpanKind.INTERNAL, + { + op: 'http', + description: 'test name', + data: { + url: 'https://www.example.com/my-path', + }, + 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); + }); +}); + +describe('getSanitizedUrl', () => { + it.each([ + [ + 'works without attributes', + {}, + SpanKind.CLIENT, + { + urlPath: undefined, + url: undefined, + fragment: undefined, + query: undefined, + hasRoute: false, + }, + ], + [ + 'uses url without query for client request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: 'http://example.com/', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: false, + }, + ], + [ + 'uses url without hash for client request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/sub#hash', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/sub#hash', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: 'http://example.com/sub', + url: 'http://example.com/sub', + fragment: '#hash', + query: undefined, + hasRoute: false, + }, + ], + [ + 'uses route if available for client request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [ATTR_HTTP_ROUTE]: '/my-route', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: '/my-route', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: true, + }, + ], + [ + 'falls back to target for client request if url not available', + { + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.CLIENT, + { + urlPath: '/', + url: undefined, + fragment: undefined, + query: undefined, + hasRoute: false, + }, + ], + [ + 'uses target without query for server request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.SERVER, + { + urlPath: '/', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: false, + }, + ], + [ + 'uses target without hash for server request', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/sub#hash', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.SERVER, + { + urlPath: '/sub', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: false, + }, + ], + [ + 'uses route for server request if available', + { + [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', + [SEMATTRS_HTTP_METHOD]: 'GET', + [SEMATTRS_HTTP_TARGET]: '/?what=true', + [ATTR_HTTP_ROUTE]: '/my-route', + [SEMATTRS_HTTP_HOST]: 'example.com:80', + [SEMATTRS_HTTP_STATUS_CODE]: 200, + }, + SpanKind.SERVER, + { + urlPath: '/my-route', + url: 'http://example.com/', + fragment: undefined, + query: '?what=true', + hasRoute: true, + }, + ], + ])('%s', (_, attributes, kind, expected) => { + const actual = getSanitizedUrl(attributes, kind); + + 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/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts new file mode 100644 index 000000000000..8f453bb9792c --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts @@ -0,0 +1,44 @@ +import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SentrySampler } from '../../../../packages/opentelemetry/src/sampler'; +import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; +import { openTelemetrySetupCheck } from '../../../../packages/opentelemetry/src/utils/setupCheck'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('openTelemetrySetupCheck', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + cleanupOtel(provider); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + it('returns empty array by default', () => { + const setup = openTelemetrySetupCheck(); + expect(setup).toEqual([]); + }); + + it('returns all setup parts', () => { + const client = new TestClient(getDefaultTestClientOptions()); + [provider] = setupOtel(client); + + const setup = openTelemetrySetupCheck(); + expect(setup).toEqual(['SentrySpanProcessor', 'SentrySampler', 'SentryPropagator', 'SentryContextManager']); + }); + + it('returns partial setup parts', () => { + const client = new TestClient(getDefaultTestClientOptions()); + provider = new BasicTracerProvider({ + sampler: new SentrySampler(client), + spanProcessors: [new SentrySpanProcessor()], + }); + + const setup = openTelemetrySetupCheck(); + expect(setup).toEqual(['SentrySampler', 'SentrySpanProcessor']); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts new file mode 100644 index 000000000000..fbf6e1b69991 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts @@ -0,0 +1,108 @@ +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { captureException, setCurrentClient } from '@sentry/core'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupEventContextTrace } from '../../../../packages/opentelemetry/src/setupEventContextTrace'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import type { TestClientInterface } from '../helpers/TestClient'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +describe('setupEventContextTrace', () => { + const beforeSend = vi.fn(() => null); + let client: TestClientInterface; + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + sampleRate: 1, + tracesSampleRate: 1, + beforeSend, + debug: true, + dsn: PUBLIC_DSN, + }), + ); + + setCurrentClient(client); + client.init(); + + setupEventContextTrace(client); + [provider] = setupOtel(client); + }); + + afterEach(() => { + beforeSend.mockReset(); + cleanupOtel(provider); + }); + + afterAll(() => { + vi.clearAllMocks(); + }); + + it('works with no active span', async () => { + const error = new Error('test'); + captureException(error); + await client.flush(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }), + }), + expect.objectContaining({ + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }), + ); + }); + + it('works with active span', async () => { + const error = new Error('test'); + + let outerId: string | undefined; + let innerId: string | undefined; + let traceId: string | undefined; + + client.tracer.startActiveSpan('outer', outerSpan => { + outerId = outerSpan.spanContext().spanId; + traceId = outerSpan.spanContext().traceId; + + client.tracer.startActiveSpan('inner', innerSpan => { + innerId = innerSpan.spanContext().spanId; + captureException(error); + }); + }); + + await client.flush(); + + expect(outerId).toBeDefined(); + expect(innerId).toBeDefined(); + expect(traceId).toBeDefined(); + + expect(beforeSend).toHaveBeenCalledTimes(1); + expect(beforeSend).toHaveBeenCalledWith( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: innerId, + parent_span_id: outerId, + trace_id: traceId, + }, + }), + }), + expect.objectContaining({ + event_id: expect.any(String), + originalException: error, + syntheticException: expect.any(Error), + }), + ); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts new file mode 100644 index 000000000000..c1f9fe2a18c7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts @@ -0,0 +1,78 @@ +import type { Span, SpanOptions } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + spanToJSON, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setupOtel } from '../helpers/initOtel'; +import { cleanupOtel } from '../helpers/mockSdkInit'; +import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; + +describe('spanToJSON', () => { + describe('OpenTelemetry Span', () => { + let provider: BasicTracerProvider | undefined; + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + [provider] = setupOtel(client); + }); + + afterEach(() => { + cleanupOtel(provider); + }); + + function createSpan(name: string, params?: SpanOptions): Span { + return trace.getTracer('test').startSpan(name, params); + } + + it('works with a simple span', () => { + const span = createSpan('test span', { startTime: [123, 0] }); + + expect(spanToJSON(span)).toEqual({ + span_id: span.spanContext().spanId, + trace_id: span.spanContext().traceId, + start_timestamp: 123, + description: 'test span', + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + }); + }); + + it('works with a full span', () => { + const span = createSpan('test span', { startTime: [123, 0] }); + + span.setAttributes({ + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + }); + + span.setStatus({ code: 2, message: 'unknown_error' }); + span.end([456, 0]); + + expect(spanToJSON(span)).toEqual({ + span_id: span.spanContext().spanId, + trace_id: span.spanContext().traceId, + start_timestamp: 123, + timestamp: 456, + description: 'test span', + op: 'test op', + origin: 'auto', + data: { + attr1: 'value1', + attr2: 2, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + }, + status: 'unknown_error', + }); + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts new file mode 100644 index 000000000000..00c9eccdf98e --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts @@ -0,0 +1,80 @@ +import type { Span } from '@opentelemetry/api'; +import { describe, expect, it } from 'vitest'; +import { + spanHasAttributes, + spanHasEvents, + spanHasKind, + spanHasParentId, +} from '../../../../packages/opentelemetry/src/utils/spanTypes'; + +describe('spanTypes', () => { + describe('spanHasAttributes', () => { + it.each([ + [{}, false], + [{ attributes: null }, false], + [{ attributes: {} }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasAttributes(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.attributes).toBeDefined(); + } + }); + }); + + describe('spanHasKind', () => { + it.each([ + [{}, false], + [{ kind: null }, false], + [{ 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); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.kind).toBeDefined(); + } + }); + }); + + describe('spanHasParentId', () => { + it.each([ + [{}, false], + [{ parentSpanId: null }, false], + [{ parentSpanId: 'TEST_PARENT_ID' }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasParentId(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.parentSpanId).toBeDefined(); + } + }); + }); + + describe('spanHasEvents', () => { + it.each([ + [{}, false], + [{ events: null }, false], + [{ events: [] }, true], + ])('works with %p', (span, expected) => { + const castSpan = span as unknown as Span; + const actual = spanHasEvents(castSpan); + + expect(actual).toBe(expected); + + if (actual) { + expect(castSpan.events).toBeDefined(); + } + }); + }); +}); diff --git a/dev-packages/opentelemetry-v2-tests/tsconfig.json b/dev-packages/opentelemetry-v2-tests/tsconfig.json new file mode 100644 index 000000000000..b9f9b425c7df --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./build", + "types": ["node", "vitest/globals"] + }, + "include": ["test/**/*", "vite.config.ts"] +} diff --git a/dev-packages/opentelemetry-v2-tests/tsconfig.test.json b/dev-packages/opentelemetry-v2-tests/tsconfig.test.json new file mode 100644 index 000000000000..ca7dbeb3be94 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "vite.config.ts"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node"] + + // other package-specific, test-specific options + } +} diff --git a/dev-packages/opentelemetry-v2-tests/vite.config.ts b/dev-packages/opentelemetry-v2-tests/vite.config.ts new file mode 100644 index 000000000000..d7ea407dfac7 --- /dev/null +++ b/dev-packages/opentelemetry-v2-tests/vite.config.ts @@ -0,0 +1,11 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + coverage: { + enabled: false, + }, + }, +}; diff --git a/package.json b/package.json index 6bdca4b79365..13e1a600e83d 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,8 @@ "dev-packages/size-limit-gh-action", "dev-packages/clear-cache-gh-action", "dev-packages/external-contributor-gh-action", - "dev-packages/rollup-utils" + "dev-packages/rollup-utils", + "dev-packages/opentelemetry-v2-tests" ], "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index ffdce534792e..8018a62a20d4 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -139,7 +139,18 @@ export function spanToJSON(span: Span): SpanJSON { // Handle a span from @opentelemetry/sdk-base-trace's `Span` class if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { - const { attributes, startTime, name, endTime, parentSpanId, status, links } = span; + const { attributes, startTime, name, endTime, status, links } = span; + + // In preparation for the next major of OpenTelemetry, we want to support + // looking up the parent span id according to the new API + // In OTel v1, the parent span id is accessed as `parentSpanId` + // In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + const parentSpanId = + 'parentSpanId' in span + ? span.parentSpanId + : 'parentSpanContext' in span + ? (span.parentSpanContext as { spanId?: string } | undefined)?.spanId + : undefined; return { span_id, diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index ee1387b8bf82..3ad6aac7e027 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -43,11 +43,11 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", - "@opentelemetry/semantic-conventions": "^1.28.0" + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", + "@opentelemetry/core": "^1.30.1 || ^2.0.0", + "@opentelemetry/instrumentation": "^0.57.1 || ^0.200.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", + "@opentelemetry/semantic-conventions": "^1.30.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/opentelemetry/src/custom/client.ts b/packages/opentelemetry/src/custom/client.ts index 70afb6f10752..a1f0e4792048 100644 --- a/packages/opentelemetry/src/custom/client.ts +++ b/packages/opentelemetry/src/custom/client.ts @@ -49,12 +49,7 @@ export function wrapClientClass< */ public async flush(timeout?: number): Promise { const provider = this.traceProvider; - const spanProcessor = provider?.activeSpanProcessor; - - if (spanProcessor) { - await spanProcessor.forceFlush(); - } - + await provider?.forceFlush(); return super.flush(timeout); } } diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ca7d2823feee..f9c403a47dfc 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -28,6 +28,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from './semanticAttributes'; +import { getParentSpanId } from './utils/getParentSpanId'; import { getRequestSpanData } from './utils/getRequestSpanData'; import type { SpanNode } from './utils/groupSpansWithParents'; import { getLocalParentId, groupSpansWithParents } from './utils/groupSpansWithParents'; @@ -255,7 +256,7 @@ export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEve // even if `span.parentSpanId` is set // this is the case when we are starting a new trace, where we have a virtual span based on the propagationContext // We only want to continue the traceId in this case, but ignore the parent span - const parent_span_id = span.parentSpanId; + const parent_span_id = getParentSpanId(span); const status = mapStatus(span); @@ -321,8 +322,9 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS const span_id = span.spanContext().spanId; const trace_id = span.spanContext().traceId; + const parentSpanId = getParentSpanId(span); - const { attributes, startTime, endTime, parentSpanId, links } = span; + const { attributes, startTime, endTime, links } = span; const { op, description, data, origin = 'manual' } = getSpanData(span); const allData = { diff --git a/packages/opentelemetry/src/utils/getParentSpanId.ts b/packages/opentelemetry/src/utils/getParentSpanId.ts new file mode 100644 index 000000000000..63f4ab0b80f4 --- /dev/null +++ b/packages/opentelemetry/src/utils/getParentSpanId.ts @@ -0,0 +1,16 @@ +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; + +/** + * Get the parent span id from a span. + * In OTel v1, the parent span id is accessed as `parentSpanId` + * In OTel v2, the parent span id is accessed as `spanId` on the `parentSpanContext` + */ +export function getParentSpanId(span: ReadableSpan): string | undefined { + if ('parentSpanId' in span) { + return span.parentSpanId as string | undefined; + } else if ('parentSpanContext' in span) { + return (span.parentSpanContext as { spanId?: string } | undefined)?.spanId; + } + + return undefined; +} diff --git a/packages/opentelemetry/src/utils/groupSpansWithParents.ts b/packages/opentelemetry/src/utils/groupSpansWithParents.ts index ddc779e9f760..fcbb635d4b2b 100644 --- a/packages/opentelemetry/src/utils/groupSpansWithParents.ts +++ b/packages/opentelemetry/src/utils/groupSpansWithParents.ts @@ -1,5 +1,6 @@ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE } from '../semanticAttributes'; +import { getParentSpanId } from './getParentSpanId'; export interface SpanNode { id: string; @@ -33,7 +34,7 @@ export function getLocalParentId(span: ReadableSpan): string | undefined { const parentIsRemote = span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE] === true; // If the parentId is the trace parent ID, we pretend it's undefined // As this means the parent exists somewhere else - return !parentIsRemote ? span.parentSpanId : undefined; + return !parentIsRemote ? getParentSpanId(span) : undefined; } function createOrUpdateSpanNodeAndRefs(nodeMap: SpanMap, span: ReadableSpan): void { diff --git a/packages/opentelemetry/src/utils/spanTypes.ts b/packages/opentelemetry/src/utils/spanTypes.ts index 2009692177ac..15b7a5a987ad 100644 --- a/packages/opentelemetry/src/utils/spanTypes.ts +++ b/packages/opentelemetry/src/utils/spanTypes.ts @@ -1,6 +1,7 @@ import type { SpanKind, SpanStatus } from '@opentelemetry/api'; import type { ReadableSpan, TimedEvent } from '@opentelemetry/sdk-trace-base'; import type { AbstractSpan } from '../types'; +import { getParentSpanId } from './getParentSpanId'; /** * Check if a given span has attributes. @@ -55,7 +56,7 @@ export function spanHasParentId( span: SpanType, ): span is SpanType & { parentSpanId: string } { const castSpan = span as ReadableSpan; - return !!castSpan.parentSpanId; + return !!getParentSpanId(castSpan); } /** diff --git a/yarn.lock b/yarn.lock index 4aa628eb3130..1acd6df25742 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3912,14 +3912,14 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== -"@fastify/otel@git+https://github.com/getsentry/fastify-otel.git#otel-v1": +"@fastify/otel@getsentry/fastify-otel#otel-v1": version "0.8.0" - resolved "git+https://github.com/getsentry/fastify-otel.git#39826f0b6bb23e82fc83819d96c5440a504ab5bc" + resolved "https://codeload.github.com/getsentry/fastify-otel/tar.gz/d6bb1756c3db3d00d4d82c39c93ee3316e06d305" dependencies: "@opentelemetry/core" "^1.30.1" "@opentelemetry/instrumentation" "^0.57.2" "@opentelemetry/semantic-conventions" "^1.28.0" - minimatch "^10.0.1" + minimatch "^9" "@gar/promisify@^1.1.3": version "1.1.3" @@ -5420,6 +5420,13 @@ dependencies: "@octokit/openapi-types" "^18.0.0" +"@opentelemetry/api-logs@0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz#f9015fd844920c13968715b3cdccf5a4d4ff907e" + integrity sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q== + dependencies: + "@opentelemetry/api" "^1.3.0" + "@opentelemetry/api-logs@0.52.1": version "0.52.1" resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz#52906375da4d64c206b0c4cb8ffa209214654ecc" @@ -5444,6 +5451,11 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz#4f76280691a742597fd0bf682982126857622948" integrity sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA== +"@opentelemetry/context-async-hooks@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.0.tgz#c98a727238ca199cda943780acf6124af8d8cd80" + integrity sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA== + "@opentelemetry/core@1.30.1", "@opentelemetry/core@^1.1.0", "@opentelemetry/core@^1.26.0", "@opentelemetry/core@^1.30.1", "@opentelemetry/core@^1.8.0": version "1.30.1" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.30.1.tgz#a0b468bb396358df801881709ea38299fc30ab27" @@ -5451,6 +5463,13 @@ dependencies: "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/core@2.0.0", "@opentelemetry/core@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.0.0.tgz#37e9f0e9ddec4479b267aca6f32d88757c941b3a" + integrity sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/instrumentation-amqplib@^0.46.1": version "0.46.1" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz#7101678488d0e942162ca85c9ac6e93e1f3e0008" @@ -5681,6 +5700,17 @@ semver "^7.5.2" shimmer "^1.2.1" +"@opentelemetry/instrumentation@^0.200.0": + version "0.200.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.200.0.tgz#29d1d4f70cbf0cb1ca9f2f78966379b0be96bddc" + integrity sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg== + dependencies: + "@opentelemetry/api-logs" "0.200.0" + "@types/shimmer" "^1.2.0" + import-in-the-middle "^1.8.1" + require-in-the-middle "^7.1.1" + shimmer "^1.2.1" + "@opentelemetry/instrumentation@^0.52.1": version "0.52.1" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz#2e7e46a38bd7afbf03cf688c862b0b43418b7f48" @@ -5711,6 +5741,14 @@ "@opentelemetry/core" "1.30.1" "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/resources@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.0.0.tgz#15c04794c32b7d0b3c7589225ece6ae9bba25989" + integrity sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/sdk-trace-base@^1.30.1": version "1.30.1" resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz#41a42234096dc98e8f454d24551fc80b816feb34" @@ -5720,15 +5758,24 @@ "@opentelemetry/resources" "1.30.1" "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/sdk-trace-base@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.0.tgz#ebc06ea7537dea62f3882f8236c1234f4faf6b23" + integrity sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw== + dependencies: + "@opentelemetry/core" "2.0.0" + "@opentelemetry/resources" "2.0.0" + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/semantic-conventions@1.28.0": version "1.28.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6" integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== -"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.30.0": - version "1.32.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.32.0.tgz#a15e8f78f32388a7e4655e7f539570e40958ca3f" - integrity sha512-s0OpmpQFSfMrmedAn9Lhg4KWJELHCU6uU9dtIJ28N8UGhf9Y55im5X8fEzwhwDwiSqN+ZPSNrDJF7ivf/AuRPQ== +"@opentelemetry/semantic-conventions@^1.25.1", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0": + version "1.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.33.0.tgz#ec8ebd2ac768ab366aff94e0e7f27e8ae24fa49f" + integrity sha512-TIpZvE8fiEILFfTlfPnltpBaD3d9/+uQHVCyC3vfdh6WfCXKhNFzoP5RyDDIndfvZC5GrA4pyEDNyjPloJud+w== "@opentelemetry/sql-common@^0.40.1": version "0.40.1" @@ -20807,7 +20854,7 @@ minimatch@5.1.0, minimatch@^5.0.1, minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimatch@^10.0.0, minimatch@^10.0.1: +minimatch@^10.0.0: version "10.0.1" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== @@ -20828,7 +20875,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.0, minimatch@^9.0.4: +minimatch@^9, minimatch@^9.0.0, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== From a874d763171289b5556973a4842f580d4bbb7ec0 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 12 May 2025 08:56:35 -0400 Subject: [PATCH 23/28] feat(cloudflare): Improve http span data (#16232) While helping debug work in https://github.com/getsentry/sentry-mcp, I noticed that we didn't attach `url.path` to the http fetch spans, which made using the trace explorer harder to use. This PR updates the cloudflare http instrumentation (fetch handlers for regular workers and durable objects) to use a new `getHttpSpanDetailsFromUrlObject` abstraction I built. `getHttpSpanDetailsFromUrlObject` returns the http span name and it's associated attributes, taking care to handle source, route names, and handling both relative and full URLs. This is related to all the https://github.com/getsentry/sentry-javascript/issues/15767 work I've been doing. --- packages/cloudflare/src/request.ts | 31 +- packages/cloudflare/test/request.test.ts | 7 +- packages/core/src/index.ts | 1 + packages/core/src/utils-hoist/url.ts | 99 +++++- packages/core/test/utils-hoist/url.test.ts | 384 +++++++++++++++++++-- 5 files changed, 474 insertions(+), 48 deletions(-) diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 8e2f3de06df0..f1905609fb94 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -1,17 +1,13 @@ import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; -import type { SpanAttributes } from '@sentry/core'; import { captureException, continueTrace, flush, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, + getHttpSpanDetailsFromUrlObject, + parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SEMANTIC_ATTRIBUTE_URL_FULL, setHttpStatus, startSpan, - stripUrlQueryAndFragment, withIsolationScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; @@ -42,28 +38,15 @@ export function wrapRequestHandler( const client = init(options); isolationScope.setClient(client); - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method, - [SEMANTIC_ATTRIBUTE_URL_FULL]: request.url, - }; + const urlObject = parseStringToURLObject(request.url); + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'auto.http.cloudflare', request); const contentLength = request.headers.get('content-length'); if (contentLength) { attributes['http.request.body.size'] = parseInt(contentLength, 10); } - let pathname = ''; - try { - const url = new URL(request.url); - pathname = url.pathname; - attributes['server.address'] = url.hostname; - attributes['url.scheme'] = url.protocol.replace(':', ''); - } catch { - // skip - } + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; addCloudResourceContext(isolationScope); if (request) { @@ -74,8 +57,6 @@ export function wrapRequestHandler( } } - const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`; - // Do not capture spans for OPTIONS and HEAD requests if (request.method === 'OPTIONS' || request.method === 'HEAD') { try { @@ -96,7 +77,7 @@ export function wrapRequestHandler( // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ return startSpan( { - name: routeName, + name, attributes, }, async span => { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a778b60befeb..4fc9b308ec54 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -254,12 +254,13 @@ describe('withSentry', () => { data: { 'sentry.origin': 'auto.http.cloudflare', 'sentry.op': 'http.server', - 'sentry.source': 'url', + 'sentry.source': 'route', 'http.request.method': 'GET', 'url.full': 'https://example.com/', 'server.address': 'example.com', 'network.protocol.name': 'HTTP/1.1', - 'url.scheme': 'https', + 'url.scheme': 'https:', + 'url.path': '/', 'sentry.sample_rate': 1, 'http.response.status_code': 200, 'http.request.body.size': 10, @@ -269,6 +270,8 @@ describe('withSentry', () => { span_id: expect.stringMatching(/[a-f0-9]{16}/), status: 'ok', trace_id: expect.stringMatching(/[a-f0-9]{32}/), + parent_span_id: undefined, + links: undefined, }); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c35ea212b94..6d281fde0ac9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -255,6 +255,7 @@ export { parseUrl, stripUrlQueryAndFragment, parseStringToURLObject, + getHttpSpanDetailsFromUrlObject, isURLObjectRelative, getSanitizedUrlStringFromUrlObject, } from './utils-hoist/url'; diff --git a/packages/core/src/utils-hoist/url.ts b/packages/core/src/utils-hoist/url.ts index 7a7893a36b68..ca09e6e8b5e7 100644 --- a/packages/core/src/utils-hoist/url.ts +++ b/packages/core/src/utils-hoist/url.ts @@ -1,3 +1,11 @@ +import { + SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_URL_FULL, +} from '../semanticAttributes'; +import type { SpanAttributes } from '../types-hoist/span'; + type PartialURL = { host?: string; path?: string; @@ -53,7 +61,7 @@ export function isURLObjectRelative(url: URLObject): url is RelativeURL { * @returns The parsed URL object or undefined if the URL is invalid */ export function parseStringToURLObject(url: string, urlBase?: string | URL | undefined): URLObject | undefined { - const isRelative = url.startsWith('/'); + const isRelative = url.indexOf('://') <= 0 && url.indexOf('//') !== 0; const base = urlBase ?? (isRelative ? DEFAULT_BASE_URL : undefined); try { // Use `canParse` to short-circuit the URL constructor if it's not a valid URL @@ -107,6 +115,95 @@ export function getSanitizedUrlStringFromUrlObject(url: URLObject): string { return newUrl.toString(); } +type PartialRequest = { + method?: string; +}; + +function getHttpSpanNameFromUrlObject( + urlObject: URLObject | undefined, + kind: 'server' | 'client', + request?: PartialRequest, + routeName?: string, +): string { + const method = request?.method?.toUpperCase() ?? 'GET'; + const route = routeName + ? routeName + : urlObject + ? kind === 'client' + ? getSanitizedUrlStringFromUrlObject(urlObject) + : urlObject.pathname + : '/'; + + return `${method} ${route}`; +} + +/** + * Takes a parsed URL object and returns a set of attributes for the span + * that represents the HTTP request for that url. This is used for both server + * and client http spans. + * + * Follows https://opentelemetry.io/docs/specs/semconv/http/. + * + * @param urlObject - see {@link parseStringToURLObject} + * @param kind - The type of HTTP operation (server or client) + * @param spanOrigin - The origin of the span + * @param request - The request object, see {@link PartialRequest} + * @param routeName - The name of the route, must be low cardinality + * @returns The span name and attributes for the HTTP operation + */ +export function getHttpSpanDetailsFromUrlObject( + urlObject: URLObject | undefined, + kind: 'server' | 'client', + spanOrigin: string, + request?: PartialRequest, + routeName?: string, +): [name: string, attributes: SpanAttributes] { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }; + + if (routeName) { + // This is based on https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name + attributes[kind === 'server' ? 'http.route' : 'url.template'] = routeName; + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + } + + if (request?.method) { + attributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] = request.method.toUpperCase(); + } + + if (urlObject) { + if (urlObject.search) { + attributes['url.query'] = urlObject.search; + } + if (urlObject.hash) { + attributes['url.fragment'] = urlObject.hash; + } + if (urlObject.pathname) { + attributes['url.path'] = urlObject.pathname; + if (urlObject.pathname === '/') { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + } + } + + if (!isURLObjectRelative(urlObject)) { + attributes[SEMANTIC_ATTRIBUTE_URL_FULL] = urlObject.href; + if (urlObject.port) { + attributes['url.port'] = urlObject.port; + } + if (urlObject.protocol) { + attributes['url.scheme'] = urlObject.protocol; + } + if (urlObject.hostname) { + attributes[kind === 'server' ? 'server.address' : 'url.domain'] = urlObject.hostname; + } + } + } + + return [getHttpSpanNameFromUrlObject(urlObject, kind, request, routeName), attributes]; +} + /** * Parses string form of URL into an object * // borrowed from https://tools.ietf.org/html/rfc3986#appendix-B diff --git a/packages/core/test/utils-hoist/url.test.ts b/packages/core/test/utils-hoist/url.test.ts index 7f1d1dae8b40..67ec8b31644f 100644 --- a/packages/core/test/utils-hoist/url.test.ts +++ b/packages/core/test/utils-hoist/url.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getHttpSpanDetailsFromUrlObject, getSanitizedUrlString, getSanitizedUrlStringFromUrlObject, isURLObjectRelative, @@ -203,28 +204,86 @@ describe('parseUrl', () => { }); describe('parseStringToURLObject', () => { - it('returns undefined for invalid URLs', () => { - expect(parseStringToURLObject('invalid-url')).toBeUndefined(); - }); - - it('returns a URL object for valid URLs', () => { - expect(parseStringToURLObject('https://somedomain.com')).toBeInstanceOf(URL); - }); - - it('returns a URL object for valid URLs with a base URL', () => { - expect(parseStringToURLObject('https://somedomain.com', 'https://base.com')).toBeInstanceOf(URL); - }); - - it('returns a relative URL object for relative URLs', () => { - expect(parseStringToURLObject('/path/to/happiness')).toEqual({ - isRelative: true, - pathname: '/path/to/happiness', - search: '', - hash: '', - }); + it.each([ + [ + 'invalid URL', + 'invalid-url', + { + isRelative: true, + pathname: '/invalid-url', + search: '', + hash: '', + }, + ], + ['valid absolute URL', 'https://somedomain.com', expect.any(URL)], + ['valid absolute URL with base', 'https://somedomain.com', expect.any(URL), 'https://base.com'], + [ + 'relative URL', + '/path/to/happiness', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '', + hash: '', + }, + ], + [ + 'relative URL with query', + '/path/to/happiness?q=1', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '?q=1', + hash: '', + }, + ], + [ + 'relative URL with hash', + '/path/to/happiness#section', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '', + hash: '#section', + }, + ], + [ + 'relative URL with query and hash', + '/path/to/happiness?q=1#section', + { + isRelative: true, + pathname: '/path/to/happiness', + search: '?q=1', + hash: '#section', + }, + ], + ['URL with port', 'https://somedomain.com:8080/path', expect.any(URL)], + ['URL with auth', 'https://user:pass@somedomain.com', expect.any(URL)], + ['URL with special chars', 'https://somedomain.com/path/with spaces/and/special@chars', expect.any(URL)], + ['URL with unicode', 'https://somedomain.com/path/with/unicode/测试', expect.any(URL)], + ['URL with multiple query params', 'https://somedomain.com/path?q1=1&q2=2&q3=3', expect.any(URL)], + ['URL with encoded chars', 'https://somedomain.com/path/%20%2F%3F%23', expect.any(URL)], + ['URL with IPv4', 'https://192.168.1.1/path', expect.any(URL)], + ['URL with IPv6', 'https://[2001:db8::1]/path', expect.any(URL)], + ['URL with subdomain', 'https://sub.somedomain.com/path', expect.any(URL)], + ['URL with multiple subdomains', 'https://sub1.sub2.somedomain.com/path', expect.any(URL)], + ['URL with trailing slash', 'https://somedomain.com/path/', expect.any(URL)], + ['URL with empty path', 'https://somedomain.com', expect.any(URL)], + ['URL with root path', 'https://somedomain.com/', expect.any(URL)], + ['URL with file extension', 'https://somedomain.com/path/file.html', expect.any(URL)], + ['URL with custom protocol', 'custom://somedomain.com/path', expect.any(URL)], + ['URL with query containing special chars', 'https://somedomain.com/path?q=hello+world&x=1/2', expect.any(URL)], + ['URL with hash containing special chars', 'https://somedomain.com/path#section/1/2', expect.any(URL)], + [ + 'URL with all components', + 'https://user:pass@sub.somedomain.com:8080/path/file.html?q=1#section', + expect.any(URL), + ], + ])('handles %s', (_, url: string, expected: any, base?: string) => { + expect(parseStringToURLObject(url, base)).toEqual(expected); }); - it('does not throw an error if URl.canParse is not defined', () => { + it('does not throw an error if URL.canParse is not defined', () => { const canParse = (URL as any).canParse; delete (URL as any).canParse; expect(parseStringToURLObject('https://somedomain.com')).toBeInstanceOf(URL); @@ -286,6 +345,48 @@ describe('getSanitizedUrlStringFromUrlObject', () => { ['url with port 4433', 'http://172.31.12.144:4433/test', 'http://172.31.12.144:4433/test'], ['url with port 443', 'http://172.31.12.144:443/test', 'http://172.31.12.144/test'], ['url with IP and port 80', 'http://172.31.12.144:80/test', 'http://172.31.12.144/test'], + ['invalid URL', 'invalid-url', '/invalid-url'], + ['valid absolute URL with base', 'https://somedomain.com', 'https://somedomain.com/'], + ['relative URL', '/path/to/happiness', '/path/to/happiness'], + ['relative URL with query', '/path/to/happiness?q=1', '/path/to/happiness'], + ['relative URL with hash', '/path/to/happiness#section', '/path/to/happiness'], + ['relative URL with query and hash', '/path/to/happiness?q=1#section', '/path/to/happiness'], + [ + 'URL with special chars', + 'https://somedomain.com/path/with spaces/and/special@chars', + 'https://somedomain.com/path/with%20spaces/and/special@chars', + ], + [ + 'URL with unicode', + 'https://somedomain.com/path/with/unicode/测试', + 'https://somedomain.com/path/with/unicode/%E6%B5%8B%E8%AF%95', + ], + ['URL with multiple query params', 'https://somedomain.com/path?q1=1&q2=2&q3=3', 'https://somedomain.com/path'], + ['URL with encoded chars', 'https://somedomain.com/path/%20%2F%3F%23', 'https://somedomain.com/path/%20%2F%3F%23'], + ['URL with IPv4', 'https://192.168.1.1/path', 'https://192.168.1.1/path'], + ['URL with IPv6', 'https://[2001:db8::1]/path', 'https://[2001:db8::1]/path'], + ['URL with subdomain', 'https://sub.somedomain.com/path', 'https://sub.somedomain.com/path'], + ['URL with multiple subdomains', 'https://sub1.sub2.somedomain.com/path', 'https://sub1.sub2.somedomain.com/path'], + ['URL with trailing slash', 'https://somedomain.com/path/', 'https://somedomain.com/path/'], + ['URL with empty path', 'https://somedomain.com', 'https://somedomain.com/'], + ['URL with root path', 'https://somedomain.com/', 'https://somedomain.com/'], + ['URL with file extension', 'https://somedomain.com/path/file.html', 'https://somedomain.com/path/file.html'], + ['URL with custom protocol', 'custom://somedomain.com/path', 'custom://somedomain.com/path'], + [ + 'URL with query containing special chars', + 'https://somedomain.com/path?q=hello+world&x=1/2', + 'https://somedomain.com/path', + ], + [ + 'URL with hash containing special chars', + 'https://somedomain.com/path#section/1/2', + 'https://somedomain.com/path', + ], + [ + 'URL with all components', + 'https://user:pass@sub.somedomain.com:8080/path/file.html?q=1#section', + 'https://%filtered%:%filtered%@sub.somedomain.com:8080/path/file.html', + ], ])('returns a sanitized URL for a %s', (_, rawUrl: string, sanitizedURL: string) => { const urlObject = parseStringToURLObject(rawUrl); if (!urlObject) { @@ -294,3 +395,246 @@ describe('getSanitizedUrlStringFromUrlObject', () => { expect(getSanitizedUrlStringFromUrlObject(urlObject)).toEqual(sanitizedURL); }); }); + +describe('getHttpSpanDetailsFromUrlObject', () => { + it('handles undefined URL object', () => { + const [name, attributes] = getHttpSpanDetailsFromUrlObject(undefined, 'server', 'test-origin'); + expect(name).toBe('GET /'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + }); + }); + + it('handles relative URL object', () => { + const urlObject = parseStringToURLObject('/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + }); + }); + + it('handles absolute URL object', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users?q=test#section')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.query': '?q=test', + 'url.fragment': '#section', + 'url.full': 'https://example.com/api/users?q=test#section', + 'server.address': 'example.com', + 'url.scheme': 'https:', + }); + }); + + it('handles URL object with request method', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin', { method: 'POST' }); + expect(name).toBe('POST /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://example.com/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.request.method': 'POST', + }); + }); + + it('handles URL object with route name', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + undefined, + '/api/users/:id', + ); + expect(name).toBe('GET /api/users/:id'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/users', + 'url.full': 'https://example.com/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/users/:id', + }); + }); + + it('handles root path URL', () => { + const urlObject = parseStringToURLObject('https://example.com/')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/', + 'url.full': 'https://example.com/', + 'server.address': 'example.com', + 'url.scheme': 'https:', + }); + }); + + it('handles URL with port', () => { + const urlObject = parseStringToURLObject('https://example.com:8080/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://example.com:8080/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'url.port': '8080', + }); + }); + + it('handles URL with non-standard port and request method', () => { + const urlObject = parseStringToURLObject('https://example.com:3000/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin', { method: 'PUT' }); + expect(name).toBe('PUT /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://example.com:3000/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'url.port': '3000', + 'http.request.method': 'PUT', + }); + }); + + it('handles URL with route name and request method', () => { + const urlObject = parseStringToURLObject('https://example.com/api/users/123')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + { method: 'PATCH' }, + '/api/users/:id', + ); + expect(name).toBe('PATCH /api/users/:id'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/users/123', + 'url.full': 'https://example.com/api/users/123', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/users/:id', + 'http.request.method': 'PATCH', + }); + }); + + it('handles URL with query params and route name', () => { + const urlObject = parseStringToURLObject('https://example.com/api/search?q=test&page=1')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + undefined, + '/api/search', + ); + expect(name).toBe('GET /api/search'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/search', + 'url.query': '?q=test&page=1', + 'url.full': 'https://example.com/api/search?q=test&page=1', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/search', + }); + }); + + it('handles URL with fragment and route name', () => { + const urlObject = parseStringToURLObject('https://example.com/api/docs#section-1')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + urlObject, + 'server', + 'test-origin', + undefined, + '/api/docs', + ); + expect(name).toBe('GET /api/docs'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'route', + 'url.path': '/api/docs', + 'url.fragment': '#section-1', + 'url.full': 'https://example.com/api/docs#section-1', + 'server.address': 'example.com', + 'url.scheme': 'https:', + 'http.route': '/api/docs', + }); + }); + + it('handles URL with auth credentials', () => { + const urlObject = parseStringToURLObject('https://user:pass@example.com/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://user:pass@example.com/api/users', + 'server.address': 'example.com', + 'url.scheme': 'https:', + }); + }); + + it('handles URL with IPv4 address', () => { + const urlObject = parseStringToURLObject('https://192.168.1.1:8080/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://192.168.1.1:8080/api/users', + 'server.address': '192.168.1.1', + 'url.scheme': 'https:', + 'url.port': '8080', + }); + }); + + it('handles URL with IPv6 address', () => { + const urlObject = parseStringToURLObject('https://[2001:db8::1]:8080/api/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /api/users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/api/users', + 'url.full': 'https://[2001:db8::1]:8080/api/users', + 'server.address': '[2001:db8::1]', + 'url.scheme': 'https:', + 'url.port': '8080', + }); + }); + + it('handles URL with subdomain', () => { + const urlObject = parseStringToURLObject('https://api.example.com/users')!; + const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'server', 'test-origin'); + expect(name).toBe('GET /users'); + expect(attributes).toEqual({ + 'sentry.origin': 'test-origin', + 'sentry.source': 'url', + 'url.path': '/users', + 'url.full': 'https://api.example.com/users', + 'server.address': 'api.example.com', + 'url.scheme': 'https:', + }); + }); +}); From ce090950fe229264ae71f385efc6ac3d7541ec80 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 12 May 2025 15:11:03 +0200 Subject: [PATCH 24/28] chore: Update lockfile for fastify-otel fork (#16263) Forgot to update the lockfile after updating the minimatch dep on our fastiy/otel fork. From f533f0aba4416f485e068ab2d0f7790bcc4a9635 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 15:36:32 +0200 Subject: [PATCH 25/28] test(node): Ensure outgoing http & fetch are tested in esm & cjs (#16261) While doing this, I also fixed a problem with our test runner when using both `expectNoErrorOutput()` and `expect` - the former leads to the test finishing before we check the `expect` content, so now we make sure both of these are not used together. No test content should have changed, just moved around. --- .../node-integration-tests/.eslintrc.js | 3 + .../requests/fetch-breadcrumbs/instrument.mjs | 18 ++ .../scenario.mjs} | 13 +- .../requests/fetch-breadcrumbs/scenario.ts | 34 ---- .../requests/fetch-breadcrumbs/test.ts | 130 +++++++------ .../fetch-no-tracing-no-spans/instrument.mjs | 10 + .../fetch-no-tracing-no-spans/scenario.mjs | 12 ++ .../fetch-no-tracing-no-spans/scenario.ts | 24 --- .../fetch-no-tracing-no-spans/test.ts | 83 ++++---- .../requests/fetch-no-tracing/instrument.mjs | 10 + .../requests/fetch-no-tracing/scenario.mjs | 12 ++ .../requests/fetch-no-tracing/scenario.ts | 24 --- .../tracing/requests/fetch-no-tracing/test.ts | 83 ++++---- .../instrument.mjs | 11 ++ .../fetch-sampled-no-active-span/scenario.mjs | 12 ++ .../fetch-sampled-no-active-span/test.ts | 82 ++++---- .../requests/fetch-unsampled/instrument.mjs | 11 ++ .../{scenario.ts => scenario.mjs} | 13 +- .../tracing/requests/fetch-unsampled/test.ts | 82 ++++---- .../requests/http-breadcrumbs/instrument.mjs | 17 ++ .../{scenario.ts => scenario.mjs} | 28 +-- .../tracing/requests/http-breadcrumbs/test.ts | 143 +++++++------- .../requests/http-no-tracing/instrument.mjs | 17 ++ .../{scenario.ts => scenario.mjs} | 28 +-- .../tracing/requests/http-no-tracing/test.ts | 183 +++++++++--------- .../tracing/requests/http-sampled-esm/test.ts | 44 ----- .../instrument.mjs | 0 .../{scenario.ts => scenario.mjs} | 18 +- .../http-sampled-no-active-span/test.ts | 84 ++++---- .../requests/http-sampled/instrument.mjs | 11 ++ .../scenario.mjs | 1 - .../tracing/requests/http-sampled/scenario.ts | 36 ---- .../tracing/requests/http-sampled/test.ts | 72 +++---- .../requests/http-unsampled/instrument.mjs | 11 ++ .../{scenario.ts => scenario.mjs} | 18 +- .../tracing/requests/http-unsampled/test.ts | 84 ++++---- .../node-integration-tests/utils/runner.ts | 6 + 37 files changed, 706 insertions(+), 762 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/instrument.mjs rename dev-packages/node-integration-tests/suites/tracing/requests/{fetch-sampled-no-active-span/scenario.ts => fetch-breadcrumbs/scenario.mjs} (50%) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.mjs delete mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/instrument.mjs rename dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/{scenario.ts => scenario.mjs} (56%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/instrument.mjs rename dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/{scenario.ts => scenario.mjs} (51%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/instrument.mjs rename dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/{scenario.ts => scenario.mjs} (51%) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/test.ts rename dev-packages/node-integration-tests/suites/tracing/requests/{http-sampled-esm => http-sampled-no-active-span}/instrument.mjs (100%) rename dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/{scenario.ts => scenario.mjs} (55%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/instrument.mjs rename dev-packages/node-integration-tests/suites/tracing/requests/{http-sampled-esm => http-sampled}/scenario.mjs (90%) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/instrument.mjs rename dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/{scenario.ts => scenario.mjs} (59%) diff --git a/dev-packages/node-integration-tests/.eslintrc.js b/dev-packages/node-integration-tests/.eslintrc.js index a3501df39470..0598ba3f5ca1 100644 --- a/dev-packages/node-integration-tests/.eslintrc.js +++ b/dev-packages/node-integration-tests/.eslintrc.js @@ -18,6 +18,9 @@ module.exports = { sourceType: 'module', ecmaVersion: 'latest', }, + globals: { + fetch: 'readonly', + }, rules: { '@typescript-eslint/typedef': 'off', // Explicitly allow ts-ignore with description for Node integration tests diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/instrument.mjs new file mode 100644 index 000000000000..76fc52f6a863 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/instrument.mjs @@ -0,0 +1,18 @@ +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', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, + tracesSampleRate: 0.0, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs similarity index 50% rename from dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs index 8cc876bd2e44..21694ba54e9d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs @@ -1,16 +1,8 @@ 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', - tracePropagationTargets: [/\/v0/, 'v1'], - tracesSampleRate: 1.0, - integrations: [], - transport: loggingTransport, -}); +async function run() { + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); -async function run(): Promise { await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); @@ -19,5 +11,4 @@ async function run(): Promise { Sentry.captureException(new Error('foo')); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.ts deleted file mode 100644 index 6f2d895436d6..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.ts +++ /dev/null @@ -1,34 +0,0 @@ -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', - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [], - transport: loggingTransport, - tracesSampleRate: 0.0, - // Ensure this gets a correct hint - beforeBreadcrumb(breadcrumb, hint) { - breadcrumb.data = breadcrumb.data || {}; - const req = hint?.request as { path?: string }; - breadcrumb.data.ADDED_PATH = req?.path; - return breadcrumb; - }, -}); - -async function run(): Promise { - Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); - - // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented - await new Promise(resolve => setTimeout(resolve, 100)); - await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); - - Sentry.captureException(new Error('foo')); -} - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts index c14a6ab528ac..cab9c61a1b65 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -1,78 +1,80 @@ -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { - test('outgoing fetch requests create breadcrumbs', async () => { - const [SERVER_URL, closeTestServer] = await createTestServer().start(); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing fetch requests create breadcrumbs xxx', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .ensureNoErrorOutput() - .expect({ - event: { - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 404, - ADDED_PATH: '/api/v0', + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 404, - ADDED_PATH: '/api/v1', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 404, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 404, - ADDED_PATH: '/api/v2', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 404, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 404, - ADDED_PATH: '/api/v3', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 404, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', }, - timestamp: expect.any(Number), - type: 'http', - }, - ], - exception: { - values: [ { - type: 'Error', - value: 'foo', + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 404, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', }, ], + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, }, - }, - }) - .start() - .completed(); - closeTestServer(); + }) + .start() + .completed(); + + closeTestServer(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/instrument.mjs new file mode 100644 index 000000000000..c3c5e4fdb3de --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/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', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [Sentry.nativeNodeFetchIntegration({ spans: false })], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.mjs new file mode 100644 index 000000000000..eb0eeb584f45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; + +async function run() { + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts deleted file mode 100644 index 14c47de483f1..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/scenario.ts +++ /dev/null @@ -1,24 +0,0 @@ -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', - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [Sentry.nativeNodeFetchIntegration({ spans: false })], - transport: loggingTransport, -}); - -async function run(): Promise { - // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented - await new Promise(resolve => setTimeout(resolve, 100)); - await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); - - Sentry.captureException(new Error('foo')); -} - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts index 0da3d7fd6501..f61532d9de8b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts @@ -1,49 +1,50 @@ -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { - test('outgoing fetch requests are correctly instrumented with tracing & spans are disabled', async () => { - expect.assertions(11); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing fetch requests are correctly instrumented with tracing & spans are disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .ensureNoErrorOutput() - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, }, - }, - }) - .start() - .completed(); - closeTestServer; + }) + .start() + .completed(); + closeTestServer; + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/instrument.mjs new file mode 100644 index 000000000000..0a2184dacd2e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/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', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.mjs new file mode 100644 index 000000000000..eb0eeb584f45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; + +async function run() { + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.ts deleted file mode 100644 index 9011cb232ef8..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.ts +++ /dev/null @@ -1,24 +0,0 @@ -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', - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [], - transport: loggingTransport, -}); - -async function run(): Promise { - // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented - await new Promise(resolve => setTimeout(resolve, 100)); - await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); - - Sentry.captureException(new Error('foo')); -} - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts index 1975199514ff..b4594c4d9c41 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts @@ -1,49 +1,50 @@ -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { - test('outgoing fetch requests are correctly instrumented with tracing disabled', async () => { - expect.assertions(11); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing fetch requests are correctly instrumented with tracing disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .ensureNoErrorOutput() - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, }, - }, - }) - .start() - .completed(); - closeTestServer(); + }) + .start() + .completed(); + closeTestServer(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/instrument.mjs new file mode 100644 index 000000000000..99792f59545a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/instrument.mjs @@ -0,0 +1,11 @@ +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', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.mjs new file mode 100644 index 000000000000..eb0eeb584f45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; + +async function run() { + await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); + await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + + Sentry.captureException(new Error('foo')); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts index 00da2285f060..32f24517b3f6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -1,48 +1,50 @@ -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { - test('outgoing sampled fetch requests without active span are correctly instrumented', async () => { - expect.assertions(11); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing sampled fetch requests without active span are correctly instrumented', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - }) - .get('/api/v1', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, }, - }, - }) - .start() - .completed(); - closeTestServer(); + }) + .start() + .completed(); + closeTestServer(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/instrument.mjs new file mode 100644 index 000000000000..9063352bc6d9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/instrument.mjs @@ -0,0 +1,11 @@ +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', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.mjs similarity index 56% rename from dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.mjs index 7c55f5c1f060..ce2ca3afa178 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.mjs @@ -1,16 +1,6 @@ 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', - tracePropagationTargets: [/\/v0/, 'v1'], - tracesSampleRate: 0, - integrations: [], - transport: loggingTransport, -}); - -async function run(): Promise { +async function run() { // Wrap in span that is not sampled await Sentry.startSpan({ name: 'outer' }, async () => { await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); @@ -22,5 +12,4 @@ async function run(): Promise { Sentry.captureException(new Error('foo')); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts index b6dcf5e6b116..097236ba4e7f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts @@ -1,48 +1,50 @@ -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { - test('outgoing fetch requests are correctly instrumented when not sampled', async () => { - expect.assertions(11); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing fetch requests are correctly instrumented when not sampled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); - }) - .get('/api/v1', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], + }, }, - }, - }) - .start() - .completed(); - closeTestServer(); + }) + .start() + .completed(); + closeTestServer(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/instrument.mjs new file mode 100644 index 000000000000..dfe7b95bfeb7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/instrument.mjs @@ -0,0 +1,17 @@ +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', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs similarity index 51% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs index 87df3af73cd7..2ee57c8651e0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs @@ -1,24 +1,7 @@ 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', - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [], - transport: loggingTransport, - // Ensure this gets a correct hint - beforeBreadcrumb(breadcrumb, hint) { - breadcrumb.data = breadcrumb.data || {}; - const req = hint?.request as { path?: string }; - breadcrumb.data.ADDED_PATH = req?.path; - return breadcrumb; - }, -}); - import * as http from 'http'; -async function run(): Promise { +async function run() { Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); @@ -29,11 +12,10 @@ async function run(): Promise { Sentry.captureException(new Error('foo')); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); -function makeHttpRequest(url: string): Promise { - return new Promise(resolve => { +function makeHttpRequest(url) { + return new Promise(resolve => { http .request(url, httpRes => { httpRes.on('data', () => { @@ -47,8 +29,8 @@ function makeHttpRequest(url: string): Promise { }); } -function makeHttpGet(url: string): Promise { - return new Promise(resolve => { +function makeHttpGet(url) { + return new Promise(resolve => { http.get(url, httpRes => { httpRes.on('data', () => { // we don't care about data diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts index 86d61866ad38..318d4628453b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts @@ -1,76 +1,79 @@ -import { expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; -test('outgoing http requests create breadcrumbs', async () => { - const [SERVER_URL, closeTestServer] = await createTestServer().start(); +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing http requests create breadcrumbs', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer().start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .ensureNoErrorOutput() - .expect({ - event: { - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 404, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 404, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 404, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 404, - ADDED_PATH: '/api/v3', + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 404, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 404, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 404, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 404, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], }, - timestamp: expect.any(Number), - type: 'http', }, - ], - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], - }, - }, - }) - .start() - .completed(); - closeTestServer(); + }) + .start() + .completed(); + closeTestServer(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/instrument.mjs new file mode 100644 index 000000000000..dfe7b95bfeb7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/instrument.mjs @@ -0,0 +1,17 @@ +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', + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, + // Ensure this gets a correct hint + beforeBreadcrumb(breadcrumb, hint) { + breadcrumb.data = breadcrumb.data || {}; + const req = hint?.request; + breadcrumb.data.ADDED_PATH = req?.path; + return breadcrumb; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.mjs similarity index 51% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.mjs index 87df3af73cd7..2ee57c8651e0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.mjs @@ -1,24 +1,7 @@ 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', - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [], - transport: loggingTransport, - // Ensure this gets a correct hint - beforeBreadcrumb(breadcrumb, hint) { - breadcrumb.data = breadcrumb.data || {}; - const req = hint?.request as { path?: string }; - breadcrumb.data.ADDED_PATH = req?.path; - return breadcrumb; - }, -}); - import * as http from 'http'; -async function run(): Promise { +async function run() { Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); @@ -29,11 +12,10 @@ async function run(): Promise { Sentry.captureException(new Error('foo')); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); -function makeHttpRequest(url: string): Promise { - return new Promise(resolve => { +function makeHttpRequest(url) { + return new Promise(resolve => { http .request(url, httpRes => { httpRes.on('data', () => { @@ -47,8 +29,8 @@ function makeHttpRequest(url: string): Promise { }); } -function makeHttpGet(url: string): Promise { - return new Promise(resolve => { +function makeHttpGet(url) { + return new Promise(resolve => { http.get(url, httpRes => { httpRes.on('data', () => { // we don't care about data diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index 77b6f10217ed..7922fe3a443f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -1,97 +1,100 @@ -import { expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; -test('outgoing http requests are correctly instrumented with tracing disabled', async () => { - expect.assertions(11); +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing http requests are correctly instrumented with tracing disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .ensureNoErrorOutput() - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], }, - ], - }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', - }, - ], - }, - }) - .start() - .completed(); - closeTestServer(); + }) + .start() + .completed(); + closeTestServer(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/test.ts deleted file mode 100644 index 8bd7dd5f2502..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { join } from 'path'; -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; - -describe('outgoing http in ESM', () => { - test('outgoing sampled http requests are correctly instrumented in ESM', async () => { - expect.assertions(11); - - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - }) - .get('/api/v1', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); - - const instrumentPath = join(__dirname, 'instrument.mjs'); - await createRunner(__dirname, 'scenario.mjs') - .withInstrument(instrumentPath) - .withEnv({ SERVER_URL }) - .expect({ - transaction: { - // we're not too concerned with the actual transaction here since this is tested elsewhere - }, - }) - .start() - .completed(); - closeTestServer(); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/instrument.mjs rename to dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.mjs similarity index 55% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.mjs index 94755b6febd1..58f603a719df 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/scenario.mjs @@ -1,18 +1,7 @@ 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, - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [], - transport: loggingTransport, -}); - import * as http from 'http'; -async function run(): Promise { +async function run() { await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); @@ -21,11 +10,10 @@ async function run(): Promise { Sentry.captureException(new Error('foo')); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); -function makeHttpRequest(url: string): Promise { - return new Promise(resolve => { +function makeHttpRequest(url) { + return new Promise(resolve => { http .request(url, httpRes => { httpRes.on('data', () => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts index 6811dc3bb45e..8d1afff8c867 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -1,46 +1,50 @@ -import { expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; -test('outgoing sampled http requests without active span are correctly instrumented', async () => { - expect.assertions(11); +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing sampled http requests without active span are correctly instrumented', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - }) - .get('/api/v1', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], }, - ], - }, - }, - }) - .start() - .completed(); - closeTestServer(); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/instrument.mjs new file mode 100644 index 000000000000..518e3f83de83 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/instrument.mjs @@ -0,0 +1,11 @@ +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, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.mjs similarity index 90% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/scenario.mjs rename to dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.mjs index 9fafd4b528af..8fc9afadfe30 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-esm/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.mjs @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/node'; import * as http from 'http'; -// eslint-disable-next-line @typescript-eslint/no-floating-promises Sentry.startSpan({ name: 'test_span' }, async () => { await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts deleted file mode 100644 index c31007afe9d0..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts +++ /dev/null @@ -1,36 +0,0 @@ -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, - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [], - transport: loggingTransport, -}); - -import * as http from 'http'; - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -Sentry.startSpan({ name: 'test_span' }, async () => { - await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); - await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); - await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); - await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); -}); - -function makeHttpRequest(url: string): Promise { - return new Promise(resolve => { - http - .request(url, httpRes => { - httpRes.on('data', () => { - // we don't care about data - }); - httpRes.on('end', () => { - resolve(); - }); - }) - .end(); - }); -} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts index 0e52c83af91b..5951db7f51b7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -1,39 +1,43 @@ -import { expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; -test('outgoing sampled http requests are correctly instrumented', async () => { - expect.assertions(11); +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing sampled http requests are correctly instrumented', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - }) - .get('/api/v1', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .expect({ - transaction: { - // we're not too concerned with the actual transaction here since this is tested elsewhere - }, - }) - .start() - .completed(); - closeTestServer(); + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + // we're not too concerned with the actual transaction here since this is tested elsewhere + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/instrument.mjs new file mode 100644 index 000000000000..9063352bc6d9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/instrument.mjs @@ -0,0 +1,11 @@ +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', + tracePropagationTargets: [/\/v0/, 'v1'], + tracesSampleRate: 0, + integrations: [], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.mjs similarity index 59% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.mjs index e137f0ff1cf8..189243665ab0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/scenario.mjs @@ -1,18 +1,7 @@ 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', - tracePropagationTargets: [/\/v0/, 'v1'], - tracesSampleRate: 0, - integrations: [], - transport: loggingTransport, -}); - import * as http from 'http'; -async function run(): Promise { +async function run() { // Wrap in span that is not sampled await Sentry.startSpan({ name: 'outer' }, async () => { await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); @@ -24,11 +13,10 @@ async function run(): Promise { Sentry.captureException(new Error('foo')); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); -function makeHttpRequest(url: string): Promise { - return new Promise(resolve => { +function makeHttpRequest(url) { + return new Promise(resolve => { http .request(url, httpRes => { httpRes.on('data', () => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts index 140ef37908f3..4e83d2e3feb1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -1,46 +1,50 @@ -import { expect, test } from 'vitest'; -import { createRunner } from '../../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; -test('outgoing http requests are correctly instrumented when not sampled', async () => { - expect.assertions(11); +describe('outgoing http', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('outgoing http requests are correctly instrumented when not sampled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); - }) - .get('/api/v1', headers => { - expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v1', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'foo', + }, + ], }, - ], - }, - }, - }) - .start() - .completed(); - closeTestServer(); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index e6062a6322b8..97b1efa2dbb4 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -251,6 +251,9 @@ export function createRunner(...paths: string[]) { return { expect: function (expected: Expected) { + if (ensureNoErrorOutput) { + throw new Error('You should not use `ensureNoErrorOutput` when using `expect`!'); + } expectedEnvelopes.push(expected); return this; }, @@ -299,6 +302,9 @@ export function createRunner(...paths: string[]) { return this; }, ensureNoErrorOutput: function () { + if (expectedEnvelopes.length > 0) { + throw new Error('You should not use `ensureNoErrorOutput` when using `expect`!'); + } ensureNoErrorOutput = true; return this; }, From f4ee9bf42884d80fe23afc07ced9a70ece765598 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 16:03:53 +0200 Subject: [PATCH 26/28] ref(core): Remove duplicate internal `DEBUG_BUILD` constant (#16264) Noticed we still have this twice from the utils-hoist folder. --------- Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- .../tracing/requests/http-no-tracing-no-spans/test.ts | 2 -- packages/core/src/utils-hoist/baggage.ts | 2 +- packages/core/src/utils-hoist/debug-build.ts | 8 -------- packages/core/src/utils-hoist/dsn.ts | 2 +- packages/core/src/utils-hoist/instrument/handlers.ts | 2 +- packages/core/src/utils-hoist/logger.ts | 2 +- packages/core/src/utils-hoist/object.ts | 2 +- packages/core/src/utils-hoist/supports.ts | 2 +- packages/core/test/utils-hoist/dsn.test.ts | 2 +- 9 files changed, 7 insertions(+), 17 deletions(-) delete mode 100644 packages/core/src/utils-hoist/debug-build.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 41f688178d1c..fe9cba032344 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -32,7 +32,6 @@ describe('outgoing http requests with tracing & spans disabled', () => { await createRunner() .withEnv({ SERVER_URL }) - .ensureNoErrorOutput() .expect({ event: { exception: { @@ -131,7 +130,6 @@ describe('outgoing http requests with tracing & spans disabled', () => { await createRunner() .withEnv({ SERVER_URL }) - .ensureNoErrorOutput() .expect({ event: { exception: { diff --git a/packages/core/src/utils-hoist/baggage.ts b/packages/core/src/utils-hoist/baggage.ts index e3204d8b2105..4a44ee5c35b0 100644 --- a/packages/core/src/utils-hoist/baggage.ts +++ b/packages/core/src/utils-hoist/baggage.ts @@ -1,5 +1,5 @@ import type { DynamicSamplingContext } from '../types-hoist/envelope'; -import { DEBUG_BUILD } from './debug-build'; +import { DEBUG_BUILD } from './../debug-build'; import { isString } from './is'; import { logger } from './logger'; diff --git a/packages/core/src/utils-hoist/debug-build.ts b/packages/core/src/utils-hoist/debug-build.ts deleted file mode 100644 index 60aa50940582..000000000000 --- a/packages/core/src/utils-hoist/debug-build.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const __DEBUG_BUILD__: boolean; - -/** - * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. - * - * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. - */ -export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/core/src/utils-hoist/dsn.ts b/packages/core/src/utils-hoist/dsn.ts index 37801096dd22..b3fdf9fb5d08 100644 --- a/packages/core/src/utils-hoist/dsn.ts +++ b/packages/core/src/utils-hoist/dsn.ts @@ -1,5 +1,5 @@ import type { DsnComponents, DsnLike, DsnProtocol } from '../types-hoist/dsn'; -import { DEBUG_BUILD } from './debug-build'; +import { DEBUG_BUILD } from './../debug-build'; import { consoleSandbox, logger } from './logger'; /** Regular expression used to parse a Dsn. */ diff --git a/packages/core/src/utils-hoist/instrument/handlers.ts b/packages/core/src/utils-hoist/instrument/handlers.ts index 672c819e17a6..9d6222662912 100644 --- a/packages/core/src/utils-hoist/instrument/handlers.ts +++ b/packages/core/src/utils-hoist/instrument/handlers.ts @@ -1,4 +1,4 @@ -import { DEBUG_BUILD } from '../debug-build'; +import { DEBUG_BUILD } from '../../debug-build'; import { logger } from '../logger'; import { getFunctionName } from '../stacktrace'; diff --git a/packages/core/src/utils-hoist/logger.ts b/packages/core/src/utils-hoist/logger.ts index 8eefa9f96c39..0c1e8f4d169b 100644 --- a/packages/core/src/utils-hoist/logger.ts +++ b/packages/core/src/utils-hoist/logger.ts @@ -1,6 +1,6 @@ import { getGlobalSingleton } from '../carrier'; import type { ConsoleLevel } from '../types-hoist/instrument'; -import { DEBUG_BUILD } from './debug-build'; +import { DEBUG_BUILD } from './../debug-build'; import { GLOBAL_OBJ } from './worldwide'; /** Prefix for logging strings */ diff --git a/packages/core/src/utils-hoist/object.ts b/packages/core/src/utils-hoist/object.ts index 2710a2b42f9f..366e3f2c5e98 100644 --- a/packages/core/src/utils-hoist/object.ts +++ b/packages/core/src/utils-hoist/object.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { WrappedFunction } from '../types-hoist/wrappedfunction'; +import { DEBUG_BUILD } from './../debug-build'; import { htmlTreeAsString } from './browser'; -import { DEBUG_BUILD } from './debug-build'; import { isElement, isError, isEvent, isInstanceOf, isPrimitive } from './is'; import { logger } from './logger'; import { truncate } from './string'; diff --git a/packages/core/src/utils-hoist/supports.ts b/packages/core/src/utils-hoist/supports.ts index e486d672a625..2336c41b0672 100644 --- a/packages/core/src/utils-hoist/supports.ts +++ b/packages/core/src/utils-hoist/supports.ts @@ -1,4 +1,4 @@ -import { DEBUG_BUILD } from './debug-build'; +import { DEBUG_BUILD } from './../debug-build'; import { logger } from './logger'; import { GLOBAL_OBJ } from './worldwide'; diff --git a/packages/core/test/utils-hoist/dsn.test.ts b/packages/core/test/utils-hoist/dsn.test.ts index 86d7eb0a9552..6d34b599c6c9 100644 --- a/packages/core/test/utils-hoist/dsn.test.ts +++ b/packages/core/test/utils-hoist/dsn.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { DEBUG_BUILD } from '../../src/utils-hoist/debug-build'; +import { DEBUG_BUILD } from '../../src/debug-build'; import { dsnToString, makeDsn } from '../../src/utils-hoist/dsn'; import { logger } from '../../src/utils-hoist/logger'; From cdf0445079ff62903ffe019b367189904e4c15a4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 May 2025 16:18:18 +0200 Subject: [PATCH 27/28] test(node): Fix integration test issue due to missing rebase (#16266) Oops, slipped through... From 8dc41614ab20841813643813c63523de734744f2 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 12 May 2025 15:48:53 +0200 Subject: [PATCH 28/28] meta(changelog): Update changelog for 9.18.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 383d17e4cb33..d117a6907bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.18.0 + +### Important changes + +- **feat: Support Node 24 ([#16236](https://github.com/getsentry/sentry-javascript/pull/16236))** + +We now also publish profiling binaries for Node 24. + +### Other changes + +- deps(node): Bump `import-in-the-middle` to `1.13.1` ([#16260](https://github.com/getsentry/sentry-javascript/pull/16260)) +- feat: Export `consoleLoggingIntegration` from vercel edge sdk ([#16228](https://github.com/getsentry/sentry-javascript/pull/16228)) +- feat(cloudflare): Add support for email, queue, and tail handler ([#16233](https://github.com/getsentry/sentry-javascript/pull/16233)) +- feat(cloudflare): Improve http span data ([#16232](https://github.com/getsentry/sentry-javascript/pull/16232)) +- feat(nextjs): Add more attributes for generation functions ([#16214](https://github.com/getsentry/sentry-javascript/pull/16214)) +- feat(opentelemetry): Widen peer dependencies to support Otel v2 ([#16246](https://github.com/getsentry/sentry-javascript/pull/16246)) +- fix(core): Gracefully handle invalid baggage entries ([#16257](https://github.com/getsentry/sentry-javascript/pull/16257)) +- fix(node): Ensure traces are propagated without spans in Node 22+ ([#16221](https://github.com/getsentry/sentry-javascript/pull/16221)) +- fix(node): Use sentry forked `@fastify/otel` dependency with pinned Otel v1 deps ([#16256](https://github.com/getsentry/sentry-javascript/pull/16256)) +- fix(remix): Remove vendored types ([#16218](https://github.com/getsentry/sentry-javascript/pull/16218)) + ## 9.17.0 - feat(node): Migrate to `@fastify/otel` ([#15542](https://github.com/getsentry/sentry-javascript/pull/15542))