diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 978e728e1f56..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] + node: [18, 20, 22, '^24.0.1'] 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 }}) @@ -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/.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/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)) 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/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/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/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/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-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/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/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-no-spans/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-no-spans/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-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..fe9cba032344 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -0,0 +1,202 @@ +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 }) + .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 }) + .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/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.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.mjs new file mode 100644 index 000000000000..2ee57c8651e0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/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/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; }, 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/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/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/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/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/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, + }, + ]; +} 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/baggage.ts b/packages/core/src/utils-hoist/baggage.ts index 9b97f0a92eea..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'; @@ -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/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/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/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/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/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); + }); +}); 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'; 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:', + }); + }); +}); 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 => { diff --git a/packages/node/package.json b/packages/node/package.json index b0d4126673ad..df166ebb7090 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", @@ -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/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 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.`, ); }); } 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/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/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/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/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts new file mode 100644 index 000000000000..705359eab62c --- /dev/null +++ b/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts @@ -0,0 +1,37 @@ +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; + } + + const transaction = event.transaction; + + if (matchedRegexes.some(regex => 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 d1e6b32b1d96..c980078ac7b5 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,7 +1,13 @@ +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 +15,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...'); @@ -20,5 +27,6 @@ export function init(options: NodeOptions): NodeClient | undefined { setTag('runtime', 'node'); DEBUG_BUILD && logger.log('SDK successfully initialized'); + return client; } 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..58ddf3e215d6 --- /dev/null +++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts @@ -0,0 +1,66 @@ +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 55c12935fe66..57b51d16c042 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -1,6 +1,9 @@ +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, 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'); @@ -39,7 +42,34 @@ 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('adds the low quality transactions filter integration by default', () => { + const filterSpy = vi.spyOn(LowQualityModule, 'lowQualityTransactionsFilterIntegration'); + + reactRouterInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(filterSpy).toHaveBeenCalled(); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const initOptions = nodeInit.mock.calls[0]?.[0]; + + expect(initOptions).toBeDefined(); + + const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; + expect(Array.isArray(defaultIntegrations)).toBe(true); + + const filterIntegration = defaultIntegrations.find( + integration => integration.name === 'LowQualityTransactionsFilter', + ); + + expect(filterIntegration).toBeDefined(); }); }); }); 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; 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'; 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/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'; 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( diff --git a/scripts/ci-unit-tests.ts b/scripts/ci-unit-tests.ts index cd53df2dfd68..2802bde62fa6 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']; diff --git a/yarn.lock b/yarn.lock index 5952c9d32733..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@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@getsentry/fastify-otel#otel-v1": + version "0.8.0" + resolved "https://codeload.github.com/getsentry/fastify-otel/tar.gz/d6bb1756c3db3d00d4d82c39c93ee3316e06d305" 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 "^9" "@gar/promisify@^1.1.3": version "1.1.3" @@ -5451,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" @@ -5458,7 +5463,7 @@ dependencies: "@opentelemetry/semantic-conventions" "1.28.0" -"@opentelemetry/core@^2.0.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== @@ -5736,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" @@ -5745,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.29.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== + 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" @@ -6394,13 +6416,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 +13234,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" @@ -17685,10 +17707,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" @@ -20853,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== @@ -21606,10 +21628,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"