diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 5f0479a16749..4a2d16e13598 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,6 +1,6 @@ name: 🐞 Bug Report description: Tell us about something that's not working the way we (probably) intend. -type: 'bug' +labels: ['Bug'] body: - type: checkboxes attributes: diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 185de4888de1..67039ae57441 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -1,6 +1,6 @@ name: 💡 Feature Request description: Create a feature request for a sentry-javascript SDK. -type: 'enhancement' +labels: ['Feature'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/flaky.yml b/.github/ISSUE_TEMPLATE/flaky.yml index 4c733b8505b3..1b9290cc3bbc 100644 --- a/.github/ISSUE_TEMPLATE/flaky.yml +++ b/.github/ISSUE_TEMPLATE/flaky.yml @@ -1,8 +1,7 @@ name: ❅ Flaky Test description: Report a flaky test in CI title: '[Flaky CI]: ' -type: 'task' -labels: ['Type: Tests'] +labels: ['Tests'] body: - type: dropdown id: type diff --git a/.github/ISSUE_TEMPLATE/internal.yml b/.github/ISSUE_TEMPLATE/internal.yml index 308d04db7eb5..bbd6b805ffb6 100644 --- a/.github/ISSUE_TEMPLATE/internal.yml +++ b/.github/ISSUE_TEMPLATE/internal.yml @@ -1,6 +1,5 @@ name: 💡 [Internal] Blank Issue description: Only for Sentry Employees! Create an issue without a template. -type: 'task' body: - type: markdown attributes: diff --git a/.size-limit.js b/.size-limit.js index d66ece2b690d..6128fee06b3d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -47,7 +47,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '76 KB', + limit: '77 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -79,7 +79,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '81 KB', + limit: '82 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -219,7 +219,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '38.5 KB', + limit: '39 KB', }, // Node SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index fce135710fb8..4a4ca5eec07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,65 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.16.0 + +### Important changes + +- **feat: Create a Vite plugin that injects sentryConfig into the global config ([#16197](https://github.com/getsentry/sentry-javascript/pull/16197))** + +Add a new plugin `makeConfigInjectorPlugin` within our existing vite plugin that updates the global vite config with sentry options + +- **feat(browser): Add option to sample linked traces consistently ([#16037](https://github.com/getsentry/sentry-javascript/pull/16037))** + +This PR implements consistent sampling across traces as outlined in ([#15754](https://github.com/getsentry/sentry-javascript/pull/15754)) + +- **feat(cloudflare): Add support for durable objects ([#16180](https://github.com/getsentry/sentry-javascript/pull/16180))** + +This PR introduces a new `instrumentDurableObjectWithSentry` method to the SDK, which instruments durable objects. We capture both traces and errors automatically. + +- **feat(node): Add Prisma integration by default ([#16073](https://github.com/getsentry/sentry-javascript/pull/16073))** + +[Prisma integration](https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/integrations/prisma/) is enabled by default, it should work for both ESM and CJS. + +- **feat(react-router): Add client-side router instrumentation ([#16185](https://github.com/getsentry/sentry-javascript/pull/16185))** + +Adds client-side instrumentation for react router's `HydratedRouter`. To enable it, simply replace `browserTracingIntegration()` with `reactRouterTracingIntegration()` in your client-side init call. + +- **fix(node): Avoid double-wrapping http module ([#16177](https://github.com/getsentry/sentry-javascript/pull/16177))** + +When running your application in ESM mode, there have been scenarios that resulted in the `http`/`https` emitting duplicate spans for incoming requests. This was apparently caused by us double-wrapping the modules for incoming request isolation. + +In order to solve this problem, the modules are no longer monkey patched by us for request isolation. Instead, we register diagnostics*channel hooks to handle request isolation now. +While this is generally not expected to break anything, there is one tiny change that \_may* affect you if you have been relying on very specific functionality: + +The `ignoreOutgoingRequests` option of `httpIntegration` receives the `RequestOptions` as second argument. This type is not changed, however due to how the wrapping now works, we no longer pass through the full RequestOptions, but re-construct this partially based on the generated request. For the vast majority of cases, this should be fine, but for the sake of completeness, these are the only fields that may be available there going forward - other fields that _may_ have existed before may no longer be set: + +```ts +ignoreOutgoingRequests(url: string, { + method: string; + protocol: string; + host: string; + hostname: string; // same as host + path: string; + headers: OutgoingHttpHeaders; +}) +``` + +### Other changes + +- feat(cloudflare): Add logs exports ([#16165](https://github.com/getsentry/sentry-javascript/pull/16165)) +- feat(vercel-edge): Add logs export ([#16166](https://github.com/getsentry/sentry-javascript/pull/16166)) +- feat(cloudflare): Read `SENTRY_RELEASE` from `env` ([#16201](https://github.com/getsentry/sentry-javascript/pull/16201)) +- feat(node): Drop `http.server` spans with 404 status by default ([#16205](https://github.com/getsentry/sentry-javascript/pull/16205)) +- fix(browser): Respect manually set sentry tracing headers in XHR requests ([#16184](https://github.com/getsentry/sentry-javascript/pull/16184)) +- fix(core): Respect manually set sentry tracing headers in fetch calls ([#16183](https://github.com/getsentry/sentry-javascript/pull/16183)) +- fix(feedback): Prevent `removeFromDom()` from throwing ([#16030](https://github.com/getsentry/sentry-javascript/pull/16030)) +- fix(node): Use class constructor in docstring for winston transport ([#16167](https://github.com/getsentry/sentry-javascript/pull/16167)) +- fix(node): Fix vercel flushing logic & add test for it ([#16208](https://github.com/getsentry/sentry-javascript/pull/16208)) +- fix(node): Fix 404 route handling in express 5 ([#16211](https://github.com/getsentry/sentry-javascript/pull/16211)) +- fix(logs): Ensure logs can be flushed correctly ([#16216](https://github.com/getsentry/sentry-javascript/pull/16216)) +- ref(core): Switch to standardized log envelope ([#16133](https://github.com/getsentry/sentry-javascript/pull/16133)) + ## 9.15.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 7075cdf3a979..e3aabc0d2fe6 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { OtelLogEnvelope } from '@sentry/core'; +import type { LogEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers'; @@ -12,237 +12,114 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page const url = await getLocalTestUrl({ testDir: __dirname }); - const event = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser); + const event = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser); const envelopeItems = event[1]; expect(envelopeItems[0]).toEqual([ { - type: 'otel_log', + type: 'log', + item_count: 8, + content_type: 'application/vnd.sentry.items.log+json', }, { - severityText: 'trace', - body: { stringValue: 'console.trace 123 false' }, - attributes: [ + items: [ { - key: 'sentry.origin', - value: { - stringValue: 'auto.console.logging', + timestamp: expect.any(Number), + level: 'trace', + severity_number: 1, + trace_id: expect.any(String), + body: 'console.trace 123 false', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', + timestamp: expect.any(Number), + level: 'debug', + severity_number: 5, + trace_id: expect.any(String), + body: 'console.debug 123 false', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), + timestamp: expect.any(Number), + level: 'info', + severity_number: 10, + trace_id: expect.any(String), + body: 'console.log 123 false', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 1, - }, - ]); - - expect(envelopeItems[1]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'debug', - body: { stringValue: 'console.debug 123 false' }, - attributes: [ { - key: 'sentry.origin', - value: { - stringValue: 'auto.console.logging', + timestamp: expect.any(Number), + level: 'info', + severity_number: 9, + trace_id: expect.any(String), + body: 'console.info 123 false', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', + timestamp: expect.any(Number), + level: 'warn', + severity_number: 13, + trace_id: expect.any(String), + body: 'console.warn 123 false', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 5, - }, - ]); - - expect(envelopeItems[2]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'info', - body: { stringValue: 'console.log 123 false' }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.console.logging', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 10, - }, - ]); - - expect(envelopeItems[3]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'info', - body: { stringValue: 'console.info 123 false' }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.console.logging', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 9, - }, - ]); - - expect(envelopeItems[4]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'warn', - body: { stringValue: 'console.warn 123 false' }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.console.logging', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 13, - }, - ]); - - expect(envelopeItems[5]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'error', - body: { stringValue: 'console.error 123 false' }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.console.logging', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 17, - }, - ]); - - expect(envelopeItems[6]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'error', - body: { stringValue: 'Assertion failed: console.assert 123 false' }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.console.logging', + timestamp: expect.any(Number), + level: 'error', + severity_number: 17, + trace_id: expect.any(String), + body: 'console.error 123 false', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', + timestamp: expect.any(Number), + level: 'error', + severity_number: 17, + trace_id: expect.any(String), + body: 'Assertion failed: console.assert 123 false', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), + timestamp: expect.any(Number), + level: 'info', + severity_number: 10, + trace_id: expect.any(String), + body: '', + attributes: { + 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 17, }, ]); }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts index de011cbf46a9..7fc4e02bad07 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import type { OtelLogEnvelope } from '@sentry/core'; +import type { LogEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers'; @@ -12,510 +12,180 @@ sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page const url = await getLocalTestUrl({ testDir: __dirname }); - const event = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser); + const event = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser); const envelopeItems = event[1]; expect(envelopeItems[0]).toEqual([ { - type: 'otel_log', + type: 'log', + item_count: 12, + content_type: 'application/vnd.sentry.items.log+json', }, { - severityText: 'trace', - body: { stringValue: 'test trace' }, - attributes: [ + items: [ { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', + timestamp: expect.any(Number), + level: 'trace', + body: 'test trace', + severity_number: 1, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'debug', + body: 'test debug', + severity_number: 5, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'test info', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 1, - }, - ]); - - expect(envelopeItems[1]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'debug', - body: { stringValue: 'test debug' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 5, - }, - ]); - - expect(envelopeItems[2]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'info', - body: { stringValue: 'test info' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 9, - }, - ]); - - expect(envelopeItems[3]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'warn', - body: { stringValue: 'test warn' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 13, - }, - ]); - - expect(envelopeItems[4]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'error', - body: { stringValue: 'test error' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 17, - }, - ]); - - expect(envelopeItems[5]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'fatal', - body: { stringValue: 'test fatal' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 21, - }, - ]); - - expect(envelopeItems[6]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'trace', - body: { stringValue: 'test trace stringArg false 123' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'sentry.message.template', - value: { - stringValue: 'test %s %s %s %s', - }, - }, - { - key: 'sentry.message.parameter.0', - value: { - stringValue: 'trace', - }, - }, - { - key: 'sentry.message.parameter.1', - value: { - stringValue: 'stringArg', - }, - }, - { - key: 'sentry.message.parameter.2', - value: { - boolValue: false, - }, - }, - { - key: 'sentry.message.parameter.3', - value: { - doubleValue: 123, - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 1, - }, - ]); - - expect(envelopeItems[7]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'debug', - body: { stringValue: 'test debug stringArg false 123' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'sentry.message.template', - value: { - stringValue: 'test %s %s %s %s', - }, - }, - { - key: 'sentry.message.parameter.0', - value: { - stringValue: 'debug', - }, - }, - { - key: 'sentry.message.parameter.1', - value: { - stringValue: 'stringArg', - }, - }, - { - key: 'sentry.message.parameter.2', - value: { - boolValue: false, - }, - }, - { - key: 'sentry.message.parameter.3', - value: { - doubleValue: 123, - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 5, - }, - ]); - - expect(envelopeItems[8]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'info', - body: { stringValue: 'test info stringArg false 123' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'sentry.message.template', - value: { - stringValue: 'test %s %s %s %s', - }, - }, - { - key: 'sentry.message.parameter.0', - value: { - stringValue: 'info', - }, - }, - { - key: 'sentry.message.parameter.1', - value: { - stringValue: 'stringArg', - }, - }, - { - key: 'sentry.message.parameter.2', - value: { - boolValue: false, - }, - }, - { - key: 'sentry.message.parameter.3', - value: { - doubleValue: 123, - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 9, - }, - ]); - - expect(envelopeItems[9]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'warn', - body: { stringValue: 'test warn stringArg false 123' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'sentry.message.template', - value: { - stringValue: 'test %s %s %s %s', - }, - }, - { - key: 'sentry.message.parameter.0', - value: { - stringValue: 'warn', - }, - }, - { - key: 'sentry.message.parameter.1', - value: { - stringValue: 'stringArg', - }, - }, - { - key: 'sentry.message.parameter.2', - value: { - boolValue: false, - }, - }, - { - key: 'sentry.message.parameter.3', - value: { - doubleValue: 123, - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 13, - }, - ]); - - expect(envelopeItems[10]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'error', - body: { stringValue: 'test error stringArg false 123' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'sentry.message.template', - value: { - stringValue: 'test %s %s %s %s', - }, - }, - { - key: 'sentry.message.parameter.0', - value: { - stringValue: 'error', - }, - }, - { - key: 'sentry.message.parameter.1', - value: { - stringValue: 'stringArg', - }, - }, - { - key: 'sentry.message.parameter.2', - value: { - boolValue: false, - }, - }, - { - key: 'sentry.message.parameter.3', - value: { - doubleValue: 123, - }, - }, - ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 17, - }, - ]); - - expect(envelopeItems[11]).toEqual([ - { - type: 'otel_log', - }, - { - severityText: 'fatal', - body: { stringValue: 'test fatal stringArg false 123' }, - attributes: [ - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.browser', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'sentry.message.template', - value: { - stringValue: 'test %s %s %s %s', - }, - }, - { - key: 'sentry.message.parameter.0', - value: { - stringValue: 'fatal', - }, - }, - { - key: 'sentry.message.parameter.1', - value: { - stringValue: 'stringArg', - }, - }, - { - key: 'sentry.message.parameter.2', - value: { - boolValue: false, - }, - }, - { - key: 'sentry.message.parameter.3', - value: { - doubleValue: 123, + }, + { + timestamp: expect.any(Number), + level: 'warn', + body: 'test warn', + severity_number: 13, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'test error', + severity_number: 17, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'fatal', + body: 'test fatal', + severity_number: 21, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'trace', + body: 'test trace stringArg false 123', + severity_number: 1, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'test %s %s %s %s', type: 'string' }, + 'sentry.message.parameter.0': { value: 'trace', type: 'string' }, + 'sentry.message.parameter.1': { value: 'stringArg', type: 'string' }, + 'sentry.message.parameter.2': { value: false, type: 'boolean' }, + 'sentry.message.parameter.3': { value: 123, type: 'integer' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'debug', + body: 'test debug stringArg false 123', + severity_number: 5, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'test %s %s %s %s', type: 'string' }, + 'sentry.message.parameter.0': { value: 'debug', type: 'string' }, + 'sentry.message.parameter.1': { value: 'stringArg', type: 'string' }, + 'sentry.message.parameter.2': { value: false, type: 'boolean' }, + 'sentry.message.parameter.3': { value: 123, type: 'integer' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'test info stringArg false 123', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'test %s %s %s %s', type: 'string' }, + 'sentry.message.parameter.0': { value: 'info', type: 'string' }, + 'sentry.message.parameter.1': { value: 'stringArg', type: 'string' }, + 'sentry.message.parameter.2': { value: false, type: 'boolean' }, + 'sentry.message.parameter.3': { value: 123, type: 'integer' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'warn', + body: 'test warn stringArg false 123', + severity_number: 13, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'test %s %s %s %s', type: 'string' }, + 'sentry.message.parameter.0': { value: 'warn', type: 'string' }, + 'sentry.message.parameter.1': { value: 'stringArg', type: 'string' }, + 'sentry.message.parameter.2': { value: false, type: 'boolean' }, + 'sentry.message.parameter.3': { value: 123, type: 'integer' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'test error stringArg false 123', + severity_number: 17, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'test %s %s %s %s', type: 'string' }, + 'sentry.message.parameter.0': { value: 'error', type: 'string' }, + 'sentry.message.parameter.1': { value: 'stringArg', type: 'string' }, + 'sentry.message.parameter.2': { value: false, type: 'boolean' }, + 'sentry.message.parameter.3': { value: 123, type: 'integer' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'fatal', + body: 'test fatal stringArg false 123', + severity_number: 21, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.message.template': { value: 'test %s %s %s %s', type: 'string' }, + 'sentry.message.parameter.0': { value: 'fatal', type: 'string' }, + 'sentry.message.parameter.1': { value: 'stringArg', type: 'string' }, + 'sentry.message.parameter.2': { value: false, type: 'boolean' }, + 'sentry.message.parameter.3': { value: 123, type: 'integer' }, }, }, ], - timeUnixNano: expect.any(String), - traceId: expect.any(String), - severityNumber: 21, }, ]); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/init.js new file mode 100644 index 000000000000..1415ef740b55 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/init.js @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + ], + tracePropagationTargets: ['someurl.com'], + tracesSampler: ctx => { + if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 1; + } + return ctx.inheritOrSampleWith(0); + }, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/subject.js new file mode 100644 index 000000000000..1feeadf34b10 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('https://someUrl.com'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/template.html new file mode 100644 index 000000000000..f27a71d043f9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/template.html @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts new file mode 100644 index 000000000000..915c91f2599e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test'; +import { + extractTraceparentData, + parseBaggageHeader, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, +} from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + eventAndTraceHeaderRequestParser, + shouldSkipTracingTest, + waitForTracingHeadersOnUrl, + waitForTransactionRequest, +} from '../../../../../../utils/helpers'; + +sentryTest.describe('When `consistentTraceSampling` is `true`', () => { + sentryTest('Continues sampling decision from initial pageload', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadTraceContext, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => { + return evt.contexts?.trace?.op === 'pageload'; + }); + await page.goto(url); + + const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise); + const pageloadSampleRand = Number(res[1]?.sample_rand); + const pageloadTraceContext = res[0].contexts?.trace; + + expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1); + expect(pageloadSampleRand).toBeGreaterThanOrEqual(0); + + return { pageloadTraceContext: res[0].contexts?.trace, pageloadSampleRand }; + }); + + const customTraceContext = await sentryTest.step('Custom trace', async () => { + const customTrace1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom'); + await page.locator('#btn1').click(); + const [customTrace1Event, customTraceTraceHeader] = eventAndTraceHeaderRequestParser( + await customTrace1RequestPromise, + ); + + const customTraceContext = customTrace1Event.contexts?.trace; + + expect(customTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + // although we "continue the trace" from pageload, this is actually a root span, + // so there must not be a parent span id + expect(customTraceContext?.parent_span_id).toBeUndefined(); + + expect(pageloadSampleRand).toEqual(Number(customTraceTraceHeader?.sample_rand)); + + return customTraceContext; + }); + + await sentryTest.step('Navigation', async () => { + const navigation1RequestPromise = waitForTransactionRequest( + page, + evt => evt.contexts?.trace?.op === 'navigation', + ); + await page.goto(`${url}#foo`); + const [navigationEvent, navigationTraceHeader] = eventAndTraceHeaderRequestParser( + await navigation1RequestPromise, + ); + const navTraceContext = navigationEvent.contexts?.trace; + + expect(navTraceContext?.trace_id).not.toEqual(customTraceContext?.trace_id); + expect(navTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + + expect(navTraceContext?.links).toEqual([ + { + trace_id: customTraceContext?.trace_id, + span_id: customTraceContext?.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + expect(navTraceContext?.parent_span_id).toBeUndefined(); + + expect(pageloadSampleRand).toEqual(Number(navigationTraceHeader?.sample_rand)); + }); + }); + + sentryTest('Propagates continued sampling decision to outgoing requests', async ({ page, getLocalTestUrl }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadTraceContext, pageloadSampleRand } = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); + + const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise); + const pageloadSampleRand = Number(res[1]?.sample_rand); + + expect(pageloadSampleRand).toBeGreaterThanOrEqual(0); + + const pageloadTraceContext = res[0].contexts?.trace; + + expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1); + + return { pageloadTraceContext: pageloadTraceContext, pageloadSampleRand }; + }); + + await sentryTest.step('Make fetch request', async () => { + const fetchTracePromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom'); + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + + const [fetchTraceEvent, fetchTraceTraceHeader] = eventAndTraceHeaderRequestParser(await fetchTracePromise); + + const fetchTraceSampleRand = Number(fetchTraceTraceHeader?.sample_rand); + const fetchTraceTraceContext = fetchTraceEvent.contexts?.trace; + const httpClientSpan = fetchTraceEvent.spans?.find(span => span.op === 'http.client'); + + expect(fetchTraceSampleRand).toEqual(pageloadSampleRand); + + expect(fetchTraceTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toEqual( + pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE], + ); + expect(fetchTraceTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: fetchTraceTraceContext?.trace_id, + parentSpanId: httpClientSpan?.span_id, + parentSampled: true, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${pageloadSampleRand}`, + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-trace_id': fetchTraceTraceContext?.trace_id, + 'sentry-transaction': 'custom root span 2', + }); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/init.js new file mode 100644 index 000000000000..0b26aa6be474 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + ], + tracePropagationTargets: ['someurl.com'], + tracesSampleRate: 1, + debug: true, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/subject.js new file mode 100644 index 000000000000..1feeadf34b10 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('https://someUrl.com'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/template.html new file mode 100644 index 000000000000..6347fa37fc00 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/template.html @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts new file mode 100644 index 000000000000..8c73bde21c9a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-negative/test.ts @@ -0,0 +1,100 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + getMultipleSentryEnvelopeRequests, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, + waitForTracingHeadersOnUrl, +} from '../../../../../../utils/helpers'; + +const metaTagSampleRand = 0.9; +const metaTagSampleRate = 0.2; +const metaTagTraceId = '12345678901234567890123456789012'; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest( + 'Continues negative sampling decision from meta tag across all traces and downstream propagations', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + let txnsReceived = 0; + // @ts-expect-error - no need to return something valid here + getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'transaction' }, () => { + ++txnsReceived; + return {}; + }); + + const clientReportPromise = waitForClientReportRequest(page); + + await sentryTest.step('Initial pageload', async () => { + await page.goto(url); + expect(txnsReceived).toEqual(0); + }); + + await sentryTest.step('Custom instrumented button click', async () => { + await page.locator('#btn1').click(); + expect(txnsReceived).toEqual(0); + }); + + await sentryTest.step('Navigation', async () => { + await page.goto(`${url}#foo`); + expect(txnsReceived).toEqual(0); + }); + + await sentryTest.step('Make fetch request', async () => { + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com'); + + await page.locator('#btn2').click(); + const { baggage, sentryTrace } = await tracingHeadersPromise; + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: expect.not.stringContaining(metaTagTraceId), + parentSpanId: expect.stringMatching(/^[0-9a-f]{16}$/), + parentSampled: false, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'false', + 'sentry-trace_id': expect.not.stringContaining(metaTagTraceId), + 'sentry-transaction': 'custom root span 2', + }); + }); + + await sentryTest.step('Client report', async () => { + await hidePage(page); + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 4, + reason: 'sample_rate', + }, + ], + }); + }); + + await sentryTest.step('Wait for transactions to be discarded', async () => { + // give it a little longer just in case a txn is pending to be sent + await page.waitForTimeout(1000); + expect(txnsReceived).toEqual(0); + }); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/init.js new file mode 100644 index 000000000000..4c65e3d977de --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'session-storage', + consistentTraceSampling: true, + }), + ], + tracePropagationTargets: ['someurl.com'], + tracesSampler: ({ inheritOrSampleWith }) => { + return inheritOrSampleWith(0); + }, + debug: true, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/page-1.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/page-1.html new file mode 100644 index 000000000000..9a0719b7e505 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/page-1.html @@ -0,0 +1,15 @@ + + + + + + + + +

Another Page

+ Go To the next page + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/page-2.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/page-2.html new file mode 100644 index 000000000000..27cd47bba7c1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/page-2.html @@ -0,0 +1,10 @@ + + + + + + +

Another Page

+ Go To the next page + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/subject.js new file mode 100644 index 000000000000..376b2102e462 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1?.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2?.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('https://someUrl.com'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/template.html new file mode 100644 index 000000000000..eab1fecca6c4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/template.html @@ -0,0 +1,14 @@ + + + + + + + + Go To another page + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts new file mode 100644 index 000000000000..840c465a9b0d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta-precedence/test.ts @@ -0,0 +1,105 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + eventAndTraceHeaderRequestParser, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, + waitForTracingHeadersOnUrl, + waitForTransactionRequest, +} from '../../../../../../utils/helpers'; + +const metaTagSampleRand = 0.9; +const metaTagSampleRate = 0.2; +const metaTagTraceIdIndex = '12345678901234567890123456789012'; +const metaTagTraceIdPage1 = 'a2345678901234567890123456789012'; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest( + 'meta tag decision has precedence over sampling decision from previous trace in session storage', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clientReportPromise = waitForClientReportRequest(page); + + await sentryTest.step('Initial pageload', async () => { + await page.goto(url); + }); + + await sentryTest.step('Make fetch request', async () => { + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: expect.not.stringContaining(metaTagTraceIdIndex), + parentSpanId: expect.stringMatching(/^[0-9a-f]{16}$/), + parentSampled: false, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'false', + 'sentry-trace_id': expect.not.stringContaining(metaTagTraceIdIndex), + 'sentry-transaction': 'custom root span 2', + }); + }); + + await sentryTest.step('Client report', async () => { + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 2, + reason: 'sample_rate', + }, + ], + }); + }); + + await sentryTest.step('Navigate to another page with meta tags', async () => { + const page1Pageload = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.locator('a').click(); + + const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await page1Pageload); + const pageloadTraceContext = pageloadEvent.contexts?.trace; + + expect(Number(pageloadTraceHeader?.sample_rand)).toBe(0.12); + expect(Number(pageloadTraceHeader?.sample_rate)).toBe(0.2); + expect(pageloadTraceContext?.trace_id).toEqual(metaTagTraceIdPage1); + }); + + await sentryTest.step('Navigate to another page without meta tags', async () => { + const page2Pageload = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.locator('a').click(); + + const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await page2Pageload); + const pageloadTraceContext = pageloadEvent.contexts?.trace; + + expect(Number(pageloadTraceHeader?.sample_rand)).toBe(0.12); + expect(Number(pageloadTraceHeader?.sample_rate)).toBe(0.2); + expect(pageloadTraceContext?.trace_id).not.toEqual(metaTagTraceIdPage1); + expect(pageloadTraceContext?.trace_id).not.toEqual(metaTagTraceIdIndex); + }); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/init.js new file mode 100644 index 000000000000..e100eb49469a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + }), + ], + tracePropagationTargets: ['someurl.com'], + // only take into account sampling from meta tag; otherwise sample negatively + tracesSampleRate: 0, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/subject.js new file mode 100644 index 000000000000..1feeadf34b10 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('https://someUrl.com'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/template.html new file mode 100644 index 000000000000..c6a798a60c24 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/template.html @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/test.ts new file mode 100644 index 000000000000..54f374b6ca11 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/meta/test.ts @@ -0,0 +1,176 @@ +import { expect } from '@playwright/test'; +import { + extractTraceparentData, + parseBaggageHeader, + SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, +} from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + eventAndTraceHeaderRequestParser, + shouldSkipTracingTest, + waitForTracingHeadersOnUrl, + waitForTransactionRequest, +} from '../../../../../../utils/helpers'; + +const metaTagSampleRand = 0.051121; +const metaTagSampleRate = 0.2; + +sentryTest.describe('When `consistentTraceSampling` is `true` and page contains tags', () => { + sentryTest('Continues sampling decision across all traces from meta tag', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + + await page.goto(url); + + const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await pageloadRequestPromise); + const pageloadTraceContext = pageloadEvent.contexts?.trace; + + expect(Number(pageloadTraceHeader?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(pageloadTraceHeader?.sample_rate)).toBe(metaTagSampleRate); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined(); + expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined(); + + return pageloadTraceContext; + }); + + const customTraceContext = await sentryTest.step('Custom trace', async () => { + const customTrace1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom'); + + await page.locator('#btn1').click(); + + const [customTrace1Event, customTraceTraceHeader] = eventAndTraceHeaderRequestParser( + await customTrace1RequestPromise, + ); + + const customTraceContext = customTrace1Event.contexts?.trace; + + expect(customTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + expect(customTraceContext?.parent_span_id).toBeUndefined(); + + expect(Number(customTraceTraceHeader?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(customTraceTraceHeader?.sample_rate)).toBe(metaTagSampleRate); + expect(Boolean(customTraceTraceHeader?.sampled)).toBe(true); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(customTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined(); + + // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header) + expect(customTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBe(metaTagSampleRate); + + return customTraceContext; + }); + + await sentryTest.step('Navigation', async () => { + const navigation1RequestPromise = waitForTransactionRequest( + page, + evt => evt.contexts?.trace?.op === 'navigation', + ); + + await page.goto(`${url}#foo`); + + const [navigationEvent, navigationTraceHeader] = eventAndTraceHeaderRequestParser( + await navigation1RequestPromise, + ); + + const navigationTraceContext = navigationEvent.contexts?.trace; + + expect(navigationTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + expect(navigationTraceContext?.trace_id).not.toEqual(customTraceContext?.trace_id); + + expect(navigationTraceContext?.parent_span_id).toBeUndefined(); + + expect(Number(navigationTraceHeader?.sample_rand)).toEqual(metaTagSampleRand); + expect(Number(navigationTraceHeader?.sample_rate)).toEqual(metaTagSampleRate); + expect(Boolean(navigationTraceHeader?.sampled)).toEqual(true); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(navigationTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined(); + + // but we need to set this attribute to still be able to correctly add the sample rate to the DSC (checked above in trace header) + expect(navigationTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBe( + metaTagSampleRate, + ); + }); + }); + + sentryTest( + 'Propagates continued tag sampling decision to outgoing requests', + async ({ page, getLocalTestUrl }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + + await page.goto(url); + + const [pageloadEvent, pageloadTraceHeader] = eventAndTraceHeaderRequestParser(await pageloadRequestPromise); + const pageloadTraceContext = pageloadEvent.contexts?.trace; + + expect(Number(pageloadTraceHeader?.sample_rand)).toBe(metaTagSampleRand); + expect(Number(pageloadTraceHeader?.sample_rate)).toBe(metaTagSampleRate); + + // since the local sample rate was not applied, the sample rate attribute shouldn't be set + expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined(); + expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBeUndefined(); + + return pageloadTraceContext; + }); + + await sentryTest.step('Make fetch request', async () => { + const fetchTracePromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom'); + const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'https://someUrl.com'); + + await page.locator('#btn2').click(); + + const { baggage, sentryTrace } = await tracingHeadersPromise; + + const [fetchTraceEvent, fetchTraceTraceHeader] = eventAndTraceHeaderRequestParser(await fetchTracePromise); + + const fetchTraceSampleRand = Number(fetchTraceTraceHeader?.sample_rand); + const fetchTraceTraceContext = fetchTraceEvent.contexts?.trace; + const httpClientSpan = fetchTraceEvent.spans?.find(span => span.op === 'http.client'); + + expect(fetchTraceSampleRand).toEqual(metaTagSampleRand); + + expect(fetchTraceTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBeUndefined(); + expect(fetchTraceTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]).toBe( + metaTagSampleRate, + ); + + expect(fetchTraceTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + + expect(sentryTrace).toBeDefined(); + expect(baggage).toBeDefined(); + + expect(extractTraceparentData(sentryTrace)).toEqual({ + traceId: fetchTraceTraceContext?.trace_id, + parentSpanId: httpClientSpan?.span_id, + parentSampled: true, + }); + + expect(parseBaggageHeader(baggage)).toEqual({ + 'sentry-environment': 'production', + 'sentry-public_key': 'public', + 'sentry-sample_rand': `${metaTagSampleRand}`, + 'sentry-sample_rate': `${metaTagSampleRate}`, + 'sentry-sampled': 'true', + 'sentry-trace_id': fetchTraceTraceContext?.trace_id, + 'sentry-transaction': 'custom root span 2', + }); + }); + }, + ); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/init.js new file mode 100644 index 000000000000..686bbef5f992 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/init.js @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + linkPreviousTrace: 'in-memory', + consistentTraceSampling: true, + enableInp: false, + }), + ], + tracePropagationTargets: ['someurl.com'], + tracesSampler: ctx => { + if (ctx.attributes && ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 1; + } + if (ctx.name === 'custom root span 1') { + return 0; + } + if (ctx.name === 'custom root span 2') { + return 1; + } + return ctx.inheritOrSampleWith(0); + }, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/subject.js new file mode 100644 index 000000000000..1feeadf34b10 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/subject.js @@ -0,0 +1,17 @@ +const btn1 = document.getElementById('btn1'); + +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 1', op: 'custom' }, () => {}); + }); +}); + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ name: 'custom root span 2', op: 'custom' }, async () => { + await fetch('https://someUrl.com'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/template.html new file mode 100644 index 000000000000..f27a71d043f9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/template.html @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts new file mode 100644 index 000000000000..9e896798be90 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/tracesSampler-precedence/test.ts @@ -0,0 +1,139 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser'; +import type { ClientReport } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + eventAndTraceHeaderRequestParser, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, + waitForTransactionRequest, +} from '../../../../../../utils/helpers'; + +/** + * This test demonstrates that: + * - explicit sampling decisions in `tracesSampler` has precedence over consistent sampling + * - despite consistentTraceSampling being activated, there are still a lot of cases where the trace chain can break + */ +sentryTest.describe('When `consistentTraceSampling` is `true`', () => { + sentryTest('explicit sampling decisions in `tracesSampler` have precedence', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const { pageloadTraceContext } = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => { + return evt.contexts?.trace?.op === 'pageload'; + }); + await page.goto(url); + + const res = eventAndTraceHeaderRequestParser(await pageloadRequestPromise); + const pageloadSampleRand = Number(res[1]?.sample_rand); + const pageloadTraceContext = res[0].contexts?.trace; + + expect(pageloadTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1); + expect(pageloadSampleRand).toBeGreaterThanOrEqual(0); + + return { pageloadTraceContext: res[0].contexts?.trace, pageloadSampleRand }; + }); + + await sentryTest.step('Custom trace is sampled negatively (explicitly in tracesSampler)', async () => { + const clientReportPromise = waitForClientReportRequest(page); + + await page.locator('#btn1').click(); + + await page.waitForTimeout(500); + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }); + }); + + await sentryTest.step('Subsequent navigation trace is also sampled negatively', async () => { + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(`${url}#foo`); + + await page.waitForTimeout(500); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport).toEqual({ + timestamp: expect.any(Number), + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }); + }); + + const { customTrace2Context } = await sentryTest.step( + 'Custom trace 2 is sampled positively (explicitly in tracesSampler)', + async () => { + const customTrace2RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom'); + + await page.locator('#btn2').click(); + + const [customTrace2Event] = eventAndTraceHeaderRequestParser(await customTrace2RequestPromise); + + const customTrace2Context = customTrace2Event.contexts?.trace; + + expect(customTrace2Context?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1); + expect(customTrace2Context?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + expect(customTrace2Context?.parent_span_id).toBeUndefined(); + + expect(customTrace2Context?.links).toEqual([ + { + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: false, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + }, + ]); + + return { customTrace2Context }; + }, + ); + + await sentryTest.step('Navigation trace is sampled positively (inherited from previous trace)', async () => { + const navigationRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + + await page.goto(`${url}#bar`); + + const [navigationEvent] = eventAndTraceHeaderRequestParser(await navigationRequestPromise); + + const navigationTraceContext = navigationEvent.contexts?.trace; + + expect(navigationTraceContext?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]).toBe(1); + expect(navigationTraceContext?.trace_id).not.toEqual(customTrace2Context?.trace_id); + expect(navigationTraceContext?.parent_span_id).toBeUndefined(); + + expect(navigationTraceContext?.links).toEqual([ + { + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + span_id: customTrace2Context?.span_id, + trace_id: customTrace2Context?.trace_id, + }, + ]); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/subject.js rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/subject.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/template.html rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/template.html diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/default/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/init.js rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/init.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/init.js rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/init.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/template.html rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/template.html diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/template.html rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/template.html diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/init.js rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/init.js rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/init.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/init.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/init.js rename to dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/init.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/subject.js similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/subject.js rename to dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/subject.js diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/template.html b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/template.html similarity index 100% rename from dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/template.html rename to dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/template.html diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts similarity index 94% rename from dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/test.ts rename to dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts index 18f37a5c9c28..7c0f6db3483b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/trace-header-merging/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts @@ -32,6 +32,8 @@ async function assertRequests({ }); }); + expect(requests).toHaveLength(2); + requests.forEach(request => { const headers = request.headers(); @@ -39,7 +41,7 @@ async function assertRequests({ expect(headers['sentry-trace']).not.toContain(','); // No multiple baggage entries - expect(headers['baggage'].match(/sentry-trace_id/g) ?? []).toHaveLength(1); + expect(headers['baggage'].match(/sentry-release/g) ?? []).toHaveLength(1); }); } diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/init.js new file mode 100644 index 000000000000..b2280b70e307 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/subject.js new file mode 100644 index 000000000000..6301cb2916a4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/subject.js @@ -0,0 +1,3 @@ +fetch('http://sentry-test-site.example/api/test/', { + headers: { 'sentry-trace': 'abc-123-1', baggage: 'sentry-trace_id=abc' }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/test.ts new file mode 100644 index 000000000000..226a791f74bd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-with-custom-sentry-headers/test.ts @@ -0,0 +1,22 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest("instrumentation doesn't override manually added sentry headers", async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/api/test/'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const request = await requestPromise; + + const headers = await request.allHeaders(); + + expect(headers['sentry-trace']).toBe('abc-123-1'); + expect(headers.baggage).toBe('sentry-trace_id=abc'); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-merged-baggage-headers/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-merged-baggage-headers/subject.js new file mode 100644 index 000000000000..584c264d1f0a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-merged-baggage-headers/subject.js @@ -0,0 +1,7 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test-site.example/1'); +xhr.setRequestHeader('X-Test-Header', 'existing-header'); +xhr.setRequestHeader('baggage', 'someVendor-foo=bar'); + +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-merged-baggage-headers/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-merged-baggage-headers/test.ts new file mode 100644 index 000000000000..3086347cbd0c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-merged-baggage-headers/test.ts @@ -0,0 +1,25 @@ +import { expect } from '@playwright/test'; +import { TRACEPARENT_REGEXP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('merges `baggage` headers of pre-existing non-sentry XHR requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/1'); + + await page.goto(url); + + const request = await requestPromise; + + const requestHeaders = request.headers(); + expect(requestHeaders).toMatchObject({ + 'sentry-trace': expect.stringMatching(TRACEPARENT_REGEXP), + baggage: expect.stringMatching(/^someVendor-foo=bar, sentry-.*$/), + 'x-test-header': 'existing-header', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers/subject.js new file mode 100644 index 000000000000..595ab4b67bac --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers/subject.js @@ -0,0 +1,8 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test-site.example/1'); +xhr.setRequestHeader('X-Test-Header', 'existing-header'); +xhr.setRequestHeader('sentry-trace', '123-abc-1'); +xhr.setRequestHeader('baggage', ' sentry-release=1.1.1, sentry-trace_id=123'); + +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers/test.ts new file mode 100644 index 000000000000..49d4b3091258 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-with-custom-sentry-headers/test.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'attaches manually passed in `sentry-trace` and `baggage` headers to XHR requests', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/1'); + + await page.goto(url); + + const request = await requestPromise; + + const requestHeaders = request.headers(); + expect(requestHeaders).toMatchObject({ + 'sentry-trace': '123-abc-1', + baggage: 'sentry-release=1.1.1, sentry-trace_id=123', + 'x-test-header': 'existing-header', + }); + }, +); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index feecd7c5ce09..e4ebd8b19313 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -1,9 +1,11 @@ +/* eslint-disable max-lines */ import type { Page, Request } from '@playwright/test'; import type { + ClientReport, Envelope, EnvelopeItem, EnvelopeItemType, - Event, + Event as SentryEvent, EventEnvelope, EventEnvelopeHeaders, SessionContext, @@ -27,7 +29,7 @@ export const envelopeParser = (request: Request | null): unknown[] => { }); }; -export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): T => { +export const envelopeRequestParser = (request: Request | null, envelopeIndex = 2): T => { return envelopeParser(request)[envelopeIndex] as T; }; @@ -48,7 +50,7 @@ export const properEnvelopeParser = (request: Request | null): EnvelopeItem[] => return items; }; -export type EventAndTraceHeader = [Event, EventEnvelopeHeaders['trace']]; +export type EventAndTraceHeader = [SentryEvent, EventEnvelopeHeaders['trace']]; /** * Returns the first event item and `trace` envelope header from an envelope. @@ -67,7 +69,7 @@ const properFullEnvelopeParser = (request: Request | null): }; function getEventAndTraceHeader(envelope: EventEnvelope): EventAndTraceHeader { - const event = envelope[1][0]?.[1] as Event | undefined; + const event = envelope[1][0]?.[1] as SentryEvent | undefined; const trace = envelope[0]?.trace; if (!event || !trace) { @@ -77,7 +79,7 @@ function getEventAndTraceHeader(envelope: EventEnvelope): EventAndTraceHeader { return [event, trace]; } -export const properEnvelopeRequestParser = (request: Request | null, envelopeIndex = 1): T => { +export const properEnvelopeRequestParser = (request: Request | null, envelopeIndex = 1): T => { return properEnvelopeParser(request)[0]?.[envelopeIndex] as T; }; @@ -180,13 +182,13 @@ export async function runScriptInSandbox( * * @param {Page} page * @param {string} [url] - * @return {*} {Promise>} + * @return {*} {Promise>} */ -export async function getSentryEvents(page: Page, url?: string): Promise> { +export async function getSentryEvents(page: Page, url?: string): Promise> { if (url) { await page.goto(url); } - const eventsHandle = await page.evaluateHandle>('window.events'); + const eventsHandle = await page.evaluateHandle>('window.events'); return eventsHandle.jsonValue(); } @@ -201,7 +203,7 @@ export async function waitForTransactionRequestOnUrl(page: Page, url: string): P return req; } -export function waitForErrorRequest(page: Page, callback?: (event: Event) => boolean): Promise { +export function waitForErrorRequest(page: Page, callback?: (event: SentryEvent) => boolean): Promise { return page.waitForRequest(req => { const postData = req.postData(); if (!postData) { @@ -254,6 +256,31 @@ export function waitForTransactionRequest( }); } +export function waitForClientReportRequest(page: Page, callback?: (report: ClientReport) => boolean): Promise { + return page.waitForRequest(req => { + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + const maybeReport = envelopeRequestParser>(req); + + if (typeof maybeReport.discarded_events !== 'object') { + return false; + } + + if (callback) { + return callback(maybeReport as ClientReport); + } + + return true; + } catch { + return false; + } + }); +} + export async function waitForSession(page: Page): Promise { const req = await page.waitForRequest(req => { const postData = req.postData(); @@ -419,3 +446,36 @@ export async function getFirstSentryEnvelopeRequest( return req; } + +export async function hidePage(page: Page): Promise { + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: function () { + return 'hidden'; + }, + }); + + // Dispatch the visibilitychange event to notify listeners + document.dispatchEvent(new Event('visibilitychange')); + }); +} + +export async function waitForTracingHeadersOnUrl( + page: Page, + url: string, +): Promise<{ baggage: string; sentryTrace: string }> { + return new Promise<{ baggage: string; sentryTrace: string }>(resolve => { + page + .route(url, (route, req) => { + const baggage = req.headers()['baggage']; + const sentryTrace = req.headers()['sentry-trace']; + resolve({ baggage, sentryTrace }); + return route.fulfill({ status: 200, body: 'ok' }); + }) + .catch(error => { + // Handle any routing setup errors + throw error; + }); + }); +} diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index a1dceaeebfda..718fdf478053 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -16,7 +16,7 @@ "clean": "rimraf tmp node_modules && yarn clean:test-applications && yarn clean:pnpm", "ci:build-matrix": "ts-node ./lib/getTestMatrix.ts", "ci:build-matrix-optional": "ts-node ./lib/getTestMatrix.ts --optional=true", - "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,pnpm-lock.yaml,.last-run.json,test-results}", + "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,.astro,.output,pnpm-lock.yaml,.last-run.json,test-results}", "clean:pnpm": "pnpm store prune" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts index 2bcf6cbf2362..eb70f7362e63 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts @@ -62,7 +62,6 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { }); expect(serverPageRequestTxn).toMatchObject({ - breadcrumbs: expect.any(Array), contexts: { app: expect.any(Object), cloud_resource: expect.any(Object), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts index 346928c44ebc..084824824225 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -125,20 +125,3 @@ test('Should send a transaction event with correct status for a generateMetadata expect((await transactionPromise).contexts?.trace?.status).toBe('ok'); }); - -test('Should send a transaction event with correct status for a generateMetadata() function invocation with notfound()', async ({ - page, -}) => { - const testTitle = 'notfound-foobar'; - - const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/generation-functions/with-notfound?metadataTitle=${testTitle}` - ); - }); - - await page.goto(`/generation-functions/with-notfound?metadataTitle=${testTitle}`); - - expect((await transactionPromise).contexts?.trace?.status).toBe('not_found'); -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts index 386b8c6e117f..df5361852508 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts @@ -5,5 +5,5 @@ export async function GET() { } export async function POST() { - return NextResponse.json({ name: 'John Doe' }, { status: 404 }); + return NextResponse.json({ name: 'John Doe' }, { status: 403 }); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts index 648ee81839ac..02f2b6dc4f24 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts @@ -28,7 +28,7 @@ test('Should create a transaction for route handlers and correctly set span stat const routehandlerTransaction = await routehandlerTransactionPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('not_found'); + expect(routehandlerTransaction.contexts?.trace?.status).toBe('permission_denied'); expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index 6b320e26eb8a..76a02ea2c255 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -13,6 +13,9 @@ Sentry.init({ debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, + _experiments: { + enableLogs: true, + }, }); import { TRPCError, initTRPC } from '@trpc/server'; @@ -30,6 +33,11 @@ app.get('/test-success', function (req, res) { res.send({ version: 'v1' }); }); +app.get('/test-log', function (req, res) { + Sentry.logger.debug('Accessed /test-log route'); + res.send({ message: 'Log sent' }); +}); + app.get('/test-param/:param', function (req, res) { res.send({ paramWas: req.params.param }); }); @@ -123,8 +131,8 @@ export const appRouter = t.router({ .mutation(() => { throw new Error('I crashed in a trpc handler'); }), - dontFindSomething: procedure.mutation(() => { - throw new TRPCError({ code: 'NOT_FOUND', cause: new Error('Page not found') }); + unauthorized: procedure.mutation(() => { + throw new TRPCError({ code: 'UNAUTHORIZED', cause: new Error('Unauthorized') }); }), }); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/logs.test.ts new file mode 100644 index 000000000000..f2e125696af6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLog, SerializedLogContainer } from '@sentry/core'; + +test('should send logs', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-express', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts index 633306ae713a..3cb458f81175 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts @@ -109,12 +109,12 @@ test('Should record transaction and error for a trpc handler that returns a stat const transactionEventPromise = waitForTransaction('node-express', transactionEvent => { return ( transactionEvent.transaction === 'POST /trpc' && - !!transactionEvent.spans?.find(span => span.description === 'trpc/dontFindSomething') + !!transactionEvent.spans?.find(span => span.description === 'trpc/unauthorized') ); }); const errorEventPromise = waitForError('node-express', errorEvent => { - return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Page not found')); + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Unauthorized')); }); const trpcClient = createTRPCProxyClient({ @@ -125,7 +125,7 @@ test('Should record transaction and error for a trpc handler that returns a stat ], }); - await expect(trpcClient.dontFindSomething.mutate()).rejects.toBeDefined(); + await expect(trpcClient.unauthorized.mutate()).rejects.toBeDefined(); await expect(transactionEventPromise).resolves.toBeDefined(); await expect(errorEventPromise).resolves.toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js b/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js index ae803aa3edf7..8b68e8412aba 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js +++ b/dev-packages/e2e-tests/test-applications/node-hapi/src/app.js @@ -102,7 +102,7 @@ const init = async () => { const path = request.route.path; if (path.includes('boom-4xx')) { - throw Boom.notFound('4xx not found (onPreResponse)'); + throw Boom.badRequest('4xx bad request (onPreResponse)'); } else if (path.includes('boom-5xx')) { throw Boom.gatewayTimeout('5xx not implemented (onPreResponse)'); } else if (path.includes('JS-error-onPreResponse')) { diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-hapi/tests/errors.test.ts index 6531b83baa8e..4f2fba52aa95 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-hapi/tests/errors.test.ts @@ -77,7 +77,7 @@ test('Does not send errors to Sentry if boom throws in "onPreResponse" after JS const response4xx = await fetch(`${baseURL}/test-failure-boom-4xx`); const response5xx = await fetch(`${baseURL}/test-failure-boom-5xx`); - expect(response4xx.status).toBe(404); + expect(response4xx.status).toBe(400); expect(response5xx.status).toBe(504); const transactionEvent4xx = await transactionEventPromise4xx; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx index 2200fcea97c3..925c1e6ab143 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx @@ -8,7 +8,7 @@ Sentry.init({ // todo: get this from env dsn: 'https://username@domain/123', tunnel: `http://localhost:3031/`, // proxy server - integrations: [Sentry.browserTracingIntegration()], + integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, tracePropagationTargets: [/^\//], }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts index bb7472366681..1d8ab1b24a74 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts @@ -12,6 +12,7 @@ export default [ ]), ...prefix('performance', [ index('routes/performance/index.tsx'), + route('ssr', 'routes/performance/ssr.tsx'), route('with/:param', 'routes/performance/dynamic-param.tsx'), route('static', 'routes/performance/static.tsx'), ]), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx index 9d55975e61a5..99086aadfeae 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx @@ -1,3 +1,13 @@ +import { Link } from 'react-router'; + export default function PerformancePage() { - return

Performance Page

; + return ( +
+

Performance Page

+ +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/ssr.tsx new file mode 100644 index 000000000000..253e964ff15d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/ssr.tsx @@ -0,0 +1,7 @@ +export default function SsrPage() { + return ( +
+

SSR Page

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts new file mode 100644 index 000000000000..57e3e764d6a8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - navigation performance', () => { + test('should create navigation transaction', async ({ page }) => { + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/ssr'; + }); + + await page.goto(`/performance`); // pageload + await page.waitForTimeout(1000); // give it a sec before navigation + await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation + + const transaction = await navigationPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react-router', + 'sentry.op': 'navigation', + 'sentry.source': 'url', + }, + op: 'navigation', + origin: 'auto.navigation.react-router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/ssr', + type: 'transaction', + transaction_info: { source: 'url' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/ssr'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should update navigation transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with/:param'; + }); + + await page.goto(`/performance`); // pageload + await page.waitForTimeout(1000); // give it a sec before navigation + await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react-router', + 'sentry.op': 'navigation', + 'sentry.source': 'route', + }, + op: 'navigation', + origin: 'auto.navigation.react-router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts index c53494c723b4..e283ea522c4a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts @@ -53,6 +53,56 @@ test.describe('client - pageload performance', () => { }); }); + test('should update pageload transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with/:param'; + }); + + await page.goto(`/performance/with/sentry`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.browser', + 'sentry.op': 'pageload', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.browser', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + // todo: this page is currently not prerendered (see react-router.config.ts) test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { diff --git a/dev-packages/node-integration-tests/.eslintrc.js b/dev-packages/node-integration-tests/.eslintrc.js index c69f03a3b819..a3501df39470 100644 --- a/dev-packages/node-integration-tests/.eslintrc.js +++ b/dev-packages/node-integration-tests/.eslintrc.js @@ -12,10 +12,11 @@ module.exports = { }, }, { - files: ['suites/**/*.ts'], + files: ['suites/**/*.ts', 'suites/**/*.mjs'], parserOptions: { project: ['tsconfig.test.json'], sourceType: 'module', + ecmaVersion: 'latest', }, rules: { '@typescript-eslint/typedef': 'off', diff --git a/dev-packages/node-integration-tests/.gitignore b/dev-packages/node-integration-tests/.gitignore new file mode 100644 index 000000000000..365cb959a94c --- /dev/null +++ b/dev-packages/node-integration-tests/.gitignore @@ -0,0 +1 @@ +suites/**/tmp_* diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index e23491383d66..b393851d68d4 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -39,7 +39,6 @@ "ai": "^4.0.6", "amqplib": "^0.10.7", "apollo-server": "^3.11.1", - "axios": "^1.7.7", "body-parser": "^1.20.3", "connect": "^3.7.0", "cors": "^2.8.5", diff --git a/dev-packages/node-integration-tests/src/index.ts b/dev-packages/node-integration-tests/src/index.ts index 5ad4ac1978a1..ed6a150bd8d6 100644 --- a/dev-packages/node-integration-tests/src/index.ts +++ b/dev-packages/node-integration-tests/src/index.ts @@ -25,7 +25,10 @@ export function loggingTransport(_options: BaseTransportOptions): Transport { * Setting this port to something specific is useful for local debugging but dangerous for * CI/CD environments where port collisions can cause flakes! */ -export function startExpressServerAndSendPortToRunner(app: Express, port: number | undefined = undefined): void { +export function startExpressServerAndSendPortToRunner( + app: Pick, + port: number | undefined = undefined, +): void { const server = app.listen(port || 0, () => { const address = server.address() as AddressInfo; diff --git a/dev-packages/node-integration-tests/suites/anr/app-path.mjs b/dev-packages/node-integration-tests/suites/anr/app-path.mjs index 97f28d07c59e..5ace9ebbb3e8 100644 --- a/dev-packages/node-integration-tests/suites/anr/app-path.mjs +++ b/dev-packages/node-integration-tests/suites/anr/app-path.mjs @@ -1,10 +1,9 @@ +import * as Sentry from '@sentry/node'; import * as assert from 'assert'; import * as crypto from 'crypto'; import * as path from 'path'; import * as url from 'url'; -import * as Sentry from '@sentry/node'; - global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' }; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/dev-packages/node-integration-tests/suites/anr/basic-multiple.mjs b/dev-packages/node-integration-tests/suites/anr/basic-multiple.mjs index 49c28cb21dbf..d24e5d9f20d9 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic-multiple.mjs +++ b/dev-packages/node-integration-tests/suites/anr/basic-multiple.mjs @@ -1,8 +1,7 @@ +import * as Sentry from '@sentry/node'; import * as assert from 'assert'; import * as crypto from 'crypto'; -import * as Sentry from '@sentry/node'; - global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' }; setTimeout(() => { diff --git a/dev-packages/node-integration-tests/suites/anr/basic.mjs b/dev-packages/node-integration-tests/suites/anr/basic.mjs index 85b5cfb55c35..19b766c1e0e4 100644 --- a/dev-packages/node-integration-tests/suites/anr/basic.mjs +++ b/dev-packages/node-integration-tests/suites/anr/basic.mjs @@ -1,8 +1,7 @@ +import * as Sentry from '@sentry/node'; import * as assert from 'assert'; import * as crypto from 'crypto'; -import * as Sentry from '@sentry/node'; - global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' }; setTimeout(() => { diff --git a/dev-packages/node-integration-tests/suites/anr/indefinite.mjs b/dev-packages/node-integration-tests/suites/anr/indefinite.mjs index 000c63a12cf3..927e4e5fc4ad 100644 --- a/dev-packages/node-integration-tests/suites/anr/indefinite.mjs +++ b/dev-packages/node-integration-tests/suites/anr/indefinite.mjs @@ -1,8 +1,7 @@ +import * as Sentry from '@sentry/node'; import * as assert from 'assert'; import * as crypto from 'crypto'; -import * as Sentry from '@sentry/node'; - setTimeout(() => { process.exit(); }, 10000); diff --git a/dev-packages/node-integration-tests/suites/anr/isolated.mjs b/dev-packages/node-integration-tests/suites/anr/isolated.mjs index 26ec9eaf4546..c0ca76da4319 100644 --- a/dev-packages/node-integration-tests/suites/anr/isolated.mjs +++ b/dev-packages/node-integration-tests/suites/anr/isolated.mjs @@ -1,8 +1,7 @@ +import * as Sentry from '@sentry/node'; import * as assert from 'assert'; import * as crypto from 'crypto'; -import * as Sentry from '@sentry/node'; - setTimeout(() => { process.exit(); }, 10000); diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index 5bee31aa571c..08b6a6571e17 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -107,7 +107,7 @@ const ANR_EVENT_WITH_DEBUG_META: Event = { }, }; -describe('should report ANR when event loop blocked', { timeout: 60_000 }, () => { +describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => { afterAll(() => { cleanupChildProcesses(); }); diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs index 298952d58ced..4377723e6f4b 100644 --- a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs @@ -1,7 +1,7 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; import { spawn } from 'child_process'; import { join } from 'path'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; -import * as Sentry from '@sentry/node'; import { Worker } from 'worker_threads'; const __dirname = new URL('.', import.meta.url).pathname; @@ -13,16 +13,18 @@ Sentry.init({ transport: loggingTransport, }); -await new Promise(resolve => { - const child = spawn('sleep', ['a']); - child.on('error', resolve); - child.on('exit', resolve); -}); +(async () => { + await new Promise(resolve => { + const child = spawn('sleep', ['a']); + child.on('error', resolve); + child.on('exit', resolve); + }); -await new Promise(resolve => { - const worker = new Worker(join(__dirname, 'worker.mjs')); - worker.on('error', resolve); - worker.on('exit', resolve); -}); + await new Promise(resolve => { + const worker = new Worker(join(__dirname, 'worker.mjs')); + worker.on('error', resolve); + worker.on('exit', resolve); + }); -throw new Error('This is a test error'); + throw new Error('This is a test error'); +})(); diff --git a/dev-packages/node-integration-tests/suites/child-process/fork.js b/dev-packages/node-integration-tests/suites/child-process/fork.js index c6e5cd3f0b7f..064a21d57657 100644 --- a/dev-packages/node-integration-tests/suites/child-process/fork.js +++ b/dev-packages/node-integration-tests/suites/child-process/fork.js @@ -10,8 +10,7 @@ Sentry.init({ transport: loggingTransport, }); -// eslint-disable-next-line no-unused-vars -const _child = fork(path.join(__dirname, 'child.mjs')); +fork(path.join(__dirname, 'child.mjs')); setTimeout(() => { throw new Error('Exiting main process'); diff --git a/dev-packages/node-integration-tests/suites/child-process/fork.mjs b/dev-packages/node-integration-tests/suites/child-process/fork.mjs index 88503fa887a9..f28b7f313ec7 100644 --- a/dev-packages/node-integration-tests/suites/child-process/fork.mjs +++ b/dev-packages/node-integration-tests/suites/child-process/fork.mjs @@ -1,7 +1,7 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; import { fork } from 'child_process'; import * as path from 'path'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; -import * as Sentry from '@sentry/node'; const __dirname = new URL('.', import.meta.url).pathname; @@ -12,7 +12,7 @@ Sentry.init({ transport: loggingTransport, }); -const _child = fork(path.join(__dirname, 'child.mjs')); +fork(path.join(__dirname, 'child.mjs')); setTimeout(() => { throw new Error('Exiting main process'); diff --git a/dev-packages/node-integration-tests/suites/child-process/worker.js b/dev-packages/node-integration-tests/suites/child-process/worker.js index 99b645d9001c..5468f7f45a9d 100644 --- a/dev-packages/node-integration-tests/suites/child-process/worker.js +++ b/dev-packages/node-integration-tests/suites/child-process/worker.js @@ -10,8 +10,7 @@ Sentry.init({ transport: loggingTransport, }); -// eslint-disable-next-line no-unused-vars -const _worker = new Worker(path.join(__dirname, 'child.js')); +new Worker(path.join(__dirname, 'child.js')); setTimeout(() => { process.exit(); diff --git a/dev-packages/node-integration-tests/suites/child-process/worker.mjs b/dev-packages/node-integration-tests/suites/child-process/worker.mjs index dcca0bcc4105..d0a749d464e3 100644 --- a/dev-packages/node-integration-tests/suites/child-process/worker.mjs +++ b/dev-packages/node-integration-tests/suites/child-process/worker.mjs @@ -1,6 +1,6 @@ -import * as path from 'path'; -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as path from 'path'; import { Worker } from 'worker_threads'; const __dirname = new URL('.', import.meta.url).pathname; @@ -12,7 +12,7 @@ Sentry.init({ transport: loggingTransport, }); -const _worker = new Worker(path.join(__dirname, 'child.mjs')); +new Worker(path.join(__dirname, 'child.mjs')); setTimeout(() => { process.exit(); diff --git a/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/instrument.mjs b/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/instrument.mjs index 89dcca029527..9ffde125d498 100644 --- a/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/instrument.mjs @@ -1,5 +1,5 @@ -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/test.ts b/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/test.ts index f469a585b9ca..276e8930b1ea 100644 --- a/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/test.ts +++ b/dev-packages/node-integration-tests/suites/contextLines/filename-with-spaces/test.ts @@ -8,7 +8,7 @@ describe('ContextLines integration in ESM', () => { const instrumentPath = join(__dirname, 'instrument.mjs'); await createRunner(__dirname, 'scenario with space.mjs') - .withFlags('--import', instrumentPath) + .withInstrument(instrumentPath) .expect({ event: { exception: { diff --git a/dev-packages/node-integration-tests/suites/esm/import-in-the-middle/app.mjs b/dev-packages/node-integration-tests/suites/esm/import-in-the-middle/app.mjs index fbd43f8540dc..ac7d4b329b6d 100644 --- a/dev-packages/node-integration-tests/suites/esm/import-in-the-middle/app.mjs +++ b/dev-packages/node-integration-tests/suites/esm/import-in-the-middle/app.mjs @@ -1,5 +1,5 @@ -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as iitm from 'import-in-the-middle'; new iitm.Hook((_, name) => { @@ -14,6 +14,8 @@ Sentry.init({ transport: loggingTransport, }); -await import('./sub-module.mjs'); -await import('http'); -await import('os'); +(async () => { + await import('./sub-module.mjs'); + await import('http'); + await import('os'); +})(); diff --git a/dev-packages/node-integration-tests/suites/esm/modules-integration/app.mjs b/dev-packages/node-integration-tests/suites/esm/modules-integration/app.mjs index 5b2300d7037c..a131c93e86fc 100644 --- a/dev-packages/node-integration-tests/suites/esm/modules-integration/app.mjs +++ b/dev-packages/node-integration-tests/suites/esm/modules-integration/app.mjs @@ -1,5 +1,5 @@ -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/dev-packages/node-integration-tests/suites/esm/warn-esm/server.mjs b/dev-packages/node-integration-tests/suites/esm/warn-esm/server.mjs index fa486c9ddac8..7d4f1952eee2 100644 --- a/dev-packages/node-integration-tests/suites/esm/warn-esm/server.mjs +++ b/dev-packages/node-integration-tests/suites/esm/warn-esm/server.mjs @@ -1,5 +1,6 @@ -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', @@ -7,9 +8,6 @@ Sentry.init({ transport: loggingTransport, }); -import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; -import express from 'express'; - const app = express(); app.get('/test/success', (req, res) => { diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument-filterStatusCode.mjs new file mode 100644 index 000000000000..31473a90df73 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument-filterStatusCode.mjs @@ -0,0 +1,14 @@ +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, + transport: loggingTransport, + integrations: [ + Sentry.httpIntegration({ + dropSpansForIncomingRequestStatusCodes: [499, [300, 399]], + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument.mjs new file mode 100644 index 000000000000..5cade6bb7ba1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/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', + // disable attaching headers to /test/* endpoints + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs new file mode 100644 index 000000000000..f2e20014f48f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/', (_req, res) => { + res.send({ response: 'response 0' }); +}); + +app.get('/499', (_req, res) => { + res.status(499).send({ response: 'response 499' }); +}); + +app.get('/300', (_req, res) => { + res.status(300).send({ response: 'response 300' }); +}); + +app.get('/399', (_req, res) => { + res.status(399).send({ response: 'response 399' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs new file mode 100644 index 000000000000..fe3e190a4bdd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs @@ -0,0 +1,40 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); +app.use(bodyParser.json()); +app.use(bodyParser.text()); +app.use(bodyParser.raw()); + +app.get('/', (_req, res) => { + res.send({ response: 'response 0' }); +}); + +app.get('/test/express', (_req, res) => { + res.send({ response: 'response 1' }); +}); + +app.get(/\/test\/regex/, (_req, res) => { + res.send({ response: 'response 2' }); +}); + +app.get(['/test/array1', /\/test\/array[2-9]/], (_req, res) => { + res.send({ response: 'response 3' }); +}); + +app.get(['/test/arr/:id', /\/test\/arr[0-9]*\/required(path)?(\/optionalPath)?\/(lastParam)?/], (_req, res) => { + res.send({ response: 'response 4' }); +}); + +app.post('/test-post', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts index cf20276f4fc2..4618f801a087 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts @@ -1,14 +1,14 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -describe('express tracing', () => { +describe('express v5 tracing', () => { afterAll(() => { cleanupChildProcesses(); }); - describe('CJS', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('should create and send transactions for Express routes and spans for middlewares.', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { contexts: { @@ -51,7 +51,7 @@ describe('express tracing', () => { }); test('should set a correct transaction name for routes specified in RegEx', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'GET /\\/test\\/regex/', @@ -77,10 +77,36 @@ describe('express tracing', () => { await runner.completed(); }); + test('handles root route correctly', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /', + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + + test('ignores 404 routes by default', async () => { + const runner = createRunner() + .expect({ + // No transaction is sent for the 404 route + transaction: { + transaction: 'GET /', + }, + }) + .start(); + runner.makeRequest('get', '/does-not-exist', { expectError: true }); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + test.each([['array1'], ['array5']])( 'should set a correct transaction name for routes consisting of arrays of routes for %p', - ((segment: string, done: () => void) => { - createRunner(__dirname, 'server.js') + async (segment: string) => { + const runner = await createRunner() .expect({ transaction: { transaction: 'GET /test/array1,/\\/test\\/array[2-9]/', @@ -101,9 +127,10 @@ describe('express tracing', () => { }, }, }) - .start(done) - .makeRequest('get', `/test/${segment}`); - }) as any, + .start(); + await runner.makeRequest('get', `/test/${segment}`); + await runner.completed(); + }, ); test.each([ @@ -113,8 +140,8 @@ describe('express tracing', () => { ['arr/requiredPath'], ['arr/required/lastParam'], ['arr55/required/lastParam'], - ])('should handle more complex regexes in route arrays correctly for %p', ((segment: string, done: () => void) => { - createRunner(__dirname, 'server.js') + ])('should handle more complex regexes in route arrays correctly for %p', async (segment: string) => { + const runner = await createRunner() .expect({ transaction: { transaction: 'GET /test/arr/:id,/\\/test\\/arr[0-9]*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?/', @@ -135,13 +162,14 @@ describe('express tracing', () => { }, }, }) - .start(done) - .makeRequest('get', `/test/${segment}`); - }) as any); + .start(); + await runner.makeRequest('get', `/test/${segment}`); + await runner.completed(); + }); describe('request data', () => { test('correctly captures JSON request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -161,12 +189,17 @@ describe('express tracing', () => { }) .start(); - runner.makeRequest('post', '/test-post', { data: { foo: 'bar', other: 1 } }); + runner.makeRequest('post', '/test-post', { + data: JSON.stringify({ foo: 'bar', other: 1 }), + headers: { + 'Content-Type': 'application/json', + }, + }); await runner.completed(); }); test('correctly captures plain text request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -191,7 +224,7 @@ describe('express tracing', () => { }); test('correctly captures text buffer request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -216,7 +249,7 @@ describe('express tracing', () => { }); test('correctly captures non-text buffer request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -244,4 +277,56 @@ describe('express tracing', () => { }); }); }); + + describe('filter status codes', () => { + createEsmAndCjsTests( + __dirname, + 'scenario-filterStatusCode.mjs', + 'instrument-filterStatusCode.mjs', + (createRunner, test) => { + // We opt-out of the default 404 filtering in order to test how 404 spans are handled + test('handles 404 route correctly', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /does-not-exist', + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'http.response.status_code': 404, + url: expect.stringMatching(/\/does-not-exist$/), + 'http.method': 'GET', + 'http.url': expect.stringMatching(/\/does-not-exist$/), + 'http.target': '/does-not-exist', + }, + op: 'http.server', + status: 'not_found', + }, + }, + }, + }) + .start(); + runner.makeRequest('get', '/does-not-exist', { expectError: true }); + await runner.completed(); + }); + + test('filters defined status codes', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /', + }, + }) + .start(); + await runner.makeRequest('get', '/499', { expectError: true }); + await runner.makeRequest('get', '/300', { expectError: true }); + await runner.makeRequest('get', '/399', { expectError: true }); + await runner.makeRequest('get', '/'); + await runner.completed(); + }); + }, + ); + }); }); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts index 227cf6042c44..f8cbf3c2bd57 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts @@ -3,7 +3,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -describe('express tracing', () => { +describe('express v5 tracing', () => { afterAll(() => { cleanupChildProcesses(); }); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts index e99c5bf44700..34d8cd515ec3 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts @@ -1,7 +1,7 @@ import { afterAll, describe, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -describe('express tracing experimental', () => { +describe('express v5 tracing', () => { afterAll(() => { cleanupChildProcesses(); }); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tsconfig.test.json b/dev-packages/node-integration-tests/suites/express-v5/tsconfig.test.json new file mode 100644 index 000000000000..3c43903cfdd1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tsconfig.test.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts b/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts index 5dc6aedecbd4..5286ab8d2953 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts @@ -54,7 +54,12 @@ describe('express without tracing', () => { }) .start(); - runner.makeRequest('post', '/test-post', { data: { foo: 'bar', other: 1 } }); + runner.makeRequest('post', '/test-post', { + headers: { + 'Content-Type': 'application/json', + }, + data: JSON.stringify({ foo: 'bar', other: 1 }), + }); await runner.completed(); }); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/instrument-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express/tracing/instrument-filterStatusCode.mjs new file mode 100644 index 000000000000..31473a90df73 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/tracing/instrument-filterStatusCode.mjs @@ -0,0 +1,14 @@ +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, + transport: loggingTransport, + integrations: [ + Sentry.httpIntegration({ + dropSpansForIncomingRequestStatusCodes: [499, [300, 399]], + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/instrument.mjs b/dev-packages/node-integration-tests/suites/express/tracing/instrument.mjs new file mode 100644 index 000000000000..56c180aa1978 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/tracing/instrument.mjs @@ -0,0 +1,21 @@ +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', + // disable attaching headers to /test/* endpoints + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.httpIntegration({ + ignoreIncomingRequestBody: url => { + if (url.includes('/test-post-ignore-body')) { + return true; + } + return false; + }, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/scenario-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express/tracing/scenario-filterStatusCode.mjs new file mode 100644 index 000000000000..f2e20014f48f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/tracing/scenario-filterStatusCode.mjs @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/', (_req, res) => { + res.send({ response: 'response 0' }); +}); + +app.get('/499', (_req, res) => { + res.status(499).send({ response: 'response 499' }); +}); + +app.get('/300', (_req, res) => { + res.status(300).send({ response: 'response 300' }); +}); + +app.get('/399', (_req, res) => { + res.status(399).send({ response: 'response 399' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/server.js b/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs similarity index 54% rename from dev-packages/node-integration-tests/suites/express-v5/tracing/server.js rename to dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs index f9b4ae24b339..8b48ee0dbc44 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/server.js +++ b/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs @@ -1,20 +1,8 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const bodyParser = require('body-parser'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import express from 'express'; const app = express(); @@ -23,6 +11,10 @@ app.use(bodyParser.json()); app.use(bodyParser.text()); app.use(bodyParser.raw()); +app.get('/', (_req, res) => { + res.send({ response: 'response 0' }); +}); + app.get('/test/express', (_req, res) => { res.send({ response: 'response 1' }); }); @@ -43,6 +35,10 @@ app.post('/test-post', function (req, res) { res.send({ status: 'ok', body: req.body }); }); +app.post('/test-post-ignore-body', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/server.js b/dev-packages/node-integration-tests/suites/express/tracing/server.js deleted file mode 100644 index 7540b63bb07c..000000000000 --- a/dev-packages/node-integration-tests/suites/express/tracing/server.js +++ /dev/null @@ -1,62 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, - integrations: [ - Sentry.httpIntegration({ - ignoreIncomingRequestBody: url => { - if (url.includes('/test-post-ignore-body')) { - return true; - } - return false; - }, - }), - ], -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const bodyParser = require('body-parser'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); -app.use(bodyParser.json()); -app.use(bodyParser.text()); -app.use(bodyParser.raw()); - -app.get('/test/express', (_req, res) => { - res.send({ response: 'response 1' }); -}); - -app.get(/\/test\/regex/, (_req, res) => { - res.send({ response: 'response 2' }); -}); - -app.get(['/test/array1', /\/test\/array[2-9]/], (_req, res) => { - res.send({ response: 'response 3' }); -}); - -app.get(['/test/arr/:id', /\/test\/arr[0-9]*\/required(path)?(\/optionalPath)?\/(lastParam)?/], (_req, res) => { - res.send({ response: 'response 4' }); -}); - -app.post('/test-post', function (req, res) { - res.send({ status: 'ok', body: req.body }); -}); - -app.post('/test-post-ignore-body', function (req, res) { - res.send({ status: 'ok', body: req.body }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index 109bab2e659c..63706c0e5cb2 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -1,15 +1,15 @@ -import { afterAll, describe, expect, test } from 'vitest'; +import { afterAll, describe, expect } from 'vitest'; import { assertSentryTransaction } from '../../../utils/assertions'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('express tracing', () => { afterAll(() => { cleanupChildProcesses(); }); - describe('CJS', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('should create and send transactions for Express routes and spans for middlewares.', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { contexts: { @@ -52,7 +52,7 @@ describe('express tracing', () => { }); test('should set a correct transaction name for routes specified in RegEx', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'GET /\\/test\\/regex/', @@ -78,10 +78,36 @@ describe('express tracing', () => { await runner.completed(); }); + test('handles root page correctly', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /', + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + + test('ignores 404 routes by default', async () => { + const runner = createRunner() + .expect({ + // No transaction is sent for the 404 route + transaction: { + transaction: 'GET /', + }, + }) + .start(); + runner.makeRequest('get', '/does-not-exist', { expectError: true }); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + test.each([['array1'], ['array5']])( 'should set a correct transaction name for routes consisting of arrays of routes for %p', - ((segment: string, done: () => void) => { - createRunner(__dirname, 'server.js') + async (segment: string) => { + const runner = await createRunner() .expect({ transaction: { transaction: 'GET /test/array1,/\\/test\\/array[2-9]/', @@ -102,9 +128,10 @@ describe('express tracing', () => { }, }, }) - .start(done) - .makeRequest('get', `/test/${segment}`); - }) as any, + .start(); + await runner.makeRequest('get', `/test/${segment}`); + await runner.completed(); + }, ); test.each([ @@ -116,8 +143,8 @@ describe('express tracing', () => { ['arr55/required/lastParam'], ['arr/requiredPath/optionalPath/'], ['arr/requiredPath/optionalPath/lastParam'], - ])('should handle more complex regexes in route arrays correctly for %p', ((segment: string, done: () => void) => { - createRunner(__dirname, 'server.js') + ])('should handle more complex regexes in route arrays correctly for %p', async (segment: string) => { + const runner = await createRunner() .expect({ transaction: { transaction: 'GET /test/arr/:id,/\\/test\\/arr[0-9]*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?/', @@ -138,13 +165,14 @@ describe('express tracing', () => { }, }, }) - .start(done) - .makeRequest('get', `/test/${segment}`); - }) as any); + .start(); + await runner.makeRequest('get', `/test/${segment}`); + await runner.completed(); + }); describe('request data', () => { test('correctly captures JSON request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -164,12 +192,17 @@ describe('express tracing', () => { }) .start(); - runner.makeRequest('post', '/test-post', { data: { foo: 'bar', other: 1 } }); + runner.makeRequest('post', '/test-post', { + headers: { + 'Content-Type': 'application/json', + }, + data: JSON.stringify({ foo: 'bar', other: 1 }), + }); await runner.completed(); }); test('correctly captures plain text request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -194,7 +227,7 @@ describe('express tracing', () => { }); test('correctly captures text buffer request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -219,7 +252,7 @@ describe('express tracing', () => { }); test('correctly captures non-text buffer request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: { transaction: 'POST /test-post', @@ -247,7 +280,7 @@ describe('express tracing', () => { }); test('correctly ignores request data', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() .expect({ transaction: e => { assertSentryTransaction(e, { @@ -275,4 +308,57 @@ describe('express tracing', () => { }); }); }); + + describe('filter status codes', () => { + createEsmAndCjsTests( + __dirname, + 'scenario-filterStatusCode.mjs', + 'instrument-filterStatusCode.mjs', + (createRunner, test) => { + // We opt-out of the default 404 filtering in order to test how 404 spans are handled + test('handles 404 route correctly', async () => { + const runner = createRunner() + .expect({ + transaction: { + // FIXME: This is incorrect, sadly :( + transaction: 'GET /', + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'http.response.status_code': 404, + url: expect.stringMatching(/\/does-not-exist$/), + 'http.method': 'GET', + 'http.url': expect.stringMatching(/\/does-not-exist$/), + 'http.target': '/does-not-exist', + }, + op: 'http.server', + status: 'not_found', + }, + }, + }, + }) + .start(); + runner.makeRequest('get', '/does-not-exist', { expectError: true }); + await runner.completed(); + }); + + test('filters defined status codes', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /', + }, + }) + .start(); + await runner.makeRequest('get', '/499', { expectError: true }); + await runner.makeRequest('get', '/300', { expectError: true }); + await runner.makeRequest('get', '/399', { expectError: true }); + await runner.makeRequest('get', '/'); + await runner.completed(); + }); + }, + ); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/init.ts b/dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/amqplib/init.ts rename to dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs b/dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs new file mode 100644 index 000000000000..4ecdd158f785 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; +import http from 'http'; + +const app = express(); + +app.get('/test', (_req, res) => { + http.get(`http://localhost:${app.port}/test2`, httpRes => { + httpRes.on('data', () => { + setTimeout(() => { + res.send({ response: 'response 1' }); + }, 200); + }); + }); +}); + +app.get('/test2', (_req, res) => { + res.send({ response: 'response 2' }); +}); + +app.get('/test3', (_req, res) => { + res.send({ response: 'response 3' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/with-http/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/test.ts new file mode 100644 index 000000000000..10dbefa74a9a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/with-http/test.ts @@ -0,0 +1,33 @@ +import { afterAll, describe } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('express with http import', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('it works when importing the http module', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /test2', + }, + }) + .expect({ + transaction: { + transaction: 'GET /test', + }, + }) + .expect({ + transaction: { + transaction: 'GET /test3', + }, + }) + .start(); + await runner.makeRequest('get', '/test'); + await runner.makeRequest('get', '/test3'); + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts index 5dc6aedecbd4..5286ab8d2953 100644 --- a/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/without-tracing/test.ts @@ -54,7 +54,12 @@ describe('express without tracing', () => { }) .start(); - runner.makeRequest('post', '/test-post', { data: { foo: 'bar', other: 1 } }); + runner.makeRequest('post', '/test-post', { + headers: { + 'Content-Type': 'application/json', + }, + data: JSON.stringify({ foo: 'bar', other: 1 }), + }); await runner.completed(); }); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/deny-inspector.mjs b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/deny-inspector.mjs index 16546a328a9a..bbf5655ea903 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/deny-inspector.mjs +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/deny-inspector.mjs @@ -1,9 +1,5 @@ import { register } from 'node:module'; -const hookScript = Buffer.from(` - - `); - register( new URL(`data:application/javascript, export async function resolve(specifier, context, nextResolve) { @@ -16,6 +12,8 @@ export async function resolve(specifier, context, nextResolve) { import.meta.url, ); -const Sentry = await import('@sentry/node'); +(async () => { + const Sentry = await import('@sentry/node'); -Sentry.init({}); + Sentry.init({}); +})(); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs index 8fec9dbb1dad..c0a26a6aa709 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs @@ -1,6 +1,6 @@ /* eslint-disable no-unused-vars */ -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/dev-packages/node-integration-tests/suites/tracing/ai/scenario.js b/dev-packages/node-integration-tests/suites/tracing/ai/scenario.js deleted file mode 100644 index 780e322c0639..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/ai/scenario.js +++ /dev/null @@ -1,58 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -const { generateText } = require('ai'); -const { MockLanguageModelV1 } = require('ai/test'); - -async function run() { - await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { - await generateText({ - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'First span here!', - }), - }), - prompt: 'Where is the first span?', - }); - - // This span should have input and output prompts attached because telemetry is explicitly enabled. - await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'Second span here!', - }), - }), - prompt: 'Where is the second span?', - }); - - // This span should not be captured because we've disabled telemetry - await generateText({ - experimental_telemetry: { isEnabled: false }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'Third span here!', - }), - }), - prompt: 'Where is the third span?', - }); - }); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/ai/test.ts b/dev-packages/node-integration-tests/suites/tracing/ai/test.ts index bb380febab78..c0a3ccb4a78a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ai/test.ts @@ -1,6 +1,5 @@ -import { join } from 'node:path'; -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // `ai` SDK only support Node 18+ describe('ai', () => { @@ -126,15 +125,9 @@ describe('ai', () => { ]), }; - test('creates ai related spans - cjs', async () => { - await createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); - - test('creates ai related spans - esm', async () => { - await createRunner(__dirname, 'scenario.mjs') - .withFlags('--import', join(__dirname, 'instrument.mjs')) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates ai related spans ', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/constants.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/constants.ts deleted file mode 100644 index b7a3e8f79ea2..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -const amqpUsername = 'sentry'; -const amqpPassword = 'sentry'; - -export const AMQP_URL = `amqp://${amqpUsername}:${amqpPassword}@localhost:5672/`; -export const ACKNOWLEDGEMENT = { noAck: false }; - -export const QUEUE_OPTIONS = { - durable: true, // Make the queue durable - exclusive: false, // Not exclusive - autoDelete: false, // Don't auto-delete the queue - arguments: { - 'x-message-ttl': 30000, // Message TTL of 30 seconds - 'x-max-length': 1000, // Maximum queue length of 1000 messages - }, -}; diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/instrument.mjs @@ -0,0 +1,9 @@ +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, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario-message.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario-message.ts deleted file mode 100644 index 16e9cf370fe3..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario-message.ts +++ /dev/null @@ -1,26 +0,0 @@ -import './init'; -import * as Sentry from '@sentry/node'; -import { connectToRabbitMQ, consumeMessageFromQueue, createQueue, sendMessageToQueue } from './utils'; - -const queueName = 'queue1'; - -// Stop the process from exiting before the transaction is sent -// eslint-disable-next-line @typescript-eslint/no-empty-function -setInterval(() => {}, 1000); - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -(async () => { - const { connection, channel } = await connectToRabbitMQ(); - await createQueue(queueName, channel); - - const consumeMessagePromise = consumeMessageFromQueue(queueName, channel); - - await Sentry.startSpan({ name: 'root span' }, async () => { - sendMessageToQueue(queueName, channel, JSON.stringify({ foo: 'bar01' })); - }); - - await consumeMessagePromise; - - await channel.close(); - await connection.close(); -})(); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario.mjs new file mode 100644 index 000000000000..3baf2732ddb9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario.mjs @@ -0,0 +1,75 @@ +import * as Sentry from '@sentry/node'; +import amqp from 'amqplib'; + +const queueName = 'queue1'; +const amqpUsername = 'sentry'; +const amqpPassword = 'sentry'; + +const AMQP_URL = `amqp://${amqpUsername}:${amqpPassword}@localhost:5672/`; +const ACKNOWLEDGEMENT = { noAck: false }; + +const QUEUE_OPTIONS = { + durable: true, // Make the queue durable + exclusive: false, // Not exclusive + autoDelete: false, // Don't auto-delete the queue + arguments: { + 'x-message-ttl': 30000, // Message TTL of 30 seconds + 'x-max-length': 1000, // Maximum queue length of 1000 messages + }, +}; + +(async () => { + const { connection, channel } = await connectToRabbitMQ(); + await createQueue(queueName, channel); + + const consumeMessagePromise = consumeMessageFromQueue(queueName, channel); + + await Sentry.startSpan({ name: 'root span' }, async () => { + sendMessageToQueue(queueName, channel, JSON.stringify({ foo: 'bar01' })); + }); + + await consumeMessagePromise; + + await channel.close(); + await connection.close(); + + // Stop the process from exiting before the transaction is sent + setInterval(() => {}, 1000); +})(); + +async function connectToRabbitMQ() { + const connection = await amqp.connect(AMQP_URL); + const channel = await connection.createChannel(); + return { connection, channel }; +} + +async function createQueue(queueName, channel) { + await channel.assertQueue(queueName, QUEUE_OPTIONS); +} + +function sendMessageToQueue(queueName, channel, message) { + channel.sendToQueue(queueName, Buffer.from(message)); +} + +async function consumer(queueName, channel) { + return new Promise((resolve, reject) => { + channel + .consume( + queueName, + message => { + if (message) { + channel.ack(message); + resolve(); + } else { + reject(new Error('No message received')); + } + }, + ACKNOWLEDGEMENT, + ) + .catch(reject); + }); +} + +async function consumeMessageFromQueue(queueName, channel) { + await consumer(queueName, channel); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts index 250e1e31c40c..ad84a74b929a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts @@ -1,6 +1,6 @@ import type { TransactionEvent } from '@sentry/core'; -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; const EXPECTED_MESSAGE_SPAN_PRODUCER = expect.objectContaining({ op: 'message', @@ -29,26 +29,28 @@ describe('amqplib auto-instrumentation', () => { cleanupChildProcesses(); }); - test('should be able to send and receive messages', { timeout: 90_000 }, async () => { - await createRunner(__dirname, 'scenario-message.ts') - .withDockerCompose({ - workingDirectory: [__dirname], - readyMatches: ['Time to start RabbitMQ'], - }) - .expect({ - transaction: (transaction: TransactionEvent) => { - expect(transaction.transaction).toEqual('root span'); - expect(transaction.spans?.length).toEqual(1); - expect(transaction.spans![0]).toMatchObject(EXPECTED_MESSAGE_SPAN_PRODUCER); - }, - }) - .expect({ - transaction: (transaction: TransactionEvent) => { - expect(transaction.transaction).toEqual('queue1 process'); - expect(transaction.contexts?.trace).toMatchObject(EXPECTED_MESSAGE_SPAN_CONSUMER); - }, - }) - .start() - .completed(); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('should be able to send and receive messages', async () => { + await createTestRunner() + .withDockerCompose({ + workingDirectory: [__dirname], + readyMatches: ['Time to start RabbitMQ'], + }) + .expect({ + transaction: (transaction: TransactionEvent) => { + expect(transaction.transaction).toEqual('root span'); + expect(transaction.spans?.length).toEqual(1); + expect(transaction.spans![0]).toMatchObject(EXPECTED_MESSAGE_SPAN_PRODUCER); + }, + }) + .expect({ + transaction: (transaction: TransactionEvent) => { + expect(transaction.transaction).toEqual('queue1 process'); + expect(transaction.contexts?.trace).toMatchObject(EXPECTED_MESSAGE_SPAN_CONSUMER); + }, + }) + .start() + .completed(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/utils.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/utils.ts deleted file mode 100644 index 702c100aa67c..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Channel, Connection } from 'amqplib'; -import amqp from 'amqplib'; -import { ACKNOWLEDGEMENT, AMQP_URL, QUEUE_OPTIONS } from './constants'; - -export type RabbitMQData = { - connection: Connection; - channel: Channel; -}; - -export async function connectToRabbitMQ(): Promise { - const connection = await amqp.connect(AMQP_URL); - const channel = await connection.createChannel(); - return { connection, channel }; -} - -export async function createQueue(queueName: string, channel: Channel): Promise { - await channel.assertQueue(queueName, QUEUE_OPTIONS); -} - -export function sendMessageToQueue(queueName: string, channel: Channel, message: string): void { - channel.sendToQueue(queueName, Buffer.from(message)); -} - -async function consumer(queueName: string, channel: Channel): Promise { - return new Promise((resolve, reject) => { - channel - .consume( - queueName, - message => { - if (message) { - channel.ack(message); - resolve(); - } else { - reject(new Error('No message received')); - } - }, - ACKNOWLEDGEMENT, - ) - .catch(reject); - }); -} - -export async function consumeMessageFromQueue(queueName: string, channel: Channel): Promise { - await consumer(queueName, channel); -} diff --git a/dev-packages/node-integration-tests/suites/tracing/connect/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/connect/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/connect/instrument.mjs @@ -0,0 +1,9 @@ +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, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/connect/scenario.js b/dev-packages/node-integration-tests/suites/tracing/connect/scenario.mjs similarity index 52% rename from dev-packages/node-integration-tests/suites/tracing/connect/scenario.js rename to dev-packages/node-integration-tests/suites/tracing/connect/scenario.mjs index db95fad457b2..39054ba009c5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/connect/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/connect/scenario.mjs @@ -1,18 +1,10 @@ -const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import connect from 'connect'; +import http from 'http'; const port = 5986; -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -const connect = require('connect'); -const http = require('http'); - const run = async () => { const app = connect(); diff --git a/dev-packages/node-integration-tests/suites/tracing/connect/test.ts b/dev-packages/node-integration-tests/suites/tracing/connect/test.ts index 8b03de6e6a37..0c37c58f8a4c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/connect/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/connect/test.ts @@ -1,5 +1,5 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('connect auto-instrumentation', () => { afterAll(async () => { @@ -36,27 +36,32 @@ describe('connect auto-instrumentation', () => { }, }; - test('CJS - should auto-instrument `connect` package.', async () => { - const runner = createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('should auto-instrument `connect` package.', async () => { + const runner = createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); - test('CJS - should capture errors in `connect` middleware.', async () => { - const runner = createRunner(__dirname, 'scenario.js') - .ignore('transaction') - .expect({ event: EXPECTED_EVENT }) - .start(); - runner.makeRequest('get', '/error'); - await runner.completed(); - }); + test('should capture errors in `connect` middleware.', async () => { + const runner = createTestRunner().ignore('transaction').expect({ event: EXPECTED_EVENT }).start(); + runner.makeRequest('get', '/error'); + await runner.completed(); + }); - test('CJS - should report errored transactions.', async () => { - const runner = createRunner(__dirname, 'scenario.js') - .ignore('event') - .expect({ transaction: { transaction: 'GET /error' } }) - .start(); - runner.makeRequest('get', '/error'); - await runner.completed(); - }); + test('should report errored transactions.', async () => { + const runner = createTestRunner() + .ignore('event') + .expect({ transaction: { transaction: 'GET /error' } }) + .start(); + runner.makeRequest('get', '/error'); + await runner.completed(); + }); + }, + { failsOnEsm: true }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs new file mode 100644 index 000000000000..38687180a665 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.dataloaderIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.js b/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.js deleted file mode 100644 index 7bea9a1372ff..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.js +++ /dev/null @@ -1,34 +0,0 @@ -const { loggingTransport, startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, - integrations: [Sentry.dataloaderIntegration()], -}); - -const PORT = 8008; - -// Stop the process from exiting before the transaction is sent -setInterval(() => {}, 1000); - -const run = async () => { - const express = require('express'); - const Dataloader = require('dataloader'); - - const app = express(); - const dataloader = new Dataloader(async keys => keys.map((_, idx) => idx), { - cache: false, - }); - - app.get('/', (req, res) => { - const user = dataloader.load('user-1'); - res.send(user); - }); - - startExpressServerAndSendPortToRunner(app, PORT); -}; - -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.mjs new file mode 100644 index 000000000000..c392ca9b4972 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.mjs @@ -0,0 +1,24 @@ +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Dataloader from 'dataloader'; +import express from 'express'; + +const PORT = 8008; + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const run = async () => { + const app = express(); + const dataloader = new Dataloader(async keys => keys.map((_, idx) => idx), { + cache: false, + }); + + app.get('/', (req, res) => { + const user = dataloader.load('user-1'); + res.send(user); + }); + + startExpressServerAndSendPortToRunner(app, PORT); +}; + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts index bd495ea24a7a..1a653dc6496a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts @@ -1,5 +1,5 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('dataloader auto-instrumentation', () => { afterAll(async () => { @@ -32,9 +32,17 @@ describe('dataloader auto-instrumentation', () => { ]), }; - test('should auto-instrument `dataloader` package.', async () => { - const runner = createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('should auto-instrument `dataloader` package.', async () => { + const runner = createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + }, + { failsOnEsm: true }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/genericPool/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/genericPool/instrument.mjs @@ -0,0 +1,9 @@ +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, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool/scenario.js b/dev-packages/node-integration-tests/suites/tracing/genericPool/scenario.mjs similarity index 74% rename from dev-packages/node-integration-tests/suites/tracing/genericPool/scenario.js rename to dev-packages/node-integration-tests/suites/tracing/genericPool/scenario.mjs index 74d5f73693f5..47f0fb46a4fd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/genericPool/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/genericPool/scenario.mjs @@ -1,19 +1,10 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); +import * as Sentry from '@sentry/node'; +import genericPool from 'generic-pool'; +import mysql from 'mysql'; // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); -const mysql = require('mysql'); -const genericPool = require('generic-pool'); - const factory = { create: function () { return mysql.createConnection({ @@ -67,5 +58,4 @@ async function run() { ); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts b/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts index 42f2a3603f95..dd18c456e958 100644 --- a/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/genericPool/test.ts @@ -1,35 +1,37 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('genericPool auto instrumentation', () => { afterAll(() => { cleanupChildProcesses(); }); - test('should auto-instrument `genericPool` package when calling pool.require()', async () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ - expect.objectContaining({ - description: expect.stringMatching(/^generic-pool\.ac?quire/), - origin: 'auto.db.otel.generic_pool', - data: { - 'sentry.origin': 'auto.db.otel.generic_pool', - }, - status: 'ok', - }), + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should auto-instrument `genericPool` package when calling pool.require()', async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: expect.stringMatching(/^generic-pool\.ac?quire/), + origin: 'auto.db.otel.generic_pool', + data: { + 'sentry.origin': 'auto.db.otel.generic_pool', + }, + status: 'ok', + }), - expect.objectContaining({ - description: expect.stringMatching(/^generic-pool\.ac?quire/), - origin: 'auto.db.otel.generic_pool', - data: { - 'sentry.origin': 'auto.db.otel.generic_pool', - }, - status: 'ok', - }), - ]), - }; + expect.objectContaining({ + description: expect.stringMatching(/^generic-pool\.ac?quire/), + origin: 'auto.db.otel.generic_pool', + data: { + 'sentry.origin': 'auto.db.otel.generic_pool', + }, + status: 'ok', + }), + ]), + }; - await createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument.mjs @@ -0,0 +1,9 @@ +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, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.js b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs similarity index 60% rename from dev-packages/node-integration-tests/suites/tracing/hapi/scenario.js rename to dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs index f3171eb085e0..c1497d2c5e2f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/scenario.mjs @@ -1,15 +1,7 @@ -const { loggingTransport, sendPortToRunner } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -const Hapi = require('@hapi/hapi'); -const Boom = require('@hapi/boom'); +import Boom from '@hapi/boom'; +import Hapi from '@hapi/hapi'; +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; const port = 5999; @@ -22,7 +14,7 @@ const run = async () => { server.route({ method: 'GET', path: '/', - handler: (_request, _h) => { + handler: () => { return 'Hello World!'; }, }); @@ -30,7 +22,7 @@ const run = async () => { server.route({ method: 'GET', path: '/error', - handler: (_request, _h) => { + handler: () => { return new Error('Sentry Test Error'); }, }); @@ -38,7 +30,7 @@ const run = async () => { server.route({ method: 'GET', path: '/error/{id}', - handler: (_request, _h) => { + handler: () => { return new Error('Sentry Test Error'); }, }); @@ -46,7 +38,7 @@ const run = async () => { server.route({ method: 'GET', path: '/boom-error', - handler: (_request, _h) => { + handler: () => { return new Boom.Boom('Sentry Test Error'); }, }); @@ -54,7 +46,7 @@ const run = async () => { server.route({ method: 'GET', path: '/promise-error', - handler: async (_request, _h) => { + handler: async () => { return Promise.reject(new Error('Sentry Test Error')); }, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts index e783c77e2fc5..5693f55a6a8c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts @@ -1,5 +1,5 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('hapi auto-instrumentation', () => { afterAll(async () => { @@ -36,62 +36,64 @@ describe('hapi auto-instrumentation', () => { }, }; - test('CJS - should auto-instrument `@hapi/hapi` package.', async () => { - const runner = createRunner(__dirname, 'scenario.js').expect({ transaction: EXPECTED_TRANSACTION }).start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should auto-instrument `@hapi/hapi` package.', async () => { + const runner = createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); - test('CJS - should handle returned plain errors in routes.', async () => { - const runner = createRunner(__dirname, 'scenario.js') - .expect({ - transaction: { - transaction: 'GET /error', - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/error', { expectError: true }); - await runner.completed(); - }); + test('should handle returned plain errors in routes.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /error', + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); + }); - test('CJS - should assign parameterized transactionName to error.', async () => { - const runner = createRunner(__dirname, 'scenario.js') - .expect({ - event: { - ...EXPECTED_ERROR_EVENT, - transaction: 'GET /error/{id}', - }, - }) - .ignore('transaction') - .start(); - runner.makeRequest('get', '/error/123', { expectError: true }); - await runner.completed(); - }); + test('should assign parameterized transactionName to error.', async () => { + const runner = createRunner() + .expect({ + event: { + ...EXPECTED_ERROR_EVENT, + transaction: 'GET /error/{id}', + }, + }) + .ignore('transaction') + .start(); + runner.makeRequest('get', '/error/123', { expectError: true }); + await runner.completed(); + }); - test('CJS - should handle returned Boom errors in routes.', async () => { - const runner = createRunner(__dirname, 'scenario.js') - .expect({ - transaction: { - transaction: 'GET /boom-error', - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/boom-error', { expectError: true }); - await runner.completed(); - }); + test('should handle returned Boom errors in routes.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /boom-error', + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/boom-error', { expectError: true }); + await runner.completed(); + }); - test('CJS - should handle promise rejections in routes.', async () => { - const runner = createRunner(__dirname, 'scenario.js') - .expect({ - transaction: { - transaction: 'GET /promise-error', - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/promise-error', { expectError: true }); - await runner.completed(); + test('should handle promise rejections in routes.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /promise-error', + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/promise-error', { expectError: true }); + await runner.completed(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.js b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs similarity index 66% rename from dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.js rename to dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs index d10c24db500d..8cf2e8a5248f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs @@ -1,5 +1,5 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', @@ -39,20 +39,3 @@ Sentry.init({ }), ], }); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/test', (_req, res) => { - res.send({ response: 'response 1' }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs new file mode 100644 index 000000000000..44122f375857 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +app.get('/test', (_req, res) => { + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 6e4ad622ea26..9682f4aa28ac 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; import { createTestServer } from '../../../utils/server'; describe('httpIntegration', () => { @@ -7,52 +7,48 @@ describe('httpIntegration', () => { cleanupChildProcesses(); }); - test('allows to pass instrumentation options to integration', async () => { - // response shape seems different on Node 14, so we skip this there - const nodeMajorVersion = Number(process.versions.node.split('.')[0]); - if (nodeMajorVersion <= 14) { - return; - } - - const runner = createRunner(__dirname, 'server.js') - .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - url: expect.stringMatching(/\/test$/), - 'http.response.status_code': 200, - attr1: 'yes', - attr2: 'yes', - attr3: 'yes', + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('allows to pass instrumentation options to integration', async () => { + const runner = createRunner() + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + url: expect.stringMatching(/\/test$/), + 'http.response.status_code': 200, + attr1: 'yes', + attr2: 'yes', + attr3: 'yes', + }, + op: 'http.server', + status: 'ok', }, - op: 'http.server', - status: 'ok', - }, - }, - extra: { - requestHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', - }, - responseHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', }, - applyCustomAttributesOnSpanCalled: { - reqUrl: expect.stringMatching(/\/test$/), - reqMethod: 'GET', - resUrl: expect.stringMatching(/\/test$/), - resMethod: 'GET', + extra: { + requestHookCalled: { + url: expect.stringMatching(/\/test$/), + method: 'GET', + }, + responseHookCalled: { + url: expect.stringMatching(/\/test$/), + method: 'GET', + }, + applyCustomAttributesOnSpanCalled: { + reqUrl: expect.stringMatching(/\/test$/), + reqMethod: 'GET', + resUrl: expect.stringMatching(/\/test$/), + resMethod: 'GET', + }, }, }, - }, - }) - .start(); - runner.makeRequest('get', '/test'); - await runner.completed(); + }) + .start(); + runner.makeRequest('get', '/test'); + await runner.completed(); + }); }); test('allows to pass experimental config through to integration', async () => { @@ -155,7 +151,7 @@ describe('httpIntegration', () => { expect(breadcrumbs![0]?.data?.url).toEqual(`${SERVER_URL}/pass`); }, }) - .start(closeTestServer); + .start(); runner.makeRequest('get', '/testUrl'); await runner.completed(); closeTestServer(); diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/kafkajs/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/instrument.mjs @@ -0,0 +1,9 @@ +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, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/scenario.js b/dev-packages/node-integration-tests/suites/tracing/kafkajs/scenario.mjs similarity index 67% rename from dev-packages/node-integration-tests/suites/tracing/kafkajs/scenario.js rename to dev-packages/node-integration-tests/suites/tracing/kafkajs/scenario.mjs index d4541aa3a7de..d92414c2818b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/kafkajs/scenario.js +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/scenario.mjs @@ -1,17 +1,7 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); -const { Kafka } = require('kafkajs'); +import { Kafka } from 'kafkajs'; async function run() { const kafka = new Kafka({ @@ -54,10 +44,6 @@ async function run() { }, ], }); - - // Wait for the message to be received - await new Promise(resolve => setTimeout(resolve, 5000)); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts index f1d6e46db743..6e03921c4b73 100644 --- a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts @@ -1,54 +1,56 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('kafkajs', () => { afterAll(() => { cleanupChildProcesses(); }); - test('traces producers and consumers', { timeout: 60_000 }, async () => { - await createRunner(__dirname, 'scenario.js') - .withDockerCompose({ - workingDirectory: [__dirname], - readyMatches: ['9092'], - }) - .expect({ - transaction: { - transaction: 'test-topic', - contexts: { - trace: expect.objectContaining({ - op: 'message', - status: 'ok', - data: expect.objectContaining({ - 'messaging.system': 'kafka', - 'messaging.destination': 'test-topic', - 'otel.kind': 'PRODUCER', - 'sentry.op': 'message', - 'sentry.origin': 'auto.kafkajs.otel.producer', + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('traces producers and consumers', { timeout: 60_000 }, async () => { + await createRunner() + .withDockerCompose({ + workingDirectory: [__dirname], + readyMatches: ['9092'], + }) + .expect({ + transaction: { + transaction: 'test-topic', + contexts: { + trace: expect.objectContaining({ + op: 'message', + status: 'ok', + data: expect.objectContaining({ + 'messaging.system': 'kafka', + 'messaging.destination': 'test-topic', + 'otel.kind': 'PRODUCER', + 'sentry.op': 'message', + 'sentry.origin': 'auto.kafkajs.otel.producer', + }), }), - }), + }, }, - }, - }) - .expect({ - transaction: { - transaction: 'test-topic', - contexts: { - trace: expect.objectContaining({ - op: 'message', - status: 'ok', - data: expect.objectContaining({ - 'messaging.system': 'kafka', - 'messaging.destination': 'test-topic', - 'otel.kind': 'CONSUMER', - 'sentry.op': 'message', - 'sentry.origin': 'auto.kafkajs.otel.consumer', + }) + .expect({ + transaction: { + transaction: 'test-topic', + contexts: { + trace: expect.objectContaining({ + op: 'message', + status: 'ok', + data: expect.objectContaining({ + 'messaging.system': 'kafka', + 'messaging.destination': 'test-topic', + 'otel.kind': 'CONSUMER', + 'sentry.op': 'message', + 'sentry.origin': 'auto.kafkajs.otel.consumer', + }), }), - }), + }, }, - }, - }) - .start() - .completed(); + }) + .start() + .completed(); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/instrument-withMysql2.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/instrument-withMysql2.mjs new file mode 100644 index 000000000000..b40a8eaed8dd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/knex/instrument-withMysql2.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.knexIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/instrument-withPg.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/instrument-withPg.mjs new file mode 100644 index 000000000000..b40a8eaed8dd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/knex/instrument-withPg.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.knexIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withMysql2.js b/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withMysql2.mjs similarity index 70% rename from dev-packages/node-integration-tests/suites/tracing/knex/scenario-withMysql2.js rename to dev-packages/node-integration-tests/suites/tracing/knex/scenario-withMysql2.mjs index 5d57e38d9318..300e90a3df3e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withMysql2.js +++ b/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withMysql2.mjs @@ -1,19 +1,9 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, - integrations: [Sentry.knexIntegration()], -}); +import * as Sentry from '@sentry/node'; +import knex from 'knex'; // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); -const knex = require('knex').default; - const mysql2Client = knex({ client: 'mysql2', connection: { @@ -49,5 +39,4 @@ async function run() { ); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withPostgres.js b/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withPg.mjs similarity index 70% rename from dev-packages/node-integration-tests/suites/tracing/knex/scenario-withPostgres.js rename to dev-packages/node-integration-tests/suites/tracing/knex/scenario-withPg.mjs index a9f2d558a618..a5f4c62c85b9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withPostgres.js +++ b/dev-packages/node-integration-tests/suites/tracing/knex/scenario-withPg.mjs @@ -1,19 +1,9 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, - integrations: [Sentry.knexIntegration()], -}); +import * as Sentry from '@sentry/node'; +import knex from 'knex'; // Stop the process from exiting before the transaction is sent setInterval(() => {}, 1000); -const knex = require('knex').default; - const pgClient = knex({ client: 'pg', connection: { @@ -49,5 +39,4 @@ async function run() { ); } -// eslint-disable-next-line @typescript-eslint/no-floating-promises run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/test.ts b/dev-packages/node-integration-tests/suites/tracing/knex/test.ts index 8fd445d51525..f60ea76a1c54 100644 --- a/dev-packages/node-integration-tests/suites/tracing/knex/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/knex/test.ts @@ -1,129 +1,137 @@ -import { describe, expect, test } from 'vitest'; -import { createRunner } from '../../../utils/runner'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../utils/runner'; describe('knex auto instrumentation', () => { // Update this if another knex version is installed const KNEX_VERSION = '2.5.1'; - test('should auto-instrument `knex` package when using `pg` client', { timeout: 60_000 }, async () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - 'knex.version': KNEX_VERSION, - 'db.system': 'postgresql', - 'db.name': 'tests', - 'sentry.origin': 'auto.db.otel.knex', - 'sentry.op': 'db', - 'net.peer.name': 'localhost', - 'net.peer.port': 5445, - }), - status: 'ok', - description: - 'create table "User" ("id" serial primary key, "createdAt" timestamptz(3) not null default CURRENT_TIMESTAMP(3), "email" text not null, "name" text not null)', - origin: 'auto.db.otel.knex', - }), - expect.objectContaining({ - data: expect.objectContaining({ - 'knex.version': KNEX_VERSION, - 'db.system': 'postgresql', - 'db.name': 'tests', - 'sentry.origin': 'auto.db.otel.knex', - 'sentry.op': 'db', - 'net.peer.name': 'localhost', - 'net.peer.port': 5445, - }), - status: 'ok', - // In the knex-otel spans, the placeholders (e.g., `$1`) are replaced by a `?`. - description: 'insert into "User" ("email", "name") values (?, ?)', - origin: 'auto.db.otel.knex', - }), + describe('with `pg` client', () => { + createEsmAndCjsTests(__dirname, 'scenario-withPg.mjs', 'instrument-withPg.mjs', (createRunner, test) => { + test('should auto-instrument `knex` package', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'knex.version': KNEX_VERSION, + 'db.system': 'postgresql', + 'db.name': 'tests', + 'sentry.origin': 'auto.db.otel.knex', + 'sentry.op': 'db', + 'net.peer.name': 'localhost', + 'net.peer.port': 5445, + }), + status: 'ok', + description: + 'create table "User" ("id" serial primary key, "createdAt" timestamptz(3) not null default CURRENT_TIMESTAMP(3), "email" text not null, "name" text not null)', + origin: 'auto.db.otel.knex', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'knex.version': KNEX_VERSION, + 'db.system': 'postgresql', + 'db.name': 'tests', + 'sentry.origin': 'auto.db.otel.knex', + 'sentry.op': 'db', + 'net.peer.name': 'localhost', + 'net.peer.port': 5445, + }), + status: 'ok', + // In the knex-otel spans, the placeholders (e.g., `$1`) are replaced by a `?`. + description: 'insert into "User" ("email", "name") values (?, ?)', + origin: 'auto.db.otel.knex', + }), - expect.objectContaining({ - data: expect.objectContaining({ - 'knex.version': KNEX_VERSION, - 'db.operation': 'select', - 'db.sql.table': 'User', - 'db.system': 'postgresql', - 'db.name': 'tests', - 'db.statement': 'select * from "User"', - 'sentry.origin': 'auto.db.otel.knex', - 'sentry.op': 'db', - }), - status: 'ok', - description: 'select * from "User"', - origin: 'auto.db.otel.knex', - }), - ]), - }; + expect.objectContaining({ + data: expect.objectContaining({ + 'knex.version': KNEX_VERSION, + 'db.operation': 'select', + 'db.sql.table': 'User', + 'db.system': 'postgresql', + 'db.name': 'tests', + 'db.statement': 'select * from "User"', + 'sentry.origin': 'auto.db.otel.knex', + 'sentry.op': 'db', + }), + status: 'ok', + description: 'select * from "User"', + origin: 'auto.db.otel.knex', + }), + ]), + }; - await createRunner(__dirname, 'scenario-withPostgres.js') - .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); + await createRunner() + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + }); }); - test('should auto-instrument `knex` package when using `mysql2` client', { timeout: 60_000 }, async () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - 'knex.version': KNEX_VERSION, - 'db.system': 'mysql2', - 'db.name': 'tests', - 'db.user': 'root', - 'sentry.origin': 'auto.db.otel.knex', - 'sentry.op': 'db', - 'net.peer.name': 'localhost', - 'net.peer.port': 3307, - }), - status: 'ok', - description: - 'create table `User` (`id` int unsigned not null auto_increment primary key, `createdAt` timestamp(3) not null default CURRENT_TIMESTAMP(3), `email` text not null, `name` text not null)', - origin: 'auto.db.otel.knex', - }), - expect.objectContaining({ - data: expect.objectContaining({ - 'knex.version': KNEX_VERSION, - 'db.system': 'mysql2', - 'db.name': 'tests', - 'db.user': 'root', - 'sentry.origin': 'auto.db.otel.knex', - 'sentry.op': 'db', - 'net.peer.name': 'localhost', - 'net.peer.port': 3307, - }), - status: 'ok', - description: 'insert into `User` (`email`, `name`) values (?, ?)', - origin: 'auto.db.otel.knex', - }), + describe('with `mysql2` client', () => { + createEsmAndCjsTests(__dirname, 'scenario-withMysql2.mjs', 'instrument-withMysql2.mjs', (createRunner, test) => { + test('should auto-instrument `knex` package', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'knex.version': KNEX_VERSION, + 'db.system': 'mysql2', + 'db.name': 'tests', + 'db.user': 'root', + 'sentry.origin': 'auto.db.otel.knex', + 'sentry.op': 'db', + 'net.peer.name': 'localhost', + 'net.peer.port': 3307, + }), + status: 'ok', + description: + 'create table `User` (`id` int unsigned not null auto_increment primary key, `createdAt` timestamp(3) not null default CURRENT_TIMESTAMP(3), `email` text not null, `name` text not null)', + origin: 'auto.db.otel.knex', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'knex.version': KNEX_VERSION, + 'db.system': 'mysql2', + 'db.name': 'tests', + 'db.user': 'root', + 'sentry.origin': 'auto.db.otel.knex', + 'sentry.op': 'db', + 'net.peer.name': 'localhost', + 'net.peer.port': 3307, + }), + status: 'ok', + description: 'insert into `User` (`email`, `name`) values (?, ?)', + origin: 'auto.db.otel.knex', + }), - expect.objectContaining({ - data: expect.objectContaining({ - 'knex.version': KNEX_VERSION, - 'db.operation': 'select', - 'db.sql.table': 'User', - 'db.system': 'mysql2', - 'db.name': 'tests', - 'db.statement': 'select * from `User`', - 'db.user': 'root', - 'sentry.origin': 'auto.db.otel.knex', - 'sentry.op': 'db', - }), - status: 'ok', - description: 'select * from `User`', - origin: 'auto.db.otel.knex', - }), - ]), - }; + expect.objectContaining({ + data: expect.objectContaining({ + 'knex.version': KNEX_VERSION, + 'db.operation': 'select', + 'db.sql.table': 'User', + 'db.system': 'mysql2', + 'db.name': 'tests', + 'db.statement': 'select * from `User`', + 'db.user': 'root', + 'sentry.origin': 'auto.db.otel.knex', + 'sentry.op': 'db', + }), + status: 'ok', + description: 'select * from `User`', + origin: 'auto.db.otel.knex', + }), + ]), + }; - await createRunner(__dirname, 'scenario-withMysql2.js') - .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port: 3306'] }) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); + await createRunner() + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port: 3306'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts index bd617f021778..c1b88699d8a0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts @@ -12,15 +12,14 @@ describe('getTraceMetaTags', () => { const runner = createRunner(__dirname, 'server.js').start(); - const response = await runner.makeRequest('get', '/test', { + const response = await runner.makeRequest<{ response: string }>('get', '/test', { headers: { 'sentry-trace': `${traceId}-${parentSpanId}-1`, baggage: 'sentry-environment=production,sentry-sample_rand=0.42', }, }); - // @ts-ignore - response is defined, types just don't reflect it - const html = response?.response as unknown as string; + const html = response?.response; expect(html).toMatch(//); expect(html).toContain(''); @@ -29,12 +28,11 @@ describe('getTraceMetaTags', () => { test('injects tags with new trace if no incoming headers', async () => { const runner = createRunner(__dirname, 'server.js').start(); - const response = await runner.makeRequest('get', '/test'); + const response = await runner.makeRequest<{ response: string }>('get', '/test'); - // @ts-ignore - response is defined, types just don't reflect it - const html = response?.response as unknown as string; + const html = response?.response; - const traceId = html.match(//)?.[1]; + const traceId = html?.match(//)?.[1]; expect(traceId).not.toBeUndefined(); expect(html).toContain(' { test('injects tags with negative sampling decision if tracesSampleRate is 0', async () => { const runner = createRunner(__dirname, 'server-tracesSampleRate-zero.js').start(); - const response = await runner.makeRequest('get', '/test'); + const response = await runner.makeRequest<{ response: string }>('get', '/test'); - // @ts-ignore - response is defined, types just don't reflect it - const html = response?.response as unknown as string; + const html = response?.response; - const traceId = html.match(//)?.[1]; + const traceId = html?.match(//)?.[1]; expect(traceId).not.toBeUndefined(); expect(html).toContain(' { const runner = createRunner(__dirname, 'server-sdk-disabled.js').start(); - const response = await runner.makeRequest('get', '/test', { + const response = await runner.makeRequest<{ response: string }>('get', '/test', { headers: { 'sentry-trace': `${traceId}-${parentSpanId}-1`, baggage: 'sentry-environment=production', }, }); - // @ts-ignore - response is defined, types just don't reflect it - const html = response?.response as unknown as string; + const html = response?.response; expect(html).not.toContain('"sentry-trace"'); expect(html).not.toContain('"baggage"'); 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-esm/instrument.mjs index d843ca07fce8..518e3f83de83 100644 --- 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-esm/instrument.mjs @@ -1,5 +1,5 @@ -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', 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-esm/scenario.mjs index 07e234983b4d..9fafd4b528af 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-esm/scenario.mjs @@ -1,5 +1,5 @@ -import * as http from 'http'; 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 () => { 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 index 05c8913a9d2d..8bd7dd5f2502 100644 --- 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 @@ -30,7 +30,7 @@ describe('outgoing http in ESM', () => { const instrumentPath = join(__dirname, 'instrument.mjs'); await createRunner(__dirname, 'scenario.mjs') - .withFlags('--import', instrumentPath) + .withInstrument(instrumentPath) .withEnv({ SERVER_URL }) .expect({ transaction: { diff --git a/dev-packages/node-integration-tests/suites/tsconfig.json b/dev-packages/node-integration-tests/suites/tsconfig.json index c8f7f2d29f65..38ca0b13bcdd 100644 --- a/dev-packages/node-integration-tests/suites/tsconfig.json +++ b/dev-packages/node-integration-tests/suites/tsconfig.json @@ -1,9 +1,3 @@ { - "extends": "../tsconfig.test.json", - - "compilerOptions": { - // Although this seems wrong to include `DOM` here, it's necessary to make - // global fetch available in tests in lower Node versions. - "lib": ["DOM", "ES2018"], - } + "extends": "../tsconfig.test.json" } diff --git a/dev-packages/node-integration-tests/suites/vercel/instrument.mjs b/dev-packages/node-integration-tests/suites/vercel/instrument.mjs new file mode 100644 index 000000000000..e3a1dbab7ba5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/instrument.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +process.env.VERCEL = 'true'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + // We look at debug logs in this test + debug: true, +}); diff --git a/dev-packages/node-integration-tests/suites/vercel/scenario.mjs b/dev-packages/node-integration-tests/suites/vercel/scenario.mjs new file mode 100644 index 000000000000..b2ed413175df --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/scenario.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test/express', (_req, res) => { + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/vercel/test.ts b/dev-packages/node-integration-tests/suites/vercel/test.ts new file mode 100644 index 000000000000..4517d0eaf115 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/test.ts @@ -0,0 +1,53 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../utils/runner'; + +describe('vercel xxx', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should flush events correctly on Vercel', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /test/express', + }, + }) + .start(); + runner.makeRequest('get', '/test/express'); + await runner.completed(); + + const actualLogs = runner.getLogs(); + + // We want to test that the following logs are present in this order + // other logs may be in between + const expectedLogs = [ + 'Sentry Logger [log]: @sentry/instrumentation-http Patching server.emit', + 'Sentry Logger [log]: @sentry/instrumentation-http Handling incoming request', + 'Sentry Logger [log]: @sentry/instrumentation-http Patching request.on', + 'Sentry Logger [debug]: @opentelemetry_sentry-patched/instrumentation-http http instrumentation incomingRequest', + 'Sentry Logger [log]: [Tracing] Starting sampled root span', + // later... + 'Sentry Logger [log]: Patching response to flush on Vercel', + 'Sentry Logger [log]: Patching res.end()', + // later... + 'Sentry Logger [log]: Flushing events before Vercel Lambda freeze', + 'Sentry Logger [log]: SpanExporter exported 4 spans, 0 spans are waiting for their parent spans to finish', + ]; + + // Test that the order of logs is correct + for (const log of actualLogs) { + if (expectedLogs.length === 0) { + break; + } + + if (log === expectedLogs[0]) { + expectedLogs.shift(); + } + } + + expect(expectedLogs).toEqual([]); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/winston/test.ts b/dev-packages/node-integration-tests/suites/winston/test.ts index e28ec8f586ff..034210f8690b 100644 --- a/dev-packages/node-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-integration-tests/suites/winston/test.ts @@ -9,92 +9,36 @@ describe('winston integration', () => { test('should capture winston logs with default levels', async () => { const runner = createRunner(__dirname, 'subject.ts') .expect({ - otel_log: { - severityText: 'info', - body: { - stringValue: 'Test info message', - }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.logging.winston', - }, - }, - { - key: 'sentry.release', - value: { - stringValue: '1.0.0', - }, - }, - { - key: 'sentry.environment', - value: { - stringValue: 'test', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.node', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'server.address', - value: { - stringValue: expect.any(String), - }, - }, - ], - }, - }) - .expect({ - otel_log: { - severityText: 'error', - body: { - stringValue: 'Test error message', - }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.logging.winston', - }, - }, - { - key: 'sentry.release', - value: { - stringValue: '1.0.0', - }, - }, - { - key: 'sentry.environment', - value: { - stringValue: 'test', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.node', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'server.address', - value: { - stringValue: expect.any(String), + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, }, }, ], @@ -109,92 +53,66 @@ describe('winston integration', () => { const runner = createRunner(__dirname, 'subject.ts') .withEnv({ CUSTOM_LEVELS: 'true' }) .expect({ - otel_log: { - severityText: 'info', - body: { - stringValue: 'Test info message', - }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.logging.winston', - }, - }, - { - key: 'sentry.release', - value: { - stringValue: '1.0.0', - }, - }, - { - key: 'sentry.environment', - value: { - stringValue: 'test', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.node', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'server.address', - value: { - stringValue: expect.any(String), - }, - }, - ], - }, - }) - .expect({ - otel_log: { - severityText: 'error', - body: { - stringValue: 'Test error message', - }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.logging.winston', - }, - }, - { - key: 'sentry.release', - value: { - stringValue: '1.0.0', - }, - }, - { - key: 'sentry.environment', - value: { - stringValue: 'test', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.node', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'server.address', - value: { - stringValue: expect.any(String), + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, }, }, ], @@ -209,92 +127,53 @@ describe('winston integration', () => { const runner = createRunner(__dirname, 'subject.ts') .withEnv({ WITH_METADATA: 'true' }) .expect({ - otel_log: { - severityText: 'info', - body: { - stringValue: 'Test info message', - }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.logging.winston', - }, - }, - { - key: 'sentry.release', - value: { - stringValue: '1.0.0', - }, - }, - { - key: 'sentry.environment', - value: { - stringValue: 'test', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.node', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'server.address', - value: { - stringValue: expect.any(String), - }, - }, - ], - }, - }) - .expect({ - otel_log: { - severityText: 'error', - body: { - stringValue: 'Test error message', - }, - attributes: [ - { - key: 'sentry.origin', - value: { - stringValue: 'auto.logging.winston', - }, - }, - { - key: 'sentry.release', - value: { - stringValue: '1.0.0', - }, - }, - { - key: 'sentry.environment', - value: { - stringValue: 'test', - }, - }, - { - key: 'sentry.sdk.name', - value: { - stringValue: 'sentry.javascript.node', - }, - }, - { - key: 'sentry.sdk.version', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'server.address', - value: { - stringValue: expect.any(String), + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test info message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Test error message', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'Test message with metadata', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + foo: { value: 'bar', type: 'string' }, + number: { value: 42, type: 'integer' }, }, }, ], diff --git a/dev-packages/node-integration-tests/utils/assertions.ts b/dev-packages/node-integration-tests/utils/assertions.ts index 9eee29e36ecd..296bdc608bb4 100644 --- a/dev-packages/node-integration-tests/utils/assertions.ts +++ b/dev-packages/node-integration-tests/utils/assertions.ts @@ -3,7 +3,7 @@ import type { Envelope, Event, SerializedCheckIn, - SerializedOtelLog, + SerializedLogContainer, SerializedSession, SessionAggregates, TransactionEvent, @@ -67,7 +67,10 @@ export function assertSentryClientReport(actual: ClientReport, expected: Partial }); } -export function assertSentryOtelLog(actual: SerializedOtelLog, expected: Partial): void { +export function assertSentryLogContainer( + actual: SerializedLogContainer, + expected: Partial, +): void { expect(actual).toMatchObject({ ...expected, }); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index f327dd759bfb..e6062a6322b8 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -6,22 +6,22 @@ import type { Event, EventEnvelope, SerializedCheckIn, - SerializedOtelLog, + SerializedLogContainer, SerializedSession, SessionAggregates, TransactionEvent, } from '@sentry/core'; import { normalize } from '@sentry/core'; -import axios from 'axios'; import { execSync, spawn, spawnSync } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { join } from 'path'; +import { afterAll, beforeAll, describe, test } from 'vitest'; import { assertEnvelopeHeader, assertSentryCheckIn, assertSentryClientReport, assertSentryEvent, - assertSentryOtelLog, + assertSentryLogContainer, assertSentrySession, assertSentrySessions, assertSentryTransaction, @@ -40,13 +40,13 @@ export function cleanupChildProcesses(): void { process.on('exit', cleanupChildProcesses); /** Promise only resolves when fn returns true */ -async function waitFor(fn: () => boolean, timeout = 10_000): Promise { +async function waitFor(fn: () => boolean, timeout = 10_000, message = 'Timed out waiting'): Promise { let remaining = timeout; while (fn() === false) { await new Promise(resolve => setTimeout(resolve, 100)); remaining -= 100; if (remaining < 0) { - throw new Error('Timed out waiting for server port'); + throw new Error(message); } } } @@ -121,7 +121,7 @@ type ExpectedSession = Partial | ((event: SerializedSession) type ExpectedSessions = Partial | ((event: SessionAggregates) => void); type ExpectedCheckIn = Partial | ((event: SerializedCheckIn) => void); type ExpectedClientReport = Partial | ((event: ClientReport) => void); -type ExpectedOtelLog = Partial | ((event: SerializedOtelLog) => void); +type ExpectedLogContainer = Partial | ((event: SerializedLogContainer) => void); type Expected = | { @@ -143,7 +143,7 @@ type Expected = client_report: ExpectedClientReport; } | { - otel_log: ExpectedOtelLog; + log: ExpectedLogContainer; }; type ExpectedEnvelopeHeader = @@ -151,7 +151,7 @@ type ExpectedEnvelopeHeader = | { transaction: Partial } | { session: Partial } | { sessions: Partial } - | { otel_log: Partial }; + | { log: Partial }; type StartResult = { completed(): Promise; @@ -160,10 +160,71 @@ type StartResult = { makeRequest( method: 'get' | 'post', path: string, - options?: { headers?: Record; data?: unknown; expectError?: boolean }, + options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, ): Promise; }; +export function createEsmAndCjsTests( + cwd: string, + scenarioPath: string, + instrumentPath: string, + callback: ( + createTestRunner: () => ReturnType, + testFn: typeof test | typeof test.fails, + mode: 'esm' | 'cjs', + ) => void, + options?: { failsOnCjs?: boolean; failsOnEsm?: boolean }, +): void { + const mjsScenarioPath = join(cwd, scenarioPath); + const mjsInstrumentPath = join(cwd, instrumentPath); + + if (!mjsScenarioPath.endsWith('.mjs')) { + throw new Error(`Scenario path must end with .mjs: ${scenarioPath}`); + } + + if (!existsSync(mjsInstrumentPath)) { + throw new Error(`Instrument file not found: ${mjsInstrumentPath}`); + } + + const cjsScenarioPath = join(cwd, `tmp_${scenarioPath.replace('.mjs', '.cjs')}`); + const cjsInstrumentPath = join(cwd, `tmp_${instrumentPath.replace('.mjs', '.cjs')}`); + + describe('esm', () => { + const testFn = options?.failsOnEsm ? test.fails : test; + callback(() => createRunner(mjsScenarioPath).withFlags('--import', mjsInstrumentPath), testFn, 'esm'); + }); + + describe('cjs', () => { + beforeAll(() => { + // For the CJS runner, we create some temporary files... + convertEsmFileToCjs(mjsScenarioPath, cjsScenarioPath); + convertEsmFileToCjs(mjsInstrumentPath, cjsInstrumentPath); + }); + + afterAll(() => { + try { + unlinkSync(cjsInstrumentPath); + } catch { + // Ignore errors here + } + try { + unlinkSync(cjsScenarioPath); + } catch { + // Ignore errors here + } + }); + + const testFn = options?.failsOnCjs ? test.fails : test; + callback(() => createRunner(cjsScenarioPath).withFlags('--require', cjsInstrumentPath), testFn, 'cjs'); + }); +} + +function convertEsmFileToCjs(inputPath: string, outputPath: string): void { + const cjsFileContent = readFileSync(inputPath, 'utf8'); + const cjsFileContentConverted = convertEsmToCjs(cjsFileContent); + writeFileSync(outputPath, cjsFileContentConverted); +} + /** Creates a test runner */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createRunner(...paths: string[]) { @@ -215,6 +276,10 @@ export function createRunner(...paths: string[]) { flags.push(...args); return this; }, + withInstrument: function (instrumentPath: string) { + flags.push('--import', instrumentPath); + return this; + }, withMockSentryServer: function () { withSentryServer = true; return this; @@ -237,13 +302,10 @@ export function createRunner(...paths: string[]) { ensureNoErrorOutput = true; return this; }, - start: function (done?: (e?: unknown) => void): StartResult { - let resolve: (value: void) => void; - let reject: (reason?: unknown) => void; - const completePromise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); + start: function (): StartResult { + let isComplete = false; + let completeError: Error | undefined; + const expectedEnvelopeCount = Math.max(expectedEnvelopes.length, (expectedEnvelopeHeaders || []).length); let envelopeCount = 0; @@ -252,13 +314,13 @@ export function createRunner(...paths: string[]) { let child: ReturnType | undefined; function complete(error?: Error): void { - child?.kill(); - done?.(normalize(error)); - if (error) { - reject(error); - } else { - resolve(); + if (isComplete) { + return; } + + isComplete = true; + completeError = error || undefined; + child?.kill(); } /** Called after each expect callback to check if we're complete */ @@ -332,8 +394,8 @@ export function createRunner(...paths: string[]) { } else if ('client_report' in expected) { expectClientReport(item[1] as ClientReport, expected.client_report); expectCallbackCalled(); - } else if ('otel_log' in expected) { - expectOtelLog(item[1] as SerializedOtelLog, expected.otel_log); + } else if ('log' in expected) { + expectLog(item[1] as SerializedLogContainer, expected.log); expectCallbackCalled(); } else { throw new Error( @@ -453,8 +515,12 @@ export function createRunner(...paths: string[]) { .catch(e => complete(e)); return { - completed: function (): Promise { - return completePromise; + completed: async function (): Promise { + await waitFor(() => isComplete, 120_000, 'Timed out waiting for test to complete'); + + if (completeError) { + throw completeError; + } }, childHasExited: function (): boolean { return hasExited; @@ -465,32 +531,43 @@ export function createRunner(...paths: string[]) { makeRequest: async function ( method: 'get' | 'post', path: string, - options: { headers?: Record; data?: unknown; expectError?: boolean } = {}, + options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, ): Promise { try { - await waitFor(() => scenarioServerPort !== undefined); + await waitFor(() => scenarioServerPort !== undefined, 10_000, 'Timed out waiting for server port'); } catch (e) { complete(e as Error); return; } const url = `http://localhost:${scenarioServerPort}${path}`; - const data = options.data; + const body = options.data; const headers = options.headers || {}; const expectError = options.expectError || false; - if (process.env.DEBUG) log('making request', method, url, headers, data); + if (process.env.DEBUG) log('making request', method, url, headers, body); try { - const res = - method === 'post' ? await axios.post(url, data, { headers }) : await axios.get(url, { headers }); + const res = await fetch(url, { headers, method, body }); + + if (!res.ok) { + if (!expectError) { + complete(new Error(`Expected request to "${path}" to succeed, but got a ${res.status} response`)); + } + + return; + } if (expectError) { complete(new Error(`Expected request to "${path}" to fail, but got a ${res.status} response`)); return; } - return res.data; + if (res.headers.get('content-type')?.includes('application/json')) { + return await res.json(); + } + + return (await res.text()) as T; } catch (e) { if (expectError) { return; @@ -558,10 +635,43 @@ function expectClientReport(item: ClientReport, expected: ExpectedClientReport): } } -function expectOtelLog(item: SerializedOtelLog, expected: ExpectedOtelLog): void { +function expectLog(item: SerializedLogContainer, expected: ExpectedLogContainer): void { if (typeof expected === 'function') { expected(item); } else { - assertSentryOtelLog(item, expected); + assertSentryLogContainer(item, expected); } } + +/** + * Converts ESM import statements to CommonJS require statements + * @param content The content of an ESM file + * @returns The content with require statements instead of imports + */ +function convertEsmToCjs(content: string): string { + let newContent = content; + + // Handle default imports: import x from 'y' -> const x = require('y') + newContent = newContent.replace( + /import\s+([\w*{}\s,]+)\s+from\s+['"]([^'"]+)['"]/g, + (_, imports: string, module: string) => { + if (imports.includes('* as')) { + // Handle namespace imports: import * as x from 'y' -> const x = require('y') + return `const ${imports.replace('* as', '').trim()} = require('${module}')`; + } else if (imports.includes('{')) { + // Handle named imports: import {x, y} from 'z' -> const {x, y} = require('z') + return `const ${imports} = require('${module}')`; + } else { + // Handle default imports: import x from 'y' -> const x = require('y') + return `const ${imports} = require('${module}')`; + } + }, + ); + + // Handle side-effect imports: import 'x' -> require('x') + newContent = newContent.replace(/import\s+['"]([^'"]+)['"]/g, (_, module) => { + return `require('${module}')`; + }); + + return newContent; +} diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 92b7ddcea364..99036353d4d7 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -10,7 +10,6 @@ import { getDynamicSamplingContextFromSpan, getIsolationScope, getLocationHref, - getRootSpan, GLOBAL_OBJ, logger, propagationContextFromHeaders, @@ -36,12 +35,7 @@ import { import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; import { registerBackgroundTabDetection } from './backgroundtab'; -import type { PreviousTraceInfo } from './previousTrace'; -import { - addPreviousTraceSpanLink, - getPreviousTraceFromSessionStorage, - storePreviousTraceInSessionStorage, -} from './previousTrace'; +import { linkTraces } from './linkedTraces'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -166,13 +160,32 @@ export interface BrowserTracingOptions { * * - `'off'`: The previous trace data will not be stored or linked. * - * Note that your `tracesSampleRate` or `tracesSampler` config significantly influences - * how often traces will be linked. + * You can also use {@link BrowserTracingOptions.consistentTraceSampling} to get + * consistent trace sampling of subsequent traces. Otherwise, by default, your + * `tracesSampleRate` or `tracesSampler` config significantly influences how often + * traces will be linked. * * @default 'in-memory' - see explanation above */ linkPreviousTrace: 'in-memory' | 'session-storage' | 'off'; + /** + * If true, Sentry will consistently sample subsequent traces based on the + * sampling decision of the initial trace. For example, if the initial page + * load trace was sampled positively, all subsequent traces (e.g. navigations) + * are also sampled positively. In case the initial trace was sampled negatively, + * all subsequent traces are also sampled negatively. + * + * This option allows you to get consistent, linked traces within a user journey + * while maintaining an overall quota based on your trace sampling settings. + * + * This option is only effective if {@link BrowserTracingOptions.linkPreviousTrace} + * is enabled (i.e. not set to `'off'`). + * + * @default `false` - this is an opt-in feature. + */ + consistentTraceSampling: boolean; + /** * _experiments allows the user to send options to define how this integration works. * @@ -214,6 +227,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongAnimationFrame: true, enableInp: true, linkPreviousTrace: 'in-memory', + consistentTraceSampling: false, _experiments: {}, ...defaultRequestInstrumentationOptions, }; @@ -265,6 +279,7 @@ export const browserTracingIntegration = ((_options: Partial { - if (getRootSpan(span) !== span) { - return; - } - - if (linkPreviousTrace === 'session-storage') { - const updatedPreviousTraceInfo = addPreviousTraceSpanLink(getPreviousTraceFromSessionStorage(), span); - storePreviousTraceInSessionStorage(updatedPreviousTraceInfo); - } else { - inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span); - } - }); + linkTraces(client, { linkPreviousTrace, consistentTraceSampling }); } if (WINDOW.location) { diff --git a/packages/browser/src/tracing/linkedTraces.ts b/packages/browser/src/tracing/linkedTraces.ts new file mode 100644 index 000000000000..cd487aad9afa --- /dev/null +++ b/packages/browser/src/tracing/linkedTraces.ts @@ -0,0 +1,239 @@ +import type { Client, PropagationContext, Span } from '@sentry/core'; +import { + type SpanContextData, + getCurrentScope, + getRootSpan, + logger, + SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, + spanToJSON, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../exports'; + +export interface PreviousTraceInfo { + /** + * Span context of the previous trace's local root span + */ + spanContext: SpanContextData; + + /** + * Timestamp in seconds when the previous trace was started + */ + startTimestamp: number; + + /** + * sample rate of the previous trace + */ + sampleRate: number; + + /** + * The sample rand of the previous trace + */ + sampleRand: number; +} + +// 1h in seconds +export const PREVIOUS_TRACE_MAX_DURATION = 3600; + +// session storage key +export const PREVIOUS_TRACE_KEY = 'sentry_previous_trace'; + +export const PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE = 'sentry.previous_trace'; + +/** + * Takes care of linking traces and applying the (consistent) sampling behavoiour based on the passed options + * @param options - options for linking traces and consistent trace sampling (@see BrowserTracingOptions) + * @param client - Sentry client + */ +export function linkTraces( + client: Client, + { + linkPreviousTrace, + consistentTraceSampling, + }: { + linkPreviousTrace: 'session-storage' | 'in-memory'; + consistentTraceSampling: boolean; + }, +): void { + const useSessionStorage = linkPreviousTrace === 'session-storage'; + + let inMemoryPreviousTraceInfo = useSessionStorage ? getPreviousTraceFromSessionStorage() : undefined; + + client.on('spanStart', span => { + if (getRootSpan(span) !== span) { + return; + } + + const oldPropagationContext = getCurrentScope().getPropagationContext(); + inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span, oldPropagationContext); + + if (useSessionStorage) { + storePreviousTraceInSessionStorage(inMemoryPreviousTraceInfo); + } + }); + + let isFirstTraceOnPageload = true; + if (consistentTraceSampling) { + /* + When users opt into `consistentTraceSampling`, we need to ensure that we propagate + the previous trace's sample rate and rand to the current trace. This is necessary because otherwise, span + metric extrapolation is inaccurate, as we'd propagate too high of a sample rate for the subsequent traces. + + So therefore, we pretend that the previous trace was the parent trace of the newly started trace. To do that, + we mutate the propagation context of the current trace and set the sample rate and sample rand of the previous trace. + Timing-wise, it is fine because it happens before we even sample the root span. + + @see https://github.com/getsentry/sentry-javascript/issues/15754 + */ + client.on('beforeSampling', mutableSamplingContextData => { + if (!inMemoryPreviousTraceInfo) { + return; + } + + const scope = getCurrentScope(); + const currentPropagationContext = scope.getPropagationContext(); + + // We do not want to force-continue the sampling decision if we continue a trace + // that was started on the backend. Most prominently, this will happen in MPAs where + // users hard-navigate between pages. In this case, the sampling decision of a potentially + // started trace on the server takes precedence. + // Why? We want to prioritize inter-trace consistency over intra-trace consistency. + if (isFirstTraceOnPageload && currentPropagationContext.parentSpanId) { + isFirstTraceOnPageload = false; + return; + } + + scope.setPropagationContext({ + ...currentPropagationContext, + dsc: { + ...currentPropagationContext.dsc, + sample_rate: String(inMemoryPreviousTraceInfo.sampleRate), + sampled: String(spanContextSampled(inMemoryPreviousTraceInfo.spanContext)), + }, + sampleRand: inMemoryPreviousTraceInfo.sampleRand, + }); + + mutableSamplingContextData.parentSampled = spanContextSampled(inMemoryPreviousTraceInfo.spanContext); + mutableSamplingContextData.parentSampleRate = inMemoryPreviousTraceInfo.sampleRate; + + mutableSamplingContextData.spanAttributes = { + ...mutableSamplingContextData.spanAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE]: inMemoryPreviousTraceInfo.sampleRate, + }; + }); + } +} + +/** + * Adds a previous_trace span link to the passed span if the passed + * previousTraceInfo is still valid. + * + * @returns the updated previous trace info (based on the current span/trace) to + * be used on the next call + */ +export function addPreviousTraceSpanLink( + previousTraceInfo: PreviousTraceInfo | undefined, + span: Span, + oldPropagationContext: PropagationContext, +): PreviousTraceInfo { + const spanJson = spanToJSON(span); + + function getSampleRate(): number { + try { + return ( + Number(oldPropagationContext.dsc?.sample_rate) ?? Number(spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]) + ); + } catch { + return 0; + } + } + + const updatedPreviousTraceInfo = { + spanContext: span.spanContext(), + startTimestamp: spanJson.start_timestamp, + sampleRate: getSampleRate(), + sampleRand: oldPropagationContext.sampleRand, + }; + + if (!previousTraceInfo) { + return updatedPreviousTraceInfo; + } + + const previousTraceSpanCtx = previousTraceInfo.spanContext; + if (previousTraceSpanCtx.traceId === spanJson.trace_id) { + // This means, we're still in the same trace so let's not update the previous trace info + // or add a link to the current span. + // Once we move away from the long-lived, route-based trace model, we can remove this cases + return previousTraceInfo; + } + + // Only add the link if the startTimeStamp of the previous trace's root span is within + // PREVIOUS_TRACE_MAX_DURATION (1h) of the current root span's startTimestamp + // This is done to + // - avoid adding links to "stale" traces + // - enable more efficient querying for previous/next traces in Sentry + if (Date.now() / 1000 - previousTraceInfo.startTimestamp <= PREVIOUS_TRACE_MAX_DURATION) { + if (DEBUG_BUILD) { + logger.info( + `Adding previous_trace ${previousTraceSpanCtx} link to span ${{ + op: spanJson.op, + ...span.spanContext(), + }}`, + ); + } + + span.addLink({ + context: previousTraceSpanCtx, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }); + + // TODO: Remove this once EAP can store span links. We currently only set this attribute so that we + // can obtain the previous trace information from the EAP store. Long-term, EAP will handle + // span links and then we should remove this again. Also throwing in a TODO(v10), to remind us + // to check this at v10 time :) + span.setAttribute( + PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE, + `${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${ + spanContextSampled(previousTraceSpanCtx) ? 1 : 0 + }`, + ); + } + + return updatedPreviousTraceInfo; +} + +/** + * Stores @param previousTraceInfo in sessionStorage. + */ +export function storePreviousTraceInSessionStorage(previousTraceInfo: PreviousTraceInfo): void { + try { + WINDOW.sessionStorage.setItem(PREVIOUS_TRACE_KEY, JSON.stringify(previousTraceInfo)); + } catch (e) { + // Ignore potential errors (e.g. if sessionStorage is not available) + DEBUG_BUILD && logger.warn('Could not store previous trace in sessionStorage', e); + } +} + +/** + * Retrieves the previous trace from sessionStorage if available. + */ +export function getPreviousTraceFromSessionStorage(): PreviousTraceInfo | undefined { + try { + const previousTraceInfo = WINDOW.sessionStorage?.getItem(PREVIOUS_TRACE_KEY); + // @ts-expect-error - intentionally risking JSON.parse throwing when previousTraceInfo is null to save bundle size + return JSON.parse(previousTraceInfo); + } catch (e) { + return undefined; + } +} + +/** + * see {@link import('@sentry/core').spanIsSampled} + */ +export function spanContextSampled(ctx: SpanContextData): boolean { + return ctx.traceFlags === 0x1; +} diff --git a/packages/browser/src/tracing/previousTrace.ts b/packages/browser/src/tracing/previousTrace.ts deleted file mode 100644 index 6d53833d718d..000000000000 --- a/packages/browser/src/tracing/previousTrace.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { Span } from '@sentry/core'; -import { type SpanContextData, logger, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanToJSON } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; -import { WINDOW } from '../exports'; - -export interface PreviousTraceInfo { - /** - * Span context of the previous trace's local root span - */ - spanContext: SpanContextData; - - /** - * Timestamp in seconds when the previous trace was started - */ - startTimestamp: number; -} - -// 1h in seconds -export const PREVIOUS_TRACE_MAX_DURATION = 3600; - -// session storage key -export const PREVIOUS_TRACE_KEY = 'sentry_previous_trace'; - -export const PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE = 'sentry.previous_trace'; - -/** - * Adds a previous_trace span link to the passed span if the passed - * previousTraceInfo is still valid. - * - * @returns the updated previous trace info (based on the current span/trace) to - * be used on the next call - */ -export function addPreviousTraceSpanLink( - previousTraceInfo: PreviousTraceInfo | undefined, - span: Span, -): PreviousTraceInfo { - const spanJson = spanToJSON(span); - - if (!previousTraceInfo) { - return { - spanContext: span.spanContext(), - startTimestamp: spanJson.start_timestamp, - }; - } - - const previousTraceSpanCtx = previousTraceInfo.spanContext; - if (previousTraceSpanCtx.traceId === spanJson.trace_id) { - // This means, we're still in the same trace so let's not update the previous trace info - // or add a link to the current span. - // Once we move away from the long-lived, route-based trace model, we can remove this cases - return previousTraceInfo; - } - - // Only add the link if the startTimeStamp of the previous trace's root span is within - // PREVIOUS_TRACE_MAX_DURATION (1h) of the current root span's startTimestamp - // This is done to - // - avoid adding links to "stale" traces - // - enable more efficient querying for previous/next traces in Sentry - if (Date.now() / 1000 - previousTraceInfo.startTimestamp <= PREVIOUS_TRACE_MAX_DURATION) { - if (DEBUG_BUILD) { - logger.info( - `Adding previous_trace ${previousTraceSpanCtx} link to span ${{ - op: spanJson.op, - ...span.spanContext(), - }}`, - ); - } - - span.addLink({ - context: previousTraceSpanCtx, - attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', - }, - }); - - // TODO: Remove this once EAP can store span links. We currently only set this attribute so that we - // can obtain the previous trace information from the EAP store. Long-term, EAP will handle - // span links and then we should remove this again. Also throwing in a TODO(v10), to remind us - // to check this at v10 time :) - span.setAttribute( - PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE, - `${previousTraceSpanCtx.traceId}-${previousTraceSpanCtx.spanId}-${ - previousTraceSpanCtx.traceFlags === 0x1 ? 1 : 0 - }`, - ); - } - - return { - spanContext: span.spanContext(), - startTimestamp: spanToJSON(span).start_timestamp, - }; -} - -/** - * Stores @param previousTraceInfo in sessionStorage. - */ -export function storePreviousTraceInSessionStorage(previousTraceInfo: PreviousTraceInfo): void { - try { - WINDOW.sessionStorage.setItem(PREVIOUS_TRACE_KEY, JSON.stringify(previousTraceInfo)); - } catch (e) { - // Ignore potential errors (e.g. if sessionStorage is not available) - DEBUG_BUILD && logger.warn('Could not store previous trace in sessionStorage', e); - } -} - -/** - * Retrieves the previous trace from sessionStorage if available. - */ -export function getPreviousTraceFromSessionStorage(): PreviousTraceInfo | undefined { - try { - const previousTraceInfo = WINDOW.sessionStorage?.getItem(PREVIOUS_TRACE_KEY); - // @ts-expect-error - intentionally risking JSON.parse throwing when previousTraceInfo is null to save bundle size - return JSON.parse(previousTraceInfo); - } catch (e) { - return undefined; - } -} diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 4fb816d1319d..cc987fca2966 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -420,21 +420,38 @@ function setHeaderOnXhr( sentryTraceHeader: string, sentryBaggageHeader: string | undefined, ): void { + const originalHeaders = xhr.__sentry_xhr_v3__?.request_headers; + + if (originalHeaders?.['sentry-trace']) { + // bail if a sentry-trace header is already set + return; + } + try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion xhr.setRequestHeader!('sentry-trace', sentryTraceHeader); if (sentryBaggageHeader) { - // From MDN: "If this method is called several times with the same header, the values are merged into one single request header." - // We can therefore simply set a baggage header without checking what was there before - // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - xhr.setRequestHeader!('baggage', sentryBaggageHeader); + // only add our headers if + // - no pre-existing baggage header exists + // - or it is set and doesn't yet contain sentry values + const originalBaggageHeader = originalHeaders?.['baggage']; + if (!originalBaggageHeader || !baggageHeaderHasSentryValues(originalBaggageHeader)) { + // From MDN: "If this method is called several times with the same header, the values are merged into one single request header." + // We can therefore simply set a baggage header without checking what was there before + // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + xhr.setRequestHeader!('baggage', sentryBaggageHeader); + } } } catch (_) { // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } +function baggageHeaderHasSentryValues(baggageHeader: string): boolean { + return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); +} + function getFullURL(url: string): string | undefined { try { // By adding a base URL to new URL(), this will also work for relative urls diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 7d6c308a3ca5..728bee5fd1dd 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -28,7 +28,7 @@ import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from '../../src/tracing/browserTracingIntegration'; -import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/previousTrace'; +import { PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE } from '../../src/tracing/linkedTraces'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; const oldTextEncoder = global.window.TextEncoder; diff --git a/packages/browser/test/tracing/previousTrace.test.ts b/packages/browser/test/tracing/linkedTraces.test.ts similarity index 52% rename from packages/browser/test/tracing/previousTrace.test.ts rename to packages/browser/test/tracing/linkedTraces.test.ts index f11e2f0d67e5..7c075da588ef 100644 --- a/packages/browser/test/tracing/previousTrace.test.ts +++ b/packages/browser/test/tracing/linkedTraces.test.ts @@ -1,14 +1,146 @@ -import { SentrySpan, spanToJSON, timestampInSeconds } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { addChildSpanToSpan, SentrySpan, spanToJSON, timestampInSeconds } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PreviousTraceInfo } from '../../src/tracing/previousTrace'; +import { BrowserClient } from '../../src'; +import type { PreviousTraceInfo } from '../../src/tracing/linkedTraces'; import { addPreviousTraceSpanLink, getPreviousTraceFromSessionStorage, + linkTraces, PREVIOUS_TRACE_KEY, PREVIOUS_TRACE_MAX_DURATION, PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE, + spanContextSampled, storePreviousTraceInSessionStorage, -} from '../../src/tracing/previousTrace'; +} from '../../src/tracing/linkedTraces'; + +describe('linkTraces', () => { + describe('adds a previous trace span link on span start', () => { + // @ts-expect-error - mock contains only necessary API + const client = new BrowserClient({ transport: () => {}, integrations: [], stackParser: () => [] }); + + let spanStartCb: (span: Span) => void; + + // @ts-expect-error - this is fine for testing + const clientOnSpy = vi.spyOn(client, 'on').mockImplementation((event, cb) => { + // @ts-expect-error - this is fine for testing + if (event === 'spanStart') { + spanStartCb = cb; + } + }); + + it('registers a spanStart handler', () => { + expect(clientOnSpy).toHaveBeenCalledWith('spanStart', expect.any(Function)); + expect(clientOnSpy).toHaveBeenCalledOnce(); + }); + + beforeEach(() => { + linkTraces(client, { linkPreviousTrace: 'in-memory', consistentTraceSampling: false }); + }); + + it("doesn't add a link if the passed span is not the root span", () => { + const rootSpan = new SentrySpan({ + name: 'test', + parentSpanId: undefined, + sampled: true, + spanId: '123', + traceId: '456', + }); + + const childSpan = new SentrySpan({ + name: 'test', + parentSpanId: '123', + spanId: '456', + traceId: '789', + sampled: true, + }); + + addChildSpanToSpan(rootSpan, childSpan); + + spanStartCb(childSpan); + + expect(spanToJSON(childSpan).links).toBeUndefined(); + }); + + it('adds a link from the first trace root span to the second trace root span', () => { + const rootSpanTrace1 = new SentrySpan({ + name: 'test', + parentSpanId: undefined, + sampled: true, + spanId: '123', + traceId: '456', + }); + + spanStartCb(rootSpanTrace1); + + expect(spanToJSON(rootSpanTrace1).links).toBeUndefined(); + + const rootSpanTrace2 = new SentrySpan({ + name: 'test', + parentSpanId: undefined, + sampled: true, + spanId: '789', + traceId: 'def', + }); + + spanStartCb(rootSpanTrace2); + + expect(spanToJSON(rootSpanTrace2).links).toEqual([ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + span_id: '123', + trace_id: '456', + sampled: true, + }, + ]); + }); + + it("doesn't add a link to the second root span if it is part of the same trace", () => { + const rootSpanTrace1 = new SentrySpan({ + name: 'test', + parentSpanId: undefined, + sampled: true, + spanId: '123', + traceId: 'def', + }); + + spanStartCb(rootSpanTrace1); + + expect(spanToJSON(rootSpanTrace1).links).toBeUndefined(); + + const rootSpan2Trace = new SentrySpan({ + name: 'test', + parentSpanId: undefined, + sampled: true, + spanId: '789', + traceId: 'def', + }); + + spanStartCb(rootSpan2Trace); + + expect(spanToJSON(rootSpan2Trace).links).toBeUndefined(); + }); + }); + + // only basic tests here, rest is tested in browser-integration-tests + describe('consistentTraceSampling', () => { + // @ts-expect-error - mock contains only necessary API + const client = new BrowserClient({ transport: () => {}, integrations: [], stackParser: () => [] }); + const clientOnSpy = vi.spyOn(client, 'on'); + + beforeEach(() => { + linkTraces(client, { linkPreviousTrace: 'in-memory', consistentTraceSampling: true }); + }); + + it('registers a beforeSampling handler', () => { + expect(clientOnSpy).toHaveBeenCalledWith('spanStart', expect.any(Function)); + expect(clientOnSpy).toHaveBeenCalledWith('beforeSampling', expect.any(Function)); + expect(clientOnSpy).toHaveBeenCalledTimes(2); + }); + }); +}); describe('addPreviousTraceSpanLink', () => { it(`adds a previous_trace span link to startSpanOptions if the previous trace was created within ${PREVIOUS_TRACE_MAX_DURATION}s`, () => { @@ -22,6 +154,8 @@ describe('addPreviousTraceSpanLink', () => { }, // max time reached almost exactly startTimestamp: currentSpanStart - PREVIOUS_TRACE_MAX_DURATION + 1, + sampleRand: 0.0126, + sampleRate: 0.5, }; const currentSpan = new SentrySpan({ @@ -33,7 +167,14 @@ describe('addPreviousTraceSpanLink', () => { sampled: true, }); - const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + const oldPropagationContext = { + sampleRand: 0.0126, + traceId: '123', + sampled: true, + dsc: { sample_rand: '0.0126', sample_rate: '0.5' }, + }; + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext); const spanJson = spanToJSON(currentSpan); @@ -55,6 +196,8 @@ describe('addPreviousTraceSpanLink', () => { expect(updatedPreviousTraceInfo).toEqual({ spanContext: currentSpan.spanContext(), startTimestamp: currentSpanStart, + sampleRand: 0.0126, + sampleRate: 0.5, }); }); @@ -68,6 +211,8 @@ describe('addPreviousTraceSpanLink', () => { traceFlags: 0, }, startTimestamp: Date.now() / 1000 - PREVIOUS_TRACE_MAX_DURATION - 1, + sampleRand: 0.0126, + sampleRate: 0.5, }; const currentSpan = new SentrySpan({ @@ -75,7 +220,14 @@ describe('addPreviousTraceSpanLink', () => { startTimestamp: currentSpanStart, }); - const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + const oldPropagationContext = { + sampleRand: 0.0126, + traceId: '123', + sampled: true, + dsc: { sample_rand: '0.0126', sample_rate: '0.5' }, + }; + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext); const spanJson = spanToJSON(currentSpan); @@ -87,6 +239,8 @@ describe('addPreviousTraceSpanLink', () => { expect(updatedPreviousTraceInfo).toEqual({ spanContext: currentSpan.spanContext(), startTimestamp: currentSpanStart, + sampleRand: 0.0126, + sampleRate: 0.5, }); }); @@ -98,6 +252,15 @@ describe('addPreviousTraceSpanLink', () => { traceFlags: 1, }, startTimestamp: Date.now() / 1000, + sampleRand: 0.0126, + sampleRate: 0.5, + }; + + const oldPropagationContext = { + sampleRand: 0.0126, + traceId: '123', + sampled: true, + dsc: { sample_rand: '0.0126', sample_rate: '0.5' }, }; const currentSpanStart = timestampInSeconds(); @@ -119,7 +282,7 @@ describe('addPreviousTraceSpanLink', () => { startTimestamp: currentSpanStart, }); - const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext); expect(spanToJSON(currentSpan).links).toEqual([ { @@ -143,6 +306,8 @@ describe('addPreviousTraceSpanLink', () => { expect(updatedPreviousTraceInfo).toEqual({ spanContext: currentSpan.spanContext(), startTimestamp: currentSpanStart, + sampleRand: 0.0126, + sampleRate: 0.5, }); }); @@ -150,13 +315,22 @@ describe('addPreviousTraceSpanLink', () => { const currentSpanStart = timestampInSeconds(); const currentSpan = new SentrySpan({ name: 'test', startTimestamp: currentSpanStart }); - const updatedPreviousTraceInfo = addPreviousTraceSpanLink(undefined, currentSpan); + const oldPropagationContext = { + sampleRand: 0.0126, + traceId: '123', + sampled: false, + dsc: { sample_rand: '0.0126', sample_rate: '0.5', sampled: 'false' }, + }; + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(undefined, currentSpan, oldPropagationContext); const spanJson = spanToJSON(currentSpan); expect(spanJson.links).toBeUndefined(); expect(Object.keys(spanJson.data)).not.toContain(PREVIOUS_TRACE_TMP_SPAN_ATTRIBUTE); expect(updatedPreviousTraceInfo).toEqual({ + sampleRand: 0.0126, + sampleRate: 0.5, spanContext: currentSpan.spanContext(), startTimestamp: currentSpanStart, }); @@ -178,9 +352,18 @@ describe('addPreviousTraceSpanLink', () => { traceFlags: 1, }, startTimestamp: currentSpanStart - 1, + sampleRand: 0.0126, + sampleRate: 0.5, + }; + + const oldPropagationContext = { + sampleRand: 0.0126, + traceId: '123', + sampled: true, + dsc: { sample_rand: '0.0126', sample_rate: '0.5' }, }; - const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan, oldPropagationContext); const spanJson = spanToJSON(currentSpan); expect(spanJson.links).toBeUndefined(); @@ -213,6 +396,8 @@ describe('store and retrieve previous trace data via sessionStorage ', () => { traceFlags: 1, }, startTimestamp: Date.now() / 1000, + sampleRand: 0.0126, + sampleRate: 0.5, }; storePreviousTraceInSessionStorage(previousTraceInfo); @@ -231,6 +416,8 @@ describe('store and retrieve previous trace data via sessionStorage ', () => { traceFlags: 1, }, startTimestamp: Date.now() / 1000, + sampleRand: 0.0126, + sampleRate: 0.5, }; expect(() => storePreviousTraceInSessionStorage(previousTraceInfo)).not.toThrow(); @@ -238,3 +425,24 @@ describe('store and retrieve previous trace data via sessionStorage ', () => { expect(getPreviousTraceFromSessionStorage()).toBeUndefined(); }); }); + +describe('spanContextSampled', () => { + it('returns true if traceFlags is 1', () => { + const spanContext = { + traceId: '123', + spanId: '456', + traceFlags: 1, + }; + + expect(spanContextSampled(spanContext)).toBe(true); + }); + + it.each([0, 2, undefined as unknown as number])('returns false if traceFlags is %s', flags => { + const spanContext = { + traceId: '123', + spanId: '456', + traceFlags: flags, + }; + expect(spanContextSampled(spanContext)).toBe(false); + }); +}); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts new file mode 100644 index 000000000000..d595ccfa5985 --- /dev/null +++ b/packages/cloudflare/src/durableobject.ts @@ -0,0 +1,225 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { + captureException, + flush, + getClient, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + withIsolationScope, + withScope, +} from '@sentry/core'; +import type { DurableObject } from 'cloudflare:workers'; +import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import type { CloudflareOptions } from './client'; +import { isInstrumented, markAsInstrumented } from './instrument'; +import { getFinalOptions } from './options'; +import { wrapRequestHandler } from './request'; +import { init } from './sdk'; + +type MethodWrapperOptions = { + spanName?: string; + spanOp?: string; + options: CloudflareOptions; + context: ExecutionContext | DurableObjectState; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function wrapMethodWithSentry any>( + wrapperOptions: MethodWrapperOptions, + handler: T, + callback?: (...args: Parameters) => void, +): T { + if (isInstrumented(handler)) { + return handler; + } + + markAsInstrumented(handler); + + return new Proxy(handler, { + apply(target, thisArg, args: Parameters) { + const currentClient = getClient(); + // if a client is already set, use withScope, otherwise use withIsolationScope + const sentryWithScope = currentClient ? withScope : withIsolationScope; + return sentryWithScope(async scope => { + // In certain situations, the passed context can become undefined. + // For example, for Astro while prerendering pages at build time. + // see: https://github.com/getsentry/sentry-javascript/issues/13217 + const context = wrapperOptions.context as ExecutionContext | undefined; + + const currentClient = scope.getClient(); + if (!currentClient) { + const client = init(wrapperOptions.options); + scope.setClient(client); + } + + if (!wrapperOptions.spanName) { + try { + if (callback) { + callback(...args); + } + return await Reflect.apply(target, thisArg, args); + } catch (e) { + captureException(e, { + mechanism: { + type: 'cloudflare_durableobject', + handled: false, + }, + }); + throw e; + } finally { + context?.waitUntil(flush(2000)); + } + } + + const attributes = wrapperOptions.spanOp + ? { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare_durableobjects', + } + : {}; + + // Only create these spans if they have a parent span. + return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, async () => { + try { + return await Reflect.apply(target, thisArg, args); + } catch (e) { + captureException(e, { + mechanism: { + type: 'cloudflare_durableobject', + handled: false, + }, + }); + throw e; + } finally { + context?.waitUntil(flush(2000)); + } + }); + }); + }, + }); +} + +/** + * Instruments a Durable Object class to capture errors and performance data. + * + * Instruments the following methods: + * - fetch + * - alarm + * - webSocketMessage + * - webSocketClose + * - webSocketError + * + * as well as any other public RPC methods on the Durable Object instance. + * + * @param optionsCallback Function that returns the options for the SDK initialization. + * @param DurableObjectClass The Durable Object class to instrument. + * @returns The instrumented Durable Object class. + * + * @example + * ```ts + * class MyDurableObjectBase extends DurableObject { + * constructor(ctx: DurableObjectState, env: Env) { + * super(ctx, env); + * } + * } + * + * export const MyDurableObject = instrumentDurableObjectWithSentry( + * env => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * }), + * MyDurableObjectBase, + * ); + * ``` + */ +export function instrumentDurableObjectWithSentry>( + optionsCallback: (env: E) => CloudflareOptions, + DurableObjectClass: new (state: DurableObjectState, env: E) => T, +): new (state: DurableObjectState, env: E) => T { + return new Proxy(DurableObjectClass, { + construct(target, [context, env]) { + setAsyncLocalStorageAsyncContextStrategy(); + + const options = getFinalOptions(optionsCallback(env), env); + + const obj = new target(context, env); + + // These are the methods that are available on a Durable Object + // ref: https://developers.cloudflare.com/durable-objects/api/base/ + // obj.alarm + // obj.fetch + // obj.webSocketError + // obj.webSocketClose + // obj.webSocketMessage + + // Any other public methods on the Durable Object instance are RPC calls. + + if (obj.fetch && typeof obj.fetch === 'function' && !isInstrumented(obj.fetch)) { + obj.fetch = new Proxy(obj.fetch, { + apply(target, thisArg, args) { + return wrapRequestHandler({ options, request: args[0], context }, () => + Reflect.apply(target, thisArg, args), + ); + }, + }); + + markAsInstrumented(obj.fetch); + } + + if (obj.alarm && typeof obj.alarm === 'function') { + obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, obj.alarm); + } + + if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { + obj.webSocketMessage = wrapMethodWithSentry( + { options, context, spanName: 'webSocketMessage' }, + obj.webSocketMessage, + ); + } + + if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { + obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose); + } + + if (obj.webSocketError && typeof obj.webSocketError === 'function') { + obj.webSocketError = wrapMethodWithSentry( + { options, context, spanName: 'webSocketError' }, + obj.webSocketError, + (_, error) => + captureException(error, { + mechanism: { + type: 'cloudflare_durableobject_websocket', + handled: false, + }, + }), + ); + } + + for (const method of Object.getOwnPropertyNames(obj)) { + if ( + method === 'fetch' || + method === 'alarm' || + method === 'webSocketError' || + method === 'webSocketClose' || + method === 'webSocketMessage' + ) { + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + const value = (obj as any)[method] as unknown; + if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + (obj as any)[method] = wrapMethodWithSentry( + { options, context, spanName: method, spanOp: 'rpc' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value as (...args: any[]) => any, + ); + } + } + + return obj; + }, + }); +} diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 2fa42afbbb79..7e1667d6dc56 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,8 +1,3 @@ -import type { - ExportedHandler, - ExportedHandlerFetchHandler, - ExportedHandlerScheduledHandler, -} from '@cloudflare/workers-types'; import { captureException, flush, @@ -13,15 +8,12 @@ import { } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; +import { isInstrumented, markAsInstrumented } from './instrument'; +import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; -/** - * Extract environment generic from exported handler. - */ -type ExtractEnv

= P extends ExportedHandler ? Env : never; - /** * Wrapper for Cloudflare handlers. * @@ -33,85 +25,73 @@ type ExtractEnv

= P extends ExportedHandler ? Env : never; * @param handler {ExportedHandler} The handler to wrap. * @returns The wrapped handler. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withSentry>( - optionsCallback: (env: ExtractEnv) => CloudflareOptions, - handler: E, -): E { +export function withSentry( + optionsCallback: (env: Env) => CloudflareOptions, + handler: ExportedHandler, +): ExportedHandler { setAsyncLocalStorageAsyncContextStrategy(); - if ('fetch' in handler && typeof handler.fetch === 'function' && !isInstrumented(handler.fetch)) { - handler.fetch = new Proxy(handler.fetch, { - apply(target, thisArg, args: Parameters>>) { - const [request, env, context] = args; - const options = optionsCallback(env); - return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); - }, - }); + try { + if ('fetch' in handler && typeof handler.fetch === 'function' && !isInstrumented(handler.fetch)) { + handler.fetch = new Proxy(handler.fetch, { + apply(target, thisArg, args: Parameters>) { + const [request, env, context] = args; - markAsInstrumented(handler.fetch); - } + const options = getFinalOptions(optionsCallback(env), env); - if ('scheduled' in handler && typeof handler.scheduled === 'function' && !isInstrumented(handler.scheduled)) { - handler.scheduled = new Proxy(handler.scheduled, { - apply(target, thisArg, args: Parameters>>) { - const [event, env, context] = args; - return withIsolationScope(isolationScope => { - const options = optionsCallback(env); - const client = init(options); - isolationScope.setClient(client); + return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); + }, + }); - addCloudResourceContext(isolationScope); + markAsInstrumented(handler.fetch); + } - return startSpan( - { - op: 'faas.cron', - name: `Scheduled Cron ${event.cron}`, - attributes: { - 'faas.cron': event.cron, - 'faas.time': new Date(event.scheduledTime).toISOString(), - 'faas.trigger': 'timer', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare', - [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)); - } - }, - ); - }); - }, - }); + if ('scheduled' in handler && typeof handler.scheduled === 'function' && !isInstrumented(handler.scheduled)) { + handler.scheduled = new Proxy(handler.scheduled, { + apply(target, thisArg, args: Parameters>) { + const [event, env, context] = args; + return withIsolationScope(isolationScope => { + const options = getFinalOptions(optionsCallback(env), env); - markAsInstrumented(handler.scheduled); - } + const client = init(options); + isolationScope.setClient(client); - return handler; -} + addCloudResourceContext(isolationScope); -type SentryInstrumented = T & { - __SENTRY_INSTRUMENTED__?: boolean; -}; + return startSpan( + { + op: 'faas.cron', + name: `Scheduled Cron ${event.cron}`, + attributes: { + 'faas.cron': event.cron, + 'faas.time': new Date(event.scheduledTime).toISOString(), + 'faas.trigger': 'timer', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare', + [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)); + } + }, + ); + }); + }, + }); -function markAsInstrumented(handler: T): void { - try { - (handler as SentryInstrumented).__SENTRY_INSTRUMENTED__ = true; - } catch { - // ignore errors here + markAsInstrumented(handler.scheduled); + } + // 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 } -} -function isInstrumented(handler: T): boolean | undefined { - try { - return (handler as SentryInstrumented).__SENTRY_INSTRUMENTED__; - } catch { - return false; - } + return handler; } diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5a62c51e7e41..64a6f57fccc2 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -89,9 +89,13 @@ export { spanToBaggageHeader, updateSpanName, wrapMcpServerWithSentry, + consoleLoggingIntegration, } from '@sentry/core'; +export * as logger from './logs/exports'; + export { withSentry } from './handler'; +export { instrumentDurableObjectWithSentry } from './durableobject'; export { sentryPagesPlugin } from './pages-plugin'; export { wrapRequestHandler } from './request'; diff --git a/packages/cloudflare/src/instrument.ts b/packages/cloudflare/src/instrument.ts new file mode 100644 index 000000000000..1ebe4262644a --- /dev/null +++ b/packages/cloudflare/src/instrument.ts @@ -0,0 +1,25 @@ +type SentryInstrumented = T & { + __SENTRY_INSTRUMENTED__?: boolean; +}; + +/** + * Mark an object as instrumented. + */ +export function markAsInstrumented(obj: T): void { + try { + (obj as SentryInstrumented).__SENTRY_INSTRUMENTED__ = true; + } catch { + // ignore errors here + } +} + +/** + * Check if an object is instrumented. + */ +export function isInstrumented(obj: T): boolean | undefined { + try { + return (obj as SentryInstrumented).__SENTRY_INSTRUMENTED__; + } catch { + return false; + } +} diff --git a/packages/cloudflare/src/logs/exports.ts b/packages/cloudflare/src/logs/exports.ts new file mode 100644 index 000000000000..ef2614b81f55 --- /dev/null +++ b/packages/cloudflare/src/logs/exports.ts @@ -0,0 +1,205 @@ +import type { Log, LogSeverityLevel, ParameterizedString } from '@sentry/core'; +import { _INTERNAL_captureLog } from '@sentry/core'; + +/** + * Capture a log with the given level. + * + * @param level - The level of the log. + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param severityNumber - The severity number of the log. + */ +function captureLog( + level: LogSeverityLevel, + message: ParameterizedString, + attributes?: Log['attributes'], + severityNumber?: Log['severityNumber'], +): void { + _INTERNAL_captureLog({ level, message, attributes, severityNumber }); +} + +/** + * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. + * + * @example + * + * ``` + * Sentry.logger.trace('User clicked submit button', { + * buttonId: 'submit-form', + * formId: 'user-profile', + * timestamp: Date.now() + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.trace(Sentry.logger.fmt`User ${user} navigated to ${page}`, { + * userId: '123', + * sessionId: 'abc-xyz' + * }); + * ``` + */ +export function trace(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('trace', message, attributes); +} + +/** + * @summary Capture a log with the `debug` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. + * + * @example + * + * ``` + * Sentry.logger.debug('Component mounted', { + * component: 'UserProfile', + * props: { userId: 123 }, + * renderTime: 150 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.debug(Sentry.logger.fmt`API request to ${endpoint} failed`, { + * statusCode: 404, + * requestId: 'req-123', + * duration: 250 + * }); + * ``` + */ +export function debug(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('debug', message, attributes); +} + +/** + * @summary Capture a log with the `info` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. + * + * @example + * + * ``` + * Sentry.logger.info('User completed checkout', { + * orderId: 'order-123', + * amount: 99.99, + * paymentMethod: 'credit_card' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.info(Sentry.logger.fmt`User ${user} updated profile picture`, { + * userId: 'user-123', + * imageSize: '2.5MB', + * timestamp: Date.now() + * }); + * ``` + */ +export function info(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('info', message, attributes); +} + +/** + * @summary Capture a log with the `warn` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. + * + * @example + * + * ``` + * Sentry.logger.warn('Browser compatibility issue detected', { + * browser: 'Safari', + * version: '14.0', + * feature: 'WebRTC', + * fallback: 'enabled' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.warn(Sentry.logger.fmt`API endpoint ${endpoint} is deprecated`, { + * recommendedEndpoint: '/api/v2/users', + * sunsetDate: '2024-12-31', + * clientVersion: '1.2.3' + * }); + * ``` + */ +export function warn(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('warn', message, attributes); +} + +/** + * @summary Capture a log with the `error` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. + * + * @example + * + * ``` + * Sentry.logger.error('Failed to load user data', { + * error: 'NetworkError', + * url: '/api/users/123', + * statusCode: 500, + * retryCount: 3 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.error(Sentry.logger.fmt`Payment processing failed for order ${orderId}`, { + * error: 'InsufficientFunds', + * amount: 100.00, + * currency: 'USD', + * userId: 'user-456' + * }); + * ``` + */ +export function error(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('error', message, attributes); +} + +/** + * @summary Capture a log with the `fatal` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. + * + * @example + * + * ``` + * Sentry.logger.fatal('Application state corrupted', { + * lastKnownState: 'authenticated', + * sessionId: 'session-123', + * timestamp: Date.now(), + * recoveryAttempted: true + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.fatal(Sentry.logger.fmt`Critical system failure in ${service}`, { + * service: 'payment-processor', + * errorCode: 'CRITICAL_FAILURE', + * affectedUsers: 150, + * timestamp: Date.now() + * }); + * ``` + */ +export function fatal(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('fatal', message, attributes); +} + +export { fmt } from '@sentry/core'; diff --git a/packages/cloudflare/src/options.ts b/packages/cloudflare/src/options.ts new file mode 100644 index 000000000000..77a37ea51d31 --- /dev/null +++ b/packages/cloudflare/src/options.ts @@ -0,0 +1,20 @@ +import type { CloudflareOptions } from './client'; + +/** + * Merges the options passed in from the user with the options we read from + * the Cloudflare `env` environment variable object. + * + * @param userOptions - The options passed in from the user. + * @param env - The environment variables. + * + * @returns The final options. + */ +export function getFinalOptions(userOptions: CloudflareOptions, env: unknown): CloudflareOptions { + if (typeof env !== 'object' || env === null) { + return userOptions; + } + + const release = 'SENTRY_RELEASE' in env && typeof env.SENTRY_RELEASE === 'string' ? env.SENTRY_RELEASE : undefined; + + return { release, ...userOptions }; +} diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index b36eaecdc232..8e2f3de06df0 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -76,6 +76,18 @@ 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 { + return await handler(); + } catch (e) { + captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); + throw e; + } finally { + context?.waitUntil(flush(2000)); + } + } + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index e8afb8d052cd..dee32b856eb0 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -27,6 +27,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ functionToStringIntegration(), linkedErrorsIntegration(), fetchIntegration(), + // TODO(v10): the `include` object should be defined directly in the integration based on `sendDefaultPii` requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }), consoleIntegration(), ]; diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index 4d9f1c1d7903..602df308c3df 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -10,6 +10,7 @@ import { withSentry } from '../src/handler'; const MOCK_ENV = { SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337', + SENTRY_RELEASE: '1.1.1', }; describe('withSentry', () => { @@ -51,6 +52,65 @@ describe('withSentry', () => { expect(result).toBe(response); }); + + test('merges options from env and callback', async () => { + const handler = { + fetch(_request, _env, _context) { + throw new Error('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + try { + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(sentryEvent.release).toEqual('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + fetch(_request, _env, _context) { + throw new Error('test'); + }, + } satisfies ExportedHandler; + + let sentryEvent: Event = {}; + + const wrappedHandler = withSentry( + env => ({ + dsn: env.SENTRY_DSN, + release: '2.0.0', + beforeSend(event) { + sentryEvent = event; + return null; + }, + }), + handler, + ); + + try { + await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext()); + } catch { + // ignore + } + + expect(sentryEvent.release).toEqual('2.0.0'); + }); }); describe('scheduled handler', () => { @@ -70,6 +130,55 @@ describe('withSentry', () => { expect(optionsCallback).toHaveBeenLastCalledWith(MOCK_ENV); }); + test('merges options from env and callback', async () => { + const handler = { + scheduled(_controller, _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.scheduled(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + expect(sentryEvent.release).toBe('1.1.1'); + }); + + test('callback options take precedence over env options', async () => { + const handler = { + scheduled(_controller, _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.scheduled(createMockScheduledController(), 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 = { scheduled(_controller, _env, _context) { diff --git a/packages/cloudflare/test/options.test.ts b/packages/cloudflare/test/options.test.ts new file mode 100644 index 000000000000..ae8a5509b233 --- /dev/null +++ b/packages/cloudflare/test/options.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { getFinalOptions } from '../src/options'; + +describe('getFinalOptions', () => { + it('returns user options when env is not an object', () => { + const userOptions = { dsn: 'test-dsn', release: 'test-release' }; + const env = 'not-an-object'; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('returns user options when env is null', () => { + const userOptions = { dsn: 'test-dsn', release: 'test-release' }; + const env = null; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('merges options from env with user options', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { SENTRY_RELEASE: 'env-release' }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual({ dsn: 'test-dsn', release: 'user-release' }); + }); + + it('uses user options when SENTRY_RELEASE exists but is not a string', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { SENTRY_RELEASE: 123 }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('uses user options when SENTRY_RELEASE does not exist', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { OTHER_VAR: 'some-value' }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); + + it('takes user options over env options', () => { + const userOptions = { dsn: 'test-dsn', release: 'user-release' }; + const env = { SENTRY_RELEASE: 'env-release' }; + + const result = getFinalOptions(userOptions, env); + + expect(result).toEqual(userOptions); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 46854c7992bd..0dda6c86fd26 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -491,6 +491,7 @@ export abstract class Client { spanAttributes: SpanAttributes; spanName: string; parentSampled?: boolean; + parentSampleRate?: number; parentContext?: SpanContextData; }, samplingDecision: { decision: boolean }, @@ -691,6 +692,7 @@ export abstract class Client { spanAttributes: SpanAttributes; spanName: string; parentSampled?: boolean; + parentSampleRate?: number; parentContext?: SpanContextData; }, samplingDecision: { decision: boolean }, diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index a7df2ca147f2..65274d1e82a3 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -103,8 +103,15 @@ export function instrumentFetchRequest( /** * Adds sentry-trace and baggage headers to the various forms of fetch headers. + * exported only for testing purposes + * + * When we determine if we should add a baggage header, there are 3 cases: + * 1. No previous baggage header -> add baggage + * 2. Previous baggage header has no sentry baggage values -> add our baggage + * 3. Previous baggage header has sentry baggage values -> do nothing (might have been added manually by users) */ -function _addTracingHeadersToFetchRequest( +// eslint-disable-next-line complexity -- yup it's this complicated :( +export function _addTracingHeadersToFetchRequest( request: string | Request, fetchOptionsObj: { headers?: @@ -124,51 +131,41 @@ function _addTracingHeadersToFetchRequest( return undefined; } - const headers = fetchOptionsObj.headers || (isRequest(request) ? request.headers : undefined); + const originalHeaders = fetchOptionsObj.headers || (isRequest(request) ? request.headers : undefined); - if (!headers) { + if (!originalHeaders) { return { ...traceHeaders }; - } else if (isHeaders(headers)) { - const newHeaders = new Headers(headers); - newHeaders.set('sentry-trace', sentryTrace); + } else if (isHeaders(originalHeaders)) { + const newHeaders = new Headers(originalHeaders); + + // We don't want to override manually added sentry headers + if (!newHeaders.get('sentry-trace')) { + newHeaders.set('sentry-trace', sentryTrace); + } if (baggage) { const prevBaggageHeader = newHeaders.get('baggage'); - if (prevBaggageHeader) { - const prevHeaderStrippedFromSentryBaggage = stripBaggageHeaderOfSentryBaggageValues(prevBaggageHeader); - newHeaders.set( - 'baggage', - // If there are non-sentry entries (i.e. if the stripped string is non-empty/truthy) combine the stripped header and sentry baggage header - // otherwise just set the sentry baggage header - prevHeaderStrippedFromSentryBaggage ? `${prevHeaderStrippedFromSentryBaggage},${baggage}` : baggage, - ); - } else { + + if (!prevBaggageHeader) { newHeaders.set('baggage', baggage); + } else if (!baggageHeaderHasSentryBaggageValues(prevBaggageHeader)) { + newHeaders.set('baggage', `${prevBaggageHeader},${baggage}`); } } return newHeaders; - } else if (Array.isArray(headers)) { - const newHeaders = [ - ...headers - // Remove any existing sentry-trace headers - .filter(header => { - return !(Array.isArray(header) && header[0] === 'sentry-trace'); - }) - // Get rid of previous sentry baggage values in baggage header - .map(header => { - if (Array.isArray(header) && header[0] === 'baggage' && typeof header[1] === 'string') { - const [headerName, headerValue, ...rest] = header; - return [headerName, stripBaggageHeaderOfSentryBaggageValues(headerValue), ...rest]; - } else { - return header; - } - }), - // Attach the new sentry-trace header - ['sentry-trace', sentryTrace], - ]; + } else if (Array.isArray(originalHeaders)) { + const newHeaders = [...originalHeaders]; - if (baggage) { + if (!originalHeaders.find(header => header[0] === 'sentry-trace')) { + newHeaders.push(['sentry-trace', sentryTrace]); + } + + const prevBaggageHeaderWithSentryValues = originalHeaders.find( + header => header[0] === 'baggage' && baggageHeaderHasSentryBaggageValues(header[1]), + ); + + if (baggage && !prevBaggageHeaderWithSentryValues) { // If there are multiple entries with the same key, the browser will merge the values into a single request header. // Its therefore safe to simply push a "baggage" entry, even though there might already be another baggage header. newHeaders.push(['baggage', baggage]); @@ -176,26 +173,28 @@ function _addTracingHeadersToFetchRequest( return newHeaders as PolymorphicRequestHeaders; } else { - const existingBaggageHeader = 'baggage' in headers ? headers.baggage : undefined; - let newBaggageHeaders: string[] = []; - - if (Array.isArray(existingBaggageHeader)) { - newBaggageHeaders = existingBaggageHeader - .map(headerItem => - typeof headerItem === 'string' ? stripBaggageHeaderOfSentryBaggageValues(headerItem) : headerItem, - ) - .filter(headerItem => headerItem === ''); - } else if (existingBaggageHeader) { - newBaggageHeaders.push(stripBaggageHeaderOfSentryBaggageValues(existingBaggageHeader)); - } - - if (baggage) { + const existingSentryTraceHeader = 'sentry-trace' in originalHeaders ? originalHeaders['sentry-trace'] : undefined; + + const existingBaggageHeader = 'baggage' in originalHeaders ? originalHeaders.baggage : undefined; + const newBaggageHeaders: string[] = existingBaggageHeader + ? Array.isArray(existingBaggageHeader) + ? [...existingBaggageHeader] + : [existingBaggageHeader] + : []; + + const prevBaggageHeaderWithSentryValues = + existingBaggageHeader && + (Array.isArray(existingBaggageHeader) + ? existingBaggageHeader.find(headerItem => baggageHeaderHasSentryBaggageValues(headerItem)) + : baggageHeaderHasSentryBaggageValues(existingBaggageHeader)); + + if (baggage && !prevBaggageHeaderWithSentryValues) { newBaggageHeaders.push(baggage); } return { - ...(headers as Exclude), - 'sentry-trace': sentryTrace, + ...(originalHeaders as Exclude), + 'sentry-trace': (existingSentryTraceHeader as string | undefined) ?? sentryTrace, baggage: newBaggageHeaders.length > 0 ? newBaggageHeaders.join(',') : undefined, }; } @@ -219,14 +218,8 @@ function endSpan(span: Span, handlerData: HandlerDataFetch): void { span.end(); } -function stripBaggageHeaderOfSentryBaggageValues(baggageHeader: string): string { - return ( - baggageHeader - .split(',') - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .filter(baggageEntry => !baggageEntry.split('=')[0]!.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) - .join(',') - ); +function baggageHeaderHasSentryBaggageValues(baggageHeader: string): boolean { + return baggageHeader.split(',').some(baggageEntry => baggageEntry.trim().startsWith(SENTRY_BAGGAGE_KEY_PREFIX)); } function isHeaders(headers: unknown): headers is Headers { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5b25d9d8497e..6c35ea212b94 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -323,8 +323,7 @@ export type { ProfileChunkItem, SpanEnvelope, SpanItem, - OtelLogEnvelope, - OtelLogItem, + LogEnvelope, } from './types-hoist/envelope'; export type { ExtendedError } from './types-hoist/error'; export type { Event, EventHint, EventType, ErrorEvent, TransactionEvent } from './types-hoist/event'; @@ -385,13 +384,7 @@ export type { TraceFlag, } from './types-hoist/span'; export type { SpanStatus } from './types-hoist/spanStatus'; -export type { - Log, - LogSeverityLevel, - SerializedOtelLog, - SerializedLogAttribute, - SerializedLogAttributeValueType, -} from './types-hoist/log'; +export type { Log, LogSeverityLevel } from './types-hoist/log'; export type { TimedEvent } from './types-hoist/timedEvent'; export type { StackFrame } from './types-hoist/stackframe'; export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './types-hoist/stacktrace'; @@ -443,3 +436,4 @@ export type { ParameterizedString } from './types-hoist/parameterize'; export type { ContinuousProfiler, ProfilingIntegration, Profiler } from './types-hoist/profiling'; export type { ViewHierarchyData, ViewHierarchyWindow } from './types-hoist/view-hierarchy'; export type { LegacyCSPReport } from './types-hoist/csp'; +export type { SerializedLog, SerializedLogContainer } from './types-hoist/log'; diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index c8271043ece2..767d2764002f 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -21,6 +21,7 @@ type RequestDataIntegrationOptions = { include?: RequestDataIncludeOptions; }; +// TODO(v10): Change defaults based on `sendDefaultPii` const DEFAULT_INCLUDE: RequestDataIncludeOptions = { cookies: true, data: true, diff --git a/packages/core/src/logs/envelope.ts b/packages/core/src/logs/envelope.ts index 49f2a445b150..c909a9140de1 100644 --- a/packages/core/src/logs/envelope.ts +++ b/packages/core/src/logs/envelope.ts @@ -1,41 +1,47 @@ import type { DsnComponents } from '../types-hoist/dsn'; -import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope'; -import type { SerializedOtelLog } from '../types-hoist/log'; +import type { LogContainerItem, LogEnvelope } from '../types-hoist/envelope'; +import type { SerializedLog } from '../types-hoist/log'; import type { SdkMetadata } from '../types-hoist/sdkmetadata'; import { dsnToString } from '../utils-hoist/dsn'; import { createEnvelope } from '../utils-hoist/envelope'; /** - * Creates OTEL log envelope item for a serialized OTEL log. + * Creates a log container envelope item for a list of logs. * - * @param log - The serialized OTEL log to include in the envelope. - * @returns The created OTEL log envelope item. + * @param items - The logs to include in the envelope. + * @returns The created log container envelope item. */ -export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem { +export function createLogContainerEnvelopeItem(items: Array): LogContainerItem { return [ { - type: 'otel_log', + type: 'log', + item_count: items.length, + content_type: 'application/vnd.sentry.items.log+json', + }, + { + items, }, - log, ]; } /** * Creates an envelope for a list of logs. * + * Logs from multiple traces can be included in the same envelope. + * * @param logs - The logs to include in the envelope. * @param metadata - The metadata to include in the envelope. * @param tunnel - The tunnel to include in the envelope. * @param dsn - The DSN to include in the envelope. * @returns The created envelope. */ -export function createOtelLogEnvelope( - logs: Array, +export function createLogEnvelope( + logs: Array, metadata?: SdkMetadata, tunnel?: string, dsn?: DsnComponents, -): OtelLogEnvelope { - const headers: OtelLogEnvelope[0] = {}; +): LogEnvelope { + const headers: LogEnvelope[0] = {}; if (metadata?.sdk) { headers.sdk = { @@ -48,5 +54,5 @@ export function createOtelLogEnvelope( headers.dsn = dsnToString(dsn); } - return createEnvelope(headers, logs.map(createOtelLogEnvelopeItem)); + return createEnvelope(headers, [createLogContainerEnvelopeItem(logs)]); } diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index df5726a81256..a2451c6cc6c0 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -2,16 +2,19 @@ import type { Client } from '../client'; import { _getTraceInfoFromScope } from '../client'; import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import type { Log, SerializedLogAttribute, SerializedOtelLog } from '../types-hoist/log'; +import type { Log, SerializedLog, SerializedLogAttributeValue } from '../types-hoist/log'; import { _getSpanForScope } from '../utils/spanOnScope'; import { isParameterizedString } from '../utils-hoist/is'; import { logger } from '../utils-hoist/logger'; +import { timestampInSeconds } from '../utils-hoist/time'; +import { GLOBAL_OBJ } from '../utils-hoist/worldwide'; import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; -import { createOtelLogEnvelope } from './envelope'; +import { createLogEnvelope } from './envelope'; const MAX_LOG_BUFFER_SIZE = 100; -const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap>(); +// The reference to the Client <> LogBuffer map is stored to ensure it's always the same +GLOBAL_OBJ._sentryClientToLogBufferMap = new WeakMap>(); /** * Converts a log attribute to a serialized log attribute. @@ -20,22 +23,28 @@ const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap>() * @param value - The value of the log attribute. * @returns The serialized log attribute. */ -export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute { +export function logAttributeToSerializedLogAttribute(value: unknown): SerializedLogAttributeValue { switch (typeof value) { case 'number': + if (Number.isInteger(value)) { + return { + value, + type: 'integer', + }; + } return { - key, - value: { doubleValue: value }, + value, + type: 'double', }; case 'boolean': return { - key, - value: { boolValue: value }, + value, + type: 'boolean', }; case 'string': return { - key, - value: { stringValue: value }, + value, + type: 'string', }; default: { let stringValue = ''; @@ -45,8 +54,8 @@ export function logAttributeToSerializedLogAttribute(key: string, value: unknown // Do nothing } return { - key, - value: { stringValue }, + value: stringValue, + type: 'string', }; } } @@ -127,23 +136,27 @@ export function _INTERNAL_captureLog( const { level, message, attributes = {}, severityNumber } = log; - const serializedLog: SerializedOtelLog = { - severityText: level, - body: { - stringValue: message, - }, - attributes: Object.entries(attributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)), - timeUnixNano: `${new Date().getTime().toString()}000000`, - traceId: traceContext?.trace_id, - severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], + const serializedLog: SerializedLog = { + timestamp: timestampInSeconds(), + level, + body: message, + trace_id: traceContext?.trace_id, + severity_number: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], + attributes: Object.keys(attributes).reduce( + (acc, key) => { + acc[key] = logAttributeToSerializedLogAttribute(attributes[key]); + return acc; + }, + {} as Record, + ), }; - const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client); + const logBuffer = _INTERNAL_getLogBuffer(client); if (logBuffer === undefined) { - CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]); + GLOBAL_OBJ._sentryClientToLogBufferMap?.set(client, [serializedLog]); } else { - logBuffer.push(serializedLog); - if (logBuffer.length > MAX_LOG_BUFFER_SIZE) { + GLOBAL_OBJ._sentryClientToLogBufferMap?.set(client, [...logBuffer, serializedLog]); + if (logBuffer.length >= MAX_LOG_BUFFER_SIZE) { _INTERNAL_flushLogsBuffer(client, logBuffer); } } @@ -160,17 +173,17 @@ export function _INTERNAL_captureLog( * @experimental This method will experience breaking changes. This is not yet part of * the stable Sentry SDK API and can be changed or removed without warning. */ -export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array): void { - const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? []; +export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array): void { + const logBuffer = maybeLogBuffer ?? _INTERNAL_getLogBuffer(client) ?? []; if (logBuffer.length === 0) { return; } const clientOptions = client.getOptions(); - const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); + const envelope = createLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); // Clear the log buffer after envelopes have been constructed. - logBuffer.length = 0; + GLOBAL_OBJ._sentryClientToLogBufferMap?.set(client, []); client.emit('flushLogs'); @@ -187,6 +200,6 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array * @param client - The client to get the log buffer for. * @returns The log buffer for the given client. */ -export function _INTERNAL_getLogBuffer(client: Client): Array | undefined { - return CLIENT_TO_LOG_BUFFER_MAP.get(client); +export function _INTERNAL_getLogBuffer(client: Client): Array | undefined { + return GLOBAL_OBJ._sentryClientToLogBufferMap?.get(client); } diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index aa25b70f7304..9b90809c0091 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -13,6 +13,14 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SOURCE = 'sentry.source'; */ export const SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE = 'sentry.sample_rate'; +/** + * Attribute holding the sample rate of the previous trace. + * This is used to sample consistently across subsequent traces in the browser SDK. + * + * Note: Only defined on root spans, if opted into consistent sampling + */ +export const SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE = 'sentry.previous_trace_sample_rate'; + /** * Use this attribute to represent the operation of a span. */ diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index ce2d9ad7eadd..9380c75dd3be 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -2,7 +2,11 @@ import type { Client } from '../client'; import { DEFAULT_ENVIRONMENT } from '../constants'; import { getClient } from '../currentScopes'; import type { Scope } from '../scope'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../semanticAttributes'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '../semanticAttributes'; import type { DynamicSamplingContext } from '../types-hoist/envelope'; import type { Span } from '../types-hoist/span'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; @@ -85,7 +89,10 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly): Partial { if (typeof rootSpanSampleRate === 'number' || typeof rootSpanSampleRate === 'string') { dsc.sample_rate = `${rootSpanSampleRate}`; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index fb8a89f0f860..a96159692ac3 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -401,7 +401,17 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent const client = getClient(); const options: Partial = client?.getOptions() || {}; - const { name = '', attributes } = spanArguments; + const { name = '' } = spanArguments; + + const mutableSpanSamplingData = { spanAttributes: { ...spanArguments.attributes }, spanName: name, parentSampled }; + + // we don't care about the decision for the moment; this is just a placeholder + client?.emit('beforeSampling', mutableSpanSamplingData, { decision: false }); + + // If hook consumers override the parentSampled flag, we will use that value instead of the actual one + const finalParentSampled = mutableSpanSamplingData.parentSampled ?? parentSampled; + const finalAttributes = mutableSpanSamplingData.spanAttributes; + const currentPropagationContext = scope.getPropagationContext(); const [sampled, sampleRate, localSampleRateWasApplied] = scope.getScopeData().sdkProcessingMetadata[ SUPPRESS_TRACING_KEY @@ -411,8 +421,8 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent options, { name, - parentSampled, - attributes, + parentSampled: finalParentSampled, + attributes: finalAttributes, parentSampleRate: parseSampleRate(currentPropagationContext.dsc?.sample_rate), }, currentPropagationContext.sampleRand, @@ -424,7 +434,7 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: sampleRate !== undefined && localSampleRateWasApplied ? sampleRate : undefined, - ...spanArguments.attributes, + ...finalAttributes, }, sampled, }); diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index b77f22493c92..d874a4e65800 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -5,7 +5,7 @@ import type { LegacyCSPReport } from './csp'; import type { DsnComponents } from './dsn'; import type { Event } from './event'; import type { FeedbackEvent, UserFeedback } from './feedback'; -import type { SerializedOtelLog } from './log'; +import type { SerializedLogContainer } from './log'; import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; @@ -44,7 +44,7 @@ export type EnvelopeItemType = | 'replay_recording' | 'check_in' | 'span' - | 'otel_log' + | 'log' | 'raw_security'; export type BaseEnvelopeHeaders = { @@ -87,7 +87,17 @@ type CheckInItemHeaders = { type: 'check_in' }; type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type SpanItemHeaders = { type: 'span' }; -type OtelLogItemHeaders = { type: 'otel_log' }; +type LogContainerItemHeaders = { + type: 'log'; + /** + * The number of log items in the container. This must be the same as the number of log items in the payload. + */ + item_count: number; + /** + * The content type of the log items. This must be `application/vnd.sentry.items.log+json`. + */ + content_type: 'application/vnd.sentry.items.log+json'; +}; type RawSecurityHeaders = { type: 'raw_security'; sentry_release?: string; sentry_environment?: string }; export type EventItem = BaseEnvelopeItem; @@ -104,7 +114,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; -export type OtelLogItem = BaseEnvelopeItem; +export type LogContainerItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: Partial }; @@ -113,8 +123,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; -type OtelLogEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; - +type LogEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< EventEnvelopeHeaders, EventItem | AttachmentItem | UserFeedbackItem | FeedbackItem | ProfileItem @@ -126,7 +135,7 @@ export type CheckInEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; -export type OtelLogEnvelope = BaseEnvelope; +export type LogEnvelope = BaseEnvelope; export type Envelope = | EventEnvelope @@ -137,6 +146,5 @@ export type Envelope = | CheckInEnvelope | SpanEnvelope | RawSecurityEnvelope - | OtelLogEnvelope; - + | LogEnvelope; export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index 05f8e05ad228..1a6e3974e91e 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -2,27 +2,6 @@ import type { ParameterizedString } from './parameterize'; export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; -export type SerializedLogAttributeValueType = - | { - stringValue: string; - } - | { - // integers must be represented as a string - // because JSON cannot differentiate between integers and floats - intValue: string; - } - | { - boolValue: boolean; - } - | { - doubleValue: number; - }; - -export type SerializedLogAttribute = { - key: string; - value: SerializedLogAttributeValueType; -}; - export interface Log { /** * The severity level of the log. @@ -46,36 +25,49 @@ export interface Log { attributes?: Record; /** - * The severity number - generally higher severity are levels like 'error' and lower are levels like 'debug' + * The severity number. */ severityNumber?: number; } -export interface SerializedOtelLog { - severityText?: Log['level']; +export type SerializedLogAttributeValue = + | { value: string; type: 'string' } + | { value: number; type: 'integer' } + | { value: number; type: 'double' } + | { value: boolean; type: 'boolean' }; +export interface SerializedLog { /** - * The trace ID for this log + * Timestamp in seconds (epoch time) indicating when the log occurred. */ - traceId?: string; + timestamp: number; + + /** + * The severity level of the log. One of `trace`, `debug`, `info`, `warn`, `error`, `fatal`. + */ + level: LogSeverityLevel; - severityNumber?: Log['severityNumber']; + /** + * The log body. + */ + body: Log['message']; - body: { - stringValue: Log['message']; - }; + /** + * The trace ID for this log + */ + trace_id?: string; /** * Arbitrary structured data that stores information about the log - e.g., userId: 100. */ - attributes?: SerializedLogAttribute[]; + attributes?: Record; /** - * This doesn't have to be explicitly specified most of the time. If you need to set it, the value - * is the number of seconds since midnight on January 1, 1970 ("unix epoch time") - * - * @summary A timestamp representing when the log occurred. - * @link https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#:~:text=is%20info.-,timestamp,-(recommended) + * The severity number. */ - timeUnixNano?: string; + severity_number?: Log['severityNumber']; } + +export type SerializedLogContainer = { + items: Array; +}; diff --git a/packages/core/src/utils-hoist/envelope.ts b/packages/core/src/utils-hoist/envelope.ts index 0bb3d12f76b9..0f6af9440643 100644 --- a/packages/core/src/utils-hoist/envelope.ts +++ b/packages/core/src/utils-hoist/envelope.ts @@ -90,7 +90,6 @@ function decodeUTF8(input: Uint8Array): string { */ export function serializeEnvelope(envelope: Envelope): string | Uint8Array { const [envHeaders, items] = envelope; - // Initially we construct our envelope as a string and only convert to binary chunks if we encounter binary data let parts: string | Uint8Array[] = JSON.stringify(envHeaders); @@ -221,7 +220,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { feedback: 'feedback', span: 'span', raw_security: 'security', - otel_log: 'log_item', + log: 'log_item', }; /** diff --git a/packages/core/src/utils-hoist/worldwide.ts b/packages/core/src/utils-hoist/worldwide.ts index 426831038f13..3a396d96f809 100644 --- a/packages/core/src/utils-hoist/worldwide.ts +++ b/packages/core/src/utils-hoist/worldwide.ts @@ -13,6 +13,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Carrier } from '../carrier'; +import type { Client } from '../client'; +import type { SerializedLog } from '../types-hoist/log'; import type { SdkSource } from './env'; /** Internal global with common properties and Sentry extensions */ @@ -35,6 +37,12 @@ export type InternalGlobal = { id?: string; }; SENTRY_SDK_SOURCE?: SdkSource; + /** + * A map of Sentry clients to their log buffers. + * + * This is used to store logs that are sent to Sentry. + */ + _sentryClientToLogBufferMap?: WeakMap>; /** * Debug IDs are indirectly injected by Sentry CLI or bundler plugins to directly reference a particular source map * for resolving of a source file. The injected code will place an entry into the record for each loaded bundle/JS diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts new file mode 100644 index 000000000000..cafc22a562c8 --- /dev/null +++ b/packages/core/test/lib/fetch.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it, vi } from 'vitest'; +import { _addTracingHeadersToFetchRequest } from '../../src/fetch'; + +const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE } = vi.hoisted(() => ({ + DEFAULT_SENTRY_TRACE: 'defaultTraceId-defaultSpanId-1', + DEFAULT_BAGGAGE: 'sentry-trace_id=defaultTraceId,sentry-sampled=true,sentry-sample_rate=0.5,sentry-sample_rand=0.232', +})); + +const CUSTOM_SENTRY_TRACE = '123-abc-1'; +// adding in random spaces here to ensure they are trimmed and our sentry baggage item detection logic works. +// Spaces between items are allowed by the baggage spec. +const CUSTOM_BAGGAGE = ' sentry-trace_id=123 , sentry-sampled=true'; + +vi.mock('../../src/utils/traceData', () => { + return { + getTraceData: vi.fn(() => { + return { + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + }; + }), + }; +}); + +describe('_addTracingHeadersToFetchRequest', () => { + describe('when request is a string', () => { + describe('and no request headers are set', () => { + it.each([ + { + options: {}, + }, + { + options: { headers: {} }, + }, + ])('attaches sentry headers (options: $options)', ({ options }) => { + expect(_addTracingHeadersToFetchRequest('/api/test', options)).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + }); + }); + }); + + describe('and request headers are set in options', () => { + it('attaches sentry headers to headers object', () => { + expect(_addTracingHeadersToFetchRequest('/api/test', { headers: { 'custom-header': 'custom-value' } })).toEqual( + { + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + 'custom-header': 'custom-value', + }, + ); + }); + + it('attaches sentry headers to a Headers instance', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: new Headers({ 'custom-header': 'custom-value' }), + }); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + 'custom-header': 'custom-value', + }); + }); + + it('attaches sentry headers to headers array', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: [['custom-header', 'custom-value']], + }); + + expect(Array.isArray(returnedHeaders)).toBe(true); + expect(returnedHeaders).toEqual([ + ['custom-header', 'custom-value'], + ['sentry-trace', DEFAULT_SENTRY_TRACE], + ['baggage', DEFAULT_BAGGAGE], + ]); + }); + }); + + describe('and 3rd party baggage header is set', () => { + it('adds additional sentry baggage values to Headers instance', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: new Headers({ + baggage: 'custom-baggage=1,someVal=bar', + }), + }); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: `custom-baggage=1,someVal=bar,${DEFAULT_BAGGAGE}`, + }); + }); + + it('adds additional sentry baggage values to headers array', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: [['baggage', 'custom-baggage=1,someVal=bar']], + }); + + expect(Array.isArray(returnedHeaders)).toBe(true); + + expect(returnedHeaders).toEqual([ + ['baggage', 'custom-baggage=1,someVal=bar'], + ['sentry-trace', DEFAULT_SENTRY_TRACE], + ['baggage', DEFAULT_BAGGAGE], + ]); + }); + + it('adds additional sentry baggage values to headers object', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: { + baggage: 'custom-baggage=1,someVal=bar', + }, + }); + + expect(typeof returnedHeaders).toBe('object'); + + expect(returnedHeaders).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: `custom-baggage=1,someVal=bar,${DEFAULT_BAGGAGE}`, + }); + }); + + it('adds additional sentry baggage values to headers object with arrays', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: { + baggage: ['custom-baggage=1,someVal=bar', 'other-vendor-key=value'], + }, + }); + + expect(typeof returnedHeaders).toBe('object'); + + expect(returnedHeaders).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: `custom-baggage=1,someVal=bar,other-vendor-key=value,${DEFAULT_BAGGAGE}`, + }); + }); + }); + + describe('and Sentry values are already set', () => { + it('does not override them (Headers instance)', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: new Headers({ + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE, + 'custom-header': 'custom-value', + }), + }); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'custom-header': 'custom-value', + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE.trim(), + }); + }); + + it('does not override them (headers array)', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: [ + ['sentry-trace', CUSTOM_SENTRY_TRACE], + ['baggage', CUSTOM_BAGGAGE], + ['custom-header', 'custom-value'], + ], + }); + + expect(Array.isArray(returnedHeaders)).toBe(true); + + expect(returnedHeaders).toEqual([ + ['sentry-trace', CUSTOM_SENTRY_TRACE], + ['baggage', CUSTOM_BAGGAGE], + ['custom-header', 'custom-value'], + ]); + }); + + it('does not override them (headers object)', () => { + const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + headers: { + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE, + 'custom-header': 'custom-value', + }, + }); + + expect(typeof returnedHeaders).toBe('object'); + + expect(returnedHeaders).toEqual({ + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE, + 'custom-header': 'custom-value', + }); + }); + }); + }); + + describe('when request is a Request instance', () => { + describe('and no request headers are set', () => { + it('attaches sentry headers', () => { + const request = new Request('http://locahlost:3000/api/test'); + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + }); + }); + }); + + describe('and request headers are set in options', () => { + it('attaches sentry headers to headers instance', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: new Headers({ 'custom-header': 'custom-value' }), + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + 'custom-header': 'custom-value', + }); + }); + + it('attaches sentry headers to headers object', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: { 'custom-header': 'custom-value' }, + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + 'custom-header': 'custom-value', + }); + }); + + it('attaches sentry headers to headers array', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: [['custom-header', 'custom-value']], + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: DEFAULT_BAGGAGE, + 'custom-header': 'custom-value', + }); + }); + }); + + describe('and 3rd party baggage header is set', () => { + it('adds additional sentry baggage values to Headers instance', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: new Headers({ + baggage: 'custom-baggage=1,someVal=bar', + 'custom-header': 'custom-value', + }), + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'custom-header': 'custom-value', + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: `custom-baggage=1,someVal=bar,${DEFAULT_BAGGAGE}`, + }); + }); + + it('adds additional sentry baggage values to headers array', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: [['baggage', 'custom-baggage=1,someVal=bar']], + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: `custom-baggage=1,someVal=bar,${DEFAULT_BAGGAGE}`, + }); + }); + + it('adds additional sentry baggage values to headers object', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: { + baggage: 'custom-baggage=1,someVal=bar', + }, + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: `custom-baggage=1,someVal=bar,${DEFAULT_BAGGAGE}`, + }); + }); + + it('adds additional sentry baggage values to headers object with arrays', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: { + baggage: ['custom-baggage=1,someVal=bar', 'other-vendor-key=value'], + }, + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + baggage: `custom-baggage=1,someVal=bar,other-vendor-key=value,${DEFAULT_BAGGAGE}`, + }); + }); + }); + + describe('and Sentry values are already set', () => { + it('does not override them (Headers instance)', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: new Headers({ + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE, + 'custom-header': 'custom-value', + }), + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'custom-header': 'custom-value', + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE.trim(), + }); + }); + + it('does not override them (headers array)', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: [ + ['sentry-trace', CUSTOM_SENTRY_TRACE], + ['baggage', CUSTOM_BAGGAGE], + ['custom-header', 'custom-value'], + ], + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'custom-header': 'custom-value', + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE.trim(), + }); + }); + + it('does not override them (headers object)', () => { + const request = new Request('http://locahlost:3000/api/test', { + headers: { + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE, + 'custom-header': 'custom-value', + }, + }); + + const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + + expect(returnedHeaders).toBeInstanceOf(Headers); + + // @ts-expect-error -- we know it's a Headers instance and entries() exists + expect(Object.fromEntries(returnedHeaders!.entries())).toEqual({ + 'custom-header': 'custom-value', + 'sentry-trace': CUSTOM_SENTRY_TRACE, + baggage: CUSTOM_BAGGAGE.trim(), + }); + }); + }); + }); +}); diff --git a/packages/core/test/lib/logs/envelope.test.ts b/packages/core/test/lib/logs/envelope.test.ts index 5f98719bbb44..cd765cf018bc 100644 --- a/packages/core/test/lib/logs/envelope.test.ts +++ b/packages/core/test/lib/logs/envelope.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createOtelLogEnvelope, createOtelLogEnvelopeItem } from '../../../src/logs/envelope'; +import { createLogContainerEnvelopeItem, createLogEnvelope } from '../../../src/logs/envelope'; import type { DsnComponents } from '../../../src/types-hoist/dsn'; -import type { SerializedOtelLog } from '../../../src/types-hoist/log'; +import type { SerializedLog } from '../../../src/types-hoist/log'; import type { SdkMetadata } from '../../../src/types-hoist/sdkmetadata'; import * as utilsDsn from '../../../src/utils-hoist/dsn'; import * as utilsEnvelope from '../../../src/utils-hoist/envelope'; @@ -14,24 +14,23 @@ vi.mock('../../../src/utils-hoist/envelope', () => ({ createEnvelope: vi.fn((_headers, items) => [_headers, items]), })); -describe('createOtelLogEnvelopeItem', () => { +describe('createLogContainerEnvelopeItem', () => { it('creates an envelope item with correct structure', () => { - const mockLog: SerializedOtelLog = { - severityText: 'error', - body: { - stringValue: 'Test error message', - }, + const mockLog: SerializedLog = { + timestamp: 1713859200, + level: 'error', + body: 'Test error message', }; - const result = createOtelLogEnvelopeItem(mockLog); + const result = createLogContainerEnvelopeItem([mockLog, mockLog]); expect(result).toHaveLength(2); - expect(result[0]).toEqual({ type: 'otel_log' }); - expect(result[1]).toBe(mockLog); + expect(result[0]).toEqual({ type: 'log', item_count: 2, content_type: 'application/vnd.sentry.items.log+json' }); + expect(result[1]).toEqual({ items: [mockLog, mockLog] }); }); }); -describe('createOtelLogEnvelope', () => { +describe('createLogEnvelope', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2023-01-01T12:00:00Z')); @@ -46,14 +45,15 @@ describe('createOtelLogEnvelope', () => { }); it('creates an envelope with basic headers', () => { - const mockLogs: SerializedOtelLog[] = [ + const mockLogs: SerializedLog[] = [ { - severityText: 'info', - body: { stringValue: 'Test log message' }, + timestamp: 1713859200, + level: 'info', + body: 'Test log message', }, ]; - const result = createOtelLogEnvelope(mockLogs); + const result = createLogEnvelope(mockLogs); expect(result[0]).toEqual({}); @@ -62,10 +62,11 @@ describe('createOtelLogEnvelope', () => { }); it('includes SDK info when metadata is provided', () => { - const mockLogs: SerializedOtelLog[] = [ + const mockLogs: SerializedLog[] = [ { - severityText: 'info', - body: { stringValue: 'Test log message' }, + timestamp: 1713859200, + level: 'info', + body: 'Test log message', }, ]; @@ -76,7 +77,7 @@ describe('createOtelLogEnvelope', () => { }, }; - const result = createOtelLogEnvelope(mockLogs, metadata); + const result = createLogEnvelope(mockLogs, metadata); expect(result[0]).toEqual({ sdk: { @@ -87,10 +88,11 @@ describe('createOtelLogEnvelope', () => { }); it('includes DSN when tunnel and DSN are provided', () => { - const mockLogs: SerializedOtelLog[] = [ + const mockLogs: SerializedLog[] = [ { - severityText: 'info', - body: { stringValue: 'Test log message' }, + timestamp: 1713859200, + level: 'info', + body: 'Test log message', }, ]; @@ -103,88 +105,35 @@ describe('createOtelLogEnvelope', () => { publicKey: 'abc123', }; - const result = createOtelLogEnvelope(mockLogs, undefined, 'https://tunnel.example.com', dsn); + const result = createLogEnvelope(mockLogs, undefined, 'https://tunnel.example.com', dsn); expect(result[0]).toHaveProperty('dsn'); expect(utilsDsn.dsnToString).toHaveBeenCalledWith(dsn); }); it('maps each log to an envelope item', () => { - const mockLogs: SerializedOtelLog[] = [ + const mockLogs: SerializedLog[] = [ { - severityText: 'info', - body: { stringValue: 'First log message' }, + timestamp: 1713859200, + level: 'info', + body: 'First log message', }, { - severityText: 'error', - body: { stringValue: 'Second log message' }, + timestamp: 1713859200, + level: 'error', + body: 'Second log message', }, ]; - createOtelLogEnvelope(mockLogs); - - // Check that createEnvelope was called with an array of envelope items - expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith( - expect.anything(), - expect.arrayContaining([ - expect.arrayContaining([{ type: 'otel_log' }, mockLogs[0]]), - expect.arrayContaining([{ type: 'otel_log' }, mockLogs[1]]), - ]), - ); - }); -}); - -describe('Trace context in logs', () => { - it('correctly sets parent_span_id in trace context', () => { - // Create a log with trace context - const mockParentSpanId = 'abcdef1234567890'; - const mockTraceId = '00112233445566778899aabbccddeeff'; - - const mockLog: SerializedOtelLog = { - severityText: 'info', - body: { stringValue: 'Test log with trace context' }, - traceId: mockTraceId, - attributes: [ - { - key: 'sentry.trace.parent_span_id', - value: { stringValue: mockParentSpanId }, - }, - { - key: 'some.other.attribute', - value: { stringValue: 'test value' }, - }, - ], - }; - - // Create an envelope item from this log - const envelopeItem = createOtelLogEnvelopeItem(mockLog); - - // Verify the parent_span_id is preserved in the envelope item - expect(envelopeItem[1]).toBe(mockLog); - expect(envelopeItem[1].traceId).toBe(mockTraceId); - expect(envelopeItem[1].attributes).toContainEqual({ - key: 'sentry.trace.parent_span_id', - value: { stringValue: mockParentSpanId }, - }); - - // Create an envelope with this log - createOtelLogEnvelope([mockLog]); + createLogEnvelope(mockLogs); - // Verify the envelope preserves the trace information + // Check that createEnvelope was called with a single container item containing all logs expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ expect.arrayContaining([ - { type: 'otel_log' }, - expect.objectContaining({ - traceId: mockTraceId, - attributes: expect.arrayContaining([ - { - key: 'sentry.trace.parent_span_id', - value: { stringValue: mockParentSpanId }, - }, - ]), - }), + { type: 'log', item_count: 2, content_type: 'application/vnd.sentry.items.log+json' }, + { items: mockLogs }, ]), ]), ); diff --git a/packages/core/test/lib/logs/exports.test.ts b/packages/core/test/lib/logs/exports.test.ts index 9c4795d8fc53..1ae570bc5968 100644 --- a/packages/core/test/lib/logs/exports.test.ts +++ b/packages/core/test/lib/logs/exports.test.ts @@ -13,61 +13,69 @@ import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const PUBLIC_DSN = 'https://username@domain/123'; describe('logAttributeToSerializedLogAttribute', () => { - it('serializes number values', () => { - const result = logAttributeToSerializedLogAttribute('count', 42); + it('serializes integer values', () => { + const result = logAttributeToSerializedLogAttribute(42); expect(result).toEqual({ - key: 'count', - value: { doubleValue: 42 }, + value: 42, + type: 'integer', + }); + }); + + it('serializes double values', () => { + const result = logAttributeToSerializedLogAttribute(42.34); + expect(result).toEqual({ + value: 42.34, + type: 'double', }); }); it('serializes boolean values', () => { - const result = logAttributeToSerializedLogAttribute('enabled', true); + const result = logAttributeToSerializedLogAttribute(true); expect(result).toEqual({ - key: 'enabled', - value: { boolValue: true }, + value: true, + type: 'boolean', }); }); it('serializes string values', () => { - const result = logAttributeToSerializedLogAttribute('username', 'john_doe'); + const result = logAttributeToSerializedLogAttribute('username'); expect(result).toEqual({ - key: 'username', - value: { stringValue: 'john_doe' }, + value: 'username', + type: 'string', }); }); it('serializes object values as JSON strings', () => { const obj = { name: 'John', age: 30 }; - const result = logAttributeToSerializedLogAttribute('user', obj); + const result = logAttributeToSerializedLogAttribute(obj); expect(result).toEqual({ - key: 'user', - value: { stringValue: JSON.stringify(obj) }, + value: JSON.stringify(obj), + type: 'string', }); }); it('serializes array values as JSON strings', () => { const array = [1, 2, 3, 'test']; - const result = logAttributeToSerializedLogAttribute('items', array); + const result = logAttributeToSerializedLogAttribute(array); expect(result).toEqual({ - key: 'items', - value: { stringValue: JSON.stringify(array) }, + value: JSON.stringify(array), + type: 'string', }); }); it('serializes undefined values as empty strings', () => { - const result = logAttributeToSerializedLogAttribute('missing', undefined); + const result = logAttributeToSerializedLogAttribute(undefined); expect(result).toEqual({ - key: 'missing', - value: { stringValue: '' }, + value: '', + type: 'string', }); }); it('serializes null values as JSON strings', () => { - const result = logAttributeToSerializedLogAttribute('empty', null); + const result = logAttributeToSerializedLogAttribute(null); expect(result).toEqual({ - key: 'empty', - value: { stringValue: 'null' }, + value: 'null', + type: 'string', }); }); }); @@ -81,11 +89,12 @@ describe('_INTERNAL_captureLog', () => { expect(_INTERNAL_getLogBuffer(client)).toHaveLength(1); expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual( expect.objectContaining({ - severityText: 'info', - body: { - stringValue: 'test log message', - }, - timeUnixNano: expect.any(String), + level: 'info', + body: 'test log message', + timestamp: expect.any(Number), + trace_id: expect.any(String), + severity_number: 9, + attributes: {}, }), ); }); @@ -116,8 +125,8 @@ describe('_INTERNAL_captureLog', () => { expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual( expect.objectContaining({ - traceId: '3d9355f71e9c444b81161599adac6e29', - severityNumber: 17, // error level maps to 17 + trace_id: '3d9355f71e9c444b81161599adac6e29', + severity_number: 17, // error level maps to 17 }), ); }); @@ -134,12 +143,16 @@ describe('_INTERNAL_captureLog', () => { _INTERNAL_captureLog({ level: 'info', message: 'test log with metadata' }, client, undefined); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; - expect(logAttributes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: 'sentry.release', value: { stringValue: '1.0.0' } }), - expect.objectContaining({ key: 'sentry.environment', value: { stringValue: 'test' } }), - ]), - ); + expect(logAttributes).toEqual({ + 'sentry.release': { + value: '1.0.0', + type: 'string', + }, + 'sentry.environment': { + value: 'test', + type: 'string', + }, + }); }); it('includes SDK metadata in log attributes when available', () => { @@ -159,12 +172,16 @@ describe('_INTERNAL_captureLog', () => { _INTERNAL_captureLog({ level: 'info', message: 'test log with SDK metadata' }, client, undefined); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; - expect(logAttributes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: 'sentry.sdk.name', value: { stringValue: 'sentry.javascript.node' } }), - expect.objectContaining({ key: 'sentry.sdk.version', value: { stringValue: '7.0.0' } }), - ]), - ); + expect(logAttributes).toEqual({ + 'sentry.sdk.name': { + value: 'sentry.javascript.node', + type: 'string', + }, + 'sentry.sdk.version': { + value: '7.0.0', + type: 'string', + }, + }); }); it('does not include SDK metadata in log attributes when not available', () => { @@ -202,12 +219,16 @@ describe('_INTERNAL_captureLog', () => { ); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; - expect(logAttributes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: 'userId', value: { stringValue: '123' } }), - expect.objectContaining({ key: 'component', value: { stringValue: 'auth' } }), - ]), - ); + expect(logAttributes).toEqual({ + userId: { + value: '123', + type: 'string', + }, + component: { + value: 'auth', + type: 'string', + }, + }); }); it('flushes logs buffer when it reaches max size', () => { @@ -244,22 +265,20 @@ describe('_INTERNAL_captureLog', () => { _INTERNAL_captureLog({ level: 'info', message: parameterizedMessage }, client, undefined); const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; - expect(logAttributes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'sentry.message.template', - value: { stringValue: 'Hello %s, welcome to %s' }, - }), - expect.objectContaining({ - key: 'sentry.message.parameter.0', - value: { stringValue: 'John' }, - }), - expect.objectContaining({ - key: 'sentry.message.parameter.1', - value: { stringValue: 'Sentry' }, - }), - ]), - ); + expect(logAttributes).toEqual({ + 'sentry.message.template': { + value: 'Hello %s, welcome to %s', + type: 'string', + }, + 'sentry.message.parameter.0': { + value: 'John', + type: 'string', + }, + 'sentry.message.parameter.1': { + value: 'Sentry', + type: 'string', + }, + }); }); it('processes logs through beforeSendLog when provided', () => { @@ -295,13 +314,17 @@ describe('_INTERNAL_captureLog', () => { expect(logBuffer).toBeDefined(); expect(logBuffer?.[0]).toEqual( expect.objectContaining({ - body: { - stringValue: 'Modified: original message', + body: 'Modified: original message', + attributes: { + processed: { + value: true, + type: 'boolean', + }, + original: { + value: true, + type: 'boolean', + }, }, - attributes: expect.arrayContaining([ - expect.objectContaining({ key: 'processed', value: { boolValue: true } }), - expect.objectContaining({ key: 'original', value: { boolValue: true } }), - ]), }), ); }); diff --git a/packages/feedback/src/core/components/Actor.ts b/packages/feedback/src/core/components/Actor.ts index 3ba04e85ddd1..ba90e9e116ec 100644 --- a/packages/feedback/src/core/components/Actor.ts +++ b/packages/feedback/src/core/components/Actor.ts @@ -46,8 +46,8 @@ export function Actor({ triggerLabel, triggerAriaLabel, shadow, styleNonce }: Ac shadow.appendChild(el); }, removeFromDom(): void { - shadow.removeChild(el); - shadow.removeChild(style); + el.remove(); + style.remove(); }, show(): void { el.ariaHidden = 'false'; diff --git a/packages/feedback/src/modal/integration.tsx b/packages/feedback/src/modal/integration.tsx index 7ee2bdb74963..df091b767da2 100644 --- a/packages/feedback/src/modal/integration.tsx +++ b/packages/feedback/src/modal/integration.tsx @@ -44,8 +44,8 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => { } }, removeFromDom(): void { - shadowRoot.removeChild(el); - shadowRoot.removeChild(style); + el.remove(); + style.remove(); DOCUMENT.body.style.overflow = originalOverflow; }, open() { diff --git a/packages/feedback/test/core/components/Actor.test.ts b/packages/feedback/test/core/components/Actor.test.ts index 5afcc76bc1e1..98825355b7d4 100644 --- a/packages/feedback/test/core/components/Actor.test.ts +++ b/packages/feedback/test/core/components/Actor.test.ts @@ -62,4 +62,24 @@ describe('Actor', () => { expect(actorAria.el.textContent).toBe('Button'); expect(actorAria.el.ariaLabel).toBe('Aria'); }); + + it('does not throw if removeFromDom() is called when it is not mounted', () => { + const feedbackIntegration = buildFeedbackIntegration({ + lazyLoadIntegration: vi.fn(), + }); + + const configuredIntegration = feedbackIntegration({}); + mockSdk({ + sentryOptions: { + integrations: [configuredIntegration], + }, + }); + + const feedback = getFeedback(); + + const actorComponent = feedback!.createWidget(); + + expect(() => actorComponent.removeFromDom()).not.toThrowError(); + expect(() => actorComponent.removeFromDom()).not.toThrowError(); + }); }); diff --git a/packages/node/package.json b/packages/node/package.json index bfe1acb03a48..6ffe71f06fc3 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -95,7 +95,7 @@ "@opentelemetry/resources": "^1.30.1", "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.30.0", - "@prisma/instrumentation": "6.6.0", + "@prisma/instrumentation": "6.7.0", "@sentry/core": "9.15.0", "@sentry/opentelemetry": "9.15.0", "import-in-the-middle": "^1.13.0" diff --git a/packages/node/src/integrations/context.ts b/packages/node/src/integrations/context.ts index aa729cad8732..b8376ab0ada8 100644 --- a/packages/node/src/integrations/context.ts +++ b/packages/node/src/integrations/context.ts @@ -61,6 +61,7 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { const updatedContext = _updateContext(await cachedContext); + // TODO(v10): conditional with `sendDefaultPii` here? event.contexts = { ...event.contexts, app: { ...updatedContext.app, ...event.contexts?.app }, diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 5dbdeae2f925..4e044879d2aa 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ +import type { ChannelListener } from 'node:diagnostics_channel'; +import { subscribe, unsubscribe } from 'node:diagnostics_channel'; import type * as http from 'node:http'; -import type { IncomingMessage, RequestOptions } from 'node:http'; import type * as https from 'node:https'; import type { EventEmitter } from 'node:stream'; import { context, propagation } from '@opentelemetry/api'; @@ -10,6 +11,7 @@ import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opent import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core'; import { addBreadcrumb, + addNonEnumerableProperty, generateSpanId, getBreadcrumbLogLevelFromHttpStatusCode, getClient, @@ -24,14 +26,12 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { getRequestUrl } from '../../utils/getRequestUrl'; -import { stealthWrap } from './utils'; -import { getRequestInfo } from './vendor/getRequestInfo'; + +const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; type Http = typeof http; type Https = typeof https; -const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; - export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * Whether breadcrumbs should be recorded for requests. @@ -58,7 +58,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. * @param request Contains the {@type RequestOptions} object used to make the outgoing request. */ - ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean; /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. @@ -67,7 +67,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. * @param request Contains the {@type RequestOptions} object used to make the outgoing request. */ - ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean; + ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean; /** * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. @@ -108,71 +108,134 @@ export class SentryHttpInstrumentation extends InstrumentationBase { + const data = _data as { server: http.Server }; + this._patchServerEmitOnce(data.server); + }) satisfies ChannelListener; + + const onHttpClientResponseFinish = ((_data: unknown) => { + const data = _data as { request: http.ClientRequest; response: http.IncomingMessage }; + this._onOutgoingRequestFinish(data.request, data.response); + }) satisfies ChannelListener; + + const onHttpClientRequestError = ((_data: unknown) => { + const data = _data as { request: http.ClientRequest }; + this._onOutgoingRequestFinish(data.request, undefined); + }) satisfies ChannelListener; + + /** + * You may be wondering why we register these diagnostics-channel listeners + * in such a convoluted way (as InstrumentationNodeModuleDefinition...)˝, + * instead of simply subscribing to the events once in here. + * The reason for this is timing semantics: These functions are called once the http or https module is loaded. + * If we'd subscribe before that, there seem to be conflicts with the OTEL native instrumentation in some scenarios, + * especially the "import-on-top" pattern of setting up ESM applications. + */ + return [ + new InstrumentationNodeModuleDefinition( + 'http', + ['*'], + (moduleExports: Http): Http => { + if (hasRegisteredHandlers) { + return moduleExports; + } - /** Get the instrumentation for the http module. */ - private _getHttpInstrumentation(): InstrumentationNodeModuleDefinition { - return new InstrumentationNodeModuleDefinition( - 'http', - ['*'], - (moduleExports: Http): Http => { - // Patch incoming requests for request isolation - stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + hasRegisteredHandlers = true; + + subscribe('http.server.request.start', onHttpServerRequestStart); + subscribe('http.client.response.finish', onHttpClientResponseFinish); + + // When an error happens, we still want to have a breadcrumb + // In this case, `http.client.response.finish` is not triggered + subscribe('http.client.request.error', onHttpClientRequestError); + + return moduleExports; + }, + () => { + unsubscribe('http.server.request.start', onHttpServerRequestStart); + unsubscribe('http.client.response.finish', onHttpClientResponseFinish); + unsubscribe('http.client.request.error', onHttpClientRequestError); + }, + ), + new InstrumentationNodeModuleDefinition( + 'https', + ['*'], + (moduleExports: Https): Https => { + if (hasRegisteredHandlers) { + return moduleExports; + } - // Patch outgoing requests for breadcrumbs - const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); - stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); + hasRegisteredHandlers = true; - return moduleExports; - }, - () => { - // no unwrap here - }, - ); + subscribe('http.server.request.start', onHttpServerRequestStart); + subscribe('http.client.response.finish', onHttpClientResponseFinish); + + // When an error happens, we still want to have a breadcrumb + // In this case, `http.client.response.finish` is not triggered + subscribe('http.client.request.error', onHttpClientRequestError); + + return moduleExports; + }, + () => { + unsubscribe('http.server.request.start', onHttpServerRequestStart); + unsubscribe('http.client.response.finish', onHttpClientResponseFinish); + unsubscribe('http.client.request.error', onHttpClientRequestError); + }, + ), + ]; } - /** Get the instrumentation for the https module. */ - private _getHttpsInstrumentation(): InstrumentationNodeModuleDefinition { - return new InstrumentationNodeModuleDefinition( - 'https', - ['*'], - (moduleExports: Https): Https => { - // Patch incoming requests for request isolation - stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + /** + * This is triggered when an outgoing request finishes. + * It has access to the final request and response objects. + */ + private _onOutgoingRequestFinish(request: http.ClientRequest, response?: http.IncomingMessage): void { + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling finished outgoing request'); + + const _breadcrumbs = this.getConfig().breadcrumbs; + const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; + const options = getRequestOptions(request); - // Patch outgoing requests for breadcrumbs - const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); - stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); + const _ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; + const shouldCreateBreadcrumb = + typeof _ignoreOutgoingRequests === 'function' ? !_ignoreOutgoingRequests(getRequestUrl(request), options) : true; - return moduleExports; - }, - () => { - // no unwrap here - }, - ); + if (breadCrumbsEnabled && shouldCreateBreadcrumb) { + addRequestBreadcrumb(request, response); + } } /** - * Patch the incoming request function for request isolation. + * Patch a server.emit function to handle process isolation for incoming requests. + * This will only patch the emit function if it was not already patched. */ - private _getPatchIncomingRequestFunction(): ( - original: (event: string, ...args: unknown[]) => boolean, - ) => (this: unknown, event: string, ...args: unknown[]) => boolean { + private _patchServerEmitOnce(server: http.Server): void { + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalEmit = server.emit; + + // This means it was already patched, do nothing + if ((originalEmit as { __sentry_patched__?: boolean }).__sentry_patched__) { + return; + } + + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Patching server.emit'); + // eslint-disable-next-line @typescript-eslint/no-this-alias const instrumentation = this; const { ignoreIncomingRequestBody } = instrumentation.getConfig(); - return ( - original: (event: string, ...args: unknown[]) => boolean, - ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { - return function incomingRequest(this: unknown, ...args: [event: string, ...args: unknown[]]): boolean { + const newEmit = new Proxy(originalEmit, { + apply(target, thisArg, args: [event: string, ...args: unknown[]]) { // Only traces request events if (args[0] !== 'request') { - return original.apply(this, args); + return target.apply(thisArg, args); } - instrumentation._diag.debug('http instrumentation for incoming request'); + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling incoming request'); const isolationScope = getIsolationScope().clone(); const request = args[1] as http.IncomingMessage; @@ -217,97 +280,28 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - return original.apply(this, args); + return target.apply(thisArg, args); }); }); - }; - }; - } - - /** - * Patch the outgoing request function for breadcrumbs. - */ - private _getPatchOutgoingRequestFunction(): ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - original: (...args: any[]) => http.ClientRequest, - ) => (options: URL | http.RequestOptions | string, ...args: unknown[]) => http.ClientRequest { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const instrumentation = this; - - return (original: (...args: unknown[]) => http.ClientRequest): ((...args: unknown[]) => http.ClientRequest) => { - return function outgoingRequest(this: unknown, ...args: unknown[]): http.ClientRequest { - instrumentation._diag.debug('http instrumentation for outgoing requests'); - - // Making a copy to avoid mutating the original args array - // We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests` - // so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`. - // @see https://github.com/open-telemetry/opentelemetry-js/blob/7293e69c1e55ca62e15d0724d22605e61bd58952/experimental/packages/opentelemetry-instrumentation-http/src/http.ts#L756-L789 - const argsCopy = [...args]; - - const options = argsCopy.shift() as URL | http.RequestOptions | string; - - const extraOptions = - typeof argsCopy[0] === 'object' && (typeof options === 'string' || options instanceof URL) - ? (argsCopy.shift() as http.RequestOptions) - : undefined; - - const { optionsParsed } = getRequestInfo(instrumentation._diag, options, extraOptions); - - const request = original.apply(this, args) as ReturnType; - - request.prependListener('response', (response: http.IncomingMessage) => { - const _breadcrumbs = instrumentation.getConfig().breadcrumbs; - const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; - - const _ignoreOutgoingRequests = instrumentation.getConfig().ignoreOutgoingRequests; - const shouldCreateBreadcrumb = - typeof _ignoreOutgoingRequests === 'function' - ? !_ignoreOutgoingRequests(getRequestUrl(request), optionsParsed) - : true; - - if (breadCrumbsEnabled && shouldCreateBreadcrumb) { - addRequestBreadcrumb(request, response); - } - }); + }, + }); - return request; - }; - }; - } + addNonEnumerableProperty(newEmit, '__sentry_patched__', true); - /** Path the outgoing get function for breadcrumbs. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _getPatchOutgoingGetFunction(clientRequest: (...args: any[]) => http.ClientRequest) { - return (_original: unknown): ((...args: unknown[]) => http.ClientRequest) => { - // Re-implement http.get. This needs to be done (instead of using - // getPatchOutgoingRequestFunction to patch it) because we need to - // set the trace context header before the returned http.ClientRequest is - // ended. The Node.js docs state that the only differences between - // request and get are that (1) get defaults to the HTTP GET method and - // (2) the returned request object is ended immediately. The former is - // already true (at least in supported Node versions up to v10), so we - // simply follow the latter. Ref: - // https://nodejs.org/dist/latest/docs/api/http.html#http_http_get_options_callback - // https://github.com/googleapis/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-http.ts#L198 - return function outgoingGetRequest(...args: unknown[]): http.ClientRequest { - const req = clientRequest(...args); - req.end(); - return req; - }; - }; + server.emit = newEmit; } } /** Add a breadcrumb for outgoing requests. */ -function addRequestBreadcrumb(request: http.ClientRequest, response: http.IncomingMessage): void { +function addRequestBreadcrumb(request: http.ClientRequest, response: http.IncomingMessage | undefined): void { const data = getBreadcrumbData(request); - const statusCode = response.statusCode; + const statusCode = response?.statusCode; const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); addBreadcrumb( @@ -359,10 +353,12 @@ function getBreadcrumbData(request: http.ClientRequest): Partial) => { const [event, listener, ...restArgs] = args; - if (DEBUG_BUILD) { - logger.log(INSTRUMENTATION_NAME, 'Patching request.on', event); - } - if (event === 'data') { + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling request.on("data")'); const callback = new Proxy(listener, { apply: (target, thisArg, args: Parameters) => { try { @@ -451,6 +444,17 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): } } +function getRequestOptions(request: http.ClientRequest): http.RequestOptions { + return { + method: request.method, + protocol: request.protocol, + host: request.host, + hostname: request.host, + path: request.path, + headers: request.getHeaders(), + }; +} + /** * Starts a session and tracks it in the context of a given isolation scope. * When the passed response is finished, the session is put into a task and is diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts b/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts index e17194669eb0..ace8cbfb4399 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts @@ -68,8 +68,7 @@ export class SentryHttpInstrumentationBeforeOtel extends InstrumentationBase { return original.apply(this, args); } - const response = args[1] as http.OutgoingMessage; - + const response = args[2] as http.OutgoingMessage; patchResponseToFlushOnServerlessPlatforms(response); return original.apply(this, args); @@ -81,50 +80,57 @@ export class SentryHttpInstrumentationBeforeOtel extends InstrumentationBase { function patchResponseToFlushOnServerlessPlatforms(res: http.OutgoingMessage): void { // Freely extend this function with other platforms if necessary if (process.env.VERCEL) { + DEBUG_BUILD && logger.log('Patching response to flush on Vercel'); + // In some cases res.end does not seem to be defined leading to errors if passed to Proxy // https://github.com/getsentry/sentry-javascript/issues/15759 - if (typeof res.end === 'function') { - let markOnEndDone = (): void => undefined; - const onEndDonePromise = new Promise(res => { - markOnEndDone = res; - }); - - res.on('close', () => { - markOnEndDone(); - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - res.end = new Proxy(res.end, { - apply(target, thisArg, argArray) { - vercelWaitUntil( - new Promise(finishWaitUntil => { - // Define a timeout that unblocks the lambda just to be safe so we're not indefinitely keeping it alive, exploding server bills - const timeout = setTimeout(() => { - finishWaitUntil(); - }, 2000); - - onEndDonePromise - .then(() => { - DEBUG_BUILD && logger.log('Flushing events before Vercel Lambda freeze'); - return flush(2000); - }) - .then( - () => { - clearTimeout(timeout); - finishWaitUntil(); - }, - e => { - clearTimeout(timeout); - DEBUG_BUILD && logger.log('Error while flushing events for Vercel:\n', e); - finishWaitUntil(); - }, - ); - }), - ); - - return target.apply(thisArg, argArray); - }, - }); + if (typeof res.end !== 'function') { + DEBUG_BUILD && logger.warn('res.end is not a function, skipping patch...'); + return; } + + let markOnEndDone = (): void => undefined; + const onEndDonePromise = new Promise(res => { + markOnEndDone = res; + }); + + res.on('close', () => { + markOnEndDone(); + }); + + logger.log('Patching res.end()'); + + // eslint-disable-next-line @typescript-eslint/unbound-method + res.end = new Proxy(res.end, { + apply(target, thisArg, argArray) { + vercelWaitUntil( + new Promise(finishWaitUntil => { + // Define a timeout that unblocks the lambda just to be safe so we're not indefinitely keeping it alive, exploding server bills + const timeout = setTimeout(() => { + finishWaitUntil(); + }, 2000); + + onEndDonePromise + .then(() => { + DEBUG_BUILD && logger.log('Flushing events before Vercel Lambda freeze'); + return flush(2000); + }) + .then( + () => { + clearTimeout(timeout); + finishWaitUntil(); + }, + e => { + clearTimeout(timeout); + DEBUG_BUILD && logger.log('Error while flushing events for Vercel:\n', e); + finishWaitUntil(); + }, + ); + }), + ); + + return target.apply(thisArg, argArray); + }, + }); } } diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index f46724aa9b72..d25d19a86c8c 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -73,6 +73,15 @@ interface HttpOptions { */ ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + /** + * Do not capture spans for incoming HTTP requests with the given status codes. + * By default, spans with 404 status code are ignored. + * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. + * + * @default `[404]` + */ + dropSpansForIncomingRequestStatusCodes?: (number | [number, number])[]; + /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. @@ -148,9 +157,12 @@ export function _shouldInstrumentSpans(options: HttpOptions, clientOptions: Part * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. */ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => { + const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [404]; + return { name: INTEGRATION_NAME, setupOnce() { + // TODO: get rid of this too // Below, we instrument the Node.js HTTP API three times. 2 times Sentry-specific, 1 time OTEL specific. // Due to timing reasons, we sometimes need to apply Sentry instrumentation _before_ we apply the OTEL // instrumentation (e.g. to flush on serverless platforms), and sometimes we need to apply Sentry instrumentation @@ -165,19 +177,40 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => const instrumentSpans = _shouldInstrumentSpans(options, getClient()?.getOptions()); - // This is the "regular" OTEL instrumentation that emits spans - if (instrumentSpans) { - const instrumentationConfig = getConfigWithDefaults(options); - instrumentOtelHttp(instrumentationConfig); - } - - // This is Sentry-specific instrumentation that is applied _after_ any OTEL instrumentation. + // This is Sentry-specific instrumentation for request isolation and breadcrumbs instrumentSentryHttp({ ...options, // 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, }); + + // This is the "regular" OTEL instrumentation that emits spans + if (instrumentSpans) { + const instrumentationConfig = getConfigWithDefaults(options); + instrumentOtelHttp(instrumentationConfig); + } + }, + processEvent(event) { + // Drop transaction if it has a status code that should be ignored + if (event.type === 'transaction') { + const statusCode = event.contexts?.trace?.data?.['http.response.status_code']; + if ( + typeof statusCode === 'number' && + dropSpansForIncomingRequestStatusCodes.some(code => { + if (typeof code === 'number') { + return code === statusCode; + } + + const [min, max] = code; + return statusCode >= min && statusCode <= max; + }) + ) { + return null; + } + } + + return event; }, }; }); diff --git a/packages/node/src/integrations/http/vendor/getRequestInfo.ts b/packages/node/src/integrations/http/vendor/getRequestInfo.ts deleted file mode 100644 index 4fbb78e46f17..000000000000 --- a/packages/node/src/integrations/http/vendor/getRequestInfo.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* eslint-disable complexity */ - -/** - * Vendored in from https://github.com/open-telemetry/opentelemetry-js/commit/87bd98edd24c98a5fbb9a56fed4b673b7f17a724 - */ - -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RequestOptions } from 'node:http'; -import type { DiagLogger } from '@opentelemetry/api'; -import * as url from 'url'; - -/** - * Makes sure options is an url object - * return an object with default value and parsed options - * @param logger component logger - * @param options original options for the request - * @param [extraOptions] additional options for the request - */ -export const getRequestInfo = ( - logger: DiagLogger, - options: url.URL | RequestOptions | string, - extraOptions?: RequestOptions, -): { - origin: string; - pathname: string; - method: string; - invalidUrl: boolean; - optionsParsed: RequestOptions; -} => { - let pathname: string; - let origin: string; - let optionsParsed: RequestOptions; - let invalidUrl = false; - if (typeof options === 'string') { - try { - const convertedOptions = stringUrlToHttpOptions(options); - optionsParsed = convertedOptions; - pathname = convertedOptions.pathname || '/'; - } catch (e) { - invalidUrl = true; - logger.verbose( - 'Unable to parse URL provided to HTTP request, using fallback to determine path. Original error:', - e, - ); - // for backward compatibility with how url.parse() behaved. - optionsParsed = { - path: options, - }; - pathname = optionsParsed.path || '/'; - } - - origin = `${optionsParsed.protocol || 'http:'}//${optionsParsed.host}`; - if (extraOptions !== undefined) { - Object.assign(optionsParsed, extraOptions); - } - } else if (options instanceof url.URL) { - optionsParsed = { - protocol: options.protocol, - hostname: - typeof options.hostname === 'string' && options.hostname.startsWith('[') - ? options.hostname.slice(1, -1) - : options.hostname, - path: `${options.pathname || ''}${options.search || ''}`, - }; - if (options.port !== '') { - optionsParsed.port = Number(options.port); - } - if (options.username || options.password) { - optionsParsed.auth = `${options.username}:${options.password}`; - } - pathname = options.pathname; - origin = options.origin; - if (extraOptions !== undefined) { - Object.assign(optionsParsed, extraOptions); - } - } else { - optionsParsed = Object.assign({ protocol: options.host ? 'http:' : undefined }, options); - - const hostname = - optionsParsed.host || - (optionsParsed.port != null ? `${optionsParsed.hostname}${optionsParsed.port}` : optionsParsed.hostname); - origin = `${optionsParsed.protocol || 'http:'}//${hostname}`; - - pathname = (options as url.URL).pathname; - if (!pathname && optionsParsed.path) { - try { - const parsedUrl = new URL(optionsParsed.path, origin); - pathname = parsedUrl.pathname || '/'; - } catch (e) { - pathname = '/'; - } - } - } - - // some packages return method in lowercase.. - // ensure upperCase for consistency - const method = optionsParsed.method ? optionsParsed.method.toUpperCase() : 'GET'; - - return { origin, pathname, method, optionsParsed, invalidUrl }; -}; - -/** - * Mimics Node.js conversion of URL strings to RequestOptions expected by - * `http.request` and `https.request` APIs. - * - * See https://github.com/nodejs/node/blob/2505e217bba05fc581b572c685c5cf280a16c5a3/lib/internal/url.js#L1415-L1437 - * - * @param stringUrl - * @throws TypeError if the URL is not valid. - */ -function stringUrlToHttpOptions(stringUrl: string): RequestOptions & { pathname: string } { - // This is heavily inspired by Node.js handling of the same situation, trying - // to follow it as closely as possible while keeping in mind that we only - // deal with string URLs, not URL objects. - const { hostname, pathname, port, username, password, search, protocol, hash, href, origin, host } = new URL( - stringUrl, - ); - - const options: RequestOptions & { - pathname: string; - hash: string; - search: string; - href: string; - origin: string; - } = { - protocol: protocol, - hostname: hostname && hostname[0] === '[' ? hostname.slice(1, -1) : hostname, - hash: hash, - search: search, - pathname: pathname, - path: `${pathname || ''}${search || ''}`, - href: href, - origin: origin, - host: host, - }; - if (port !== '') { - options.port = Number(port); - } - if (username || password) { - options.auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; - } - return options; -} diff --git a/packages/node/src/integrations/tracing/express-v5/instrumentation.ts b/packages/node/src/integrations/tracing/express-v5/instrumentation.ts index c6070550c996..bc810341db35 100644 --- a/packages/node/src/integrations/tracing/express-v5/instrumentation.ts +++ b/packages/node/src/integrations/tracing/express-v5/instrumentation.ts @@ -164,16 +164,18 @@ export class ExpressInstrumentationV5 extends InstrumentationBase 0 ? route : undefined; + const attributes: Attributes = { // eslint-disable-next-line deprecation/deprecation - [SEMATTRS_HTTP_ROUTE]: route.length > 0 ? route : '/', + [SEMATTRS_HTTP_ROUTE]: actualRoute, }; const metadata = getLayerMetadata(route, layer, layerPath); const type = metadata.attributes[AttributeNames.EXPRESS_TYPE] as ExpressLayerType; const rpcMetadata = getRPCMetadata(context.active()); if (rpcMetadata?.type === RPCType.HTTP) { - rpcMetadata.route = route || '/'; + rpcMetadata.route = actualRoute; } // verify against the config if the layer should be ignored diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index e07a247d7d34..99a85b3514f8 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -15,6 +15,7 @@ import { instrumentMongoose, mongooseIntegration } from './mongoose'; import { instrumentMysql, mysqlIntegration } from './mysql'; import { instrumentMysql2, mysql2Integration } from './mysql2'; import { instrumentPostgres, postgresIntegration } from './postgres'; +import { prismaIntegration } from './prisma'; import { instrumentRedis, redisIntegration } from './redis'; import { instrumentTedious, tediousIntegration } from './tedious'; import { instrumentVercelAi, vercelAIIntegration } from './vercelai'; @@ -33,10 +34,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { mysql2Integration(), redisIntegration(), postgresIntegration(), - // For now, we do not include prisma by default because it has ESM issues - // See https://github.com/prisma/prisma/issues/23410 - // TODO v8: Figure out a better solution for this, maybe only disable in ESM mode? - // prismaIntegration(), + prismaIntegration(), hapiIntegration(), koaIntegration(), connectIntegration(), diff --git a/packages/node/src/integrations/tracing/prisma.ts b/packages/node/src/integrations/tracing/prisma.ts index 58c0a8019cd8..c674e0bfdfbe 100644 --- a/packages/node/src/integrations/tracing/prisma.ts +++ b/packages/node/src/integrations/tracing/prisma.ts @@ -1,6 +1,5 @@ import type { Instrumentation } from '@opentelemetry/instrumentation'; -// When importing CJS modules into an ESM module, we cannot import the named exports directly. -import * as prismaInstrumentation from '@prisma/instrumentation'; +import { PrismaInstrumentation } from '@prisma/instrumentation'; import { consoleSandbox, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core'; import { generateInstrumentOnce } from '../../otel/instrument'; import type { PrismaV5TracingHelper } from './prisma/vendor/v5-tracing-helper'; @@ -8,11 +7,6 @@ import type { PrismaV6TracingHelper } from './prisma/vendor/v6-tracing-helper'; const INTEGRATION_NAME = 'Prisma'; -const EsmInteropPrismaInstrumentation: typeof prismaInstrumentation.PrismaInstrumentation = - // @ts-expect-error We need to do the following for interop reasons - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - prismaInstrumentation.default?.PrismaInstrumentation || prismaInstrumentation.PrismaInstrumentation; - type CompatibilityLayerTraceHelper = PrismaV5TracingHelper & PrismaV6TracingHelper; function isPrismaV6TracingHelper(helper: unknown): helper is PrismaV6TracingHelper { @@ -31,7 +25,7 @@ function getPrismaTracingHelper(): unknown | undefined { return prismaTracingHelper; } -class SentryPrismaInteropInstrumentation extends EsmInteropPrismaInstrumentation { +class SentryPrismaInteropInstrumentation extends PrismaInstrumentation { public constructor() { super(); } diff --git a/packages/node/src/integrations/winston.ts b/packages/node/src/integrations/winston.ts index 74af701d7144..a485a6c56431 100644 --- a/packages/node/src/integrations/winston.ts +++ b/packages/node/src/integrations/winston.ts @@ -18,7 +18,7 @@ interface WinstonTransportOptions { * * @example * ```ts - * const transport = Sentry.createSentryWinstonTransport(Transport, { + * const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport, { * // Only capture error and warn logs * levels: ['error', 'warn'], * }); @@ -43,10 +43,10 @@ interface WinstonTransportOptions { * const winston = require('winston'); * const Transport = require('winston-transport'); * - * const transport = Sentry.createSentryWinstonTransport(Transport); + * const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport); * * const logger = winston.createLogger({ - * transports: [transport], + * transports: [new SentryWinstonTransport()], * }); * ``` */ diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts new file mode 100644 index 000000000000..e5ec2d65d5ef --- /dev/null +++ b/packages/react-router/src/client/hydratedRouter.ts @@ -0,0 +1,147 @@ +import { startBrowserTracingNavigationSpan } from '@sentry/browser'; +import type { Span } from '@sentry/core'; +import { + consoleSandbox, + getActiveSpan, + getClient, + getRootSpan, + GLOBAL_OBJ, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, +} from '@sentry/core'; +import type { DataRouter, RouterState } from 'react-router'; +import { DEBUG_BUILD } from '../common/debug-build'; + +const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __reactRouterDataRouter?: DataRouter; +}; + +const MAX_RETRIES = 40; // 2 seconds at 50ms interval + +/** + * Instruments the React Router Data Router for pageloads and navigation. + * + * This function waits for the router to be available after hydration, then: + * 1. Updates the pageload transaction with parameterized route info + * 2. Patches router.navigate() to create navigation transactions + * 3. Subscribes to router state changes to update navigation transactions with parameterized routes + */ +export function instrumentHydratedRouter(): void { + function trySubscribe(): boolean { + const router = GLOBAL_OBJ_WITH_DATA_ROUTER.__reactRouterDataRouter; + + if (router) { + // The first time we hit the router, we try to update the pageload transaction + // todo: update pageload tx here + const pageloadSpan = getActiveRootSpan(); + const pageloadName = pageloadSpan ? spanToJSON(pageloadSpan).description : undefined; + const parameterizePageloadRoute = getParameterizedRoute(router.state); + if ( + pageloadName && + normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName) && // this event is for the currently active pageload + normalizePathname(parameterizePageloadRoute) !== normalizePathname(pageloadName) // route is not parameterized yet + ) { + pageloadSpan?.updateName(parameterizePageloadRoute); + pageloadSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } + + // Patching navigate for creating accurate navigation transactions + if (typeof router.navigate === 'function') { + const originalNav = router.navigate.bind(router); + router.navigate = function sentryPatchedNavigate(...args) { + maybeCreateNavigationTransaction( + String(args[0]) || '', // will be updated anyway + 'url', // this also will be updated once we have the parameterized route + ); + return originalNav(...args); + }; + } + + // Subscribe to router state changes to update navigation transactions with parameterized routes + router.subscribe(newState => { + const navigationSpan = getActiveRootSpan(); + const navigationSpanName = navigationSpan ? spanToJSON(navigationSpan).description : undefined; + const parameterizedNavRoute = getParameterizedRoute(newState); + + if ( + navigationSpanName && // we have an active pageload tx + newState.navigation.state === 'idle' && // navigation has completed + normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) && // this event is for the currently active navigation + normalizePathname(parameterizedNavRoute) !== normalizePathname(navigationSpanName) // route is not parameterized yet + ) { + navigationSpan?.updateName(parameterizedNavRoute); + navigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + } + }); + return true; + } + return false; + } + + // Wait until the router is available (since the SDK loads before hydration) + if (!trySubscribe()) { + let retryCount = 0; + // Retry until the router is available or max retries reached + const interval = setInterval(() => { + if (trySubscribe() || retryCount >= MAX_RETRIES) { + if (retryCount >= MAX_RETRIES) { + DEBUG_BUILD && + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('Unable to instrument React Router: router not found after hydration.'); + }); + } + clearInterval(interval); + } + retryCount++; + }, 50); + } +} + +function maybeCreateNavigationTransaction(name: string, source: 'url' | 'route'): Span | undefined { + const client = getClient(); + + if (!client) { + return undefined; + } + + return startBrowserTracingNavigationSpan(client, { + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react-router', + }, + }); +} + +function getActiveRootSpan(): Span | undefined { + const activeSpan = getActiveSpan(); + if (!activeSpan) { + return undefined; + } + + const rootSpan = getRootSpan(activeSpan); + + const op = spanToJSON(rootSpan).op; + + // Only use this root span if it is a pageload or navigation span + return op === 'navigation' || op === 'pageload' ? rootSpan : undefined; +} + +function getParameterizedRoute(routerState: RouterState): string { + const lastMatch = routerState.matches[routerState.matches.length - 1]; + return normalizePathname(lastMatch?.route.path ?? routerState.location.pathname); +} + +function normalizePathname(pathname: string): string { + // Ensure it starts with a single slash + let normalized = pathname.startsWith('/') ? pathname : `/${pathname}`; + // Remove trailing slash unless it's the root + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index 8e25b84c4a0c..1bb86ec16deb 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -1,3 +1,4 @@ export * from '@sentry/browser'; export { init } from './sdk'; +export { reactRouterTracingIntegration } from './tracingIntegration'; diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts index 688a8ba460f1..a9b13f2e7cd0 100644 --- a/packages/react-router/src/client/sdk.ts +++ b/packages/react-router/src/client/sdk.ts @@ -1,19 +1,33 @@ import type { BrowserOptions } from '@sentry/browser'; import { init as browserInit } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata, setTag } from '@sentry/core'; +import { applySdkMetadata, consoleSandbox, setTag } from '@sentry/core'; + +const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; /** * Initializes the client side of the React Router SDK. */ export function init(options: BrowserOptions): Client | undefined { - const opts = { - ...options, - }; + // If BrowserTracing integration was passed to options, emit a warning + if (options.integrations && Array.isArray(options.integrations)) { + const hasBrowserTracing = options.integrations.some( + integration => integration.name === BROWSER_TRACING_INTEGRATION_ID, + ); + + if (hasBrowserTracing) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + 'browserTracingIntegration is not fully compatible with @sentry/react-router. Please use reactRouterTracingIntegration instead.', + ); + }); + } + } - applySdkMetadata(opts, 'react-router', ['react-router', 'browser']); + applySdkMetadata(options, 'react-router', ['react-router', 'browser']); - const client = browserInit(opts); + const client = browserInit(options); setTag('runtime', 'browser'); diff --git a/packages/react-router/src/client/tracingIntegration.ts b/packages/react-router/src/client/tracingIntegration.ts new file mode 100644 index 000000000000..01b71f36d92a --- /dev/null +++ b/packages/react-router/src/client/tracingIntegration.ts @@ -0,0 +1,23 @@ +import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { instrumentHydratedRouter } from './hydratedRouter'; + +/** + * Browser tracing integration for React Router (Framework) applications. + * This integration will create navigation spans and enhance transactions names with parameterized routes. + */ +export function reactRouterTracingIntegration(): Integration { + const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ + // Navigation transactions are started within the hydrated router instrumentation + instrumentNavigation: false, + }); + + return { + ...browserTracingIntegrationInstance, + name: 'ReactRouterTracingIntegration', + afterAllSetup(client) { + browserTracingIntegrationInstance.afterAllSetup(client); + instrumentHydratedRouter(); + }, + }; +} diff --git a/packages/react-router/src/vite/index.ts b/packages/react-router/src/vite/index.ts index fbe4736dbcfb..5f5b6266015a 100644 --- a/packages/react-router/src/vite/index.ts +++ b/packages/react-router/src/vite/index.ts @@ -1,3 +1,4 @@ export { sentryReactRouter } from './plugin'; export { sentryOnBuildEnd } from './buildEnd/handleOnBuildEnd'; export type { SentryReactRouterBuildOptions } from './types'; +export { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; diff --git a/packages/react-router/src/vite/makeConfigInjectorPlugin.ts b/packages/react-router/src/vite/makeConfigInjectorPlugin.ts new file mode 100644 index 000000000000..43948c8d4986 --- /dev/null +++ b/packages/react-router/src/vite/makeConfigInjectorPlugin.ts @@ -0,0 +1,23 @@ +import { type Plugin } from 'vite'; +import type { SentryReactRouterBuildOptions } from './types'; + +/** + * Creates a Vite plugin that injects the Sentry options into the global Vite config. + * This ensures the sentryConfig is available to other components that need access to it, + * like the buildEnd hook. + * + * @param options - Configuration options for the Sentry Vite plugin + * @returns A Vite plugin that injects sentryConfig into the global config + */ +export function makeConfigInjectorPlugin(options: SentryReactRouterBuildOptions): Plugin { + return { + name: 'sentry-react-router-config-injector', + enforce: 'pre', + config(config) { + return { + ...config, + sentryConfig: options, + }; + }, + }; +} diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index c8cdad32ee92..98405771ee1b 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -1,5 +1,6 @@ import type { ConfigEnv } from 'vite'; import { type Plugin } from 'vite'; +import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; import type { SentryReactRouterBuildOptions } from './types'; @@ -17,6 +18,8 @@ export async function sentryReactRouter( ): Promise { const plugins: Plugin[] = []; + plugins.push(makeConfigInjectorPlugin(options)); + if (process.env.NODE_ENV !== 'development' && config.command === 'build' && config.mode !== 'development') { plugins.push(makeEnableSourceMapsPlugin(options)); plugins.push(...(await makeCustomSentryVitePlugins(options))); diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts new file mode 100644 index 000000000000..98ed1a241d93 --- /dev/null +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -0,0 +1,111 @@ +import * as browser from '@sentry/browser'; +import * as core from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentHydratedRouter } from '../../src/client/hydratedRouter'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + spanToJSON: vi.fn(), + getClient: vi.fn(), + SEMANTIC_ATTRIBUTE_SENTRY_OP: 'op', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'origin', + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'source', + GLOBAL_OBJ: globalThis, + }; +}); +vi.mock('@sentry/browser', () => ({ + startBrowserTracingNavigationSpan: vi.fn(), +})); + +describe('instrumentHydratedRouter', () => { + let originalRouter: any; + let mockRouter: any; + let mockPageloadSpan: any; + let mockNavigationSpan: any; + + beforeEach(() => { + originalRouter = (globalThis as any).__reactRouterDataRouter; + mockRouter = { + state: { + location: { pathname: '/foo/bar' }, + matches: [{ route: { path: '/foo/:id' } }], + }, + navigate: vi.fn(), + subscribe: vi.fn(), + }; + (globalThis as any).__reactRouterDataRouter = mockRouter; + + mockPageloadSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; + mockNavigationSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; + + (core.getActiveSpan as any).mockReturnValue(mockPageloadSpan); + (core.getRootSpan as any).mockImplementation((span: any) => span); + (core.spanToJSON as any).mockImplementation((_span: any) => ({ + description: '/foo/bar', + op: 'pageload', + })); + (core.getClient as any).mockReturnValue({}); + (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan); + }); + + afterEach(() => { + (globalThis as any).__reactRouterDataRouter = originalRouter; + vi.clearAllMocks(); + }); + + it('subscribes to the router and patches navigate', () => { + instrumentHydratedRouter(); + expect(typeof mockRouter.navigate).toBe('function'); + expect(mockRouter.subscribe).toHaveBeenCalled(); + }); + + it('updates pageload transaction name if needed', () => { + instrumentHydratedRouter(); + expect(mockPageloadSpan.updateName).toHaveBeenCalled(); + expect(mockPageloadSpan.setAttribute).toHaveBeenCalled(); + }); + + it('creates navigation transaction on navigate', () => { + instrumentHydratedRouter(); + mockRouter.navigate('/bar'); + expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalled(); + }); + + it('updates navigation transaction on state change to idle', () => { + instrumentHydratedRouter(); + // Simulate a state change to idle + const callback = mockRouter.subscribe.mock.calls[0][0]; + const newState = { + location: { pathname: '/foo/bar' }, + matches: [{ route: { path: '/foo/:id' } }], + navigation: { state: 'idle' }, + }; + mockRouter.navigate('/foo/bar'); + // After navigation, the active span should be the navigation span + (core.getActiveSpan as any).mockReturnValue(mockNavigationSpan); + callback(newState); + expect(mockNavigationSpan.updateName).toHaveBeenCalled(); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalled(); + }); + + it('does not update navigation transaction on state change to loading', () => { + instrumentHydratedRouter(); + // Simulate a state change to loading (non-idle) + const callback = mockRouter.subscribe.mock.calls[0][0]; + const newState = { + location: { pathname: '/foo/bar' }, + matches: [{ route: { path: '/foo/:id' } }], + navigation: { state: 'loading' }, + }; + mockRouter.navigate('/foo/bar'); + // After navigation, the active span should be the navigation span + (core.getActiveSpan as any).mockReturnValue(mockNavigationSpan); + callback(newState); + expect(mockNavigationSpan.updateName).not.toHaveBeenCalled(); + expect(mockNavigationSpan.setAttribute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/test/client/sdk.test.ts b/packages/react-router/test/client/sdk.test.ts index 118ddd775217..d6767ccfff23 100644 --- a/packages/react-router/test/client/sdk.test.ts +++ b/packages/react-router/test/client/sdk.test.ts @@ -1,9 +1,12 @@ import * as SentryBrowser from '@sentry/browser'; import { getCurrentScope, getGlobalScope, getIsolationScope, SDK_VERSION } from '@sentry/browser'; +import * as SentryCore from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { init as reactRouterInit } from '../../src/client'; const browserInit = vi.spyOn(SentryBrowser, 'init'); +const setTag = vi.spyOn(SentryCore, 'setTag'); +const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); describe('React Router client SDK', () => { describe('init', () => { @@ -41,5 +44,20 @@ describe('React Router client SDK', () => { it('returns client from init', () => { expect(reactRouterInit({})).not.toBeUndefined(); }); + + it('sets the runtime tag to browser', () => { + reactRouterInit({}); + expect(setTag).toHaveBeenCalledWith('runtime', 'browser'); + }); + + it('warns if BrowserTracing integration is present', () => { + reactRouterInit({ + integrations: [{ name: 'BrowserTracing' }], + }); + + expect(consoleWarn).toHaveBeenCalledWith( + 'browserTracingIntegration is not fully compatible with @sentry/react-router. Please use reactRouterTracingIntegration instead.', + ); + }); }); }); diff --git a/packages/react-router/test/client/tracingIntegration.test.ts b/packages/react-router/test/client/tracingIntegration.test.ts new file mode 100644 index 000000000000..9d511b4c6bde --- /dev/null +++ b/packages/react-router/test/client/tracingIntegration.test.ts @@ -0,0 +1,30 @@ +import * as sentryBrowser from '@sentry/browser'; +import type { Client } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as hydratedRouterModule from '../../src/client/hydratedRouter'; +import { reactRouterTracingIntegration } from '../../src/client/tracingIntegration'; + +describe('reactRouterTracingIntegration', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns an integration with the correct name and properties', () => { + const integration = reactRouterTracingIntegration(); + expect(integration.name).toBe('ReactRouterTracingIntegration'); + expect(typeof integration.afterAllSetup).toBe('function'); + }); + + it('calls instrumentHydratedRouter and browserTracingIntegrationInstance.afterAllSetup in afterAllSetup', () => { + const browserTracingSpy = vi.spyOn(sentryBrowser, 'browserTracingIntegration').mockImplementation(() => ({ + afterAllSetup: vi.fn(), + name: 'BrowserTracing', + })); + const instrumentSpy = vi.spyOn(hydratedRouterModule, 'instrumentHydratedRouter').mockImplementation(() => null); + const integration = reactRouterTracingIntegration(); + integration.afterAllSetup?.({} as Client); + + expect(browserTracingSpy).toHaveBeenCalled(); + expect(instrumentSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 88d3701646f2..f01254ca8869 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeConfigInjectorPlugin } from '../../src/vite/makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; import { sentryReactRouter } from '../../src/vite/plugin'; @@ -12,57 +13,61 @@ vi.spyOn(console, 'warn').mockImplementation(() => { vi.mock('../../src/vite/makeCustomSentryVitePlugins'); vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); +vi.mock('../../src/vite/makeConfigInjectorPlugin'); describe('sentryReactRouter', () => { const mockPlugins = [{ name: 'test-plugin' }]; const mockSourceMapsPlugin = { name: 'source-maps-plugin' }; + const mockConfigInjectorPlugin = { name: 'sentry-config-injector' }; beforeEach(() => { vi.clearAllMocks(); vi.mocked(makeCustomSentryVitePlugins).mockResolvedValue(mockPlugins); vi.mocked(makeEnableSourceMapsPlugin).mockReturnValue(mockSourceMapsPlugin); + vi.mocked(makeConfigInjectorPlugin).mockReturnValue(mockConfigInjectorPlugin); }); afterEach(() => { vi.resetModules(); }); - it('should return an empty array in development mode', async () => { + it('should return sentry config injector plugin in development mode', async () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'development'; const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([]); + expect(result).toEqual([mockConfigInjectorPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); process.env.NODE_ENV = originalNodeEnv; }); - it('should return an empty array when not in build mode', async () => { + it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([]); + expect(result).toEqual([mockConfigInjectorPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); - it('should return an empty array when in development mode', async () => { + it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([]); + expect(result).toEqual([mockConfigInjectorPlugin]); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); - it('should return plugins in production build mode', async () => { + it('should return all plugins in production build mode', async () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockSourceMapsPlugin, ...mockPlugins]); + expect(result).toEqual([mockConfigInjectorPlugin, mockSourceMapsPlugin, ...mockPlugins]); + expect(makeConfigInjectorPlugin).toHaveBeenCalledWith({}); expect(makeCustomSentryVitePlugins).toHaveBeenCalledWith({}); expect(makeEnableSourceMapsPlugin).toHaveBeenCalledWith({}); @@ -81,6 +86,7 @@ describe('sentryReactRouter', () => { await sentryReactRouter(options, { command: 'build', mode: 'production' }); + expect(makeConfigInjectorPlugin).toHaveBeenCalledWith(options); expect(makeCustomSentryVitePlugins).toHaveBeenCalledWith(options); expect(makeEnableSourceMapsPlugin).toHaveBeenCalledWith(options); diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index 703b88679215..815575ede1f8 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -98,6 +98,8 @@ function _prepareXhrData( }; } + // ---- This additional network data below is only captured for URLs defined in `networkDetailAllowUrls` ---- + const xhrInfo = xhr[SENTRY_XHR_DATA_KEY]; const networkRequestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 89bcc7b6210c..1fb7cd0135ac 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -94,3 +94,5 @@ export { VercelEdgeClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { winterCGFetchIntegration } from './integrations/wintercg-fetch'; + +export * as logger from './logs/exports'; diff --git a/packages/vercel-edge/src/logs/exports.ts b/packages/vercel-edge/src/logs/exports.ts new file mode 100644 index 000000000000..ef2614b81f55 --- /dev/null +++ b/packages/vercel-edge/src/logs/exports.ts @@ -0,0 +1,205 @@ +import type { Log, LogSeverityLevel, ParameterizedString } from '@sentry/core'; +import { _INTERNAL_captureLog } from '@sentry/core'; + +/** + * Capture a log with the given level. + * + * @param level - The level of the log. + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + * @param severityNumber - The severity number of the log. + */ +function captureLog( + level: LogSeverityLevel, + message: ParameterizedString, + attributes?: Log['attributes'], + severityNumber?: Log['severityNumber'], +): void { + _INTERNAL_captureLog({ level, message, attributes, severityNumber }); +} + +/** + * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. + * + * @example + * + * ``` + * Sentry.logger.trace('User clicked submit button', { + * buttonId: 'submit-form', + * formId: 'user-profile', + * timestamp: Date.now() + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.trace(Sentry.logger.fmt`User ${user} navigated to ${page}`, { + * userId: '123', + * sessionId: 'abc-xyz' + * }); + * ``` + */ +export function trace(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('trace', message, attributes); +} + +/** + * @summary Capture a log with the `debug` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. + * + * @example + * + * ``` + * Sentry.logger.debug('Component mounted', { + * component: 'UserProfile', + * props: { userId: 123 }, + * renderTime: 150 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.debug(Sentry.logger.fmt`API request to ${endpoint} failed`, { + * statusCode: 404, + * requestId: 'req-123', + * duration: 250 + * }); + * ``` + */ +export function debug(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('debug', message, attributes); +} + +/** + * @summary Capture a log with the `info` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. + * + * @example + * + * ``` + * Sentry.logger.info('User completed checkout', { + * orderId: 'order-123', + * amount: 99.99, + * paymentMethod: 'credit_card' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.info(Sentry.logger.fmt`User ${user} updated profile picture`, { + * userId: 'user-123', + * imageSize: '2.5MB', + * timestamp: Date.now() + * }); + * ``` + */ +export function info(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('info', message, attributes); +} + +/** + * @summary Capture a log with the `warn` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. + * + * @example + * + * ``` + * Sentry.logger.warn('Browser compatibility issue detected', { + * browser: 'Safari', + * version: '14.0', + * feature: 'WebRTC', + * fallback: 'enabled' + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.warn(Sentry.logger.fmt`API endpoint ${endpoint} is deprecated`, { + * recommendedEndpoint: '/api/v2/users', + * sunsetDate: '2024-12-31', + * clientVersion: '1.2.3' + * }); + * ``` + */ +export function warn(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('warn', message, attributes); +} + +/** + * @summary Capture a log with the `error` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. + * + * @example + * + * ``` + * Sentry.logger.error('Failed to load user data', { + * error: 'NetworkError', + * url: '/api/users/123', + * statusCode: 500, + * retryCount: 3 + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.error(Sentry.logger.fmt`Payment processing failed for order ${orderId}`, { + * error: 'InsufficientFunds', + * amount: 100.00, + * currency: 'USD', + * userId: 'user-456' + * }); + * ``` + */ +export function error(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('error', message, attributes); +} + +/** + * @summary Capture a log with the `fatal` level. Requires `_experiments.enableLogs` to be enabled. + * + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. + * + * @example + * + * ``` + * Sentry.logger.fatal('Application state corrupted', { + * lastKnownState: 'authenticated', + * sessionId: 'session-123', + * timestamp: Date.now(), + * recoveryAttempted: true + * }); + * ``` + * + * @example With template strings + * + * ``` + * Sentry.logger.fatal(Sentry.logger.fmt`Critical system failure in ${service}`, { + * service: 'payment-processor', + * errorCode: 'CRITICAL_FAILURE', + * affectedUsers: 150, + * timestamp: Date.now() + * }); + * ``` + */ +export function fatal(message: ParameterizedString, attributes?: Log['attributes']): void { + captureLog('fatal', message, attributes); +} + +export { fmt } from '@sentry/core'; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 0ade5d42e635..bebce0935adf 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -59,6 +59,7 @@ export function getDefaultIntegrations(options: Options): Integration[] { linkedErrorsIntegration(), winterCGFetchIntegration(), consoleIntegration(), + // TODO(v10): integration can be included - but integration should not add IP address etc ...(options.sendDefaultPii ? [requestDataIntegration()] : []), ]; } diff --git a/yarn.lock b/yarn.lock index 20454807390f..938d89058a83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5860,10 +5860,10 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73" integrity sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw== -"@prisma/instrumentation@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.6.0.tgz#5b73164c722bcfcd29c43cb883b4735143b65eb2" - integrity sha512-M/a6njz3hbf2oucwdbjNKrSMLuyMCwgDrmTtkF1pm4Nm7CU45J/Hd6lauF2CDACTUYzu3ymcV7P0ZAhIoj6WRw== +"@prisma/instrumentation@6.7.0": + version "6.7.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.7.0.tgz#5fd97be1f89e9d9268148424a812deaea491f80a" + integrity sha512-3NuxWlbzYNevgPZbV0ktA2z6r0bfh0g22ONTxcK09a6+6MdIPjHsYx1Hnyu4yOq+j7LmupO5J69hhuOnuvj8oQ== dependencies: "@opentelemetry/instrumentation" "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" @@ -10168,7 +10168,7 @@ aws-ssl-profiles@^1.1.1: resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== -axios@1.8.2, axios@^1.0.0, axios@^1.7.7: +axios@1.8.2, axios@^1.0.0: version "1.8.2" resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==