diff --git a/.size-limit.js b/.size-limit.js index c6e86836fd4c..08adf5a80c29 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -54,7 +54,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '68 KB', + limit: '70 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); const TerserPlugin = require('terser-webpack-plugin'); @@ -210,7 +210,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '40 KB', + limit: '41 KB', }, // SvelteKit SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index d91d3da02552..bfdaee843a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,46 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.2.0 + +### Important Changes + +- **feat(node): Support Express v5 ([#15380](https://github.com/getsentry/sentry-javascript/pull/15380))** + +This release adds full tracing support for Express v5, and improves tracing support for Nest.js 11 (which uses Express v5) in the Nest.js SDK. + +- **feat(sveltekit): Add Support for Cloudflare ([#14672](https://github.com/getsentry/sentry-javascript/pull/14672))** + +This release adds support for deploying SvelteKit applications to Cloudflare Pages. +A docs update with updated instructions will follow shortly. +Until then, you can give this a try by setting up the SvelteKit SDK as usual and then following the instructions outlined in the PR. + +Thank you @SG60 for contributing this feature! + +### Other Changes + +- feat(core): Add `addLink(s)` to Sentry span ([#15452](https://github.com/getsentry/sentry-javascript/pull/15452)) +- feat(core): Add links to span options ([#15453](https://github.com/getsentry/sentry-javascript/pull/15453)) +- feat(deps): Bump @sentry/webpack-plugin from 2.22.7 to 3.1.2 ([#15328](https://github.com/getsentry/sentry-javascript/pull/15328)) +- feat(feedback): Disable Feedback submit & cancel buttons while submitting ([#15408](https://github.com/getsentry/sentry-javascript/pull/15408)) +- feat(nextjs): Add experimental flag to not strip origin information from different origin stack frames ([#15418](https://github.com/getsentry/sentry-javascript/pull/15418)) +- feat(nuxt): Add `enableNitroErrorHandler` to server options ([#15444](https://github.com/getsentry/sentry-javascript/pull/15444)) +- feat(opentelemetry): Add `addLink(s)` to span ([#15387](https://github.com/getsentry/sentry-javascript/pull/15387)) +- feat(opentelemetry): Add `links` to span options ([#15403](https://github.com/getsentry/sentry-javascript/pull/15403)) +- feat(replay): Expose rrweb recordCrossOriginIframes under \_experiments ([#14916](https://github.com/getsentry/sentry-javascript/pull/14916)) +- fix(browser): Ensure that `performance.measure` spans have a positive duration ([#15415](https://github.com/getsentry/sentry-javascript/pull/15415)) +- fix(bun): Includes correct sdk metadata ([#15459](https://github.com/getsentry/sentry-javascript/pull/15459)) +- fix(core): Add Google `gmo` error to Inbound Filters ([#15432](https://github.com/getsentry/sentry-javascript/pull/15432)) +- fix(core): Ensure `http.client` span descriptions don't contain query params or fragments ([#15404](https://github.com/getsentry/sentry-javascript/pull/15404)) +- fix(core): Filter out unactionable Facebook Mobile browser error ([#15430](https://github.com/getsentry/sentry-javascript/pull/15430)) +- fix(nestjs): Pin dependency on `@opentelemetry/instrumentation` ([#15419](https://github.com/getsentry/sentry-javascript/pull/15419)) +- fix(nuxt): Only use filename with file extension from command ([#15445](https://github.com/getsentry/sentry-javascript/pull/15445)) +- fix(nuxt): Use `SentryNuxtServerOptions` type for server init ([#15441](https://github.com/getsentry/sentry-javascript/pull/15441)) +- fix(sveltekit): Avoid loading vite config to determine source maps setting ([#15440](https://github.com/getsentry/sentry-javascript/pull/15440)) +- ref(profiling-node): Bump chunk interval to 60s ([#15361](https://github.com/getsentry/sentry-javascript/pull/15361)) + +Work in this release was contributed by @6farer, @dgavranic and @SG60. Thank you for your contributions! + ## 9.1.0 - feat(browser): Add `graphqlClientIntegration` ([#13783](https://github.com/getsentry/sentry-javascript/pull/13783)) diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/init.js new file mode 100644 index 000000000000..3ec6adbbdb75 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/subject.js new file mode 100644 index 000000000000..510fb07540ad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/subject.js @@ -0,0 +1,28 @@ +// REGULAR --- +const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' }); +rootSpan1.end(); + +Sentry.startSpan({ name: 'rootSpan2' }, rootSpan2 => { + rootSpan2.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); +}); + +// NESTED --- +Sentry.startSpan({ name: 'rootSpan3' }, async rootSpan3 => { + Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => { + childSpan1.addLink({ + context: rootSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan1.end(); + }); + + Sentry.startSpan({ name: 'childSpan3.2' }, async childSpan2 => { + childSpan2.addLink({ context: rootSpan3.spanContext() }); + + childSpan2.end(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/test.ts new file mode 100644 index 000000000000..9c556335651e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLink/test.ts @@ -0,0 +1,76 @@ +import { expect } from '@playwright/test'; +import type { SpanJSON, TransactionEvent } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers'; + +sentryTest('should link spans with addLink() in trace context', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const rootSpan1Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan1'); + const rootSpan2Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan2'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = envelopeRequestParser(await rootSpan1Promise); + const rootSpan2 = envelopeRequestParser(await rootSpan2Promise); + + const rootSpan1_traceId = rootSpan1.contexts?.trace?.trace_id as string; + const rootSpan1_spanId = rootSpan1.contexts?.trace?.span_id as string; + + expect(rootSpan1.transaction).toBe('rootSpan1'); + expect(rootSpan1.spans).toEqual([]); + + expect(rootSpan2.transaction).toBe('rootSpan2'); + expect(rootSpan2.spans).toEqual([]); + + expect(rootSpan2.contexts?.trace?.links?.length).toBe(1); + expect(rootSpan2.contexts?.trace?.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + span_id: rootSpan1_spanId, + trace_id: rootSpan1_traceId, + }); +}); + +sentryTest('should link spans with addLink() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const rootSpan1Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan1'); + const rootSpan3Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan3'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = envelopeRequestParser(await rootSpan1Promise); + const rootSpan3 = envelopeRequestParser(await rootSpan3Promise); + + const rootSpan1_traceId = rootSpan1.contexts?.trace?.trace_id as string; + const rootSpan1_spanId = rootSpan1.contexts?.trace?.span_id as string; + + const [childSpan_3_1, childSpan_3_2] = rootSpan3.spans as [SpanJSON, SpanJSON]; + const rootSpan3_traceId = rootSpan3.contexts?.trace?.trace_id as string; + const rootSpan3_spanId = rootSpan3.contexts?.trace?.span_id as string; + + expect(rootSpan3.transaction).toBe('rootSpan3'); + + expect(childSpan_3_1.description).toBe('childSpan3.1'); + expect(childSpan_3_1.links?.length).toBe(1); + expect(childSpan_3_1.links?.[0]).toMatchObject({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + span_id: rootSpan1_spanId, + trace_id: rootSpan1_traceId, + }); + + expect(childSpan_3_2.description).toBe('childSpan3.2'); + expect(childSpan_3_2.links?.[0]).toMatchObject({ + sampled: true, + span_id: rootSpan3_spanId, + trace_id: rootSpan3_traceId, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/init.js new file mode 100644 index 000000000000..3ec6adbbdb75 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/subject.js new file mode 100644 index 000000000000..af6c89848fd3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/subject.js @@ -0,0 +1,35 @@ +// REGULAR --- +const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' }); +rootSpan1.end(); + +const rootSpan2 = Sentry.startInactiveSpan({ name: 'rootSpan2' }); +rootSpan2.end(); + +Sentry.startSpan({ name: 'rootSpan3' }, rootSpan3 => { + rootSpan3.addLinks([ + { context: rootSpan1.spanContext() }, + { + context: rootSpan2.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ]); +}); + +// NESTED --- +Sentry.startSpan({ name: 'rootSpan4' }, async rootSpan4 => { + Sentry.startSpan({ name: 'childSpan4.1' }, async childSpan1 => { + Sentry.startSpan({ name: 'childSpan4.2' }, async childSpan2 => { + childSpan2.addLinks([ + { context: rootSpan4.spanContext() }, + { + context: rootSpan2.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ]); + + childSpan2.end(); + }); + + childSpan1.end(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/test.ts new file mode 100644 index 000000000000..529eae04ae03 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-addLinks/test.ts @@ -0,0 +1,93 @@ +import { expect } from '@playwright/test'; +import type { SpanJSON, TransactionEvent } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers'; + +sentryTest('should link spans with addLinks() in trace context', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const rootSpan1Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan1'); + const rootSpan2Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan2'); + const rootSpan3Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan3'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = envelopeRequestParser(await rootSpan1Promise); + const rootSpan2 = envelopeRequestParser(await rootSpan2Promise); + const rootSpan3 = envelopeRequestParser(await rootSpan3Promise); + + const rootSpan1_traceId = rootSpan1.contexts?.trace?.trace_id as string; + const rootSpan1_spanId = rootSpan1.contexts?.trace?.span_id as string; + + expect(rootSpan1.transaction).toBe('rootSpan1'); + expect(rootSpan1.spans).toEqual([]); + + const rootSpan2_traceId = rootSpan2.contexts?.trace?.trace_id as string; + const rootSpan2_spanId = rootSpan2.contexts?.trace?.span_id as string; + + expect(rootSpan2.transaction).toBe('rootSpan2'); + expect(rootSpan2.spans).toEqual([]); + + expect(rootSpan3.transaction).toBe('rootSpan3'); + expect(rootSpan3.spans).toEqual([]); + expect(rootSpan3.contexts?.trace?.links?.length).toBe(2); + expect(rootSpan3.contexts?.trace?.links).toEqual([ + { + sampled: true, + span_id: rootSpan1_spanId, + trace_id: rootSpan1_traceId, + }, + { + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + span_id: rootSpan2_spanId, + trace_id: rootSpan2_traceId, + }, + ]); +}); + +sentryTest('should link spans with addLinks() in nested startSpan() calls', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const rootSpan2Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan2'); + const rootSpan4Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan4'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan2 = envelopeRequestParser(await rootSpan2Promise); + const rootSpan4 = envelopeRequestParser(await rootSpan4Promise); + + const rootSpan2_traceId = rootSpan2.contexts?.trace?.trace_id as string; + const rootSpan2_spanId = rootSpan2.contexts?.trace?.span_id as string; + + const [childSpan_4_1, childSpan_4_2] = rootSpan4.spans as [SpanJSON, SpanJSON]; + const rootSpan4_traceId = rootSpan4.contexts?.trace?.trace_id as string; + const rootSpan4_spanId = rootSpan4.contexts?.trace?.span_id as string; + + expect(rootSpan4.transaction).toBe('rootSpan4'); + + expect(childSpan_4_1.description).toBe('childSpan4.1'); + expect(childSpan_4_1.links).toBe(undefined); + + expect(childSpan_4_2.description).toBe('childSpan4.2'); + expect(childSpan_4_2.links?.length).toBe(2); + expect(childSpan_4_2.links).toEqual([ + { + sampled: true, + span_id: rootSpan4_spanId, + trace_id: rootSpan4_traceId, + }, + { + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + span_id: rootSpan2_spanId, + trace_id: rootSpan2_traceId, + }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/init.js b/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/init.js new file mode 100644 index 000000000000..3ec6adbbdb75 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/subject.js b/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/subject.js new file mode 100644 index 000000000000..797ce3d98fa7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/subject.js @@ -0,0 +1,20 @@ +const rootSpan1 = Sentry.startInactiveSpan({ name: 'rootSpan1' }); +rootSpan1.end(); + +const rootSpan2 = Sentry.startInactiveSpan({ name: 'rootSpan2' }); +rootSpan2.end(); + +Sentry.startSpan( + { + name: 'rootSpan3', + links: [ + { context: rootSpan1.spanContext() }, + { context: rootSpan2.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }, + ], + }, + async () => { + Sentry.startSpan({ name: 'childSpan3.1' }, async childSpan1 => { + childSpan1.end(); + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/test.ts b/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/test.ts new file mode 100644 index 000000000000..c2a2ed02f0e6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/linking-spanOptions/test.ts @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test'; +import type { TransactionEvent } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers'; + +sentryTest('should link spans by adding "links" to span options', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const rootSpan1Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan1'); + const rootSpan2Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan2'); + const rootSpan3Promise = waitForTransactionRequest(page, event => event.transaction === 'rootSpan3'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const rootSpan1 = envelopeRequestParser(await rootSpan1Promise); + const rootSpan2 = envelopeRequestParser(await rootSpan2Promise); + const rootSpan3 = envelopeRequestParser(await rootSpan3Promise); + + const rootSpan1_traceId = rootSpan1.contexts?.trace?.trace_id as string; + const rootSpan1_spanId = rootSpan1.contexts?.trace?.span_id as string; + const rootSpan2_traceId = rootSpan2.contexts?.trace?.trace_id as string; + const rootSpan2_spanId = rootSpan2.contexts?.trace?.span_id as string; + + expect(rootSpan1.transaction).toBe('rootSpan1'); + expect(rootSpan1.spans).toEqual([]); + + expect(rootSpan3.transaction).toBe('rootSpan3'); + expect(rootSpan3.spans?.length).toBe(1); + expect(rootSpan3.spans?.[0].description).toBe('childSpan3.1'); + + expect(rootSpan3.contexts?.trace?.links?.length).toBe(2); + expect(rootSpan3.contexts?.trace?.links).toEqual([ + { + sampled: true, + span_id: rootSpan1_spanId, + trace_id: rootSpan1_traceId, + }, + { + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + span_id: rootSpan2_spanId, + trace_id: rootSpan2_traceId, + }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/init.js new file mode 100644 index 000000000000..de6b87574482 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/subject.js new file mode 100644 index 000000000000..37441bf4463a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/subject.js @@ -0,0 +1,19 @@ +function withRootSpan(cb) { + return Sentry.startSpan({ name: 'rootSpan' }, cb); +} + +document.getElementById('btnQuery').addEventListener('click', async () => { + await withRootSpan(() => fetch('http://sentry-test-site.example/0?id=123;page=5')); +}); + +document.getElementById('btnFragment').addEventListener('click', async () => { + await withRootSpan(() => fetch('http://sentry-test-site.example/1#fragment')); +}); + +document.getElementById('btnQueryFragment').addEventListener('click', async () => { + await withRootSpan(() => fetch('http://sentry-test-site.example/2?id=1#fragment')); +}); + +document.getElementById('btnQueryFragmentSameOrigin').addEventListener('click', async () => { + await withRootSpan(() => fetch('/api/users?id=1#fragment')); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/template.html b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/template.html new file mode 100644 index 000000000000..d02fa0868f56 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/test.ts new file mode 100644 index 000000000000..a0fea6e6af29 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-strip-query-and-fragment/test.ts @@ -0,0 +1,176 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('strips query params in fetch request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQuery').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/0', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/0?id=123;page=5', + 'http.query': '?id=123;page=5', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/0?id=123;page=5', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.fragment'); +}); + +sentryTest('strips hash fragment in fetch request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/1', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/1#fragment', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/1#fragment', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.query'); +}); + +sentryTest('strips hash fragment and query params in fetch request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/2', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/2?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/2?id=1#fragment', + }), + }); +}); + +sentryTest( + 'strips hash fragment and query params in same-origin fetch request spans', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('**/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragmentSameOrigin').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET /api/users', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test.io/api/users?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'http.response_content_length': 2, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'fetch', + 'server.address': 'sentry-test.io', + url: '/api/users?id=1#fragment', + }), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/init.js new file mode 100644 index 000000000000..de6b87574482 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/subject.js new file mode 100644 index 000000000000..e27c6d3cf013 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/subject.js @@ -0,0 +1,29 @@ +function withRootSpan(cb) { + return Sentry.startSpan({ name: 'rootSpan' }, cb); +} + +function makeXHRRequest(url) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.onload = () => resolve(xhr.responseText); + xhr.onerror = () => reject(xhr.statusText); + xhr.send(); + }); +} + +document.getElementById('btnQuery').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('http://sentry-test-site.example/0?id=123;page=5')); +}); + +document.getElementById('btnFragment').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('http://sentry-test-site.example/1#fragment')); +}); + +document.getElementById('btnQueryFragment').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('http://sentry-test-site.example/2?id=1#fragment')); +}); + +document.getElementById('btnQueryFragmentSameOrigin').addEventListener('click', async () => { + await withRootSpan(() => makeXHRRequest('/api/users?id=1#fragment')); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/template.html b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/template.html new file mode 100644 index 000000000000..533636f821c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/test.ts new file mode 100644 index 000000000000..d4ed06fcdd4e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-strip-query-and-fragment/test.ts @@ -0,0 +1,172 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('strips query params in XHR request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQuery').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/0', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/0?id=123;page=5', + 'http.query': '?id=123;page=5', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/0?id=123;page=5', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.fragment'); +}); + +sentryTest('strips hash fragment in XHR request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/1', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/1#fragment', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/1#fragment', + }), + }); + + expect(requestSpan?.data).not.toHaveProperty('http.query'); +}); + +sentryTest('strips hash fragment and query params in XHR request spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragment').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET http://sentry-test-site.example/2', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test-site.example/2?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test-site.example', + url: 'http://sentry-test-site.example/2?id=1#fragment', + }), + }); +}); + +sentryTest( + 'strips hash fragment and query params in same-origin XHR request spans', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('**/*', route => route.fulfill({ body: 'ok' })); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const txnPromise = waitForTransactionRequest(page); + await page.locator('#btnQueryFragmentSameOrigin').click(); + const transactionEvent = envelopeRequestParser(await txnPromise); + + expect(transactionEvent.transaction).toEqual('rootSpan'); + + const requestSpan = transactionEvent.spans?.find(({ op }) => op === 'http.client'); + + expect(requestSpan).toMatchObject({ + description: 'GET /api/users', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.url': 'http://sentry-test.io/api/users?id=1#fragment', + 'http.query': '?id=1', + 'http.fragment': '#fragment', + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + type: 'xhr', + 'server.address': 'sentry-test.io', + url: '/api/users?id=1#fragment', + }), + }); + }, +); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts index 0fa13fea32aa..a24d1010eca4 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts @@ -50,13 +50,11 @@ test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { }); const transactionEventPromise400 = waitForTransaction('nestjs-11', transactionEvent => { - // todo(express-5): parametrize /test-expected-400-exception/:id - return transactionEvent?.transaction === 'GET /test-expected-400-exception/123'; + return transactionEvent?.transaction === 'GET /test-expected-400-exception/:id'; }); const transactionEventPromise500 = waitForTransaction('nestjs-11', transactionEvent => { - // todo(express-5): parametrize /test-expected-500-exception/:id - return transactionEvent?.transaction === 'GET /test-expected-500-exception/123'; + return transactionEvent?.transaction === 'GET /test-expected-500-exception/:id'; }); const response400 = await fetch(`${baseURL}/test-expected-400-exception/123`); @@ -81,13 +79,11 @@ test('Does not send RpcExceptions to Sentry', async ({ baseURL }) => { errorEventOccurred = true; } - // todo(express-5): parametrize /test-expected-rpc-exception/:id - return event?.transaction === 'GET /test-expected-rpc-exception/123'; + return event?.transaction === 'GET /test-expected-rpc-exception/:id'; }); const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => { - // todo(express-5): parametrize /test-expected-rpc-exception/:id - return transactionEvent?.transaction === 'GET /test-expected-rpc-exception/123'; + return transactionEvent?.transaction === 'GET /test-expected-rpc-exception/:id'; }); const response = await fetch(`${baseURL}/test-expected-rpc-exception/123`); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts index 7e0947d53ec1..1209eae1ada9 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts @@ -15,7 +15,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: { - 'sentry.source': 'url', // todo(express-5): 'route' + 'sentry.source': 'route', 'sentry.origin': 'auto.http.otel.http', 'sentry.op': 'http.server', 'sentry.sample_rate': 1, @@ -37,7 +37,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'net.peer.port': expect.any(Number), 'http.status_code': 200, 'http.status_text': 'OK', - // 'http.route': '/test-transaction', // todo(express-5): add this line again + 'http.route': '/test-transaction', }, op: 'http.server', span_id: expect.stringMatching(/[a-f0-9]{16}/), @@ -49,7 +49,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { expect(transactionEvent).toEqual( expect.objectContaining({ spans: expect.arrayContaining([ - /* todo(express-5): add this part again { data: { 'express.name': '/test-transaction', @@ -67,7 +66,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), origin: 'auto.http.otel.express', - }, */ + }, { data: { 'sentry.origin': 'manual', @@ -117,7 +116,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { transaction: 'GET /test-transaction', type: 'transaction', transaction_info: { - source: 'url', // todo(express-5): 'route' + source: 'route', }, }), ); @@ -272,8 +271,7 @@ test('API route transaction includes nest pipe span for valid request', async ({ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - // todo(express-5): parametrize test-pipe-instrumentation/:id - transactionEvent?.transaction === 'GET /test-pipe-instrumentation/123' && + transactionEvent?.transaction === 'GET /test-pipe-instrumentation/:id' && transactionEvent?.request?.url?.includes('/test-pipe-instrumentation/123') ); }); @@ -310,8 +308,7 @@ test('API route transaction includes nest pipe span for invalid request', async const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && - // todo(express-5): parametrize test-pipe-instrumentation/:id - transactionEvent?.transaction === 'GET /test-pipe-instrumentation/abc' && + transactionEvent?.transaction === 'GET /test-pipe-instrumentation/:id' && transactionEvent?.request?.url?.includes('/test-pipe-instrumentation/abc') ); }); diff --git a/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json b/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json index b633df2df172..78536d6794ad 100644 --- a/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json +++ b/dev-packages/e2e-tests/test-applications/node-profiling-esm/package.json @@ -13,7 +13,7 @@ "@playwright/test": "~1.50.0", "@sentry/node": "latest || *", "@sentry/profiling-node": "latest || *", - "esbuild": "0.20.0", + "esbuild": "0.25.0", "typescript": "^5.7.3" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.server.config.ts index 729b2296c683..e04331934f99 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.server.config.ts @@ -5,4 +5,5 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server + enableNitroErrorHandler: false, // Error handler is defined in server/plugins/customNitroErrorHandler.ts }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts new file mode 100644 index 000000000000..2d9258936169 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts @@ -0,0 +1,85 @@ +import { Context, GLOBAL_OBJ, dropUndefinedKeys, flush, logger, vercelWaitUntil } from '@sentry/core'; +import * as SentryNode from '@sentry/node'; +import { H3Error } from 'h3'; +import type { CapturedErrorContext } from 'nitropack'; +import { defineNitroPlugin } from '#imports'; + +// Copy from SDK-internal error handler (nuxt/src/runtime/plugins/sentry.server.ts) +export default defineNitroPlugin(nitroApp => { + nitroApp.hooks.hook('error', async (error, errorContext) => { + // Do not handle 404 and 422 + if (error instanceof H3Error) { + // Do not report if status code is 3xx or 4xx + if (error.statusCode >= 300 && error.statusCode < 500) { + return; + } + } + + const { method, path } = { + method: errorContext.event?._method ? errorContext.event._method : '', + path: errorContext.event?._path ? errorContext.event._path : null, + }; + + if (path) { + SentryNode.getCurrentScope().setTransactionName(`${method} ${path}`); + } + + const structuredContext = extractErrorContext(errorContext); + + SentryNode.captureException(error, { + captureContext: { contexts: { nuxt: structuredContext } }, + mechanism: { handled: false }, + }); + + await flushIfServerless(); + }); +}); + +function extractErrorContext(errorContext: CapturedErrorContext): Context { + const structuredContext: Context = { + method: undefined, + path: undefined, + tags: undefined, + }; + + if (errorContext) { + if (errorContext.event) { + structuredContext.method = errorContext.event._method || undefined; + structuredContext.path = errorContext.event._path || undefined; + } + + if (Array.isArray(errorContext.tags)) { + structuredContext.tags = errorContext.tags || undefined; + } + } + + return dropUndefinedKeys(structuredContext); +} + +async function flushIfServerless(): Promise { + const isServerless = + !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions + !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda + !!process.env.VERCEL || + !!process.env.NETLIFY; + + // @ts-expect-error This is not typed + if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { + vercelWaitUntil(flushWithTimeout()); + } else if (isServerless) { + await flushWithTimeout(); + } +} + +async function flushWithTimeout(): Promise { + const sentryClient = SentryNode.getClient(); + const isDebug = sentryClient ? sentryClient.getOptions().debug : false; + + try { + isDebug && logger.log('Flushing events...'); + await flush(2000); + isDebug && logger.log('Done flushing events'); + } catch (e) { + isDebug && logger.log('Error while flushing events:\n', e); + } +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/.gitignore b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/.gitignore new file mode 100644 index 000000000000..bff793d5eae7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/.gitignore @@ -0,0 +1,24 @@ +test-results +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/.npmrc b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/README.md b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/README.md new file mode 100644 index 000000000000..b5b295070b44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```bash +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json new file mode 100644 index 000000000000..51fe00136f06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json @@ -0,0 +1,31 @@ +{ + "name": "sveltekit-cloudflare-pages", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "wrangler pages dev ./.svelte-kit/cloudflare --port 4173", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:e2e": "playwright test", + "test": "pnpm run test:e2e", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm run test:e2e" + }, + "dependencies": { + "@sentry/sveltekit": "latest || *" + }, + "devDependencies": { + "@playwright/test": "^1.45.3", + "@sveltejs/adapter-cloudflare": "^5.0.3", + "@sveltejs/kit": "^2.17.2", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "svelte": "^5.20.2", + "svelte-check": "^4.1.4", + "typescript": "^5.0.0", + "vite": "^6.1.1", + "wrangler": "3.105.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/playwright.config.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/playwright.config.ts new file mode 100644 index 000000000000..18bda456025e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + webServer: { + command: 'pnpm run build && pnpm run preview', + port: 4173, + }, + + testDir: 'tests', +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/app.d.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/app.d.ts new file mode 100644 index 000000000000..520c4217a10c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/app.html b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/app.html new file mode 100644 index 000000000000..77a5ff52c923 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.client.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.client.ts new file mode 100644 index 000000000000..4dc12acebc45 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.client.ts @@ -0,0 +1,8 @@ +import { env } from '$env/dynamic/public'; +import * as Sentry from '@sentry/sveltekit'; + +Sentry.init({ + dsn: env.PUBLIC_E2E_TEST_DSN, +}); + +export const handleError = Sentry.handleErrorWithSentry(); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts new file mode 100644 index 000000000000..d5067459d565 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts @@ -0,0 +1,13 @@ +import { E2E_TEST_DSN } from '$env/static/private'; +import { handleErrorWithSentry, initCloudflareSentryHandle, sentryHandle } from '@sentry/sveltekit'; +import { sequence } from '@sveltejs/kit/hooks'; + +export const handleError = handleErrorWithSentry(); + +export const handle = sequence( + initCloudflareSentryHandle({ + dsn: E2E_TEST_DSN, + tracesSampleRate: 1.0, + }), + sentryHandle(), +); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/+page.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/+page.server.ts new file mode 100644 index 000000000000..3cbde33753a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async function load() { + return { + message: 'From server load function.', + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/+page.svelte new file mode 100644 index 000000000000..e17881ceaca9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/routes/+page.svelte @@ -0,0 +1,10 @@ + + +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

+ +prerender test + +

{data.message}

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/static/favicon.png b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/static/favicon.png new file mode 100644 index 000000000000..825b9e65af7c Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/static/favicon.png differ diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/svelte.config.js b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/svelte.config.js new file mode 100644 index 000000000000..4e0314356cde --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-cloudflare'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + prerender: { + handleHttpError: 'ignore', + }, + }, +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/demo.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/demo.test.ts new file mode 100644 index 000000000000..a67e4e7f299d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tests/demo.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('home page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tsconfig.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tsconfig.json new file mode 100644 index 000000000000..0b2d8865f4ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/vite.config.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/vite.config.ts new file mode 100644 index 000000000000..706faf25f2b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/vite.config.ts @@ -0,0 +1,7 @@ +import { sentrySvelteKit } from '@sentry/sveltekit'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sentrySvelteKit({ autoUploadSourceMaps: false }), sveltekit()], +}); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/wrangler.toml b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/wrangler.toml new file mode 100644 index 000000000000..d31d2fc7f225 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/wrangler.toml @@ -0,0 +1,2 @@ +compatibility_date = "2024-12-17" +compatibility_flags = ["nodejs_compat"] diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 9798b76eae25..bbb7e300ecee 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -16,13 +16,13 @@ "build:types": "tsc -p tsconfig.types.json", "clean": "rimraf -g **/node_modules && run-p clean:script", "clean:script": "node scripts/clean.js", - "prisma-v5:init": "cd suites/tracing/prisma-orm-v5 && yarn && yarn setup", - "prisma-v6:init": "cd suites/tracing/prisma-orm-v6 && yarn && yarn setup", + "express-v5-install": "cd suites/express-v5 && yarn --no-lockfile", "lint": "eslint . --format stylish", "fix": "eslint . --format stylish --fix", "type-check": "tsc", - "pretest": "run-s --silent prisma-v5:init prisma-v6:init", + "pretest": "yarn express-v5-install", "test": "jest --config ./jest.config.js", + "test:no-prisma": "jest --config ./jest.config.js", "test:watch": "yarn test --watch" }, "dependencies": { diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts new file mode 100644 index 000000000000..079d9834b01c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts @@ -0,0 +1,31 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +Sentry.setTag('global', 'tag'); + +app.get('/test/withScope', () => { + Sentry.withScope(scope => { + scope.setTag('local', 'tag'); + throw new Error('test_error'); + }); +}); + +app.get('/test/isolationScope', () => { + Sentry.getIsolationScope().setTag('isolation-scope', 'tag'); + throw new Error('isolation_test_error'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts new file mode 100644 index 000000000000..58d4a299174c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts @@ -0,0 +1,85 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +/** + * Why does this test exist? + * + * We recently discovered that errors caught by global handlers will potentially loose scope data from the active scope + * where the error was originally thrown in. The simple example in this test (see subject.ts) demonstrates this behavior + * (in a Node environment but the same behavior applies to the browser; see the test there). + * + * This test nevertheless covers the behavior so that we're aware. + */ +test('withScope scope is NOT applied to thrown error caught by global handler', done => { + createRunner(__dirname, 'server.ts') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'test_error', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + // 'local' tag is not applied to the event + tags: expect.not.objectContaining({ local: expect.anything() }), + }, + }) + .start(done) + .makeRequest('get', '/test/withScope', { expectError: true }); +}); + +/** + * This test shows that the isolation scope set tags are applied correctly to the error. + */ +test('isolation scope is applied to thrown error caught by global handler', done => { + createRunner(__dirname, 'server.ts') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'isolation_test_error', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + tags: { + global: 'tag', + 'isolation-scope': 'tag', + }, + }, + }) + .start(done) + .makeRequest('get', '/test/isolationScope', { expectError: true }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/server.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/server.ts new file mode 100644 index 000000000000..3f52580dda1d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/server.ts @@ -0,0 +1,22 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + tracesSampleRate: 1, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test/express/:id', req => { + throw new Error(`test_error with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/test.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/test.ts new file mode 100644 index 000000000000..3ad6a3d2068f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/test.ts @@ -0,0 +1,38 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture and send Express controller error with txn name if tracesSampleRate is 0', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'test_error with id 123', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + transaction: 'GET /test/express/:id', + }, + }) + .start(done) + .makeRequest('get', '/test/express/123', { expectError: true }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/server.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/server.ts new file mode 100644 index 000000000000..38833d7a9bc7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/server.ts @@ -0,0 +1,21 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test/express/:id', req => { + throw new Error(`test_error with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/test.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/test.ts new file mode 100644 index 000000000000..b02d74016ad4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/test.ts @@ -0,0 +1,37 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture and send Express controller error if tracesSampleRate is not set.', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + mechanism: { + type: 'middleware', + handled: false, + }, + type: 'Error', + value: 'test_error with id 123', + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }, + }, + ], + }, + }, + }) + .start(done) + .makeRequest('get', '/test/express/123', { expectError: true }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-init/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-init/server.ts new file mode 100644 index 000000000000..39d56710f043 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-init/server.ts @@ -0,0 +1,74 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + // No dsn, means client is disabled + // dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +// We add http integration to ensure request isolation etc. works +const initialClient = Sentry.getClient(); +initialClient?.addIntegration(Sentry.httpIntegration()); + +// Store this so we can update the client later +const initialCurrentScope = Sentry.getCurrentScope(); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +Sentry.setTag('global', 'tag'); + +app.get('/test/no-init', (_req, res) => { + Sentry.addBreadcrumb({ message: 'no init breadcrumb' }); + Sentry.setTag('no-init', 'tag'); + + res.send({}); +}); + +app.get('/test/init', (_req, res) => { + // Call init again, but with DSN + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + }); + // Set this on initial scope, to ensure it can be inherited + initialCurrentScope.setClient(Sentry.getClient()!); + + Sentry.addBreadcrumb({ message: 'init breadcrumb' }); + Sentry.setTag('init', 'tag'); + + res.send({}); +}); + +app.get('/test/error/:id', (req, res) => { + const id = req.params.id; + Sentry.addBreadcrumb({ message: `error breadcrumb ${id}` }); + Sentry.setTag('error', id); + + Sentry.captureException(new Error(`This is an exception ${id}`)); + + setTimeout(() => { + // We flush to ensure we are sending exceptions in a certain order + Sentry.flush(1000).then( + () => { + // We send this so we can wait for this, to know the test is ended & server can be closed + if (id === '3') { + Sentry.captureException(new Error('Final exception was captured')); + } + res.send({}); + }, + () => { + res.send({}); + }, + ); + }, 1); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-init/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-init/test.ts new file mode 100644 index 000000000000..b80669a7c432 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-init/test.ts @@ -0,0 +1,70 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('allows to call init multiple times', done => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + exception: { + values: [ + { + value: 'This is an exception 2', + }, + ], + }, + breadcrumbs: [ + { + message: 'error breadcrumb 2', + timestamp: expect.any(Number), + }, + ], + tags: { + global: 'tag', + error: '2', + }, + }, + }) + .expect({ + event: { + exception: { + values: [ + { + value: 'This is an exception 3', + }, + ], + }, + breadcrumbs: [ + { + message: 'error breadcrumb 3', + timestamp: expect.any(Number), + }, + ], + tags: { + global: 'tag', + error: '3', + }, + }, + }) + .expect({ + event: { + exception: { + values: [ + { + value: 'Final exception was captured', + }, + ], + }, + }, + }) + .start(done); + + runner + .makeRequest('get', '/test/no-init') + .then(() => runner.makeRequest('get', '/test/error/1')) + .then(() => runner.makeRequest('get', '/test/init')) + .then(() => runner.makeRequest('get', '/test/error/2')) + .then(() => runner.makeRequest('get', '/test/error/3')); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/server.ts new file mode 100644 index 000000000000..673c146e9d8c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +const APIv1 = express.Router(); + +APIv1.get('/user/:userId', function (_req, res) { + Sentry.captureMessage('Custom Message'); + res.send('Success'); +}); + +const root = express.Router(); + +app.use('/api2/v1', root); +app.use('/api/v1', APIv1); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/test.ts new file mode 100644 index 000000000000..e7b9edabbfc8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/test.ts @@ -0,0 +1,13 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should construct correct url with common infixes with multiple parameterized routers.', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/user/:userId' } }) + .start(done) + .makeRequest('get', '/api/v1/user/3212'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/server.ts new file mode 100644 index 000000000000..24073af67fa4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/server.ts @@ -0,0 +1,34 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +const APIv1 = express.Router(); + +APIv1.get('/test', function (_req, res) { + Sentry.captureMessage('Custom Message'); + res.send('Success'); +}); + +const root = express.Router(); + +app.use('/api/v1', root); +app.use('/api2/v1', APIv1); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/test.ts new file mode 100644 index 000000000000..52d6b631bea2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/test.ts @@ -0,0 +1,13 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should construct correct url with common infixes with multiple routers.', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ event: { message: 'Custom Message', transaction: 'GET /api2/v1/test' } }) + .start(done) + .makeRequest('get', '/api2/v1/test'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/server.ts new file mode 100644 index 000000000000..755a32bf4389 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +const APIv1 = express.Router(); + +APIv1.get('/user/:userId', function (_req, res) { + Sentry.captureMessage('Custom Message'); + res.send('Success'); +}); + +const root = express.Router(); + +app.use('/api/v1', APIv1); +app.use('/api', root); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/test.ts new file mode 100644 index 000000000000..5fabe5b92df6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/test.ts @@ -0,0 +1,13 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should construct correct urls with multiple parameterized routers (use order reversed).', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/user/:userId' } }) + .start(done) + .makeRequest('get', '/api/v1/user/1234/'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/server.ts new file mode 100644 index 000000000000..7db74e8e3dea --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +const APIv1 = express.Router(); + +APIv1.get('/user/:userId', function (_req, res) { + Sentry.captureMessage('Custom Message'); + res.send('Success'); +}); + +const root = express.Router(); + +app.use('/api', root); +app.use('/api/v1', APIv1); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/test.ts new file mode 100644 index 000000000000..bab934f54522 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/test.ts @@ -0,0 +1,13 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should construct correct urls with multiple parameterized routers.', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/user/:userId' } }) + .start(done) + .makeRequest('get', '/api/v1/user/1234/'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/server.ts new file mode 100644 index 000000000000..654afa3b8c8d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +const APIv1 = express.Router(); + +APIv1.get('/:userId', function (_req, res) { + Sentry.captureMessage('Custom Message'); + res.send('Success'); +}); + +const root = express.Router(); + +app.use('/api/v1', APIv1); +app.use('/api', root); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/test.ts new file mode 100644 index 000000000000..94d363f4faa4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/test.ts @@ -0,0 +1,13 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should construct correct url with multiple parameterized routers of the same length (use order reversed).', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/:userId' } }) + .start(done) + .makeRequest('get', '/api/v1/1234/'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/server.ts new file mode 100644 index 000000000000..017c810ed842 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +const APIv1 = express.Router(); + +APIv1.get('/:userId', function (_req, res) { + Sentry.captureMessage('Custom Message'); + res.send('Success'); +}); + +const root = express.Router(); + +app.use('/api', root); +app.use('/api/v1', APIv1); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/test.ts new file mode 100644 index 000000000000..373b2c102c4c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/test.ts @@ -0,0 +1,13 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should construct correct url with multiple parameterized routers of the same length.', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/:userId' } }) + .start(done) + .makeRequest('get', '/api/v1/1234/'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/server.ts new file mode 100644 index 000000000000..497cbf2efffb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +app.use(cors()); + +const APIv1 = express.Router(); + +APIv1.get('/test', function (_req, res) { + Sentry.captureMessage('Custom Message'); + res.send('Success'); +}); + +const root = express.Router(); + +app.use('/api', root); +app.use('/api/v1', APIv1); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/test.ts new file mode 100644 index 000000000000..ea217bf6bc05 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/test.ts @@ -0,0 +1,13 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should construct correct urls with multiple routers.', done => { + createRunner(__dirname, 'server.ts') + .ignore('transaction') + .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/test' } }) + .start(done) + .makeRequest('get', '/api/v1/test'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/server.ts new file mode 100644 index 000000000000..b7ffeeba937a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +const APIv1 = express.Router(); + +APIv1.use( + '/users/:userId', + APIv1.get('/posts/:postId', (_req, res) => { + Sentry.captureMessage('Custom Message'); + return res.send('Success'); + }), +); + +const router = express.Router(); + +app.use('/api', router); +app.use('/api/api/v1', APIv1.use('/sub-router', APIv1)); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/test.ts new file mode 100644 index 000000000000..fe065d0dc550 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/test.ts @@ -0,0 +1,52 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('complex-router', () => { + test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route and express used multiple middlewares with route', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /api/api/v1/sub-router/users/:userId/posts/:postId', + transaction_info: { + source: 'route', + }, + }; + + createRunner(__dirname, 'server.ts') + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION as any }) + .start(done) + .makeRequest('get', '/api/api/v1/sub-router/users/123/posts/456'); + }); + + test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route and express used multiple middlewares with route and original url has query params', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /api/api/v1/sub-router/users/:userId/posts/:postId', + transaction_info: { + source: 'route', + }, + }; + + createRunner(__dirname, 'server.ts') + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION as any }) + .start(done) + .makeRequest('get', '/api/api/v1/sub-router/users/123/posts/456?param=1'); + }); + + test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route and express used multiple middlewares with route and original url ends with trailing slash and has query params', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /api/api/v1/sub-router/users/:userId/posts/:postId', + transaction_info: { + source: 'route', + }, + }; + + createRunner(__dirname, 'server.ts') + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION as any }) + .start(done) + .makeRequest('get', '/api/api/v1/sub-router/users/123/posts/456/?param=1'); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/server.ts new file mode 100644 index 000000000000..12a00ce4e1db --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +const APIv1 = express.Router(); + +APIv1.use( + '/users/:userId', + APIv1.get('/posts/:postId', (_req, res) => { + Sentry.captureMessage('Custom Message'); + return res.send('Success'); + }), +); + +const root = express.Router(); + +app.use('/api/v1', APIv1); +app.use('/api', root); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/test.ts new file mode 100644 index 000000000000..52a6ce154684 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/test.ts @@ -0,0 +1,23 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +// Before Node 16, parametrization is not working properly here +describe('middle-layer-parameterized', () => { + test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route', done => { + const EXPECTED_TRANSACTION = { + transaction: 'GET /api/v1/users/:userId/posts/:postId', + transaction_info: { + source: 'route', + }, + }; + + createRunner(__dirname, 'server.ts') + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION as any }) + .start(done) + .makeRequest('get', '/api/v1/users/123/posts/456'); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/package.json b/dev-packages/node-integration-tests/suites/express-v5/package.json new file mode 100644 index 000000000000..b3855635c556 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/package.json @@ -0,0 +1,6 @@ +{ + "name": "express-v5", + "dependencies": { + "express": "^5.0.0" + } +} diff --git a/dev-packages/node-integration-tests/suites/express-v5/requestUser/server.js b/dev-packages/node-integration-tests/suites/express-v5/requestUser/server.js new file mode 100644 index 000000000000..d93d22905506 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/requestUser/server.js @@ -0,0 +1,49 @@ +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', + transport: loggingTransport, + debug: true, +}); + +// 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.use((req, _res, next) => { + // We simulate this, which would in other cases be done by some middleware + req.user = { + id: '1', + email: 'test@sentry.io', + }; + + next(); +}); + +app.get('/test1', (_req, _res) => { + throw new Error('error_1'); +}); + +app.use((_req, _res, next) => { + Sentry.setUser({ + id: '2', + email: 'test2@sentry.io', + }); + + next(); +}); + +app.get('/test2', (_req, _res) => { + throw new Error('error_2'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/requestUser/test.ts b/dev-packages/node-integration-tests/suites/express-v5/requestUser/test.ts new file mode 100644 index 000000000000..2a9fc58a7c18 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/requestUser/test.ts @@ -0,0 +1,42 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('express user handling', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('ignores user from request', done => { + expect.assertions(2); + + createRunner(__dirname, 'server.js') + .expect({ + event: event => { + expect(event.user).toBeUndefined(); + expect(event.exception?.values?.[0]?.value).toBe('error_1'); + }, + }) + .start(done) + .makeRequest('get', '/test1', { expectError: true }); + }); + + test('using setUser in middleware works', done => { + createRunner(__dirname, 'server.js') + .expect({ + event: { + user: { + id: '2', + email: 'test2@sentry.io', + }, + exception: { + values: [ + { + value: 'error_2', + }, + ], + }, + }, + }) + .start(done) + .makeRequest('get', '/test2', { expectError: true }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-assign/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-assign/test.ts new file mode 100644 index 000000000000..513cf6146d0f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-assign/test.ts @@ -0,0 +1,147 @@ +import { parseBaggageHeader } from '@sentry/core'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from '../server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('Should overwrite baggage if the incoming request already has Sentry baggage data but no sentry-trace', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + baggage: 'sentry-release=2.0.0,sentry-environment=myEnv', + }, + }); + + expect(response).toBeDefined(); + expect(response).not.toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + baggage: 'sentry-release=2.0.0,sentry-environment=myEnv', + }, + }); +}); + +test('Should propagate sentry trace baggage data from an incoming to an outgoing request.', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,dogs=great,sentry-sample_rand=0.42', + }, + }); + + expect(response).toBeDefined(); + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,sentry-sample_rand=0.42', + }, + }); +}); + +test('Should not propagate baggage data from an incoming to an outgoing request if sentry-trace is faulty.', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '', + baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,dogs=great', + }, + }); + + expect(response).toBeDefined(); + expect(response).not.toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + baggage: 'sentry-release=2.0.0,sentry-environment=myEnv', + }, + }); +}); + +test('Should not propagate baggage if sentry-trace header is present in incoming request but no baggage header', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + }, + }); + + expect(response).toBeDefined(); + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + }, + }); +}); + +test('Should not propagate baggage and ignore original 3rd party baggage entries if sentry-trace header is present', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'foo=bar', + }, + }); + + expect(response).toBeDefined(); + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + }, + }); +}); + +test('Should populate and propagate sentry baggage if sentry-trace header does not exist', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + + expect(response).toBeDefined(); + + const parsedBaggage = parseBaggageHeader(response?.test_data.baggage); + + expect(response?.test_data.host).toBe('somewhere.not.sentry'); + expect(parsedBaggage).toStrictEqual({ + 'sentry-environment': 'prod', + 'sentry-release': '1.0', + 'sentry-public_key': 'public', + // TraceId changes, hence we only expect that the string contains the traceid key + 'sentry-trace_id': expect.stringMatching(/[\S]*/), + 'sentry-sample_rand': expect.stringMatching(/[\S]*/), + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-transaction': 'GET /test/express', + }); +}); + +test('Should populate Sentry and ignore 3rd party content if sentry-trace header does not exist', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + baggage: 'foo=bar,bar=baz', + }, + }); + + expect(response).toBeDefined(); + expect(response?.test_data.host).toBe('somewhere.not.sentry'); + + const parsedBaggage = parseBaggageHeader(response?.test_data.baggage); + expect(parsedBaggage).toStrictEqual({ + 'sentry-environment': 'prod', + 'sentry-release': '1.0', + 'sentry-public_key': 'public', + // TraceId changes, hence we only expect that the string contains the traceid key + 'sentry-trace_id': expect.stringMatching(/[\S]*/), + 'sentry-sample_rand': expect.stringMatching(/[\S]*/), + 'sentry-sample_rate': '1', + 'sentry-sampled': 'true', + 'sentry-transaction': 'GET /test/express', + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/server.ts new file mode 100644 index 000000000000..07c21c8d21ea --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/server.ts @@ -0,0 +1,38 @@ +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracePropagationTargets: [/^(?!.*express).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import http from 'http'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +Sentry.setUser({ id: 'user123' }); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const span = Sentry.getActiveSpan(); + const traceId = span?.spanContext().traceId; + const headers = http.get('http://somewhere.not.sentry/').getHeaders(); + if (traceId) { + headers['baggage'] = (headers['baggage'] as string).replace(traceId, '__SENTRY_TRACE_ID__'); + } + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/test.ts new file mode 100644 index 000000000000..72b6a7139f35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/test.ts @@ -0,0 +1,35 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from './server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should attach a baggage header to an outgoing request.', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage?.split(','); + + [ + 'sentry-environment=prod', + 'sentry-public_key=public', + 'sentry-release=1.0', + 'sentry-sample_rate=1', + 'sentry-sampled=true', + 'sentry-trace_id=__SENTRY_TRACE_ID__', + 'sentry-transaction=GET%20%2Ftest%2Fexpress', + expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), + ].forEach(item => { + expect(baggage).toContainEqual(item); + }); + + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + }, + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/server.ts new file mode 100644 index 000000000000..260fb34af5c2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/server.ts @@ -0,0 +1,43 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + // disable requests to /express + tracePropagationTargets: [/^(?!.*express).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import * as http from 'http'; +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/express', (_req, res) => { + // simulate setting a "third party" baggage header which the Sentry SDK should merge with Sentry DSC entries + const headers = http + .get({ + hostname: 'somewhere.not.sentry', + headers: { + baggage: + 'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item', + }, + }) + .getHeaders(); + + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts new file mode 100644 index 000000000000..ebf2a15bedf4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts @@ -0,0 +1,69 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from '../server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with incoming DSC', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-release=2.1.0,sentry-environment=myEnv', + }, + }); + + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage?.split(',').sort(); + + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + }, + }); + + expect(baggage).toEqual([ + 'foo=bar', + 'last=item', + 'other=vendor', + 'sentry-environment=myEnv', + 'sentry-release=2.1.0', + expect.stringMatching(/sentry-sample_rand=[0-9]+/), + 'sentry-sample_rate=0.54', + 'third=party', + ]); +}); + +test('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with new DSC', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + + expect(response).toBeDefined(); + + const baggage = response?.test_data.baggage?.split(',').sort(); + + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + }, + }); + + expect(baggage).toEqual([ + 'foo=bar', + 'last=item', + 'other=vendor', + 'sentry-environment=prod', + 'sentry-public_key=public', + 'sentry-release=1.0', + expect.stringMatching(/sentry-sample_rand=[0-9]+/), + 'sentry-sample_rate=1', + 'sentry-sampled=true', + expect.stringMatching(/sentry-trace_id=[0-9a-f]{32}/), + 'sentry-transaction=GET%20%2Ftest%2Fexpress', + 'third=party', + ]); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/server.ts new file mode 100644 index 000000000000..1c00fbd72bde --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/server.ts @@ -0,0 +1,37 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + // disable requests to /express + tracePropagationTargets: [/^(?!.*express).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import http from 'http'; +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/express', (_req, res) => { + // simulate setting a "third party" baggage header which the Sentry SDK should merge with Sentry DSC entries + const headers = http + .get({ hostname: 'somewhere.not.sentry', headers: { baggage: 'other=vendor,foo=bar,third=party' } }) + .getHeaders(); + + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/test.ts new file mode 100644 index 000000000000..0beecb54a905 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/test.ts @@ -0,0 +1,25 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from './server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should merge `baggage` header of a third party vendor with the Sentry DSC baggage items', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,sentry-sample_rand=0.42', + }, + }); + + expect(response).toBeDefined(); + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + baggage: 'other=vendor,foo=bar,third=party,sentry-release=2.0.0,sentry-environment=myEnv,sentry-sample_rand=0.42', + }, + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/server.ts new file mode 100644 index 000000000000..80bb7b38a39a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/server.ts @@ -0,0 +1,37 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + // disable requests to /express + tracePropagationTargets: [/^(?!.*express).*$/], + tracesSampleRate: 1.0, + // TODO: We're rethinking the mechanism for including Pii data in DSC, hence commenting out sendDefaultPii for now + // sendDefaultPii: true, + transport: loggingTransport, +}); + +import http from 'http'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import cors from 'cors'; +import express from 'express'; + +const app = express(); + +Sentry.setUser({ id: 'user123' }); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + const headers = http.get('http://somewhere.not.sentry/').getHeaders(); + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/test.ts new file mode 100644 index 000000000000..1001d0839aea --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/test.ts @@ -0,0 +1,20 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from '../server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('Includes transaction in baggage if the transaction name is parameterized', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + + expect(response).toBeDefined(); + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + baggage: expect.stringContaining('sentry-transaction=GET%20%2Ftest%2Fexpress'), + }, + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/server.ts new file mode 100644 index 000000000000..6ebc2d4cac95 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/server.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracePropagationTargets: [/^(?!.*express).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import http from 'http'; +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/express', (_req, res) => { + const headers = http.get('http://somewhere.not.sentry/').getHeaders(); + + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/server.ts new file mode 100644 index 000000000000..1cc4a0dcc639 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/server.ts @@ -0,0 +1,32 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import http from 'http'; +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/express', (_req, res) => { + const headers = http.get('http://somewhere.not.sentry/').getHeaders(); + + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/test.ts new file mode 100644 index 000000000000..40bbb03f8d50 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/test.ts @@ -0,0 +1,27 @@ +import { TRACEPARENT_REGEXP } from '@sentry/core'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from '../server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('Should assign `sentry-trace` header which sets parent trace id of an outgoing request.', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', + }, + }); + + expect(response).toBeDefined(); + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + 'sentry-trace': expect.stringContaining('12312012123120121231201212312012-'), + }, + }); + + expect(TRACEPARENT_REGEXP.test(response?.test_data['sentry-trace'] || '')).toBe(true); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-out/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-out/test.ts new file mode 100644 index 000000000000..db46bb491904 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-out/test.ts @@ -0,0 +1,23 @@ +import { TRACEPARENT_REGEXP } from '@sentry/core'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from '../server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should attach a `sentry-trace` header to an outgoing request.', async () => { + const runner = createRunner(__dirname, '..', 'server.ts').start(); + + const response = await runner.makeRequest('get', '/test/express'); + + expect(response).toBeDefined(); + expect(response).toMatchObject({ + test_data: { + host: 'somewhere.not.sentry', + 'sentry-trace': expect.any(String), + }, + }); + + expect(TRACEPARENT_REGEXP.test(response?.test_data['sentry-trace'] || '')).toBe(true); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/server.js b/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/server.js new file mode 100644 index 000000000000..0e73923cf88a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/server.js @@ -0,0 +1,33 @@ +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', + transport: loggingTransport, +}); + +// 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('/test1', (_req, _res) => { + throw new Error('error_1'); +}); + +app.get('/test2', (_req, _res) => { + throw new Error('error_2'); +}); + +Sentry.setupExpressErrorHandler(app, { + shouldHandleError: error => { + return error.message === 'error_2'; + }, +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/test.ts b/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/test.ts new file mode 100644 index 000000000000..ffc702d63057 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/test.ts @@ -0,0 +1,30 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('express setupExpressErrorHandler', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('allows to pass options to setupExpressErrorHandler', done => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: { + exception: { + values: [ + { + value: 'error_2', + }, + ], + }, + }, + }) + .start(done); + + // this error is filtered & ignored + runner.makeRequest('get', '/test1', { expectError: true }); + // this error is actually captured + runner.makeRequest('get', '/test2', { expectError: true }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/server.ts b/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/server.ts new file mode 100644 index 000000000000..99a9c53e932e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/server.ts @@ -0,0 +1,29 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +Sentry.setTag('global', 'tag'); + +app.get('/test/isolationScope', (_req, res) => { + // eslint-disable-next-line no-console + console.log('This is a test log.'); + Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); + Sentry.setTag('isolation-scope', 'tag'); + + res.send({}); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/test.ts b/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/test.ts new file mode 100644 index 000000000000..2e2b6945526e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/test.ts @@ -0,0 +1,38 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('correctly applies isolation scope to span', done => { + createRunner(__dirname, 'server.ts') + .expect({ + transaction: { + transaction: 'GET /test/isolationScope', + breadcrumbs: [ + { + category: 'console', + level: 'log', + message: expect.stringMatching(/\{"port":(\d+)\}/), + timestamp: expect.any(Number), + }, + { + category: 'console', + level: 'log', + message: 'This is a test log.', + timestamp: expect.any(Number), + }, + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + ], + tags: { + global: 'tag', + 'isolation-scope': 'tag', + }, + }, + }) + .start(done) + .makeRequest('get', '/test/isolationScope'); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/server.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/server.js new file mode 100644 index 000000000000..f9b4ae24b339 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/server.js @@ -0,0 +1,48 @@ +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'); + +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 }); +}); + +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 new file mode 100644 index 000000000000..6f87fdd89f76 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts @@ -0,0 +1,240 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('express tracing', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('should create and send transactions for Express routes and spans for middlewares.', done => { + 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\/express$/), + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + }, + }, + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'express.name': 'corsMiddleware', + 'express.type': 'middleware', + }), + description: 'corsMiddleware', + op: 'middleware.express', + origin: 'auto.http.otel.express', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'express.name': '/test/express', + 'express.type': 'request_handler', + }), + description: '/test/express', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + }), + ]), + }, + }) + .start(done) + .makeRequest('get', '/test/express'); + }); + + test('should set a correct transaction name for routes specified in RegEx', done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'GET /\\/test\\/regex/', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + data: { + url: expect.stringMatching(/\/test\/regex$/), + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + }, + }, + }, + }) + .start(done) + .makeRequest('get', '/test/regex'); + }); + + 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') + .expect({ + transaction: { + transaction: 'GET /test/array1,/\\/test\\/array[2-9]/', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + data: { + url: expect.stringMatching(`/test/${segment}$`), + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + }, + }, + }, + }) + .start(done) + .makeRequest('get', `/test/${segment}`); + }) as any, + ); + + test.each([ + ['arr/545'], + ['arr/required'], + ['arr/required'], + ['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') + .expect({ + transaction: { + transaction: 'GET /test/arr/:id,/\\/test\\/arr[0-9]*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?/', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + data: { + url: expect.stringMatching(`/test/${segment}$`), + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + }, + }, + }, + }) + .start(done) + .makeRequest('get', `/test/${segment}`); + }) as any); + + describe('request data', () => { + test('correctly captures JSON request data', done => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/json', + }, + data: JSON.stringify({ + foo: 'bar', + other: 1, + }), + }, + }, + }) + .start(done); + + runner.makeRequest('post', '/test-post', { data: { foo: 'bar', other: 1 } }); + }); + + test('correctly captures plain text request data', done => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'text/plain', + }, + data: 'some plain text', + }, + }, + }) + .start(done); + + runner.makeRequest('post', '/test-post', { + headers: { 'Content-Type': 'text/plain' }, + data: 'some plain text', + }); + }); + + test('correctly captures text buffer request data', done => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', + }, + data: 'some plain text in buffer', + }, + }, + }) + .start(done); + + runner.makeRequest('post', '/test-post', { + headers: { 'Content-Type': 'application/octet-stream' }, + data: Buffer.from('some plain text in buffer'), + }); + }); + + test('correctly captures non-text buffer request data', done => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', + }, + // This is some non-ascii string representation + data: expect.any(String), + }, + }, + }) + .start(done); + + const body = new Uint8Array([1, 2, 3, 4, 5]).buffer; + + runner.makeRequest('post', '/test-post', { + headers: { 'Content-Type': 'application/octet-stream' }, + data: body, + }); + }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/scenario-normalizedRequest.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/scenario-normalizedRequest.js new file mode 100644 index 000000000000..da31780f2c5f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/scenario-normalizedRequest.js @@ -0,0 +1,34 @@ +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', + transport: loggingTransport, + tracesSampler: samplingContext => { + // The sampling decision is based on whether the data in `normalizedRequest` is available --> this is what we want to test for + return ( + samplingContext.normalizedRequest.url.includes('/test-normalized-request?query=123') && + samplingContext.normalizedRequest.method && + samplingContext.normalizedRequest.query_string === 'query=123' && + !!samplingContext.normalizedRequest.headers + ); + }, +}); + +// 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-normalized-request', (_req, res) => { + res.send('Success'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/server.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/server.js new file mode 100644 index 000000000000..b60ea07b636f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/server.js @@ -0,0 +1,39 @@ +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', + transport: loggingTransport, + tracesSampler: samplingContext => { + // The name we get here is inferred at span creation time + // At this point, we sadly do not have a http.route attribute yet, + // so we infer the name from the unparameterized route instead + return ( + samplingContext.name === 'GET /test/123' && + samplingContext.attributes['sentry.op'] === 'http.server' && + samplingContext.attributes['http.method'] === 'GET' + ); + }, +}); + +// 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/:id', (_req, res) => { + res.send('Success'); +}); + +app.get('/test2', (_req, res) => { + res.send('Success'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/test.ts new file mode 100644 index 000000000000..07cc8d094d8f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/test.ts @@ -0,0 +1,44 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('express tracesSampler', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('correctly samples & passes data to tracesSampler', done => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'GET /test/:id', + }, + }) + .start(done); + + // This is not sampled + runner.makeRequest('get', '/test2?q=1'); + // This is sampled + runner.makeRequest('get', '/test/123?q=1'); + }); + }); +}); + +describe('express tracesSampler includes normalizedRequest data', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('correctly samples & passes data to tracesSampler', done => { + const runner = createRunner(__dirname, 'scenario-normalizedRequest.js') + .expect({ + transaction: { + transaction: 'GET /test-normalized-request', + }, + }) + .start(done); + + runner.makeRequest('get', '/test-normalized-request?query=123'); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/server.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/server.js new file mode 100644 index 000000000000..c98e17276d92 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/server.js @@ -0,0 +1,58 @@ +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'); + +const app = express(); + +app.use(cors()); +app.use(bodyParser.json()); +app.use(bodyParser.text()); +app.use(bodyParser.raw()); + +app.get('/test/:id/span-updateName', (_req, res) => { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + rootSpan.updateName('new-name'); + res.send({ response: 'response 1' }); +}); + +app.get('/test/:id/span-updateName-source', (_req, res) => { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + rootSpan.updateName('new-name'); + rootSpan.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'custom'); + res.send({ response: 'response 2' }); +}); + +app.get('/test/:id/updateSpanName', (_req, res) => { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + Sentry.updateSpanName(rootSpan, 'new-name'); + res.send({ response: 'response 3' }); +}); + +app.get('/test/:id/updateSpanNameAndSource', (_req, res) => { + const span = Sentry.getActiveSpan(); + const rootSpan = Sentry.getRootSpan(span); + Sentry.updateSpanName(rootSpan, 'new-name'); + rootSpan.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component'); + res.send({ response: 'response 4' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts new file mode 100644 index 000000000000..c6345713fd7e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts @@ -0,0 +1,94 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('express tracing', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + // This test documents the unfortunate behaviour of using `span.updateName` on the server-side. + // For http.server root spans (which is the root span on the server 99% of the time), Otel's http instrumentation + // calls `span.updateName` and overwrites whatever the name was set to before (by us or by users). + test("calling just `span.updateName` doesn't update the final name in express (missing source)", done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'GET /test/:id/span-updateName', + transaction_info: { + source: 'route', + }, + }, + }) + .start(done) + .makeRequest('get', '/test/123/span-updateName'); + }); + + // Also calling `updateName` AND setting a source doesn't change anything - Otel has no concept of source, this is sentry-internal. + // Therefore, only the source is updated but the name is still overwritten by Otel. + test("calling `span.updateName` and setting attribute source doesn't update the final name in express but it updates the source", done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: { + transaction: 'GET /test/:id/span-updateName-source', + transaction_info: { + source: 'custom', + }, + }, + }) + .start(done) + .makeRequest('get', '/test/123/span-updateName-source'); + }); + + // This test documents the correct way to update the span name (and implicitly the source) in Node: + test('calling `Sentry.updateSpanName` updates the final name and source in express', done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: txnEvent => { + expect(txnEvent).toMatchObject({ + transaction: 'new-name', + transaction_info: { + source: 'custom', + }, + contexts: { + trace: { + op: 'http.server', + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, + }, + }, + }); + // ensure we delete the internal attribute once we're done with it + expect(txnEvent.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); + }, + }) + .start(done) + .makeRequest('get', '/test/123/updateSpanName'); + }); + }); + + // This test documents the correct way to update the span name (and implicitly the source) in Node: + test('calling `Sentry.updateSpanName` and setting source subsequently updates the final name and sets correct source', done => { + createRunner(__dirname, 'server.js') + .expect({ + transaction: txnEvent => { + expect(txnEvent).toMatchObject({ + transaction: 'new-name', + transaction_info: { + source: 'component', + }, + contexts: { + trace: { + op: 'http.server', + data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component' }, + }, + }, + }); + // ensure we delete the internal attribute once we're done with it + expect(txnEvent.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); + }, + }) + .start(done) + .makeRequest('get', '/test/123/updateSpanNameAndSource'); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/server.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/server.js new file mode 100644 index 000000000000..d9ccc80fb7ad --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/server.js @@ -0,0 +1,30 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + debug: true, + 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 { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/test/:id1/:id2', (_req, res) => { + Sentry.captureException(new Error('error_1')); + res.send('Success'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); 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 new file mode 100644 index 000000000000..4dd004ad2239 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts @@ -0,0 +1,28 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('express tracing experimental', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('should apply the scope transactionName to error events', done => { + createRunner(__dirname, 'server.js') + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + value: 'error_1', + }, + ], + }, + transaction: 'GET /test/:id1/:id2', + }, + }) + .start(done) + .makeRequest('get', '/test/123/abc?q=1'); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/without-tracing/server.ts b/dev-packages/node-integration-tests/suites/express-v5/without-tracing/server.ts new file mode 100644 index 000000000000..5b96e8b1a2a3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/without-tracing/server.ts @@ -0,0 +1,40 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import bodyParser from 'body-parser'; +import express from 'express'; + +const app = express(); + +app.use(bodyParser.json()); +app.use(bodyParser.text()); +app.use(bodyParser.raw()); + +Sentry.setTag('global', 'tag'); + +app.get('/test/isolationScope/:id', (req, res) => { + const id = req.params.id; + Sentry.setTag('isolation-scope', 'tag'); + Sentry.setTag(`isolation-scope-${id}`, id); + + Sentry.captureException(new Error('This is an exception')); + + res.send({}); +}); + +app.post('/test-post', function (req, res) { + Sentry.captureException(new Error('This is an exception')); + + res.send({ status: 'ok', body: req.body }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); 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 new file mode 100644 index 000000000000..fdd63ad4aa4b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts @@ -0,0 +1,132 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('express without tracing', () => { + test('correctly applies isolation scope even without tracing', done => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + transaction: 'GET /test/isolationScope/1', + tags: { + global: 'tag', + 'isolation-scope': 'tag', + 'isolation-scope-1': '1', + }, + // Request is correctly set + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test\/isolationScope\/1$/), + method: 'GET', + headers: { + 'user-agent': expect.stringContaining(''), + }, + }, + }, + }) + .start(done); + + runner.makeRequest('get', '/test/isolationScope/1'); + }); + + describe('request data', () => { + test('correctly captures JSON request data', done => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/json', + }, + data: JSON.stringify({ + foo: 'bar', + other: 1, + }), + }, + }, + }) + .start(done); + + runner.makeRequest('post', '/test-post', { data: { foo: 'bar', other: 1 } }); + }); + + test('correctly captures plain text request data', done => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'text/plain', + }, + data: 'some plain text', + }, + }, + }) + .start(done); + + runner.makeRequest('post', '/test-post', { + headers: { + 'Content-Type': 'text/plain', + }, + data: 'some plain text', + }); + }); + + test('correctly captures text buffer request data', done => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', + }, + data: 'some plain text in buffer', + }, + }, + }) + .start(done); + + runner.makeRequest('post', '/test-post', { + headers: { 'Content-Type': 'application/octet-stream' }, + data: Buffer.from('some plain text in buffer'), + }); + }); + + test('correctly captures non-text buffer request data', done => { + const runner = createRunner(__dirname, 'server.ts') + .expect({ + event: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', + }, + // This is some non-ascii string representation + data: expect.any(String), + }, + }, + }) + .start(done); + + const body = new Uint8Array([1, 2, 3, 4, 5]).buffer; + + runner.makeRequest('post', '/test-post', { headers: { 'Content-Type': 'application/octet-stream' }, data: body }); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/scenario.ts new file mode 100644 index 000000000000..44ea548bab8f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/scenario.ts @@ -0,0 +1,15 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + await fetch(`${process.env.SERVER_URL}/api/v1`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts new file mode 100644 index 000000000000..006190864fe6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts @@ -0,0 +1,48 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('captures spans for outgoing fetch requests', done => { + expect.assertions(3); + + createTestServer(done) + .get('/api/v0', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .get( + '/api/v1', + () => { + // Just ensure we're called + expect(true).toBe(true); + }, + 404, + ) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + transaction: 'test_transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v0/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'ok', + }), + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v1/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'not_found', + data: expect.objectContaining({ + 'http.response.status_code': 404, + }), + }), + ]), + }, + }) + .start(closeTestServer); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/scenario.ts new file mode 100644 index 000000000000..0c72d545c39b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/scenario.ts @@ -0,0 +1,14 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0/users?id=1#fragment`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts new file mode 100644 index 000000000000..12bb11727228 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts @@ -0,0 +1,53 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('strips and handles query params in spans of outgoing fetch requests', done => { + expect.assertions(4); + + createTestServer(done) + .get('/api/v0/users', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toEqual('test_transaction'); + expect(txn.spans).toHaveLength(1); + expect(txn.spans?.[0]).toMatchObject({ + data: { + url: `${SERVER_URL}/api/v0/users`, + 'url.full': `${SERVER_URL}/api/v0/users?id=1`, + 'url.path': '/api/v0/users', + 'url.query': '?id=1', + 'url.scheme': 'http', + 'http.query': 'id=1', + 'http.request.method': 'GET', + 'http.request.method_original': 'GET', + 'http.response.header.content-length': 0, + 'http.response.status_code': 200, + 'network.peer.address': '::1', + 'network.peer.port': expect.any(Number), + 'otel.kind': 'CLIENT', + 'server.port': expect.any(Number), + 'user_agent.original': 'node', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.node_fetch', + 'server.address': 'localhost', + }, + description: `GET ${SERVER_URL}/api/v0/users`, + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'ok', + parent_span_id: txn.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: txn.contexts?.trace?.trace_id, + }); + }, + }) + .start(closeTestServer); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/spans/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/scenario.ts similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/spans/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/scenario.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts similarity index 87% rename from dev-packages/node-integration-tests/suites/tracing/spans/test.ts rename to dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts index e349622d39f8..bb642baf0e1c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts @@ -1,7 +1,7 @@ -import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; -test('should capture spans for outgoing http requests', done => { +test('captures spans for outgoing http requests', done => { expect.assertions(3); createTestServer(done) diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/scenario.ts new file mode 100644 index 000000000000..074c9778aa75 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/scenario.ts @@ -0,0 +1,31 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import * as http from 'http'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0/users?id=1#fragment`); +}); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts new file mode 100644 index 000000000000..37b638635eb9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts @@ -0,0 +1,53 @@ +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('strips and handles query params in spans of outgoing http requests', done => { + expect.assertions(4); + + createTestServer(done) + .get('/api/v0/users', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .start() + .then(([SERVER_URL, closeTestServer]) => { + createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: txn => { + expect(txn.transaction).toEqual('test_transaction'); + expect(txn.spans).toHaveLength(1); + expect(txn.spans?.[0]).toMatchObject({ + data: { + url: `${SERVER_URL}/api/v0/users`, + 'http.url': `${SERVER_URL}/api/v0/users?id=1`, + 'http.target': '/api/v0/users?id=1', + 'http.flavor': '1.1', + 'http.host': expect.stringMatching(/localhost:\d+$/), + 'http.method': 'GET', + 'http.query': 'id=1', + 'http.response.status_code': 200, + 'http.response_content_length_uncompressed': 0, + 'http.status_code': 200, + 'http.status_text': 'OK', + 'net.peer.ip': '::1', + 'net.peer.name': 'localhost', + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'CLIENT', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + }, + description: `GET ${SERVER_URL}/api/v0/users`, + op: 'http.client', + origin: 'auto.http.otel.http', + status: 'ok', + parent_span_id: txn.contexts?.trace?.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: txn.contexts?.trace?.trace_id, + }); + }, + }) + .start(closeTestServer); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLink-nested.ts b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLink-nested.ts new file mode 100644 index 000000000000..27282ffb2867 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLink-nested.ts @@ -0,0 +1,33 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => { + childSpan1.addLink({ + context: parentSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan1.end(); + }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child1.2' }, async childSpan2 => { + childSpan2.addLink({ + context: parentSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); + + childSpan2.end(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLink.ts b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLink.ts new file mode 100644 index 000000000000..d00ae669dbd7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLink.ts @@ -0,0 +1,20 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +const span1 = Sentry.startInactiveSpan({ name: 'span1' }); +span1.end(); + +Sentry.startSpan({ name: 'rootSpan' }, rootSpan => { + rootSpan.addLink({ + context: span1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLinks-nested.ts b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLinks-nested.ts new file mode 100644 index 000000000000..216beff5c87e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLinks-nested.ts @@ -0,0 +1,31 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'parent1' }, async parentSpan1 => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child1.1' }, async childSpan1 => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child2.1' }, async childSpan2 => { + childSpan2.addLinks([ + { context: parentSpan1.spanContext() }, + { + context: childSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ]); + + childSpan2.end(); + }); + + childSpan1.end(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLinks.ts b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLinks.ts new file mode 100644 index 000000000000..1ce8a8a34a8f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-addLinks.ts @@ -0,0 +1,26 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +const span1 = Sentry.startInactiveSpan({ name: 'span1' }); +span1.end(); + +const span2 = Sentry.startInactiveSpan({ name: 'span2' }); +span2.end(); + +Sentry.startSpan({ name: 'rootSpan' }, rootSpan => { + rootSpan.addLinks([ + { context: span1.spanContext() }, + { + context: span2.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ]); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/linking/scenario-span-options.ts b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-span-options.ts new file mode 100644 index 000000000000..5e6debe78fc4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/linking/scenario-span-options.ts @@ -0,0 +1,27 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +const parentSpan1 = Sentry.startInactiveSpan({ name: 'parent1' }); +parentSpan1.end(); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan( + { + name: 'parent2', + links: [{ context: parentSpan1.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }], + }, + async () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.startSpan({ name: 'child2.1' }, async childSpan1 => { + childSpan1.end(); + }); + }, +); diff --git a/dev-packages/node-integration-tests/suites/tracing/linking/test.ts b/dev-packages/node-integration-tests/suites/tracing/linking/test.ts new file mode 100644 index 000000000000..57f68c1d258f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/linking/test.ts @@ -0,0 +1,187 @@ +import { createRunner } from '../../../utils/runner'; + +describe('span links', () => { + test('should link spans by adding "links" to span options', done => { + let span1_traceId: string, span1_spanId: string; + + createRunner(__dirname, 'scenario-span-options.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent1'); + + const traceContext = event.contexts?.trace; + span1_traceId = traceContext?.trace_id as string; + span1_spanId = traceContext?.span_id as string; + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent2'); + + const traceContext = event.contexts?.trace; + expect(traceContext).toBeDefined(); + expect(traceContext?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(span1_traceId), + span_id: expect.stringMatching(span1_spanId), + }), + ]); + }, + }) + .start(done); + }); + + test('should link spans with addLink() in trace context', done => { + let span1_traceId: string, span1_spanId: string; + + createRunner(__dirname, 'scenario-addLink.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('span1'); + + span1_traceId = event.contexts?.trace?.trace_id as string; + span1_spanId = event.contexts?.trace?.span_id as string; + + expect(event.spans).toEqual([]); + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('rootSpan'); + + expect(event.contexts?.trace?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(span1_traceId), + span_id: expect.stringMatching(span1_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start(done); + }); + + test('should link spans with addLinks() in trace context', done => { + let span1_traceId: string, span1_spanId: string, span2_traceId: string, span2_spanId: string; + + createRunner(__dirname, 'scenario-addLinks.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('span1'); + + span1_traceId = event.contexts?.trace?.trace_id as string; + span1_spanId = event.contexts?.trace?.span_id as string; + + expect(event.spans).toEqual([]); + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('span2'); + + span2_traceId = event.contexts?.trace?.trace_id as string; + span2_spanId = event.contexts?.trace?.span_id as string; + + expect(event.spans).toEqual([]); + }, + }) + .expect({ + transaction: event => { + expect(event.transaction).toBe('rootSpan'); + + expect(event.contexts?.trace?.links).toEqual([ + expect.not.objectContaining({ attributes: expect.anything() }) && + expect.objectContaining({ + trace_id: expect.stringMatching(span1_traceId), + span_id: expect.stringMatching(span1_spanId), + }), + expect.objectContaining({ + trace_id: expect.stringMatching(span2_traceId), + span_id: expect.stringMatching(span2_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start(done); + }); + + test('should link spans with addLink() in nested startSpan() calls', done => { + createRunner(__dirname, 'scenario-addLink-nested.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent1'); + + const parent1_traceId = event.contexts?.trace?.trace_id as string; + const parent1_spanId = event.contexts?.trace?.span_id as string; + + const spans = event.spans || []; + const child1_1 = spans.find(span => span.description === 'child1.1'); + const child1_2 = spans.find(span => span.description === 'child1.2'); + + expect(child1_1).toBeDefined(); + expect(child1_1?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(parent1_traceId), + span_id: expect.stringMatching(parent1_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + + expect(child1_2).toBeDefined(); + expect(child1_2?.links).toEqual([ + expect.objectContaining({ + trace_id: expect.stringMatching(parent1_traceId), + span_id: expect.stringMatching(parent1_spanId), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start(done); + }); + + test('should link spans with addLinks() in nested startSpan() calls', done => { + createRunner(__dirname, 'scenario-addLinks-nested.ts') + .expect({ + transaction: event => { + expect(event.transaction).toBe('parent1'); + + const parent1_traceId = event.contexts?.trace?.trace_id as string; + const parent1_spanId = event.contexts?.trace?.span_id as string; + + const spans = event.spans || []; + const child1_1 = spans.find(span => span.description === 'child1.1'); + const child2_1 = spans.find(span => span.description === 'child2.1'); + + expect(child1_1).toBeDefined(); + + expect(child2_1).toBeDefined(); + + expect(child2_1?.links).toEqual([ + expect.not.objectContaining({ attributes: expect.anything() }) && + expect.objectContaining({ + trace_id: expect.stringMatching(parent1_traceId), + span_id: expect.stringMatching(parent1_spanId), + }), + expect.objectContaining({ + trace_id: expect.stringMatching(child1_1?.trace_id || 'non-existent-id-fallback'), + span_id: expect.stringMatching(child1_1?.span_id || 'non-existent-id-fallback'), + attributes: expect.objectContaining({ + 'sentry.link.type': 'previous_trace', + }), + }), + ]); + }, + }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/package.json b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/package.json index b8721038c83b..d3dcaf9d1328 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/package.json +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/package.json @@ -7,10 +7,9 @@ "node": ">=18" }, "scripts": { - "db-up": "docker compose up -d", "generate": "prisma generate", "migrate": "prisma migrate dev -n sentry-test", - "setup": "run-s --silent db-up generate migrate" + "setup": "run-s --silent generate migrate" }, "keywords": [], "author": "", diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts index 0ece02f2f1cb..da47ef8bc415 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts @@ -1,8 +1,20 @@ -import { createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -describe('Prisma ORM Tests', () => { +// When running docker compose, we need a larger timeout, as this takes some time... +jest.setTimeout(75000); + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('Prisma ORM v5 Tests', () => { test('CJS - should instrument PostgreSQL queries from Prisma ORM', done => { createRunner(__dirname, 'scenario.js') + .withDockerCompose({ + workingDirectory: [__dirname], + readyMatches: ['port 5432'], + setupCommand: 'yarn && yarn setup', + }) .expect({ transaction: transaction => { expect(transaction.transaction).toBe('Test Transaction'); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/package.json b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/package.json index a0b24c52e319..dc062f1b9e3b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/package.json +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/package.json @@ -7,10 +7,9 @@ "node": ">=18" }, "scripts": { - "db-up": "docker compose up -d", "generate": "prisma generate", "migrate": "prisma migrate dev -n sentry-test", - "setup": "run-s --silent db-up generate migrate" + "setup": "run-s --silent generate migrate" }, "keywords": [], "author": "", diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts index 70d2fda9cbe0..10cb5b15c95c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts @@ -1,9 +1,21 @@ import type { SpanJSON } from '@sentry/core'; -import { createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -describe('Prisma ORM Tests', () => { +// When running docker compose, we need a larger timeout, as this takes some time... +jest.setTimeout(75000); + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('Prisma ORM v6 Tests', () => { test('CJS - should instrument PostgreSQL queries from Prisma ORM', done => { createRunner(__dirname, 'scenario.js') + .withDockerCompose({ + workingDirectory: [__dirname], + readyMatches: ['port 5432'], + setupCommand: 'yarn && yarn setup', + }) .expect({ transaction: transaction => { expect(transaction.transaction).toBe('Test Transaction'); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index a5fc8df38825..152160f7118b 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import { spawn, spawnSync } from 'child_process'; +import { execSync, spawn, spawnSync } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { normalize } from '@sentry/core'; @@ -60,6 +60,10 @@ interface DockerOptions { * The strings to look for in the output to know that the docker compose is ready for the test to be run */ readyMatches: string[]; + /** + * The command to run after docker compose is up + */ + setupCommand?: string; } /** @@ -96,6 +100,9 @@ async function runDockerCompose(options: DockerOptions): Promise { if (text.includes(match)) { child.stdout.removeAllListeners(); clearTimeout(timeout); + if (options.setupCommand) { + execSync(options.setupCommand, { cwd, stdio: 'inherit' }); + } resolve(close); } } diff --git a/packages/angular/README.md b/packages/angular/README.md index 95e0379480d7..384c4c2d48c8 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/platforms/javascript/angular/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## Angular Version Compatibility diff --git a/packages/aws-serverless/README.md b/packages/aws-serverless/README.md index 5a1ea8a1cc00..9109b8e059b0 100644 --- a/packages/aws-serverless/README.md +++ b/packages/aws-serverless/README.md @@ -9,7 +9,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/browser-utils/README.md b/packages/browser-utils/README.md index 108f3f3613c7..442228c4ddc4 100644 --- a/packages/browser-utils/README.md +++ b/packages/browser-utils/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 794faa197ad5..62201a72ceab 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -425,7 +425,7 @@ export function _addMeasureSpans( startTime: number, duration: number, timeOrigin: number, -): number { +): void { const navEntry = getNavigationEntry(false); const requestTime = msToSec(navEntry ? navEntry.requestStart : 0); // Because performance.measure accepts arbitrary timestamps it can produce @@ -450,13 +450,14 @@ export function _addMeasureSpans( attributes['sentry.browser.measure_start_time'] = measureStartTimestamp; } - startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { - name: entry.name as string, - op: entry.entryType as string, - attributes, - }); - - return measureStartTimestamp; + // Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process. + if (measureStartTimestamp <= measureEndTimestamp) { + startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { + name: entry.name as string, + op: entry.entryType as string, + attributes, + }); + } } /** Instrument navigation entries */ diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index 98a3bb375c00..27d489eae140 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -92,6 +92,29 @@ describe('_addMeasureSpans', () => { }), ); }); + + it('drops measurement spans with negative duration', () => { + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const entry = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + } as PerformanceEntry; + + const timeOrigin = 100; + const startTime = 23; + const duration = -50; + + _addMeasureSpans(span, entry, startTime, duration, timeOrigin); + + expect(spans).toHaveLength(0); + }); }); describe('_addResourceSpans', () => { diff --git a/packages/browser/README.md b/packages/browser/README.md index 98bbf0cabca2..91f3cde774d9 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -14,7 +14,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## Usage diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 144aec73c977..17dd71f0abba 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -24,6 +24,7 @@ import { spanToJSON, startInactiveSpan, stringMatchesSomePattern, + stripUrlQueryAndFragment, } from '@sentry/core'; import { WINDOW } from '../helpers'; @@ -324,7 +325,9 @@ export function xhrCallback( return undefined; } - const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(sentryXhrData.url); + const { url, method } = sentryXhrData; + + const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(url); // check first if the request has finished and is tracked by an existing span which should now end if (handlerData.endTimestamp && shouldCreateSpanResult) { @@ -342,23 +345,27 @@ export function xhrCallback( return undefined; } - const fullUrl = getFullURL(sentryXhrData.url); - const host = fullUrl ? parseUrl(fullUrl).host : undefined; + const fullUrl = getFullURL(url); + const parsedUrl = fullUrl ? parseUrl(fullUrl) : parseUrl(url); + + const urlForSpanName = stripUrlQueryAndFragment(url); const hasParent = !!getActiveSpan(); const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ - name: `${sentryXhrData.method} ${sentryXhrData.url}`, + name: `${method} ${urlForSpanName}`, attributes: { + url, type: 'xhr', - 'http.method': sentryXhrData.method, + 'http.method': method, 'http.url': fullUrl, - url: sentryXhrData.url, - 'server.address': host, + 'server.address': parsedUrl?.host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + ...(parsedUrl?.search && { 'http.query': parsedUrl?.search }), + ...(parsedUrl?.hash && { 'http.fragment': parsedUrl?.hash }), }, }) : new SentryNonRecordingSpan(); @@ -366,7 +373,7 @@ export function xhrCallback( xhr.__sentry_xhr_span_id__ = span.spanContext().spanId; spans[xhr.__sentry_xhr_span_id__] = span; - if (shouldAttachHeaders(sentryXhrData.url)) { + if (shouldAttachHeaders(url)) { addTracingHeadersToXhrRequest( xhr, // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), diff --git a/packages/bun/README.md b/packages/bun/README.md index 43a80713e45b..0f6f37bd6384 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) The Sentry Bun SDK is in beta. Please help us improve the SDK by [reporting any issues or giving us feedback](https://github.com/getsentry/sentry-javascript/issues). diff --git a/packages/bun/src/client.ts b/packages/bun/src/client.ts index 40e430dc2545..a96e0c04e264 100644 --- a/packages/bun/src/client.ts +++ b/packages/bun/src/client.ts @@ -5,10 +5,7 @@ import { ServerRuntimeClient, applySdkMetadata } from '@sentry/core'; import type { BunClientOptions } from './types'; /** - * The Sentry Bun SDK Client. - * - * @see BunClientOptions for documentation on configuration options. - * @see SentryClient for usage documentation. + * @deprecated This client is no longer used in v9. */ export class BunClient extends ServerRuntimeClient { /** diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 770cf2eb2ebe..c25c65487a47 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -141,6 +141,7 @@ export { export type { BunOptions } from './types'; +// eslint-disable-next-line deprecation/deprecation export { BunClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 8bded2d492af..96f1b63f902d 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -1,4 +1,6 @@ +import * as os from 'node:os'; import { + applySdkMetadata, functionToStringIntegration, inboundFiltersIntegration, linkedErrorsIntegration, @@ -18,7 +20,6 @@ import { onUnhandledRejectionIntegration, } from '@sentry/node'; -import { BunClient } from './client'; import { bunServerIntegration } from './integrations/bunserver'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; @@ -92,8 +93,16 @@ export function getDefaultIntegrations(_options: Options): Integration[] { * * @see {@link BunOptions} for documentation on configuration options. */ -export function init(options: BunOptions = {}): NodeClient | undefined { - options.clientClass = BunClient; +export function init(userOptions: BunOptions = {}): NodeClient | undefined { + applySdkMetadata(userOptions, 'bun'); + + const options = { + ...userOptions, + platform: 'javascript', + runtime: { name: 'bun', version: Bun.version }, + serverName: userOptions.serverName || global.process.env.SENTRY_NAME || os.hostname(), + }; + options.transport = options.transport || makeFetchTransport; if (options.defaultIntegrations === undefined) { diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index fa0e2171214f..6cc08edd495d 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -1,6 +1,5 @@ import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; -import type { BunClient } from './client'; import type { BunTransportOptions } from './transports'; export interface BaseBunOptions { @@ -25,14 +24,6 @@ export interface BaseBunOptions { /** Sets an optional server name (device name) */ serverName?: string; - /** - * Specify a custom BunClient to be used. Must extend BunClient! - * This is not a public, supported API, but used internally only. - * - * @hidden - * */ - clientClass?: typeof BunClient; - /** Callback that is executed when a fatal global error occurs. */ onFatalError?(this: void, error: Error): void; } diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index dd1f738a334b..66a66476f78d 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,13 +1,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import type { Span } from '@sentry/core'; -import { getDynamicSamplingContextFromSpan, setCurrentClient, spanIsSampled, spanToJSON } from '@sentry/core'; +import { getDynamicSamplingContextFromSpan, spanIsSampled, spanToJSON } from '@sentry/core'; -import { BunClient } from '../../src/client'; +import { init } from '../../src'; +import type { NodeClient } from '../../src'; import { instrumentBunServe } from '../../src/integrations/bunserver'; import { getDefaultBunClientOptions } from '../helpers'; describe('Bun Serve Integration', () => { - let client: BunClient; + let client: NodeClient | undefined; // Fun fact: Bun = 2 21 14 :) let port: number = 22114; @@ -17,9 +18,7 @@ describe('Bun Serve Integration', () => { beforeEach(() => { const options = getDefaultBunClientOptions({ tracesSampleRate: 1 }); - client = new BunClient(options); - setCurrentClient(client); - client.init(); + client = init(options); }); afterEach(() => { @@ -31,7 +30,7 @@ describe('Bun Serve Integration', () => { test('generates a transaction around a request', async () => { let generatedSpan: Span | undefined; - client.on('spanEnd', span => { + client?.on('spanEnd', span => { generatedSpan = span; }); @@ -41,23 +40,32 @@ describe('Bun Serve Integration', () => { }, port, }); - await fetch(`http://localhost:${port}/`); + await fetch(`http://localhost:${port}/users?id=123`); server.stop(); if (!generatedSpan) { throw 'No span was generated in the test'; } - expect(spanToJSON(generatedSpan).status).toBe('ok'); - expect(spanToJSON(generatedSpan).data?.['http.response.status_code']).toEqual(200); - expect(spanToJSON(generatedSpan).op).toEqual('http.server'); - expect(spanToJSON(generatedSpan).description).toEqual('GET /'); + const spanJson = spanToJSON(generatedSpan); + expect(spanJson.status).toBe('ok'); + expect(spanJson.op).toEqual('http.server'); + expect(spanJson.description).toEqual('GET /users'); + expect(spanJson.data).toEqual({ + 'http.query': '?id=123', + 'http.request.method': 'GET', + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.bun.serve', + 'sentry.sample_rate': 1, + 'sentry.source': 'url', + }); }); test('generates a post transaction', async () => { let generatedSpan: Span | undefined; - client.on('spanEnd', span => { + client?.on('spanEnd', span => { generatedSpan = span; }); @@ -94,7 +102,7 @@ describe('Bun Serve Integration', () => { let generatedSpan: Span | undefined; - client.on('spanEnd', span => { + client?.on('spanEnd', span => { generatedSpan = span; }); @@ -130,7 +138,7 @@ describe('Bun Serve Integration', () => { test('does not create transactions for OPTIONS or HEAD requests', async () => { let generatedSpan: Span | undefined; - client.on('spanEnd', span => { + client?.on('spanEnd', span => { generatedSpan = span; }); @@ -156,7 +164,7 @@ describe('Bun Serve Integration', () => { test('intruments the server again if it is reloaded', async () => { let serverWasInstrumented = false; - client.on('spanEnd', () => { + client?.on('spanEnd', () => { serverWasInstrumented = true; }); diff --git a/packages/bun/test/sdk.test.ts b/packages/bun/test/sdk.test.ts index 11870f30c101..c4f55ddbb3bb 100644 --- a/packages/bun/test/sdk.test.ts +++ b/packages/bun/test/sdk.test.ts @@ -1,20 +1,51 @@ import { describe, expect, test } from 'bun:test'; +import type { BaseTransportOptions, Envelope, Event, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import type { NodeClient } from '../src/index'; import { init } from '../src/index'; +const envelopes: Envelope[] = []; + +function testTransport(_options: BaseTransportOptions): Transport { + return { + send(request: Envelope): Promise { + envelopes.push(request); + return Promise.resolve({ statusCode: 200 }); + }, + flush(): PromiseLike { + return new Promise(resolve => setTimeout(() => resolve(true), 100)); + }, + }; +} + describe('Bun SDK', () => { const initOptions = { dsn: 'https://00000000000000000000000000000000@o000000.ingest.sentry.io/0000000', tracesSampleRate: 1, + transport: testTransport, }; - test("calling init shouldn't fail", () => { + test('SDK works as expected', async () => { + let client: NodeClient | undefined; expect(() => { - init(initOptions); + client = init(initOptions); }).not.toThrow(); - }); - test('should return client from init', () => { - expect(init(initOptions)).not.toBeUndefined(); + expect(client).not.toBeUndefined(); + + client?.captureException(new Error('test')); + client?.flush(); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + expect(envelopes.length).toBe(1); + + const envelope = envelopes[0]; + const event = envelope?.[1][0][1] as Event; + + expect(event.sdk?.name).toBe('sentry.javascript.bun'); + + expect(event.exception?.values?.[0]?.type).toBe('Error'); + expect(event.exception?.values?.[0]?.value).toBe('test'); }); }); diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 8fc88a578808..de54f5351332 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## Install diff --git a/packages/core/README.md b/packages/core/README.md index 3e44a1dfc7fa..c4c26b591b3e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 3c43584b3951..a96d421d0023 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -5,7 +5,7 @@ import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanOrigin } from './types-hoist'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage'; import { isInstanceOf } from './utils-hoist/is'; -import { parseUrl } from './utils-hoist/url'; +import { parseUrl, stripUrlQueryAndFragment } from './utils-hoist/url'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; import { getActiveSpan } from './utils/spanUtils'; import { getTraceData } from './utils/traceData'; @@ -35,7 +35,9 @@ export function instrumentFetchRequest( return undefined; } - const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(handlerData.fetchData.url); + const { method, url } = handlerData.fetchData; + + const shouldCreateSpanResult = hasSpansEnabled() && shouldCreateSpan(url); if (handlerData.endTimestamp && shouldCreateSpanResult) { const spanId = handlerData.fetchData.__span; @@ -51,25 +53,25 @@ export function instrumentFetchRequest( return undefined; } - const { method, url } = handlerData.fetchData; - const fullUrl = getFullURL(url); - const host = fullUrl ? parseUrl(fullUrl).host : undefined; + const parsedUrl = fullUrl ? parseUrl(fullUrl) : parseUrl(url); const hasParent = !!getActiveSpan(); const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ - name: `${method} ${url}`, + name: `${method} ${stripUrlQueryAndFragment(url)}`, attributes: { url, type: 'fetch', 'http.method': method, 'http.url': fullUrl, - 'server.address': host, + 'server.address': parsedUrl?.host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + ...(parsedUrl?.search && { 'http.query': parsedUrl?.search }), + ...(parsedUrl?.hash && { 'http.fragment': parsedUrl?.hash }), }, }) : new SentryNonRecordingSpan(); diff --git a/packages/core/src/integrations/inboundfilters.ts b/packages/core/src/integrations/inboundfilters.ts index 17b4442cee57..7840ab5f6920 100644 --- a/packages/core/src/integrations/inboundfilters.ts +++ b/packages/core/src/integrations/inboundfilters.ts @@ -14,11 +14,13 @@ const DEFAULT_IGNORE_ERRORS = [ /^Javascript error: Script error\.? on line 0$/, /^ResizeObserver loop completed with undelivered notifications.$/, // The browser logs this when a ResizeObserver handler takes a bit longer. Usually this is not an actual issue though. It indicates slowness. /^Cannot redefine property: googletag$/, // This is thrown when google tag manager is used in combination with an ad blocker + /^Can't find variable: gmo$/, // Error from Google Search App https://issuetracker.google.com/issues/396043331 "undefined is not an object (evaluating 'a.L')", // Random error that happens but not actionable or noticeable to end-users. 'can\'t redefine non-configurable property "solana"', // Probably a browser extension or custom browser (Brave) throwing this error "vv().getRestrictions is not a function. (In 'vv().getRestrictions(1,a)', 'vv().getRestrictions' is undefined)", // Error thrown by GTM, seemingly not affecting end-users "Can't find variable: _AutofillCallbackHandler", // Unactionable error in instagram webview https://developers.facebook.com/community/threads/320013549791141/ /^Non-Error promise rejection captured with value: Object Not Found Matching Id:\d+, MethodName:simulateEvent, ParamCount:\d+$/, // unactionable error from CEFSharp, a .NET library that embeds chromium in .NET apps + /^Java exception was raised during method invocation$/, // error from Facebook Mobile browser (https://github.com/getsentry/sentry-javascript/issues/15065) ]; /** Options for the InboundFilters integration */ diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index ddf036f88cdb..53f103c5ed52 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -24,6 +24,7 @@ import type { TransactionEvent, TransactionSource, } from '../types-hoist'; +import type { SpanLink } from '../types-hoist/link'; import { logger } from '../utils-hoist/logger'; import { dropUndefinedKeys } from '../utils-hoist/object'; import { generateSpanId, generateTraceId } from '../utils-hoist/propagationContext'; @@ -31,6 +32,7 @@ import { timestampInSeconds } from '../utils-hoist/time'; import { TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, + convertSpanLinksForEnvelope, getRootSpan, getSpanDescendants, getStatusMessage, @@ -55,6 +57,7 @@ export class SentrySpan implements Span { protected _sampled: boolean | undefined; protected _name?: string | undefined; protected _attributes: SpanAttributes; + protected _links?: SpanLink[]; /** Epoch timestamp in seconds when the span started. */ protected _startTime: number; /** Epoch timestamp in seconds when the span ended. */ @@ -78,6 +81,7 @@ export class SentrySpan implements Span { this._traceId = spanContext.traceId || generateTraceId(); this._spanId = spanContext.spanId || generateSpanId(); this._startTime = spanContext.startTimestamp || timestampInSeconds(); + this._links = spanContext.links; this._attributes = {}; this.setAttributes({ @@ -110,12 +114,22 @@ export class SentrySpan implements Span { } /** @inheritDoc */ - public addLink(_link: unknown): this { + public addLink(link: SpanLink): this { + if (this._links) { + this._links.push(link); + } else { + this._links = [link]; + } return this; } /** @inheritDoc */ - public addLinks(_links: unknown[]): this { + public addLinks(links: SpanLink[]): this { + if (this._links) { + this._links.push(...links); + } else { + this._links = links; + } return this; } @@ -225,6 +239,7 @@ export class SentrySpan implements Span { measurements: timedEventsToMeasurements(this._events), is_segment: (this._isStandaloneSpan && getRootSpan(this) === this) || undefined, segment_id: this._isStandaloneSpan ? getRootSpan(this).spanContext().spanId : undefined, + links: convertSpanLinksForEnvelope(this._links), }); } diff --git a/packages/core/src/types-hoist/context.ts b/packages/core/src/types-hoist/context.ts index 60aa60b38868..0ad6eebf6ac3 100644 --- a/packages/core/src/types-hoist/context.ts +++ b/packages/core/src/types-hoist/context.ts @@ -1,4 +1,5 @@ import type { FeatureFlag } from '../featureFlags'; +import type { SpanLinkJSON } from './link'; import type { Primitive } from './misc'; import type { SpanOrigin } from './span'; @@ -106,6 +107,7 @@ export interface TraceContext extends Record { tags?: { [key: string]: Primitive }; trace_id: string; origin?: SpanOrigin; + links?: SpanLinkJSON[]; } export interface CloudResourceContext extends Record { diff --git a/packages/core/src/types-hoist/span.ts b/packages/core/src/types-hoist/span.ts index 2b82aab74934..d82463768b7f 100644 --- a/packages/core/src/types-hoist/span.ts +++ b/packages/core/src/types-hoist/span.ts @@ -182,6 +182,12 @@ export interface SentrySpanArguments { */ endTimestamp?: number | undefined; + /** + * Links to associate with the new span. Setting links here is preferred over addLink() + * as certain context information is only available during span creation. + */ + links?: SpanLink[]; + /** * Set to `true` if this span should be sent as a standalone segment span * as opposed to a transaction. diff --git a/packages/core/src/types-hoist/startSpanOptions.ts b/packages/core/src/types-hoist/startSpanOptions.ts index 6e5fa007bde8..eb3aa0b53299 100644 --- a/packages/core/src/types-hoist/startSpanOptions.ts +++ b/packages/core/src/types-hoist/startSpanOptions.ts @@ -1,4 +1,5 @@ import type { Scope } from '../scope'; +import type { SpanLink } from './link'; import type { Span, SpanAttributes, SpanTimeInput } from './span'; export interface StartSpanOptions { @@ -44,6 +45,12 @@ export interface StartSpanOptions { /** Attributes for the span. */ attributes?: SpanAttributes; + /** + * Links to associate with the new span. Setting links here is preferred over addLink() + * as it allows sampling decisions to consider the link information. + */ + links?: SpanLink[]; + /** * Experimental options without any stability guarantees. Use with caution! */ diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index fcf4aa1857e3..7cb19fbacf3c 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -40,7 +40,7 @@ let hasShownSpanDropWarning = false; */ export function spanToTransactionTraceContext(span: Span): TraceContext { const { spanId: span_id, traceId: trace_id } = span.spanContext(); - const { data, op, parent_span_id, status, origin } = spanToJSON(span); + const { data, op, parent_span_id, status, origin, links } = spanToJSON(span); return dropUndefinedKeys({ parent_span_id, @@ -50,6 +50,7 @@ export function spanToTransactionTraceContext(span: Span): TraceContext { op, status, origin, + links, }); } @@ -144,7 +145,7 @@ export function spanToJSON(span: Span): SpanJSON { // Handle a span from @opentelemetry/sdk-base-trace's `Span` class if (spanIsOpenTelemetrySdkTraceBaseSpan(span)) { - const { attributes, startTime, name, endTime, parentSpanId, status } = span; + const { attributes, startTime, name, endTime, parentSpanId, status, links } = span; return dropUndefinedKeys({ span_id, @@ -158,6 +159,7 @@ export function spanToJSON(span: Span): SpanJSON { status: getStatusMessage(status), op: attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP], origin: attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, + links: convertSpanLinksForEnvelope(links), }); } @@ -184,6 +186,7 @@ export interface OpenTelemetrySdkTraceBaseSpan extends Span { status: SpanStatus; endTime: SpanTimeInput; parentSpanId?: string; + links?: SpanLink[]; } /** diff --git a/packages/core/test/lib/integrations/inboundfilters.test.ts b/packages/core/test/lib/integrations/inboundfilters.test.ts index 046ee5a168d7..e06c6bda0da2 100644 --- a/packages/core/test/lib/integrations/inboundfilters.test.ts +++ b/packages/core/test/lib/integrations/inboundfilters.test.ts @@ -269,6 +269,12 @@ const GOOGLETAG_EVENT: Event = { }, }; +const GOOGLE_APP_GMO: Event = { + exception: { + values: [{ type: 'ReferenceError', value: "Can't find variable: gmo" }], + }, +}; + const CEFSHARP_EVENT: Event = { exception: { values: [ @@ -281,6 +287,17 @@ const CEFSHARP_EVENT: Event = { }, }; +const FB_MOBILE_BROWSER_EVENT: Event = { + exception: { + values: [ + { + type: 'Error', + value: 'Java exception was raised during method invocation', + }, + ], + }, +}; + const MALFORMED_EVENT: Event = { exception: { values: [ @@ -397,11 +414,21 @@ describe('InboundFilters', () => { expect(eventProcessor(GOOGLETAG_EVENT, {})).toBe(null); }); + it('uses default filters (Google App "gmo")', () => { + const eventProcessor = createInboundFiltersEventProcessor(); + expect(eventProcessor(GOOGLE_APP_GMO, {})).toBe(null); + }); + it('uses default filters (CEFSharp)', () => { const eventProcessor = createInboundFiltersEventProcessor(); expect(eventProcessor(CEFSHARP_EVENT, {})).toBe(null); }); + it('uses default filters (FB Mobile Browser)', () => { + const eventProcessor = createInboundFiltersEventProcessor(); + expect(eventProcessor(FB_MOBILE_BROWSER_EVENT, {})).toBe(null); + }); + it('filters on last exception when multiple present', () => { const eventProcessor = createInboundFiltersEventProcessor({ ignoreErrors: ['incorrect type given for parameter `chewToy`'], diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index c33b50c01a85..e15bf146bee6 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -399,6 +399,40 @@ describe('startSpan', () => { }); }); + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error _links exists on span + expect(rawSpan1?._links).toEqual(undefined); + + const span1JSON = spanToJSON(rawSpan1); + + startSpan({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2._links?.[0].context.traceId).toEqual(rawSpan1._traceId); + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.spanId).toEqual(rawSpan1?._spanId); + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + it('allows to force a transaction with forceTransaction=true', async () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); client = new TestClient(options); @@ -900,6 +934,40 @@ describe('startSpanManual', () => { }); }); + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error _links exists on span + expect(rawSpan1?._links).toEqual(undefined); + + const span1JSON = spanToJSON(rawSpan1); + + startSpanManual({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.traceId).toEqual(rawSpan1._traceId); + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.spanId).toEqual(rawSpan1?._spanId); + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + it('allows to force a transaction with forceTransaction=true', async () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); client = new TestClient(options); @@ -1237,6 +1305,44 @@ describe('startInactiveSpan', () => { }); }); + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error _links exists on span + expect(rawSpan1?._links).toEqual(undefined); + + const rawSpan2 = startInactiveSpan({ + name: 'GET users/[id]', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }); + + const span1JSON = spanToJSON(rawSpan1); + const span2JSON = spanToJSON(rawSpan2); + const span2LinkJSON = span2JSON.links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.traceId).toEqual(rawSpan1._traceId); + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.spanId).toEqual(rawSpan1?._spanId); + // @ts-expect-error _links and _traceId exist on SentrySpan + expect(rawSpan2?._links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + + // sampling decision is inherited + expect(span2LinkJSON?.sampled).toBe(Boolean(spanToJSON(rawSpan1).data['sentry.sample_rate'])); + }); + it('allows to force a transaction with forceTransaction=true', async () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); client = new TestClient(options); diff --git a/packages/core/test/utils-hoist/url.test.ts b/packages/core/test/utils-hoist/url.test.ts index cd066201945d..a16c72dc1cd2 100644 --- a/packages/core/test/utils-hoist/url.test.ts +++ b/packages/core/test/utils-hoist/url.test.ts @@ -72,3 +72,126 @@ describe('getSanitizedUrlString', () => { expect(getSanitizedUrlString(urlObject)).toEqual(sanitizedURL); }); }); + +describe('parseUrl', () => { + it.each([ + [ + 'https://somedomain.com', + { host: 'somedomain.com', path: '', search: '', hash: '', protocol: 'https', relative: '' }, + ], + [ + 'https://somedomain.com/path/to/happiness', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '', + hash: '', + protocol: 'https', + relative: '/path/to/happiness', + }, + ], + [ + 'https://somedomain.com/path/to/happiness?auhtToken=abc123¶m2=bar', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '?auhtToken=abc123¶m2=bar', + hash: '', + protocol: 'https', + relative: '/path/to/happiness?auhtToken=abc123¶m2=bar', + }, + ], + [ + 'https://somedomain.com/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '?auhtToken=abc123¶m2=bar', + hash: '#wildfragment', + protocol: 'https', + relative: '/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + }, + ], + [ + 'https://somedomain.com/path/to/happiness#somewildfragment123', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '', + hash: '#somewildfragment123', + protocol: 'https', + relative: '/path/to/happiness#somewildfragment123', + }, + ], + [ + 'https://somedomain.com/path/to/happiness#somewildfragment123?auhtToken=abc123¶m2=bar', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '', + hash: '#somewildfragment123?auhtToken=abc123¶m2=bar', + protocol: 'https', + relative: '/path/to/happiness#somewildfragment123?auhtToken=abc123¶m2=bar', + }, + ], + [ + // yup, this is a valid URL (protocol-agnostic URL) + '//somedomain.com/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + { + host: 'somedomain.com', + path: '/path/to/happiness', + search: '?auhtToken=abc123¶m2=bar', + hash: '#wildfragment', + protocol: undefined, + relative: '/path/to/happiness?auhtToken=abc123¶m2=bar#wildfragment', + }, + ], + ['', {}], + [ + '\n', + { + hash: '', + host: undefined, + path: '\n', + protocol: undefined, + relative: '\n', + search: '', + }, + ], + [ + 'somerandomString', + { + hash: '', + host: undefined, + path: 'somerandomString', + protocol: undefined, + relative: 'somerandomString', + search: '', + }, + ], + [ + 'somedomain.com', + { + host: undefined, + path: 'somedomain.com', + search: '', + hash: '', + protocol: undefined, + relative: 'somedomain.com', + }, + ], + [ + 'somedomain.com/path/?q=1#fragment', + { + host: undefined, + path: 'somedomain.com/path/', + search: '?q=1', + hash: '#fragment', + protocol: undefined, + relative: 'somedomain.com/path/?q=1#fragment', + }, + ], + ])('returns parsed partial URL object for %s', (url: string, expected: any) => { + expect(parseUrl(url)).toEqual(expected); + }); +}); diff --git a/packages/deno/README.md b/packages/deno/README.md index 8986cdf85c8d..42e643c05e4e 100644 --- a/packages/deno/README.md +++ b/packages/deno/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) The Sentry Deno SDK is in beta. Please help us improve the SDK by [reporting any issues or giving us feedback](https://github.com/getsentry/sentry-javascript/issues). diff --git a/packages/ember/README.md b/packages/ember/README.md index 2376869d107f..e0c9694d7d49 100644 --- a/packages/ember/README.md +++ b/packages/ember/README.md @@ -9,7 +9,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/eslint-config-sdk/README.md b/packages/eslint-config-sdk/README.md index c3180d237bdf..4de7b40d6218 100644 --- a/packages/eslint-config-sdk/README.md +++ b/packages/eslint-config-sdk/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/feedback/src/modal/components/Dialog.css.ts b/packages/feedback/src/modal/components/Dialog.css.ts index 8459108d1fc2..b67cae1de13c 100644 --- a/packages/feedback/src/modal/components/Dialog.css.ts +++ b/packages/feedback/src/modal/components/Dialog.css.ts @@ -115,6 +115,12 @@ const FORM = ` flex: 1 0; } +.form fieldset { + border: none; + margin: 0; + padding: 0; +} + .form__right { flex: 0 0 auto; display: flex; diff --git a/packages/feedback/src/modal/components/Form.tsx b/packages/feedback/src/modal/components/Form.tsx index a79e4d94194c..3d508f895429 100644 --- a/packages/feedback/src/modal/components/Form.tsx +++ b/packages/feedback/src/modal/components/Form.tsx @@ -61,6 +61,7 @@ export function Form({ submitButtonLabel, isRequiredLabel, } = options; + const [isSubmitting, setIsSubmitting] = useState(false); // TODO: set a ref on the form, and whenever an input changes call processForm() and setError() const [error, setError] = useState(null); @@ -97,6 +98,7 @@ export function Form({ const handleSubmit = useCallback( async (e: JSX.TargetedSubmitEvent) => { + setIsSubmitting(true); try { e.preventDefault(); if (!(e.target instanceof HTMLFormElement)) { @@ -133,8 +135,8 @@ export function Form({ setError(error as string); onSubmitError(error as Error); } - } catch { - // pass + } finally { + setIsSubmitting(false); } }, [screenshotInput && showScreenshotInput, onSubmitSuccess, onSubmitError], @@ -146,7 +148,7 @@ export function Form({ ) : null} -
+
{error ?
{error}
: null} @@ -201,6 +203,7 @@ export function Form({
- -
-
+ ); } diff --git a/packages/gatsby/README.md b/packages/gatsby/README.md index cf5eadf7045b..0473030865da 100644 --- a/packages/gatsby/README.md +++ b/packages/gatsby/README.md @@ -86,4 +86,3 @@ module.exports = { ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 883fd37f1034..d0bf2943065f 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -47,7 +47,7 @@ "dependencies": { "@sentry/core": "9.1.0", "@sentry/react": "9.1.0", - "@sentry/webpack-plugin": "2.22.7" + "@sentry/webpack-plugin": "3.1.2" }, "peerDependencies": { "gatsby": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", diff --git a/packages/google-cloud-serverless/README.md b/packages/google-cloud-serverless/README.md index 124ca28d6c16..833d47a95f56 100644 --- a/packages/google-cloud-serverless/README.md +++ b/packages/google-cloud-serverless/README.md @@ -9,7 +9,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index bf2184205688..eaa0b6140ee7 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -46,7 +46,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/instrumentation": "0.57.1", "@opentelemetry/instrumentation-nestjs-core": "0.44.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@sentry/core": "9.1.0", diff --git a/packages/nextjs/src/client/clientNormalizationIntegration.ts b/packages/nextjs/src/client/clientNormalizationIntegration.ts index e4bbb4881bc3..a7cd2c356f4e 100644 --- a/packages/nextjs/src/client/clientNormalizationIntegration.ts +++ b/packages/nextjs/src/client/clientNormalizationIntegration.ts @@ -2,30 +2,84 @@ import { rewriteFramesIntegration } from '@sentry/browser'; import { defineIntegration } from '@sentry/core'; export const nextjsClientStackFrameNormalizationIntegration = defineIntegration( - ({ assetPrefixPath }: { assetPrefixPath: string }) => { + ({ + assetPrefix, + basePath, + rewriteFramesAssetPrefixPath, + experimentalThirdPartyOriginStackFrames, + }: { + assetPrefix?: string; + basePath?: string; + rewriteFramesAssetPrefixPath: string; + experimentalThirdPartyOriginStackFrames: boolean; + }) => { const rewriteFramesInstance = rewriteFramesIntegration({ // Turn `//_next/static/...` into `app:///_next/static/...` iteratee: frame => { - try { - const { origin } = new URL(frame.filename as string); - frame.filename = frame.filename?.replace(origin, 'app://').replace(assetPrefixPath, ''); - } catch (err) { - // Filename wasn't a properly formed URL, so there's nothing we can do + if (experimentalThirdPartyOriginStackFrames) { + // Not sure why but access to global WINDOW from @sentry/Browser causes hideous ci errors + // eslint-disable-next-line no-restricted-globals + const windowOrigin = typeof window !== 'undefined' && window.location ? window.location.origin : ''; + // A filename starting with the local origin and not ending with JS is most likely JS in HTML which we do not want to rewrite + if (frame.filename?.startsWith(windowOrigin) && !frame.filename.endsWith('.js')) { + return frame; + } + + if (assetPrefix) { + // If the user defined an asset prefix, we need to strip it so that we can match it with uploaded sourcemaps. + // assetPrefix always takes priority over basePath. + if (frame.filename?.startsWith(assetPrefix)) { + frame.filename = frame.filename.replace(assetPrefix, 'app://'); + } + } else if (basePath) { + // If the user defined a base path, we need to strip it to match with uploaded sourcemaps. + // We should only do this for same-origin filenames though, so that third party assets are not rewritten. + try { + const { origin: frameOrigin } = new URL(frame.filename as string); + if (frameOrigin === windowOrigin) { + frame.filename = frame.filename?.replace(frameOrigin, 'app://').replace(basePath, ''); + } + } catch (err) { + // Filename wasn't a properly formed URL, so there's nothing we can do + } + } + } else { + try { + const { origin } = new URL(frame.filename as string); + frame.filename = frame.filename?.replace(origin, 'app://').replace(rewriteFramesAssetPrefixPath, ''); + } catch (err) { + // Filename wasn't a properly formed URL, so there's nothing we can do + } } // We need to URI-decode the filename because Next.js has wildcard routes like "/users/[id].js" which show up as "/users/%5id%5.js" in Error stacktraces. // The corresponding sources that Next.js generates have proper brackets so we also need proper brackets in the frame so that source map resolving works. - if (frame.filename?.startsWith('app:///_next')) { - frame.filename = decodeURI(frame.filename); - } + if (experimentalThirdPartyOriginStackFrames) { + if (frame.filename?.includes('/_next')) { + frame.filename = decodeURI(frame.filename); + } + + if ( + frame.filename?.match( + /\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + ) + ) { + // We don't care about these frames. It's Next.js internal code. + frame.in_app = false; + } + } else { + if (frame.filename?.startsWith('app:///_next')) { + frame.filename = decodeURI(frame.filename); + } - if ( - frame.filename?.match( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, - ) - ) { - // We don't care about these frames. It's Next.js internal code. - frame.in_app = false; + if ( + frame.filename?.match( + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + ) + ) { + // We don't care about these frames. It's Next.js internal code. + frame.in_app = false; + } } return frame; diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 163e29f0b9a7..c8e6d21837fd 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -17,6 +17,9 @@ export { browserTracingIntegration } from './browserTracingIntegration'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesAssetPrefixPath: string; + _sentryAssetPrefix?: string; + _sentryBasePath?: string; + _experimentalThirdPartyOriginStackFrames?: string; }; // Treeshakable guard to remove all code related to tracing @@ -67,13 +70,25 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] { customDefaultIntegrations.push(browserTracingIntegration()); } - // This value is injected at build time, based on the output directory specified in the build config. Though a default + // These values are injected at build time, based on the output directory specified in the build config. Though a default // is set there, we set it here as well, just in case something has gone wrong with the injection. - const assetPrefixPath = + const rewriteFramesAssetPrefixPath = process.env._sentryRewriteFramesAssetPrefixPath || globalWithInjectedValues._sentryRewriteFramesAssetPrefixPath || ''; - customDefaultIntegrations.push(nextjsClientStackFrameNormalizationIntegration({ assetPrefixPath })); + const assetPrefix = process.env._sentryAssetPrefix || globalWithInjectedValues._sentryAssetPrefix; + const basePath = process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; + const experimentalThirdPartyOriginStackFrames = + process.env._experimentalThirdPartyOriginStackFrames === 'true' || + globalWithInjectedValues._experimentalThirdPartyOriginStackFrames === 'true'; + customDefaultIntegrations.push( + nextjsClientStackFrameNormalizationIntegration({ + assetPrefix, + basePath, + rewriteFramesAssetPrefixPath, + experimentalThirdPartyOriginStackFrames, + }), + ); return customDefaultIntegrations; } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index a747684cc753..965233d08b76 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -439,6 +439,15 @@ export type SentryBuildOptions = { * Defaults to `false`. */ automaticVercelMonitors?: boolean; + + /** + * Contains a set of experimental flags that might change in future releases. These flags enable + * features that are still in development and may be modified, renamed, or removed without notice. + * Use with caution in production environments. + */ + _experimental?: Partial<{ + thirdPartyOriginStackFrames: boolean; + }>; }; export type NextConfigFunction = ( diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index f82bb4a0476e..5ff02da355a1 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -624,6 +624,10 @@ function addValueInjectionLoader( _sentryRewriteFramesAssetPrefixPath: assetPrefix ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') : '', + _sentryAssetPrefix: userNextConfig.assetPrefix, + _sentryExperimentalThirdPartyOriginStackFrames: userSentryOptions._experimental?.thirdPartyOriginStackFrames + ? 'true' + : undefined, }; if (buildContext.isServer) { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 8f57f51a8c58..ab15d1c86c0d 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -268,6 +268,14 @@ function setUpBuildTimeVariables(userNextConfig: NextConfigObject, userSentryOpt : '', }; + if (userNextConfig.assetPrefix) { + buildTimeVariables._assetsPrefix = userNextConfig.assetPrefix; + } + + if (userSentryOptions._experimental?.thirdPartyOriginStackFrames) { + buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; + } + if (rewritesTunnelPath) { buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; } @@ -276,6 +284,14 @@ function setUpBuildTimeVariables(userNextConfig: NextConfigObject, userSentryOpt buildTimeVariables._sentryBasePath = basePath; } + if (userNextConfig.assetPrefix) { + buildTimeVariables._sentryAssetPrefix = userNextConfig.assetPrefix; + } + + if (userSentryOptions._experimental?.thirdPartyOriginStackFrames) { + buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; + } + if (typeof userNextConfig.env === 'object') { userNextConfig.env = { ...buildTimeVariables, ...userNextConfig.env }; } else if (userNextConfig.env === undefined) { diff --git a/packages/node/src/integrations/tracing/express-v5/enums/AttributeNames.ts b/packages/node/src/integrations/tracing/express-v5/enums/AttributeNames.ts new file mode 100644 index 000000000000..f6a83e31b073 --- /dev/null +++ b/packages/node/src/integrations/tracing/express-v5/enums/AttributeNames.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ +export enum AttributeNames { + EXPRESS_TYPE = 'express.type', + EXPRESS_NAME = 'express.name', +} diff --git a/packages/node/src/integrations/tracing/express-v5/enums/ExpressLayerType.ts b/packages/node/src/integrations/tracing/express-v5/enums/ExpressLayerType.ts new file mode 100644 index 000000000000..5cfc47c555d9 --- /dev/null +++ b/packages/node/src/integrations/tracing/express-v5/enums/ExpressLayerType.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ +export enum ExpressLayerType { + ROUTER = 'router', + MIDDLEWARE = 'middleware', + REQUEST_HANDLER = 'request_handler', +} diff --git a/packages/node/src/integrations/tracing/express-v5/instrumentation.ts b/packages/node/src/integrations/tracing/express-v5/instrumentation.ts new file mode 100644 index 000000000000..bf2acb26c67d --- /dev/null +++ b/packages/node/src/integrations/tracing/express-v5/instrumentation.ts @@ -0,0 +1,324 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable guard-for-in */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable prefer-rest-params */ +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ + +/* + * 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 { Attributes } from '@opentelemetry/api'; +import { SpanStatusCode, context, diag, trace } from '@opentelemetry/api'; +import { RPCType, getRPCMetadata } from '@opentelemetry/core'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + isWrapped, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { SEMATTRS_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import type * as express from 'express'; +import { AttributeNames } from './enums/AttributeNames'; +import { ExpressLayerType } from './enums/ExpressLayerType'; +import type { ExpressLayer, ExpressRouter, PatchedRequest } from './internal-types'; +import { _LAYERS_STORE_PROPERTY, kLayerPatched } from './internal-types'; +import type { ExpressInstrumentationConfig, ExpressRequestInfo } from './types'; +import { asErrorAndMessage, getLayerMetadata, getLayerPath, isLayerIgnored, storeLayerPath } from './utils'; + +export const PACKAGE_VERSION = '0.1.0'; +export const PACKAGE_NAME = '@sentry/instrumentation-express-v5'; + +/** Express instrumentation for OpenTelemetry */ +export class ExpressInstrumentationV5 extends InstrumentationBase { + constructor(config: ExpressInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + } + + init() { + return [ + new InstrumentationNodeModuleDefinition( + 'express', + ['>=5.0.0'], + moduleExports => this._setup(moduleExports), + moduleExports => this._tearDown(moduleExports), + ), + ]; + } + + private _setup(moduleExports: any) { + const routerProto = moduleExports.Router.prototype; + // patch express.Router.route + if (isWrapped(routerProto.route)) { + this._unwrap(routerProto, 'route'); + } + this._wrap(routerProto, 'route', this._getRoutePatch()); + // patch express.Router.use + if (isWrapped(routerProto.use)) { + this._unwrap(routerProto, 'use'); + } + this._wrap(routerProto, 'use', this._getRouterUsePatch() as any); + // patch express.Application.use + if (isWrapped(moduleExports.application.use)) { + this._unwrap(moduleExports.application, 'use'); + } + this._wrap(moduleExports.application, 'use', this._getAppUsePatch() as any); + return moduleExports; + } + + private _tearDown(moduleExports: any) { + if (moduleExports === undefined) return; + const routerProto = moduleExports.Router.prototype; + this._unwrap(routerProto, 'route'); + this._unwrap(routerProto, 'use'); + this._unwrap(moduleExports.application, 'use'); + } + + /** + * Get the patch for Router.route function + */ + private _getRoutePatch() { + const instrumentation = this; + return function (original: express.Router['route']) { + return function route_trace(this: ExpressRouter, ...args: Parameters) { + const route = original.apply(this, args); + const layer = this.stack[this.stack.length - 1] as ExpressLayer; + instrumentation._applyPatch(layer, getLayerPath(args)); + return route; + }; + }; + } + + /** + * Get the patch for Router.use function + */ + private _getRouterUsePatch() { + const instrumentation = this; + return function (original: express.Router['use']) { + return function use(this: express.Application, ...args: Parameters) { + const route = original.apply(this, args); + const layer = this.stack[this.stack.length - 1] as ExpressLayer; + instrumentation._applyPatch(layer, getLayerPath(args)); + return route; + }; + }; + } + + /** + * Get the patch for Application.use function + */ + private _getAppUsePatch() { + const instrumentation = this; + return function (original: express.Application['use']) { + return function use( + // In express 5.x the router is stored in `router` whereas in 4.x it's stored in `_router` + this: { _router?: ExpressRouter; router?: ExpressRouter }, + ...args: Parameters + ) { + // if we access app.router in express 4.x we trigger an assertion error + // This property existed in v3, was removed in v4 and then re-added in v5 + const router = this.router; + const route = original.apply(this, args); + if (router) { + const layer = router.stack[router.stack.length - 1] as ExpressLayer; + instrumentation._applyPatch(layer, getLayerPath(args)); + } + return route; + }; + }; + } + + /** Patch each express layer to create span and propagate context */ + private _applyPatch(this: ExpressInstrumentationV5, layer: ExpressLayer, layerPath?: string) { + const instrumentation = this; + // avoid patching multiple times the same layer + if (layer[kLayerPatched] === true) return; + layer[kLayerPatched] = true; + + this._wrap(layer, 'handle', original => { + // TODO: instrument error handlers + if (original.length === 4) return original; + + const patched = function (this: ExpressLayer, req: PatchedRequest, res: express.Response) { + storeLayerPath(req, layerPath); + const route = (req[_LAYERS_STORE_PROPERTY] as string[]) + .filter(path => path !== '/' && path !== '/*') + .join('') + // remove duplicate slashes to normalize route + .replace(/\/{2,}/g, '/'); + + const attributes: Attributes = { + // eslint-disable-next-line deprecation/deprecation + [SEMATTRS_HTTP_ROUTE]: route.length > 0 ? route : '/', + }; + 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 || '/'; + } + + // verify against the config if the layer should be ignored + if (isLayerIgnored(metadata.name, type, instrumentation.getConfig())) { + if (type === ExpressLayerType.MIDDLEWARE) { + (req[_LAYERS_STORE_PROPERTY] as string[]).pop(); + } + return original.apply(this, arguments); + } + + if (trace.getSpan(context.active()) === undefined) { + return original.apply(this, arguments); + } + + const spanName = instrumentation._getSpanName( + { + request: req, + layerType: type, + route, + }, + metadata.name, + ); + const span = instrumentation.tracer.startSpan(spanName, { + attributes: Object.assign(attributes, metadata.attributes), + }); + + const { requestHook } = instrumentation.getConfig(); + if (requestHook) { + safeExecuteInTheMiddle( + () => + requestHook(span, { + request: req, + layerType: type, + route, + }), + e => { + if (e) { + diag.error('express instrumentation: request hook failed', e); + } + }, + true, + ); + } + + let spanHasEnded = false; + if (metadata.attributes[AttributeNames.EXPRESS_TYPE] !== ExpressLayerType.MIDDLEWARE) { + span.end(); + spanHasEnded = true; + } + // listener for response.on('finish') + const onResponseFinish = () => { + if (spanHasEnded === false) { + spanHasEnded = true; + span.end(); + } + }; + + // verify we have a callback + const args = Array.from(arguments); + const callbackIdx = args.findIndex(arg => typeof arg === 'function'); + if (callbackIdx >= 0) { + arguments[callbackIdx] = function () { + // express considers anything but an empty value, "route" or "router" + // passed to its callback to be an error + const maybeError = arguments[0]; + const isError = ![undefined, null, 'route', 'router'].includes(maybeError); + if (!spanHasEnded && isError) { + const [error, message] = asErrorAndMessage(maybeError); + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + } + + if (spanHasEnded === false) { + spanHasEnded = true; + req.res?.removeListener('finish', onResponseFinish); + span.end(); + } + if (!(req.route && isError)) { + (req[_LAYERS_STORE_PROPERTY] as string[]).pop(); + } + const callback = args[callbackIdx] as Function; + return callback.apply(this, arguments); + }; + } + + try { + return original.apply(this, arguments); + } catch (anyError) { + const [error, message] = asErrorAndMessage(anyError); + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + throw anyError; + } finally { + /** + * At this point if the callback wasn't called, that means either the + * layer is asynchronous (so it will call the callback later on) or that + * the layer directly end the http response, so we'll hook into the "finish" + * event to handle the later case. + */ + if (!spanHasEnded) { + res.once('finish', onResponseFinish); + } + } + }; + + // `handle` isn't just a regular function in some cases. It also contains + // some properties holding metadata and state so we need to proxy them + // through through patched function + // ref: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/1950 + // Also some apps/libs do their own patching before OTEL and have these properties + // in the proptotype. So we use a `for...in` loop to get own properties and also + // any enumerable prop in the prototype chain + // ref: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2271 + for (const key in original) { + Object.defineProperty(patched, key, { + get() { + return original[key]; + }, + set(value) { + original[key] = value; + }, + }); + } + return patched; + }); + } + + _getSpanName(info: ExpressRequestInfo, defaultName: string) { + const { spanNameHook } = this.getConfig(); + + if (!(spanNameHook instanceof Function)) { + return defaultName; + } + + try { + return spanNameHook(info, defaultName) ?? defaultName; + } catch (err) { + diag.error('express instrumentation: error calling span name rewrite hook', err); + return defaultName; + } + } +} diff --git a/packages/node/src/integrations/tracing/express-v5/internal-types.ts b/packages/node/src/integrations/tracing/express-v5/internal-types.ts new file mode 100644 index 000000000000..482dc0b6b4ea --- /dev/null +++ b/packages/node/src/integrations/tracing/express-v5/internal-types.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ + +/* + * 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 { Request } from 'express'; + +/** + * This symbol is used to mark express layer as being already instrumented + * since its possible to use a given layer multiple times (ex: middlewares) + */ +export const kLayerPatched: unique symbol = Symbol('express-layer-patched'); + +/** + * This const define where on the `request` object the Instrumentation will mount the + * current stack of express layer. + * + * It is necessary because express doesn't store the different layers + * (ie: middleware, router etc) that it called to get to the current layer. + * Given that, the only way to know the route of a given layer is to + * store the path of where each previous layer has been mounted. + * + * ex: bodyParser > auth middleware > /users router > get /:id + * in this case the stack would be: ["/users", "/:id"] + * + * ex2: bodyParser > /api router > /v1 router > /users router > get /:id + * stack: ["/api", "/v1", "/users", ":id"] + * + */ +export const _LAYERS_STORE_PROPERTY = '__ot_middlewares'; + +export type PatchedRequest = { + [_LAYERS_STORE_PROPERTY]?: string[]; +} & Request; +export type PathParams = string | RegExp | Array; + +// https://github.com/expressjs/express/blob/main/lib/router/index.js#L53 +export type ExpressRouter = { + stack: ExpressLayer[]; +}; + +// https://github.com/expressjs/express/blob/main/lib/router/layer.js#L33 +export type ExpressLayer = { + handle: Function & Record; + [kLayerPatched]?: boolean; + name: string; + path: string; + route?: ExpressLayer; +}; diff --git a/packages/node/src/integrations/tracing/express-v5/types.ts b/packages/node/src/integrations/tracing/express-v5/types.ts new file mode 100644 index 000000000000..0623cac1cbc5 --- /dev/null +++ b/packages/node/src/integrations/tracing/express-v5/types.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* + * 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 { Span } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import type { ExpressLayerType } from './enums/ExpressLayerType'; + +export type LayerPathSegment = string | RegExp | number; + +export type IgnoreMatcher = string | RegExp | ((name: string) => boolean); + +export type ExpressRequestInfo = { + /** An express request object */ + request: T; + route: string; + layerType: ExpressLayerType; +}; + +export type SpanNameHook = ( + info: ExpressRequestInfo, + /** + * If no decision is taken based on RequestInfo, the default name + * supplied by the instrumentation can be used instead. + */ + defaultName: string, +) => string; + +/** + * Function that can be used to add custom attributes to the current span or the root span on + * a Express request + * @param span - The Express middleware layer span. + * @param info - An instance of ExpressRequestInfo that contains info about the request such as the route, and the layer type. + */ +export interface ExpressRequestCustomAttributeFunction { + (span: Span, info: ExpressRequestInfo): void; +} + +/** + * Options available for the Express Instrumentation (see [documentation](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-express#express-instrumentation-options)) + */ +export interface ExpressInstrumentationConfig extends InstrumentationConfig { + /** Ignore specific based on their name */ + ignoreLayers?: IgnoreMatcher[]; + /** Ignore specific layers based on their type */ + ignoreLayersType?: ExpressLayerType[]; + spanNameHook?: SpanNameHook; + + /** Function for adding custom attributes on Express request */ + requestHook?: ExpressRequestCustomAttributeFunction; +} diff --git a/packages/node/src/integrations/tracing/express-v5/utils.ts b/packages/node/src/integrations/tracing/express-v5/utils.ts new file mode 100644 index 000000000000..45ef61ed7eb6 --- /dev/null +++ b/packages/node/src/integrations/tracing/express-v5/utils.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +/* + * 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 { Attributes } from '@opentelemetry/api'; +import { AttributeNames } from './enums/AttributeNames'; +import { ExpressLayerType } from './enums/ExpressLayerType'; +import type { ExpressLayer, PatchedRequest } from './internal-types'; +import { _LAYERS_STORE_PROPERTY } from './internal-types'; +import type { ExpressInstrumentationConfig, IgnoreMatcher, LayerPathSegment } from './types'; + +/** + * Store layers path in the request to be able to construct route later + * @param request The request where + * @param [value] the value to push into the array + */ +export const storeLayerPath = (request: PatchedRequest, value?: string): void => { + if (Array.isArray(request[_LAYERS_STORE_PROPERTY]) === false) { + Object.defineProperty(request, _LAYERS_STORE_PROPERTY, { + enumerable: false, + value: [], + }); + } + if (value === undefined) return; + (request[_LAYERS_STORE_PROPERTY] as string[]).push(value); +}; + +/** + * Recursively search the router path from layer stack + * @param path The path to reconstruct + * @param layer The layer to reconstruct from + * @returns The reconstructed path + */ +export const getRouterPath = (path: string, layer: ExpressLayer): string => { + const stackLayer = layer.handle?.stack?.[0]; + + if (stackLayer?.route?.path) { + return `${path}${stackLayer.route.path}`; + } + + if (stackLayer?.handle?.stack) { + return getRouterPath(path, stackLayer); + } + + return path; +}; + +/** + * Parse express layer context to retrieve a name and attributes. + * @param route The route of the layer + * @param layer Express layer + * @param [layerPath] if present, the path on which the layer has been mounted + */ +export const getLayerMetadata = ( + route: string, + layer: ExpressLayer, + layerPath?: string, +): { + attributes: Attributes; + name: string; +} => { + if (layer.name === 'router') { + const maybeRouterPath = getRouterPath('', layer); + const extractedRouterPath = maybeRouterPath ? maybeRouterPath : layerPath || route || '/'; + + return { + attributes: { + [AttributeNames.EXPRESS_NAME]: extractedRouterPath, + [AttributeNames.EXPRESS_TYPE]: ExpressLayerType.ROUTER, + }, + name: `router - ${extractedRouterPath}`, + }; + } else if (layer.name === 'bound dispatch' || layer.name === 'handle') { + return { + attributes: { + [AttributeNames.EXPRESS_NAME]: (route || layerPath) ?? 'request handler', + [AttributeNames.EXPRESS_TYPE]: ExpressLayerType.REQUEST_HANDLER, + }, + name: `request handler${layer.path ? ` - ${route || layerPath}` : ''}`, + }; + } else { + return { + attributes: { + [AttributeNames.EXPRESS_NAME]: layer.name, + [AttributeNames.EXPRESS_TYPE]: ExpressLayerType.MIDDLEWARE, + }, + name: `middleware - ${layer.name}`, + }; + } +}; + +/** + * Check whether the given obj match pattern + * @param constant e.g URL of request + * @param obj obj to inspect + * @param pattern Match pattern + */ +const satisfiesPattern = (constant: string, pattern: IgnoreMatcher): boolean => { + if (typeof pattern === 'string') { + return pattern === constant; + } else if (pattern instanceof RegExp) { + return pattern.test(constant); + } else if (typeof pattern === 'function') { + return pattern(constant); + } else { + throw new TypeError('Pattern is in unsupported datatype'); + } +}; + +/** + * Check whether the given request is ignored by configuration + * It will not re-throw exceptions from `list` provided by the client + * @param constant e.g URL of request + * @param [list] List of ignore patterns + * @param [onException] callback for doing something when an exception has + * occurred + */ +export const isLayerIgnored = ( + name: string, + type: ExpressLayerType, + config?: ExpressInstrumentationConfig, +): boolean => { + if (Array.isArray(config?.ignoreLayersType) && config?.ignoreLayersType?.includes(type)) { + return true; + } + if (Array.isArray(config?.ignoreLayers) === false) return false; + try { + for (const pattern of config!.ignoreLayers!) { + if (satisfiesPattern(name, pattern)) { + return true; + } + } + } catch (e) { + /* catch block */ + } + + return false; +}; + +/** + * Converts a user-provided error value into an error and error message pair + * + * @param error - User-provided error value + * @returns Both an Error or string representation of the value and an error message + */ +export const asErrorAndMessage = (error: unknown): [error: string | Error, message: string] => + error instanceof Error ? [error, error.message] : [String(error), String(error)]; + +/** + * Extracts the layer path from the route arguments + * + * @param args - Arguments of the route + * @returns The layer path + */ +export const getLayerPath = (args: [LayerPathSegment | LayerPathSegment[], ...unknown[]]): string | undefined => { + const firstArg = args[0]; + + if (Array.isArray(firstArg)) { + return firstArg.map(arg => extractLayerPathSegment(arg) || '').join(','); + } + + return extractLayerPathSegment(firstArg); +}; + +const extractLayerPathSegment = (arg: LayerPathSegment) => { + if (typeof arg === 'string') { + return arg; + } + + if (arg instanceof RegExp || typeof arg === 'number') { + return arg.toString(); + } + + return; +}; diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index 13f50cff0202..cf6f6b233cdf 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -1,4 +1,6 @@ import type * as http from 'node:http'; +import type { Span } from '@opentelemetry/api'; +import type { ExpressRequestInfo } from '@opentelemetry/instrumentation-express'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; import type { IntegrationFn } from '@sentry/core'; import { @@ -14,44 +16,58 @@ import { DEBUG_BUILD } from '../../debug-build'; import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; import { ensureIsWrapped } from '../../utils/ensureIsWrapped'; +import { ExpressInstrumentationV5 } from './express-v5/instrumentation'; const INTEGRATION_NAME = 'Express'; +const INTEGRATION_NAME_V5 = 'Express-V5'; + +function requestHook(span: Span): void { + addOriginToSpan(span, 'auto.http.otel.express'); + + const attributes = spanToJSON(span).data; + // this is one of: middleware, request_handler, router + const type = attributes['express.type']; + + if (type) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.express`); + } + + // Also update the name, we don't need to "middleware - " prefix + const name = attributes['express.name']; + if (typeof name === 'string') { + span.updateName(name); + } +} + +function spanNameHook(info: ExpressRequestInfo, defaultName: string): string { + if (getIsolationScope() === getDefaultIsolationScope()) { + DEBUG_BUILD && logger.warn('Isolation scope is still default isolation scope - skipping setting transactionName'); + return defaultName; + } + if (info.layerType === 'request_handler') { + // type cast b/c Otel unfortunately types info.request as any :( + const req = info.request as { method?: string }; + const method = req.method ? req.method.toUpperCase() : 'GET'; + getIsolationScope().setTransactionName(`${method} ${info.route}`); + } + return defaultName; +} export const instrumentExpress = generateInstrumentOnce( INTEGRATION_NAME, () => new ExpressInstrumentation({ - requestHook(span) { - addOriginToSpan(span, 'auto.http.otel.express'); - - const attributes = spanToJSON(span).data; - // this is one of: middleware, request_handler, router - const type = attributes['express.type']; - - if (type) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `${type}.express`); - } - - // Also update the name, we don't need to "middleware - " prefix - const name = attributes['express.name']; - if (typeof name === 'string') { - span.updateName(name); - } - }, - spanNameHook(info, defaultName) { - if (getIsolationScope() === getDefaultIsolationScope()) { - DEBUG_BUILD && - logger.warn('Isolation scope is still default isolation scope - skipping setting transactionName'); - return defaultName; - } - if (info.layerType === 'request_handler') { - // type cast b/c Otel unfortunately types info.request as any :( - const req = info.request as { method?: string }; - const method = req.method ? req.method.toUpperCase() : 'GET'; - getIsolationScope().setTransactionName(`${method} ${info.route}`); - } - return defaultName; - }, + requestHook: span => requestHook(span), + spanNameHook: (info, defaultName) => spanNameHook(info, defaultName), + }), +); + +export const instrumentExpressV5 = generateInstrumentOnce( + INTEGRATION_NAME_V5, + () => + new ExpressInstrumentationV5({ + requestHook: span => requestHook(span), + spanNameHook: (info, defaultName) => spanNameHook(info, defaultName), }), ); @@ -60,6 +76,7 @@ const _expressIntegration = (() => { name: INTEGRATION_NAME, setupOnce() { instrumentExpress(); + instrumentExpressV5(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 7d06689f250d..2873a2643617 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -3,7 +3,7 @@ import { instrumentOtelHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { connectIntegration, instrumentConnect } from './connect'; -import { expressIntegration, instrumentExpress } from './express'; +import { expressIntegration, instrumentExpress, instrumentExpressV5 } from './express'; import { fastifyIntegration, instrumentFastify } from './fastify'; import { genericPoolIntegration, instrumentGenericPool } from './genericPool'; import { graphqlIntegration, instrumentGraphql } from './graphql'; @@ -58,6 +58,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => return [ instrumentOtelHttp, instrumentExpress, + instrumentExpressV5, instrumentConnect, instrumentFastify, instrumentHapi, diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 62496001273b..599b564f62a2 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -3,9 +3,23 @@ import type { SentryRollupPluginOptions } from '@sentry/rollup-plugin'; import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import type { init as initVue } from '@sentry/vue'; -// Omitting 'app' as the Nuxt SDK will add the app instance in the client plugin (users do not have to provide this) +// Omitting Vue 'app' as the Nuxt SDK will add the app instance in the client plugin (users do not have to provide this) +// Adding `& object` helps TS with inferring that this is not `undefined` but an object type export type SentryNuxtClientOptions = Omit[0] & object, 'app'>; -export type SentryNuxtServerOptions = Omit[0] & object, 'app'>; +export type SentryNuxtServerOptions = Parameters[0] & { + /** + * Enables the Sentry error handler for the Nitro error hook. + * + * When enabled, exceptions are automatically sent to Sentry with additional data such as the transaction name and Nitro error context. + * It's recommended to keep this enabled unless you need to implement a custom error handler. + * + * If you need a custom implementation, disable this option and refer to the default handler as a reference: + * https://github.com/getsentry/sentry-javascript/blob/da8ba8d77a28b43da5014acc8dd98906d2180cc1/packages/nuxt/src/runtime/plugins/sentry.server.ts#L20-L46 + * + * @default true + */ + enableNitroErrorHandler?: boolean; +}; type SourceMapsOptions = { /** diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index dc9bf360af9e..b1393a076029 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -1,7 +1,6 @@ import type { Client, Integration, Options, StackParser } from '@sentry/core'; -import type { SentryNuxtClientOptions } from './common/types'; +import type { SentryNuxtClientOptions, SentryNuxtServerOptions } from './common/types'; import type * as clientSdk from './index.client'; -import type * as serverSdk from './index.server'; // We export everything from both the client part of the SDK and from the server part. Some of the exports collide, // which is not allowed, unless we re-export the colliding exports in this file - which we do below. @@ -9,7 +8,7 @@ export * from './index.client'; export * from './index.server'; // re-export colliding types -export declare function init(options: Options | SentryNuxtClientOptions | serverSdk.NodeOptions): Client | undefined; +export declare function init(options: Options | SentryNuxtClientOptions | SentryNuxtServerOptions): Client | undefined; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; export declare const getDefaultIntegrations: (options: Options) => Integration[]; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 5d828775b62f..f65ac64b9982 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,14 +1,13 @@ import { GLOBAL_OBJ, flush, - getClient, getDefaultIsolationScope, getIsolationScope, logger, vercelWaitUntil, withIsolationScope, } from '@sentry/core'; -import * as Sentry from '@sentry/node'; +import * as SentryNode from '@sentry/node'; import { type EventHandler, H3Error } from 'h3'; import { defineNitroPlugin } from 'nitropack/runtime'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; @@ -18,6 +17,17 @@ export default defineNitroPlugin(nitroApp => { nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); nitroApp.hooks.hook('error', async (error, errorContext) => { + const sentryClient = SentryNode.getClient(); + const sentryClientOptions = sentryClient?.getOptions(); + + if ( + sentryClientOptions && + 'enableNitroErrorHandler' in sentryClientOptions && + sentryClientOptions.enableNitroErrorHandler === false + ) { + return; + } + // Do not handle 404 and 422 if (error instanceof H3Error) { // Do not report if status code is 3xx or 4xx @@ -32,12 +42,12 @@ export default defineNitroPlugin(nitroApp => { }; if (path) { - Sentry.getCurrentScope().setTransactionName(`${method} ${path}`); + SentryNode.getCurrentScope().setTransactionName(`${method} ${path}`); } const structuredContext = extractErrorContext(errorContext); - Sentry.captureException(error, { + SentryNode.captureException(error, { captureContext: { contexts: { nuxt: structuredContext } }, mechanism: { handled: false }, }); @@ -67,7 +77,7 @@ async function flushIfServerless(): Promise { } async function flushWithTimeout(): Promise { - const sentryClient = getClient(); + const sentryClient = SentryNode.getClient(); const isDebug = sentryClient ? sentryClient.getOptions().debug : false; try { diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 59da97499550..c0df11485d5f 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -30,7 +30,7 @@ export function findDefaultSdkInitFile(type: 'server' | 'client'): string | unde * Extracts the filename from a node command with a path. */ export function getFilenameFromNodeStartCommand(nodeCommand: string): string | null { - const regex = /[^/\\]+$/; + const regex = /[^/\\]+\.[^/\\]+$/; const match = nodeCommand.match(regex); return match ? match[0] : null; } diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index f2f6b2b23c8d..24e5a601535e 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -107,6 +107,12 @@ describe('getFilenameFromPath', () => { const filename = getFilenameFromNodeStartCommand(path); expect(filename).toBeNull(); }); + + it('should return null for commands without file extensions', () => { + const path = 'npx @azure/static-web-apps-cli start .output/public --api-location .output/server'; + const filename = getFilenameFromNodeStartCommand(path); + expect(filename).toBeNull(); + }); }); describe('removeSentryQueryFromPath', () => { diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index c6a838a5574f..1c88afea0f51 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -11,6 +11,7 @@ import type { TransactionEvent, TransactionSource, } from '@sentry/core'; +import { convertSpanLinksForEnvelope } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -247,6 +248,7 @@ export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEve ...removeSentryAttributes(span.attributes), }); + const { links } = span; const { traceId: trace_id, spanId: span_id } = span.spanContext(); // If parentSpanIdFromTraceState is defined at all, we want it to take precedence @@ -266,6 +268,7 @@ export function createTransactionForOtelSpan(span: ReadableSpan): TransactionEve origin, op, status: getStatusMessage(status), // As per protocol, span status is allowed to be undefined + links: convertSpanLinksForEnvelope(links), }); const statusCode = attributes[ATTR_HTTP_RESPONSE_STATUS_CODE]; @@ -322,7 +325,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS const span_id = span.spanContext().spanId; const trace_id = span.spanContext().traceId; - const { attributes, startTime, endTime, parentSpanId } = span; + const { attributes, startTime, endTime, parentSpanId, links } = span; const { op, description, data, origin = 'manual' } = getSpanData(span); const allData = dropUndefinedKeys({ @@ -347,6 +350,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], sentS op, origin, measurements: timedEventsToMeasurements(span.events), + links: convertSpanLinksForEnvelope(links), }); spans.push(spanJSON); diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index 7d65a11f2295..77f3cac6ddf0 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -160,7 +160,7 @@ function getTracer(): Tracer { } function getSpanOptions(options: OpenTelemetrySpanContext): SpanOptions { - const { startTime, attributes, kind, op } = options; + const { startTime, attributes, kind, op, links } = options; // OTEL expects timestamps in ms, not seconds const fixedStartTime = typeof startTime === 'number' ? ensureTimestampInMilliseconds(startTime) : startTime; @@ -173,6 +173,7 @@ function getSpanOptions(options: OpenTelemetrySpanContext): SpanOptions { } : attributes, kind, + links, startTime: fixedStartTime, }; } diff --git a/packages/opentelemetry/test/spanExporter.test.ts b/packages/opentelemetry/test/spanExporter.test.ts index 48ab8da060de..c8052bbad2a3 100644 --- a/packages/opentelemetry/test/spanExporter.test.ts +++ b/packages/opentelemetry/test/spanExporter.test.ts @@ -1,5 +1,5 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan } from '@sentry/core'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan, startSpanManual } from '@sentry/core'; import { createTransactionForOtelSpan } from '../src/spanExporter'; import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; @@ -108,4 +108,61 @@ describe('createTransactionForOtelSpan', () => { transaction_info: { source: 'custom' }, }); }); + + it('adds span link to the trace context when adding with addLink()', () => { + const span = startInactiveSpan({ name: 'parent1' }); + span.end(); + + startSpanManual({ name: 'rootSpan' }, rootSpan => { + rootSpan.addLink({ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }); + rootSpan.end(); + + const prevTraceId = span.spanContext().traceId; + const prevSpanId = span.spanContext().spanId; + const event = createTransactionForOtelSpan(rootSpan as any); + + expect(event.contexts?.trace).toEqual( + expect.objectContaining({ + links: [ + expect.objectContaining({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + trace_id: expect.stringMatching(prevTraceId), + span_id: expect.stringMatching(prevSpanId), + }), + ], + }), + ); + }); + }); + + it('adds span link to the trace context when linked in span options', () => { + const span = startInactiveSpan({ name: 'parent1' }); + + const prevTraceId = span.spanContext().traceId; + const prevSpanId = span.spanContext().spanId; + + const linkedSpan = startInactiveSpan({ + name: 'parent2', + links: [{ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }], + }); + + span.end(); + linkedSpan.end(); + + const event = createTransactionForOtelSpan(linkedSpan as any); + + expect(event.contexts?.trace).toEqual( + expect.objectContaining({ + links: [ + expect.objectContaining({ + attributes: { 'sentry.link.type': 'previous_trace' }, + sampled: true, + trace_id: expect.stringMatching(prevTraceId), + span_id: expect.stringMatching(prevSpanId), + }), + ], + }), + ); + }); }); diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 184b93b1e71b..93d5fa448dda 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -356,6 +356,78 @@ describe('trace', () => { }); }); + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpan({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpan( + { + name: '/users/:id', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }, + rawSpan2 => { + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }, + ); + }); + it('allows to force a transaction with forceTransaction=true', async () => { const client = getClient()!; const transactionEvents: Event[] = []; @@ -617,6 +689,44 @@ describe('trace', () => { }); }); + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const rawSpan2 = startInactiveSpan({ + name: 'GET users/[id]', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }); + + const span1JSON = spanToJSON(rawSpan1); + const span2JSON = spanToJSON(rawSpan2); + const span2LinkJSON = span2JSON.links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + + // sampling decision is inherited + expect(span2LinkJSON?.sampled).toBe(Boolean(spanToJSON(rawSpan1).data['sentry.sample_rate'])); + }); + it('allows to force a transaction with forceTransaction=true', async () => { const client = getClient()!; const transactionEvents: Event[] = []; @@ -906,6 +1016,78 @@ describe('trace', () => { }); }); + it('allows to add span links', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpanManual({ name: '/users/:id' }, rawSpan2 => { + rawSpan2.addLink({ + context: rawSpan1.spanContext(), + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }); + }); + + it('allows to pass span links in span options', () => { + const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); + + // @ts-expect-error links exists on span + expect(rawSpan1?.links).toEqual([]); + + const span1JSON = spanToJSON(rawSpan1); + + startSpanManual( + { + name: '/users/:id', + links: [ + { + context: rawSpan1.spanContext(), + attributes: { 'sentry.link.type': 'previous_trace' }, + }, + ], + }, + rawSpan2 => { + const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; + + expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); + expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); + + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); + // @ts-expect-error links and _spanContext exist on span + expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); + expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); + }, + ); + }); + it('allows to force a transaction with forceTransaction=true', async () => { const client = getClient()!; const transactionEvents: Event[] = []; diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 5b455f72974d..e134a272b929 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ - -import { CpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; +import { CpuProfilerBindings, ProfileFormat, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; import type { Event, IntegrationFn, Profile, ProfileChunk, ProfilingIntegration, Span } from '@sentry/core'; import { LRUMap, @@ -18,8 +17,6 @@ import type { NodeClient } from '@sentry/node'; import { DEBUG_BUILD } from './debug-build'; import { NODE_MAJOR, NODE_VERSION } from './nodeVersion'; import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils'; -import type { RawThreadCpuProfile } from './types'; -import { ProfileFormat } from './types'; import { PROFILER_THREAD_ID_STRING, PROFILER_THREAD_NAME, @@ -30,7 +27,7 @@ import { makeProfileChunkEnvelope, } from './utils'; -const CHUNK_INTERVAL_MS = 5000; +const CHUNK_INTERVAL_MS = 1000 * 60; const PROFILE_MAP = new LRUMap(50); const PROFILE_TIMEOUTS: Record = {}; diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index 342075bde890..9ff20816895c 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -1,9 +1,8 @@ -import { CpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; +import { CpuProfilerBindings, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; import type { CustomSamplingContext, Span } from '@sentry/core'; import { logger, spanIsSampled, spanToJSON, uuid4 } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { DEBUG_BUILD } from './debug-build'; -import type { RawThreadCpuProfile } from './types'; import { isValidSampleRate } from './utils'; export const MAX_PROFILE_DURATION_MS = 30 * 1000; diff --git a/packages/profiling-node/src/types.ts b/packages/profiling-node/src/types.ts deleted file mode 100644 index 9b8f039b3c95..000000000000 --- a/packages/profiling-node/src/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Event } from '@sentry/core'; - -interface Sample { - stack_id: number; - thread_id: string; - elapsed_since_start_ns: string; -} - -interface ChunkSample { - stack_id: number; - thread_id: string; - timestamp: number; -} - -type Frame = { - function: string; - file: string; - lineno: number; - colno: number; -}; - -interface Measurement { - unit: string; - values: { - elapsed_since_start_ns: number; - value: number; - }[]; -} - -// Profile is marked as optional because it is deleted from the metadata -// by the integration before the event is processed by other integrations. -export interface ProfiledEvent extends Event { - sdkProcessingMetadata: { - profile?: RawThreadCpuProfile; - }; -} - -interface BaseProfile { - profile_id?: string; - stacks: number[][]; - frames: Frame[]; - resources: string[]; - profiler_logging_mode: 'eager' | 'lazy'; - measurements: Record; -} -export interface RawThreadCpuProfile extends BaseProfile { - samples: Sample[]; -} - -export interface RawChunkCpuProfile extends BaseProfile { - samples: ChunkSample[]; -} - -export interface PrivateV8CpuProfilerBindings { - startProfiling?: (name: string) => void; - - stopProfiling?( - name: string, - format: ProfileFormat.THREAD, - threadId: number, - collectResources: boolean, - ): RawThreadCpuProfile | null; - stopProfiling?( - name: string, - format: ProfileFormat.CHUNK, - threadId: number, - collectResources: boolean, - ): RawChunkCpuProfile | null; - - // Helper methods exposed for testing - getFrameModule(abs_path: string): string; -} - -export enum ProfileFormat { - THREAD = 0, - CHUNK = 1, -} - -export interface V8CpuProfilerBindings { - startProfiling(name: string): void; - - stopProfiling(name: string, format: ProfileFormat.THREAD): RawThreadCpuProfile | null; - stopProfiling(name: string, format: ProfileFormat.CHUNK): RawChunkCpuProfile | null; -} diff --git a/packages/profiling-node/src/utils.ts b/packages/profiling-node/src/utils.ts index e6ab3803ebdd..23b05f14f67b 100644 --- a/packages/profiling-node/src/utils.ts +++ b/packages/profiling-node/src/utils.ts @@ -27,8 +27,8 @@ import { import { env, versions } from 'process'; import { isMainThread, threadId } from 'worker_threads'; +import type { RawChunkCpuProfile, RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; import { DEBUG_BUILD } from './debug-build'; -import type { RawChunkCpuProfile, RawThreadCpuProfile } from './types'; // We require the file because if we import it, it will be included in the bundle. // I guess tsc does not check file contents when it's imported. diff --git a/packages/profiling-node/test/spanProfileUtils.test.ts b/packages/profiling-node/test/spanProfileUtils.test.ts index 758307d3fa34..c0640064c537 100644 --- a/packages/profiling-node/test/spanProfileUtils.test.ts +++ b/packages/profiling-node/test/spanProfileUtils.test.ts @@ -501,7 +501,7 @@ describe('continuous profiling', () => { expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); Sentry.profiler.startProfiler(); - jest.advanceTimersByTime(5001); + jest.advanceTimersByTime(60_001); expect(stopProfilingSpy).toHaveBeenCalledTimes(1); expect(startProfilingSpy).toHaveBeenCalledTimes(2); }); @@ -518,7 +518,7 @@ describe('continuous profiling', () => { Sentry.profiler.startProfiler(); const profilerId = getProfilerId(); - jest.advanceTimersByTime(5001); + jest.advanceTimersByTime(60_001); expect(stopProfilingSpy).toHaveBeenCalledTimes(1); expect(startProfilingSpy).toHaveBeenCalledTimes(2); expect(getProfilerId()).toBe(profilerId); @@ -552,7 +552,7 @@ describe('continuous profiling', () => { expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); Sentry.profiler.startProfiler(); - jest.advanceTimersByTime(5001); + jest.advanceTimersByTime(60_001); expect(stopProfilingSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/profiling-node/test/utils.test.ts b/packages/profiling-node/test/utils.test.ts index dac0bf16be79..2e5b79e9baee 100644 --- a/packages/profiling-node/test/utils.test.ts +++ b/packages/profiling-node/test/utils.test.ts @@ -1,6 +1,7 @@ import { addItemToEnvelope, createEnvelope, uuid4 } from '@sentry/core'; import type { Event } from '@sentry/core'; +import type { RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; import { addProfilesToEnvelope, findProfiledTransactionsFromEnvelope, @@ -8,7 +9,11 @@ import { isValidSampleRate, } from '../src/utils'; -import type { ProfiledEvent } from '../src/types'; +interface ProfiledEvent extends Event { + sdkProcessingMetadata: { + profile?: RawThreadCpuProfile; + }; +} function makeProfile( props: Partial, diff --git a/packages/react/README.md b/packages/react/README.md index 066ab7f7c828..b8d9879aa231 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -9,7 +9,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/react/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/remix/README.md b/packages/remix/README.md index 7914555728f1..fbf8480d5e25 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -48,7 +48,7 @@ import * as Sentry from '@sentry/remix'; Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1, - integrations: [new Sentry.Integrations.Prisma({ client: prisma })], + integrations: [Sentry.prismaIntegration()], // ... }); ``` diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index c2a9206feb0d..4ec1a357eac4 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -150,6 +150,8 @@ export class Replay implements Integration { // this can happen if the error is frozen or does not allow mutation for other reasons } }, + // experimental support for recording iframes from different origins + recordCrossOriginIframes: Boolean(_experiments.recordCrossOriginIframes), }; this._initialOptions = { diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 280db17db57a..cc8e6f1827ac 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -229,6 +229,11 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { captureExceptions: boolean; traceInternals: boolean; continuousCheckout: number; + /** + * Before enabling, please read the security considerations: + * https://github.com/rrweb-io/rrweb/blob/master/docs/recipes/cross-origin-iframes.md#considerations + */ + recordCrossOriginIframes: boolean; autoFlushOnFeedback: boolean; }>; } diff --git a/packages/replay-internal/src/types/rrweb.ts b/packages/replay-internal/src/types/rrweb.ts index 60e562cadf55..33f5e1b3bf7f 100644 --- a/packages/replay-internal/src/types/rrweb.ts +++ b/packages/replay-internal/src/types/rrweb.ts @@ -43,6 +43,7 @@ export type RrwebRecordOptions = { maskTextSelector?: string; blockSelector?: string; maskInputOptions?: Record; + recordCrossOriginIframes?: boolean; } & Record; export interface CanvasManagerInterface { diff --git a/packages/replay-internal/test/integration/rrweb.test.ts b/packages/replay-internal/test/integration/rrweb.test.ts index 7f156c542f08..0e6ada8b0d2a 100644 --- a/packages/replay-internal/test/integration/rrweb.test.ts +++ b/packages/replay-internal/test/integration/rrweb.test.ts @@ -40,6 +40,7 @@ describe('Integration | rrweb', () => { "maskTextFn": undefined, "maskTextSelector": ".sentry-mask,[data-sentry-mask]", "onMutation": [Function], + "recordCrossOriginIframes": false, "slimDOMOptions": "all", "unblockSelector": "", "unmaskTextSelector": "", @@ -80,6 +81,7 @@ describe('Integration | rrweb', () => { "maskTextFn": undefined, "maskTextSelector": ".sentry-mask,[data-sentry-mask]", "onMutation": [Function], + "recordCrossOriginIframes": false, "slimDOMOptions": "all", "unblockSelector": "", "unmaskTextSelector": "", @@ -131,6 +133,7 @@ describe('Integration | rrweb', () => { "maskTextFn": undefined, "maskTextSelector": ".sentry-mask,[data-sentry-mask]", "onMutation": [Function], + "recordCrossOriginIframes": false, "sampling": { "mousemove": false, }, diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index ba1535d799de..dbcc01675dc3 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -9,9 +9,7 @@ "engines": { "node": ">=18" }, - "files": [ - "/build" - ], + "files": ["/build"], "main": "build/cjs/index.server.js", "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", @@ -20,6 +18,10 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.types.d.ts", + "worker": { + "import": "./build/esm/index.worker.js", + "require": "./build/cjs/index.worker.js" + }, "browser": { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" @@ -40,11 +42,12 @@ } }, "dependencies": { + "@sentry/cloudflare": "9.1.0", "@sentry/core": "9.1.0", "@sentry/node": "9.1.0", "@sentry/opentelemetry": "9.1.0", "@sentry/svelte": "9.1.0", - "@sentry/vite-plugin": "2.22.6", + "@sentry/vite-plugin": "3.2.0", "magic-string": "0.30.7", "magicast": "0.2.8", "sorcery": "1.0.0" diff --git a/packages/sveltekit/rollup.npm.config.mjs b/packages/sveltekit/rollup.npm.config.mjs index b0a19e091ad8..ca0792cb4868 100644 --- a/packages/sveltekit/rollup.npm.config.mjs +++ b/packages/sveltekit/rollup.npm.config.mjs @@ -2,7 +2,14 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/client/index.ts', 'src/server/index.ts'], + entrypoints: [ + 'src/index.server.ts', + 'src/index.client.ts', + 'src/index.worker.ts', + 'src/client/index.ts', + 'src/server/index.ts', + 'src/worker/index.ts', + ], packageSpecificConfig: { external: ['$app/stores'], output: { diff --git a/packages/sveltekit/src/common/utils.ts b/packages/sveltekit/src/common/utils.ts index 84b384861dff..1362ee82293c 100644 --- a/packages/sveltekit/src/common/utils.ts +++ b/packages/sveltekit/src/common/utils.ts @@ -1,5 +1,7 @@ import type { HttpError, Redirect } from '@sveltejs/kit'; +export const WRAPPED_MODULE_SUFFIX = '?sentry-auto-wrap'; + export type SentryWrappedFlag = { /** * If this flag is set, we know that the load event was already wrapped once diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 3ad8b728bb5f..bf2edbfb0a0f 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -4,6 +4,12 @@ export * from './client'; export * from './vite'; export * from './server'; +export * from './worker'; + +// Use the ./server version of some functions that are also exported from ./worker +export { sentryHandle } from './server'; +// Use the ./worker version of some functions that are also exported from ./server +export { initCloudflareSentryHandle } from './worker'; import type { Client, Integration, Options, StackParser } from '@sentry/core'; import type { HandleClientError, HandleServerError } from '@sveltejs/kit'; diff --git a/packages/sveltekit/src/index.worker.ts b/packages/sveltekit/src/index.worker.ts new file mode 100644 index 000000000000..016e36c8a289 --- /dev/null +++ b/packages/sveltekit/src/index.worker.ts @@ -0,0 +1,2 @@ +export * from './worker'; +// export * from './vite'; diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts new file mode 100644 index 000000000000..48167066c6d7 --- /dev/null +++ b/packages/sveltekit/src/server-common/handle.ts @@ -0,0 +1,208 @@ +import type { Span } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + continueTrace, + getCurrentScope, + getDefaultIsolationScope, + getIsolationScope, + getTraceMetaTags, + logger, + setHttpStatus, + startSpan, + winterCGRequestToRequestData, + withIsolationScope, +} from '@sentry/core'; +import type { Handle, ResolveOptions } from '@sveltejs/kit'; + +import { DEBUG_BUILD } from '../common/debug-build'; +import { flushIfServerless, getTracePropagationData, sendErrorToSentry } from './utils'; + +export type SentryHandleOptions = { + /** + * Controls whether the SDK should capture errors and traces in requests that don't belong to a + * route defined in your SvelteKit application. + * + * By default, this option is set to `false` to reduce noise (e.g. bots sending random requests to your server). + * + * Set this option to `true` if you want to monitor requests events without a route. This might be useful in certain + * scenarios, for instance if you registered other handlers that handle these requests. + * If you set this option, you might want adjust the the transaction name in the `beforeSendTransaction` + * callback of your server-side `Sentry.init` options. You can also use `beforeSendTransaction` to filter out + * transactions that you still don't want to be sent to Sentry. + * + * @default false + */ + handleUnknownRoutes?: boolean; + + /** + * Controls if `sentryHandle` should inject a script tag into the page that enables instrumentation + * of `fetch` calls in `load` functions. + * + * @default true + */ + injectFetchProxyScript?: boolean; +}; + +export const FETCH_PROXY_SCRIPT = ` + const f = window.fetch; + if(f){ + window._sentryFetchProxy = function(...a){return f(...a)} + window.fetch = function(...a){return window._sentryFetchProxy(...a)} + } +`; +/** + * Adds Sentry tracing tags to the returned html page. + * Adds Sentry fetch proxy script to the returned html page if enabled in options. + * + * Exported only for testing + */ +export function addSentryCodeToPage(options: { injectFetchProxyScript: boolean }): NonNullable< + ResolveOptions['transformPageChunk'] +> { + return ({ html }) => { + const metaTags = getTraceMetaTags(); + const headWithMetaTags = metaTags ? `\n${metaTags}` : ''; + + const headWithFetchScript = options.injectFetchProxyScript ? `\n` : ''; + + const modifiedHead = `${headWithMetaTags}${headWithFetchScript}`; + + return html.replace('', modifiedHead); + }; +} + +/** + * We only need to inject the fetch proxy script for SvelteKit versions < 2.16.0. + * Exported only for testing. + */ +export function isFetchProxyRequired(version: string): boolean { + try { + const [major, minor] = version.trim().replace(/-.*/, '').split('.').map(Number); + if (major != null && minor != null && (major > 2 || (major === 2 && minor >= 16))) { + return false; + } + } catch { + // ignore + } + return true; +} + +async function instrumentHandle( + { event, resolve }: Parameters[0], + options: SentryHandleOptions, +): Promise { + if (!event.route?.id && !options.handleUnknownRoutes) { + return resolve(event); + } + + // caching the result of the version check in `options.injectFetchProxyScript` + // to avoid doing the dynamic import on every request + if (options.injectFetchProxyScript == null) { + try { + // @ts-expect-error - the dynamic import is fine here + const { VERSION } = await import('@sveltejs/kit'); + options.injectFetchProxyScript = isFetchProxyRequired(VERSION); + } catch { + options.injectFetchProxyScript = true; + } + } + + const routeName = `${event.request.method} ${event.route?.id || event.url.pathname}`; + + if (getIsolationScope() !== getDefaultIsolationScope()) { + getIsolationScope().setTransactionName(routeName); + } else { + DEBUG_BUILD && logger.warn('Isolation scope is default isolation scope - skipping setting transactionName'); + } + + try { + const resolveResult = await startSpan( + { + op: 'http.server', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: event.route?.id ? 'route' : 'url', + 'http.method': event.request.method, + }, + name: routeName, + }, + async (span?: Span) => { + getCurrentScope().setSDKProcessingMetadata({ + // We specifically avoid cloning the request here to avoid double read errors. + // We only read request headers so we're not consuming the body anyway. + // Note to future readers: This sounds counter-intuitive but please read + // https://github.com/getsentry/sentry-javascript/issues/14583 + normalizedRequest: winterCGRequestToRequestData(event.request), + }); + const res = await resolve(event, { + transformPageChunk: addSentryCodeToPage({ injectFetchProxyScript: options.injectFetchProxyScript ?? true }), + }); + if (span) { + setHttpStatus(span, res.status); + } + return res; + }, + ); + return resolveResult; + } catch (e: unknown) { + sendErrorToSentry(e, 'handle'); + throw e; + } finally { + await flushIfServerless(); + } +} + +/** + * A SvelteKit handle function that wraps the request for Sentry error and + * performance monitoring. + * + * Usage: + * ``` + * // src/hooks.server.ts + * import { sentryHandle } from '@sentry/sveltekit'; + * + * export const handle = sentryHandle(); + * + * // Optionally use the `sequence` function to add additional handlers. + * // export const handle = sequence(sentryHandle(), yourCustomHandler); + * ``` + */ +export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { + const { handleUnknownRoutes, ...rest } = handlerOptions ?? {}; + const options = { + handleUnknownRoutes: handleUnknownRoutes ?? false, + ...rest, + }; + + const sentryRequestHandler: Handle = input => { + // Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle) + const skipIsolation = + '_sentrySkipRequestIsolation' in input.event.locals && input.event.locals._sentrySkipRequestIsolation; + + // In case of a same-origin `fetch` call within a server`load` function, + // SvelteKit will actually just re-enter the `handle` function and set `isSubRequest` + // to `true` so that no additional network call is made. + // We want the `http.server` span of that nested call to be a child span of the + // currently active span instead of a new root span to correctly reflect this + // behavior. + if (skipIsolation || input.event.isSubRequest) { + return instrumentHandle(input, options); + } + + return withIsolationScope(isolationScope => { + // We only call continueTrace in the initial top level request to avoid + // creating a new root span for the sub request. + isolationScope.setSDKProcessingMetadata({ + // We specifically avoid cloning the request here to avoid double read errors. + // We only read request headers so we're not consuming the body anyway. + // Note to future readers: This sounds counter-intuitive but please read + // https://github.com/getsentry/sentry-javascript/issues/14583 + normalizedRequest: winterCGRequestToRequestData(input.event.request), + }); + return continueTrace(getTracePropagationData(input.event), () => instrumentHandle(input, options)); + }); + }; + + return sentryRequestHandler; +} diff --git a/packages/sveltekit/src/server/handleError.ts b/packages/sveltekit/src/server-common/handleError.ts similarity index 95% rename from packages/sveltekit/src/server/handleError.ts rename to packages/sveltekit/src/server-common/handleError.ts index 30ca4e28de1a..0f9782282e48 100644 --- a/packages/sveltekit/src/server/handleError.ts +++ b/packages/sveltekit/src/server-common/handleError.ts @@ -1,8 +1,7 @@ -import { consoleSandbox } from '@sentry/core'; -import { captureException } from '@sentry/node'; +import { captureException, consoleSandbox } from '@sentry/core'; import type { HandleServerError } from '@sveltejs/kit'; -import { flushIfServerless } from './utils'; +import { flushIfServerless } from '../server-common/utils'; // The SvelteKit default error handler just logs the error's stack trace to the console // see: https://github.com/sveltejs/kit/blob/369e7d6851f543a40c947e033bfc4a9506fdc0a8/packages/kit/src/runtime/server/index.js#L43 diff --git a/packages/sveltekit/src/server/load.ts b/packages/sveltekit/src/server-common/load.ts similarity index 96% rename from packages/sveltekit/src/server/load.ts rename to packages/sveltekit/src/server-common/load.ts index 30fab345e05b..49160a65b4a5 100644 --- a/packages/sveltekit/src/server/load.ts +++ b/packages/sveltekit/src/server-common/load.ts @@ -1,5 +1,9 @@ -import { addNonEnumerableProperty } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + addNonEnumerableProperty, + startSpan, +} from '@sentry/core'; import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; diff --git a/packages/sveltekit/src/server/rewriteFramesIntegration.ts b/packages/sveltekit/src/server-common/rewriteFramesIntegration.ts similarity index 97% rename from packages/sveltekit/src/server/rewriteFramesIntegration.ts rename to packages/sveltekit/src/server-common/rewriteFramesIntegration.ts index 44afbca2d6df..d5928f8974b0 100644 --- a/packages/sveltekit/src/server/rewriteFramesIntegration.ts +++ b/packages/sveltekit/src/server-common/rewriteFramesIntegration.ts @@ -7,7 +7,7 @@ import { join, rewriteFramesIntegration as originalRewriteFramesIntegration, } from '@sentry/core'; -import { WRAPPED_MODULE_SUFFIX } from '../vite/autoInstrument'; +import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; import type { GlobalWithSentryValues } from '../vite/injectGlobalValues'; type StackFrameIteratee = (frame: StackFrame) => StackFrame; diff --git a/packages/sveltekit/src/server/serverRoute.ts b/packages/sveltekit/src/server-common/serverRoute.ts similarity index 92% rename from packages/sveltekit/src/server/serverRoute.ts rename to packages/sveltekit/src/server-common/serverRoute.ts index 9d2cba3dbcdc..1b2169c58b8c 100644 --- a/packages/sveltekit/src/server/serverRoute.ts +++ b/packages/sveltekit/src/server-common/serverRoute.ts @@ -1,5 +1,9 @@ -import { addNonEnumerableProperty } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + addNonEnumerableProperty, + startSpan, +} from '@sentry/core'; import type { RequestEvent } from '@sveltejs/kit'; import { flushIfServerless, sendErrorToSentry } from './utils'; diff --git a/packages/sveltekit/src/server/utils.ts b/packages/sveltekit/src/server-common/utils.ts similarity index 95% rename from packages/sveltekit/src/server/utils.ts rename to packages/sveltekit/src/server-common/utils.ts index 8eae93d531ab..d6f09093b74d 100644 --- a/packages/sveltekit/src/server/utils.ts +++ b/packages/sveltekit/src/server-common/utils.ts @@ -1,5 +1,4 @@ -import { logger, objectify } from '@sentry/core'; -import { captureException, flush } from '@sentry/node'; +import { captureException, flush, logger, objectify } from '@sentry/core'; import type { RequestEvent } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index 84f29a2c70c5..da429bc1040f 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -1,208 +1,24 @@ -import type { Span } from '@sentry/core'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - continueTrace, - getCurrentScope, - getDefaultIsolationScope, - getIsolationScope, - getTraceMetaTags, - logger, - setHttpStatus, - startSpan, - winterCGRequestToRequestData, - withIsolationScope, -} from '@sentry/core'; -import type { Handle, ResolveOptions } from '@sveltejs/kit'; - -import { DEBUG_BUILD } from '../common/debug-build'; -import { flushIfServerless, getTracePropagationData, sendErrorToSentry } from './utils'; - -export type SentryHandleOptions = { - /** - * Controls whether the SDK should capture errors and traces in requests that don't belong to a - * route defined in your SvelteKit application. - * - * By default, this option is set to `false` to reduce noise (e.g. bots sending random requests to your server). - * - * Set this option to `true` if you want to monitor requests events without a route. This might be useful in certain - * scenarios, for instance if you registered other handlers that handle these requests. - * If you set this option, you might want adjust the the transaction name in the `beforeSendTransaction` - * callback of your server-side `Sentry.init` options. You can also use `beforeSendTransaction` to filter out - * transactions that you still don't want to be sent to Sentry. - * - * @default false - */ - handleUnknownRoutes?: boolean; - - /** - * Controls if `sentryHandle` should inject a script tag into the page that enables instrumentation - * of `fetch` calls in `load` functions. - * - * @default true - */ - injectFetchProxyScript?: boolean; -}; +import type { CloudflareOptions } from '@sentry/cloudflare'; +import type { Handle } from '@sveltejs/kit'; +import { init } from './sdk'; /** - * Exported only for testing - */ -export const FETCH_PROXY_SCRIPT = ` - const f = window.fetch; - if(f){ - window._sentryFetchProxy = function(...a){return f(...a)} - window.fetch = function(...a){return window._sentryFetchProxy(...a)} - } -`; - -/** - * Adds Sentry tracing tags to the returned html page. - * Adds Sentry fetch proxy script to the returned html page if enabled in options. - * - * Exported only for testing - */ -export function addSentryCodeToPage(options: { injectFetchProxyScript: boolean }): NonNullable< - ResolveOptions['transformPageChunk'] -> { - return ({ html }) => { - const metaTags = getTraceMetaTags(); - const headWithMetaTags = metaTags ? `\n${metaTags}` : ''; - - const headWithFetchScript = options.injectFetchProxyScript ? `\n` : ''; - - const modifiedHead = `${headWithMetaTags}${headWithFetchScript}`; - - return html.replace('', modifiedHead); - }; -} - -/** - * A SvelteKit handle function that wraps the request for Sentry error and - * performance monitoring. - * - * Usage: - * ``` - * // src/hooks.server.ts - * import { sentryHandle } from '@sentry/sveltekit'; + * Actual implementation in ../worker/handle.ts * - * export const handle = sentryHandle(); + * This handler initializes the Sentry Node(!) SDK with the passed options. This is necessary to get + * the SDK configured for cloudflare working in dev mode. * - * // Optionally use the `sequence` function to add additional handlers. - * // export const handle = sequence(sentryHandle(), yourCustomHandler); - * ``` + * @return version of initCLoudflareSentryHandle that is called via node/server entry point */ -export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle { - const { handleUnknownRoutes, ...rest } = handlerOptions ?? {}; - const options = { - handleUnknownRoutes: handleUnknownRoutes ?? false, - ...rest, - }; +export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { + let sentryInitialized = false; - const sentryRequestHandler: Handle = input => { - // In case of a same-origin `fetch` call within a server`load` function, - // SvelteKit will actually just re-enter the `handle` function and set `isSubRequest` - // to `true` so that no additional network call is made. - // We want the `http.server` span of that nested call to be a child span of the - // currently active span instead of a new root span to correctly reflect this - // behavior. - if (input.event.isSubRequest) { - return instrumentHandle(input, options); + return ({ event, resolve }) => { + if (!sentryInitialized) { + sentryInitialized = true; + init(options); } - return withIsolationScope(isolationScope => { - // We only call continueTrace in the initial top level request to avoid - // creating a new root span for the sub request. - isolationScope.setSDKProcessingMetadata({ - // We specifically avoid cloning the request here to avoid double read errors. - // We only read request headers so we're not consuming the body anyway. - // Note to future readers: This sounds counter-intuitive but please read - // https://github.com/getsentry/sentry-javascript/issues/14583 - normalizedRequest: winterCGRequestToRequestData(input.event.request), - }); - return continueTrace(getTracePropagationData(input.event), () => instrumentHandle(input, options)); - }); - }; - - return sentryRequestHandler; -} - -async function instrumentHandle( - { event, resolve }: Parameters[0], - options: SentryHandleOptions, -): Promise { - if (!event.route?.id && !options.handleUnknownRoutes) { return resolve(event); - } - - // caching the result of the version check in `options.injectFetchProxyScript` - // to avoid doing the dynamic import on every request - if (options.injectFetchProxyScript == null) { - try { - // @ts-expect-error - the dynamic import is fine here - const { VERSION } = await import('@sveltejs/kit'); - options.injectFetchProxyScript = isFetchProxyRequired(VERSION); - } catch { - options.injectFetchProxyScript = true; - } - } - - const routeName = `${event.request.method} ${event.route?.id || event.url.pathname}`; - - if (getIsolationScope() !== getDefaultIsolationScope()) { - getIsolationScope().setTransactionName(routeName); - } else { - DEBUG_BUILD && logger.warn('Isolation scope is default isolation scope - skipping setting transactionName'); - } - - try { - const resolveResult = await startSpan( - { - op: 'http.server', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: event.route?.id ? 'route' : 'url', - 'http.method': event.request.method, - }, - name: routeName, - }, - async (span?: Span) => { - getCurrentScope().setSDKProcessingMetadata({ - // We specifically avoid cloning the request here to avoid double read errors. - // We only read request headers so we're not consuming the body anyway. - // Note to future readers: This sounds counter-intuitive but please read - // https://github.com/getsentry/sentry-javascript/issues/14583 - normalizedRequest: winterCGRequestToRequestData(event.request), - }); - const res = await resolve(event, { - transformPageChunk: addSentryCodeToPage({ injectFetchProxyScript: options.injectFetchProxyScript ?? true }), - }); - if (span) { - setHttpStatus(span, res.status); - } - return res; - }, - ); - return resolveResult; - } catch (e: unknown) { - sendErrorToSentry(e, 'handle'); - throw e; - } finally { - await flushIfServerless(); - } -} - -/** - * We only need to inject the fetch proxy script for SvelteKit versions < 2.16.0. - * Exported only for testing. - */ -export function isFetchProxyRequired(version: string): boolean { - try { - const [major, minor] = version.trim().replace(/-.*/, '').split('.').map(Number); - if (major != null && minor != null && (major > 2 || (major === 2 && minor >= 16))) { - return false; - } - } catch { - // ignore - } - return true; + }; } diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 232e0562eb22..ccd09570b674 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -123,10 +123,11 @@ export * from '@sentry/node'; // ------------------------- // SvelteKit SDK exports: export { init } from './sdk'; -export { handleErrorWithSentry } from './handleError'; -export { wrapLoadWithSentry, wrapServerLoadWithSentry } from './load'; -export { sentryHandle } from './handle'; -export { wrapServerRouteWithSentry } from './serverRoute'; +export { handleErrorWithSentry } from '../server-common/handleError'; +export { wrapLoadWithSentry, wrapServerLoadWithSentry } from '../server-common/load'; +export { sentryHandle } from '../server-common/handle'; +export { initCloudflareSentryHandle } from './handle'; +export { wrapServerRouteWithSentry } from '../server-common/serverRoute'; /** * Tracks the Svelte component's initialization and mounting operation as well as diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts index 7f3acbf57fbd..66362e96a729 100644 --- a/packages/sveltekit/src/server/sdk.ts +++ b/packages/sveltekit/src/server/sdk.ts @@ -3,10 +3,10 @@ import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getDefaultNodeIntegrations } from '@sentry/node'; import { init as initNodeSdk } from '@sentry/node'; -import { rewriteFramesIntegration } from './rewriteFramesIntegration'; +import { rewriteFramesIntegration } from '../server-common/rewriteFramesIntegration'; /** - * + * Initialize the Server-side Sentry SDK * @param options */ export function init(options: NodeOptions): NodeClient | undefined { diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts index 1e11f2f61500..8303af502f90 100644 --- a/packages/sveltekit/src/vite/autoInstrument.ts +++ b/packages/sveltekit/src/vite/autoInstrument.ts @@ -3,8 +3,7 @@ import * as path from 'path'; import type { ExportNamedDeclaration } from '@babel/types'; import { parseModule } from 'magicast'; import type { Plugin } from 'vite'; - -export const WRAPPED_MODULE_SUFFIX = '?sentry-auto-wrap'; +import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; export type AutoInstrumentSelection = { /** diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts index 799688b33845..69fa4f1b2121 100644 --- a/packages/sveltekit/src/vite/sourceMaps.ts +++ b/packages/sveltekit/src/vite/sourceMaps.ts @@ -9,7 +9,7 @@ import { sentryVitePlugin } from '@sentry/vite-plugin'; import type { Plugin, UserConfig } from 'vite'; import MagicString from 'magic-string'; -import { WRAPPED_MODULE_SUFFIX } from './autoInstrument'; +import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; import type { GlobalSentryValues } from './injectGlobalValues'; import { VIRTUAL_GLOBAL_VALUES_FILE, getGlobalValueInjectionCode } from './injectGlobalValues'; import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from './svelteConfig'; @@ -24,13 +24,6 @@ type Sorcery = { load(filepath: string): Promise; }; -type GlobalWithSourceMapSetting = typeof globalThis & { - _sentry_sourceMapSetting?: { - updatedSourceMapSetting?: boolean | 'inline' | 'hidden'; - previousSourceMapSetting?: UserSourceMapSetting; - }; -}; - // storing this in the module scope because `makeCustomSentryVitePlugin` is called multiple times // and we only want to generate a uuid once in case we have to fall back to it. const releaseName = detectSentryRelease(); @@ -57,8 +50,6 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug const usedAdapter = options?.adapter || 'other'; const adapterOutputDir = await getAdapterOutputDir(svelteConfig, usedAdapter); - const globalWithSourceMapSetting = globalThis as GlobalWithSourceMapSetting; - const defaultPluginOptions: SentryVitePluginOptions = { release: { name: releaseName, @@ -70,61 +61,8 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug }, }; - // Including all hidden (`.*`) directories by default so that folders like .vercel, - // .netlify, etc are also cleaned up. Additionally, we include the adapter output - // dir which could be a non-hidden directory, like `build` for the Node adapter. - const defaultFileDeletionGlob = ['./.*/**/*.map', `./${adapterOutputDir}/**/*.map`]; - - if (!globalWithSourceMapSetting._sentry_sourceMapSetting) { - let configFile: { - path: string; - config: UserConfig; - dependencies: string[]; - } | null = null; - - try { - // @ts-expect-error - the dynamic import here works fine - const Vite = await import('vite'); - configFile = await Vite.loadConfigFromFile({ command: 'build', mode: 'production' }); - } catch { - if (options?.debug) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - '[Sentry] Could not import Vite to load your vite config. Please set `build.sourcemap` to `true` or `hidden` to enable source map generation.', - ); - }); - } - } - - if (configFile) { - globalWithSourceMapSetting._sentry_sourceMapSetting = getUpdatedSourceMapSetting(configFile.config); - } else { - if (options?.debug) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - '[Sentry] Could not load Vite config with Vite "production" mode. This is needed for Sentry to automatically update source map settings.', - ); - }); - } - } - - if (options?.debug && globalWithSourceMapSetting._sentry_sourceMapSetting?.previousSourceMapSetting === 'unset') { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - `[Sentry] Automatically setting \`sourceMapsUploadOptions.sourcemaps.filesToDeleteAfterUpload: [${defaultFileDeletionGlob - .map(file => `"${file}"`) - .join(', ')}]\` to delete generated source maps after they were uploaded to Sentry.`, - ); - }); - } - } - - const shouldDeleteDefaultSourceMaps = - globalWithSourceMapSetting._sentry_sourceMapSetting?.previousSourceMapSetting === 'unset' && - !options?.sourcemaps?.filesToDeleteAfterUpload; + const { promise: filesToDeleteAfterUpload, resolve: resolveFilesToDeleteAfterUpload } = + createFilesToDeleteAfterUploadPromise(); const mergedOptions = { ...defaultPluginOptions, @@ -135,9 +73,7 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug }, sourcemaps: { ...options?.sourcemaps, - filesToDeleteAfterUpload: shouldDeleteDefaultSourceMaps - ? defaultFileDeletionGlob - : options?.sourcemaps?.filesToDeleteAfterUpload, + filesToDeleteAfterUpload, }, }; @@ -163,6 +99,10 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug console.warn( 'sentry-vite-debug-id-upload-plugin not found in sentryPlugins! Cannot modify plugin - returning default Sentry Vite plugins', ); + + // resolving filesToDeleteAfterUpload here, because we return the original deletion plugin which awaits the promise + resolveFilesToDeleteAfterUpload(undefined); + return sentryPlugins; } @@ -172,6 +112,10 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug console.warn( 'sentry-file-deletion-plugin not found in sentryPlugins! Cannot modify plugin - returning default Sentry Vite plugins', ); + + // resolving filesToDeleteAfterUpload here, because we return the original deletion plugin which awaits the promise + resolveFilesToDeleteAfterUpload(undefined); + return sentryPlugins; } @@ -181,6 +125,10 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug console.warn( 'sentry-release-management-plugin not found in sentryPlugins! Cannot modify plugin - returning default Sentry Vite plugins', ); + + // resolving filesToDeleteAfterUpload here, because we return the original deletion plugin which awaits the promise + resolveFilesToDeleteAfterUpload(undefined); + return sentryPlugins; } @@ -205,37 +153,66 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug const sourceMapSettingsPlugin: Plugin = { name: 'sentry-sveltekit-update-source-map-setting-plugin', apply: 'build', // only apply this plugin at build time - config: (config: UserConfig) => { + config: async (config: UserConfig) => { const settingKey = 'build.sourcemap'; - if (globalWithSourceMapSetting._sentry_sourceMapSetting?.previousSourceMapSetting === 'unset') { + const { updatedSourceMapSetting, previousSourceMapSetting } = getUpdatedSourceMapSetting(config); + + const userProvidedFilesToDeleteAfterUpload = await options?.sourcemaps?.filesToDeleteAfterUpload; + + if (previousSourceMapSetting === 'unset') { consoleSandbox(() => { // eslint-disable-next-line no-console console.log(`[Sentry] Enabled source map generation in the build options with \`${settingKey}: "hidden"\`.`); }); + if (userProvidedFilesToDeleteAfterUpload) { + resolveFilesToDeleteAfterUpload(userProvidedFilesToDeleteAfterUpload); + } else { + // Including all hidden (`.*`) directories by default so that folders like .vercel, + // .netlify, etc are also cleaned up. Additionally, we include the adapter output + // dir which could be a non-hidden directory, like `build` for the Node adapter. + const defaultFileDeletionGlob = ['./.*/**/*.map', `./${adapterOutputDir}/**/*.map`]; + + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] Automatically setting \`sourceMapsUploadOptions.sourcemaps.filesToDeleteAfterUpload: [${defaultFileDeletionGlob + .map(file => `"${file}"`) + .join(', ')}]\` to delete generated source maps after they were uploaded to Sentry.`, + ); + }); + + // In case we enabled source maps and users didn't specify a glob patter to delete, we set a default pattern: + resolveFilesToDeleteAfterUpload(defaultFileDeletionGlob); + } + return { ...config, - build: { ...config.build, sourcemap: 'hidden' }, + build: { ...config.build, sourcemap: updatedSourceMapSetting }, }; - } else if (globalWithSourceMapSetting._sentry_sourceMapSetting?.previousSourceMapSetting === 'disabled') { + } + + if (previousSourceMapSetting === 'disabled') { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( `[Sentry] Parts of source map generation are currently disabled in your Vite configuration (\`${settingKey}: false\`). This setting is either a default setting or was explicitly set in your configuration. Sentry won't override this setting. Without source maps, code snippets on the Sentry Issues page will remain minified. To show unminified code, enable source maps in \`${settingKey}\` (e.g. by setting them to \`hidden\`).`, ); }); - } else if (globalWithSourceMapSetting._sentry_sourceMapSetting?.previousSourceMapSetting === 'enabled') { + } else if (previousSourceMapSetting === 'enabled') { if (mergedOptions?.debug) { consoleSandbox(() => { // eslint-disable-next-line no-console console.log( - `[Sentry] We discovered you enabled source map generation in your Vite configuration (\`${settingKey}\`). Sentry will keep this source map setting. This will un-minify the code snippet on the Sentry Issue page.`, + `[Sentry] We discovered you enabled source map generation in your Vite configuration (\`${settingKey}\`). Sentry will keep this source map setting. This will un-minify the code snippet on the Sentry Issue page.`, ); }); } } + resolveFilesToDeleteAfterUpload(userProvidedFilesToDeleteAfterUpload); + return config; }, }; @@ -423,7 +400,7 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug /** * Whether the user enabled (true, 'hidden', 'inline') or disabled (false) source maps */ -export type UserSourceMapSetting = 'enabled' | 'disabled' | 'unset' | undefined; +type UserSourceMapSetting = 'enabled' | 'disabled' | 'unset' | undefined; /** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-javascript/issues/13993) * @@ -445,25 +422,25 @@ export function getUpdatedSourceMapSetting(viteConfig: { sourcemap?: boolean | 'inline' | 'hidden'; }; }): { updatedSourceMapSetting: boolean | 'inline' | 'hidden'; previousSourceMapSetting: UserSourceMapSetting } { - let previousSourceMapSetting: UserSourceMapSetting; - let updatedSourceMapSetting: boolean | 'inline' | 'hidden' | undefined; - viteConfig.build = viteConfig.build || {}; - const viteSourceMap = viteConfig.build.sourcemap; - - if (viteSourceMap === false) { - previousSourceMapSetting = 'disabled'; - updatedSourceMapSetting = viteSourceMap; - } else if (viteSourceMap && ['hidden', 'inline', true].includes(viteSourceMap)) { - previousSourceMapSetting = 'enabled'; - updatedSourceMapSetting = viteSourceMap; - } else { - previousSourceMapSetting = 'unset'; - updatedSourceMapSetting = 'hidden'; + const originalSourcemapSetting = viteConfig.build.sourcemap; + + if (originalSourcemapSetting === false) { + return { + previousSourceMapSetting: 'disabled', + updatedSourceMapSetting: originalSourcemapSetting, + }; + } + + if (originalSourcemapSetting && ['hidden', 'inline', true].includes(originalSourcemapSetting)) { + return { previousSourceMapSetting: 'enabled', updatedSourceMapSetting: originalSourcemapSetting }; } - return { previousSourceMapSetting, updatedSourceMapSetting }; + return { + previousSourceMapSetting: 'unset', + updatedSourceMapSetting: 'hidden', + }; } function getFiles(dir: string): string[] { @@ -499,3 +476,22 @@ function detectSentryRelease(): string { return release; } + +/** + * Creates a deferred promise that can be resolved/rejected by calling the + * `resolve` or `reject` function. + * Inspired by: https://stackoverflow.com/a/69027809 + */ +function createFilesToDeleteAfterUploadPromise(): { + promise: Promise; + resolve: (value: string | string[] | undefined) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: string | string[] | undefined) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { resolve, reject, promise }; +} diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts new file mode 100644 index 000000000000..0d26c566ea10 --- /dev/null +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -0,0 +1,43 @@ +import { type CloudflareOptions, wrapRequestHandler } from '@sentry/cloudflare'; +import { getDefaultIntegrations as getDefaultCloudflareIntegrations } from '@sentry/cloudflare'; +import type { Handle } from '@sveltejs/kit'; + +import { addNonEnumerableProperty } from '@sentry/core'; +import { rewriteFramesIntegration } from '../server-common/rewriteFramesIntegration'; + +/** + * Initializes Sentry SvelteKit Cloudflare SDK + * This should be before the sentryHandle() call. + * + * In the Node export, this is a stub that does nothing. + */ +export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { + const opts: CloudflareOptions = { + defaultIntegrations: [...getDefaultCloudflareIntegrations(options), rewriteFramesIntegration()], + ...options, + }; + + const handleInitSentry: Handle = ({ event, resolve }) => { + // if event.platform exists (should be there in a cloudflare worker), then do the cloudflare sentry init + if (event.platform) { + // This is an optional local that the `sentryHandle` handler checks for to avoid double isolation + // In Cloudflare the `wrapRequestHandler` function already takes care of + // - request isolation + // - trace continuation + // -setting the request onto the scope + addNonEnumerableProperty(event.locals, '_sentrySkipRequestIsolation', true); + return wrapRequestHandler( + { + options: opts, + request: event.request, + // @ts-expect-error This will exist in Cloudflare + context: event.platform.context, + }, + () => resolve(event), + ); + } + return resolve(event); + }; + + return handleInitSentry; +} diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts new file mode 100644 index 000000000000..a74989b7d28e --- /dev/null +++ b/packages/sveltekit/src/worker/index.ts @@ -0,0 +1,90 @@ +// For use in cloudflare workers and other edge environments +// +// These are essentially the same as the node server exports, but using imports from @sentry/core +// instead of @sentry/node. +// +// This is expected to be used together with something like the @sentry/cloudflare package, to initialize Sentry +// in the worker. +// +// ------------------------- +// SvelteKit SDK exports: +export { handleErrorWithSentry } from '../server-common/handleError'; +export { wrapLoadWithSentry, wrapServerLoadWithSentry } from '../server-common/load'; +export { sentryHandle } from '../server-common/handle'; +export { initCloudflareSentryHandle } from './cloudflare'; +export { wrapServerRouteWithSentry } from '../server-common/serverRoute'; + +// Re-export some functions from Cloudflare SDK +export { + addBreadcrumb, + addEventProcessor, + addIntegration, + captureCheckIn, + captureConsoleIntegration, + captureEvent, + captureException, + captureFeedback, + captureMessage, + close, + continueTrace, + createTransport, + dedupeIntegration, + extraErrorDataIntegration, + flush, + functionToStringIntegration, + getActiveSpan, + getClient, + getCurrentScope, + getDefaultIntegrations, + getGlobalScope, + getIsolationScope, + getRootSpan, + getSpanDescendants, + getSpanStatusFromHttpCode, + getTraceData, + getTraceMetaTags, + inboundFiltersIntegration, + isInitialized, + lastEventId, + linkedErrorsIntegration, + requestDataIntegration, + rewriteFramesIntegration, + Scope, + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setContext, + setCurrentClient, + setExtra, + setExtras, + setHttpStatus, + setMeasurement, + setTag, + setTags, + setUser, + spanToBaggageHeader, + spanToJSON, + spanToTraceHeader, + startInactiveSpan, + startNewTrace, + suppressTracing, + startSpan, + startSpanManual, + trpcMiddleware, + withActiveSpan, + withIsolationScope, + withMonitor, + withScope, + zodErrorsIntegration, +} from '@sentry/cloudflare'; + +/** + * Tracks the Svelte component's initialization and mounting operation as well as + * updates and records them as spans. These spans are only recorded on the client-side. + * Sever-side, during SSR, this function will not record any spans. + */ +export function trackComponent(_options?: unknown): void { + // no-op on the server side +} diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index b2adb50d91b8..9c6e2b71d330 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -8,16 +8,17 @@ import { spanToJSON, } from '@sentry/core'; import type { EventEnvelopeHeaders, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import { NodeClient, setCurrentClient } from '@sentry/node'; -import * as SentryNode from '@sentry/node'; import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { vi } from 'vitest'; -import { FETCH_PROXY_SCRIPT, addSentryCodeToPage, isFetchProxyRequired, sentryHandle } from '../../src/server/handle'; +import { FETCH_PROXY_SCRIPT, addSentryCodeToPage, isFetchProxyRequired } from '../../src/server-common/handle'; +import { sentryHandle } from '../../src/server-common/handle'; import { getDefaultNodeClientOptions } from '../utils'; -const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => 'xx'); +const mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'xx'); function mockEvent(override: Record = {}): Parameters[0]['event'] { const event: Parameters[0]['event'] = { @@ -98,6 +99,7 @@ beforeEach(() => { client.init(); mockCaptureException.mockClear(); + vi.clearAllMocks(); }); describe('sentryHandle', () => { @@ -366,6 +368,23 @@ describe('sentryHandle', () => { expect(_span!).toBeDefined(); }); + + it("doesn't create an isolation scope when the `_sentrySkipRequestIsolation` local is set", async () => { + const withIsolationScopeSpy = vi.spyOn(SentryCore, 'withIsolationScope'); + const continueTraceSpy = vi.spyOn(SentryCore, 'continueTrace'); + + try { + await sentryHandle({ handleUnknownRoutes: true })({ + event: { ...mockEvent({ route: undefined }), locals: { _sentrySkipRequestIsolation: true } }, + resolve: resolve(type, isError), + }); + } catch { + // + } + + expect(withIsolationScopeSpy).not.toHaveBeenCalled(); + expect(continueTraceSpy).not.toHaveBeenCalled(); + }); }); }); @@ -394,7 +413,7 @@ describe('addSentryCodeToPage', () => { it('adds meta tags and the fetch proxy script if there is an active transaction', () => { const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); - SentryNode.startSpan({ name: 'test' }, () => { + SentryCore.startSpan({ name: 'test' }, () => { const transformed = transformPageChunk({ html, done: true }) as string; expect(transformed).toContain(' 'xx'); +const mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'xx'); const captureExceptionEventHint = { mechanism: { handled: false, type: 'sveltekit' }, diff --git a/packages/sveltekit/test/server/load.test.ts b/packages/sveltekit/test/server/load.test.ts index 1001d8464ad4..8530208347a4 100644 --- a/packages/sveltekit/test/server/load.test.ts +++ b/packages/sveltekit/test/server/load.test.ts @@ -6,15 +6,15 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import type { Event } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import { NodeClient, getCurrentScope, getIsolationScope, setCurrentClient } from '@sentry/node'; -import * as SentryNode from '@sentry/node'; import type { Load, ServerLoad } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit'; -import { wrapLoadWithSentry, wrapServerLoadWithSentry } from '../../src/server/load'; +import { wrapLoadWithSentry, wrapServerLoadWithSentry } from '../../src/server-common/load'; import { getDefaultNodeClientOptions } from '../utils'; -const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => 'xx'); +const mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'xx'); const mockStartSpan = vi.fn(); diff --git a/packages/sveltekit/test/server/rewriteFramesIntegration.test.ts b/packages/sveltekit/test/server/rewriteFramesIntegration.test.ts index 3dfd5d3e460e..1d5ca8d4d695 100644 --- a/packages/sveltekit/test/server/rewriteFramesIntegration.test.ts +++ b/packages/sveltekit/test/server/rewriteFramesIntegration.test.ts @@ -2,7 +2,7 @@ import { rewriteFramesIntegration } from '@sentry/browser'; import { basename } from '@sentry/core'; import type { Event, StackFrame } from '@sentry/core'; -import { rewriteFramesIteratee } from '../../src/server/rewriteFramesIntegration'; +import { rewriteFramesIteratee } from '../../src/server-common/rewriteFramesIntegration'; import type { GlobalWithSentryValues } from '../../src/vite/injectGlobalValues'; describe('rewriteFramesIteratee', () => { diff --git a/packages/sveltekit/test/server/serverRoute.test.ts b/packages/sveltekit/test/server/serverRoute.test.ts index de99db5a548e..046c3673a8c7 100644 --- a/packages/sveltekit/test/server/serverRoute.test.ts +++ b/packages/sveltekit/test/server/serverRoute.test.ts @@ -1,4 +1,4 @@ -import * as SentryNode from '@sentry/node'; +import * as SentryCore from '@sentry/core'; import type { NumericRange } from '@sveltejs/kit'; import { type RequestEvent, error, redirect } from '@sveltejs/kit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -26,7 +26,7 @@ describe('wrapServerRouteWithSentry', () => { }); describe('wraps a server route span around the original server route handler', () => { - const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); it('assigns the route id as name if available', () => { const wrappedRouteHandler = wrapServerRouteWithSentry(originalRouteHandler); @@ -71,7 +71,7 @@ describe('wrapServerRouteWithSentry', () => { }); }); - const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException'); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); describe('captures server route errors', () => { it('captures and rethrows normal server route error', async () => { const error = new Error('Server Route Error'); diff --git a/packages/sveltekit/test/server/utils.test.ts b/packages/sveltekit/test/server/utils.test.ts index 5e8b9b2b99a3..53e588d683ec 100644 --- a/packages/sveltekit/test/server/utils.test.ts +++ b/packages/sveltekit/test/server/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getTracePropagationData } from '../../src/server/utils'; +import { getTracePropagationData } from '../../src/server-common/utils'; const MOCK_REQUEST_EVENT: any = { request: { diff --git a/packages/sveltekit/test/vite/sourceMaps.test.ts b/packages/sveltekit/test/vite/sourceMaps.test.ts index 378cbd2099e1..1410e9b992f0 100644 --- a/packages/sveltekit/test/vite/sourceMaps.test.ts +++ b/packages/sveltekit/test/vite/sourceMaps.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { makeCustomSentryVitePlugins } from '../../src/vite/sourceMaps'; +import { getUpdatedSourceMapSetting, makeCustomSentryVitePlugins } from '../../src/vite/sourceMaps'; import type { Plugin } from 'vite'; @@ -113,7 +113,7 @@ describe('makeCustomSentryVitePlugins()', () => { const plugin = await getSentryViteSubPlugin('sentry-sveltekit-update-source-map-setting-plugin'); // @ts-expect-error this function exists! - const sentryConfig = plugin.config(originalConfig); + const sentryConfig = await plugin.config(originalConfig); expect(sentryConfig).toEqual(originalConfig); }); @@ -132,7 +132,7 @@ describe('makeCustomSentryVitePlugins()', () => { const plugin = await getSentryViteSubPlugin('sentry-sveltekit-update-source-map-setting-plugin'); // @ts-expect-error this function exists! - const sentryConfig = plugin.config(originalConfig); + const sentryConfig = await plugin.config(originalConfig); expect(sentryConfig).toEqual({ build: { @@ -155,7 +155,7 @@ describe('makeCustomSentryVitePlugins()', () => { const plugin = await getSentryViteSubPlugin('sentry-sveltekit-update-source-map-setting-plugin'); // @ts-expect-error this function exists! - const sentryConfig = plugin.config(originalConfig); + const sentryConfig = await plugin.config(originalConfig); expect(sentryConfig).toEqual({ ...originalConfig, build: { @@ -320,22 +320,23 @@ describe('makeCustomSentryVitePlugins()', () => { describe('changeViteSourceMapSettings()', () => { const cases = [ { sourcemap: false, expectedSourcemap: false, expectedPrevious: 'disabled' }, - { sourcemap: 'hidden', expectedSourcemap: 'hidden', expectedPrevious: 'enabled' }, - { sourcemap: 'inline', expectedSourcemap: 'inline', expectedPrevious: 'enabled' }, + { sourcemap: 'hidden' as const, expectedSourcemap: 'hidden', expectedPrevious: 'enabled' }, + { sourcemap: 'inline' as const, expectedSourcemap: 'inline', expectedPrevious: 'enabled' }, { sourcemap: true, expectedSourcemap: true, expectedPrevious: 'enabled' }, { sourcemap: undefined, expectedSourcemap: 'hidden', expectedPrevious: 'unset' }, ]; - it.each(cases)('handles vite source map settings $1', async ({ sourcemap, expectedSourcemap, expectedPrevious }) => { - const viteConfig = { build: { sourcemap } }; + it.each(cases)( + 'handles vite source map setting `build.sourcemap: $sourcemap`', + async ({ sourcemap, expectedSourcemap, expectedPrevious }) => { + const viteConfig = { build: { sourcemap } }; - const { getUpdatedSourceMapSetting } = await import('../../src/vite/sourceMaps'); + const result = getUpdatedSourceMapSetting(viteConfig); - const result = getUpdatedSourceMapSetting(viteConfig); - - expect(result).toEqual({ - updatedSourceMapSetting: expectedSourcemap, - previousSourceMapSetting: expectedPrevious, - }); - }); + expect(result).toEqual({ + updatedSourceMapSetting: expectedSourcemap, + previousSourceMapSetting: expectedPrevious, + }); + }, + ); }); diff --git a/packages/types/README.md b/packages/types/README.md index a7fa71c4421b..0b52be537522 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -17,7 +17,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 94eb1cbba7e6..1ff1406c3344 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/vue/README.md b/packages/vue/README.md index 21f980cfb2c8..b2b0b349b15f 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -9,7 +9,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/vue/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/packages/wasm/README.md b/packages/wasm/README.md index ab5b84482f7b..3343f6b9730c 100644 --- a/packages/wasm/README.md +++ b/packages/wasm/README.md @@ -13,7 +13,6 @@ ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) -- [TypeDoc](http://getsentry.github.io/sentry-javascript/) ## General diff --git a/yarn.lock b/yarn.lock index ab8921cdf802..0c7b60c816e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6587,16 +6587,16 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.22.6.tgz#829d6caf2c95c1c46108336de4e1049e6521435e" integrity sha512-V2g1Y1I5eSe7dtUVMBvAJr8BaLRr4CLrgNgtPaZyMT4Rnps82SrZ5zqmEkLXPumlXhLUWR6qzoMNN2u+RXVXfQ== -"@sentry/babel-plugin-component-annotate@2.22.7": - version "2.22.7" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.22.7.tgz#604c7e33d48528a13477e7af597c4d5fca51b8bd" - integrity sha512-aa7XKgZMVl6l04NY+3X7BP7yvQ/s8scn8KzQfTLrGRarziTlMGrsCOBQtCNWXOPEbtxAIHpZ9dsrAn5EJSivOQ== - "@sentry/babel-plugin-component-annotate@3.1.2": version "3.1.2" resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.1.2.tgz#5497ca5adbe775955e96c566511a0bed3ab0a3ce" integrity sha512-5h2WXRJ6swKA0TwxHHryC8M2QyOfS9QhTAL6ElPfkEYe9HhJieXmxsDpyspbqAa26ccnCUcmwE5vL34jAjt4sQ== +"@sentry/babel-plugin-component-annotate@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.2.0.tgz#17c000cf6cc315bb620eddbd95c88dfb2471cfb9" + integrity sha512-Sg7nLRP1yiJYl/KdGGxYGbjvLq5rswyeB5yESgfWX34XUNZaFgmNvw4pU/QEKVeYgcPyOulgJ+y80ewujyffTA== + "@sentry/bundler-plugin-core@2.22.6": version "2.22.6" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-2.22.6.tgz#a1ea1fd43700a3ece9e7db016997e79a2782b87d" @@ -6611,27 +6611,27 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/bundler-plugin-core@2.22.7": - version "2.22.7" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-2.22.7.tgz#28204a224cd1fef58d157e5beeb2493947a9bc35" - integrity sha512-ouQh5sqcB8vsJ8yTTe0rf+iaUkwmeUlGNFi35IkCFUQlWJ22qS6OfvNjOqFI19e6eGUXks0c/2ieFC4+9wJ+1g== +"@sentry/bundler-plugin-core@3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.1.2.tgz#29e4e686c5893b41a0d98a1bef6f0315a610bd59" + integrity sha512-lqOCvmOPzKiQenIMhmm5/mwCntwFy0dPZbVD28Dnr3MXpT1rIBg1HXjfnqQWFlMRbL9haSsWiY/TQyR/6b30YA== dependencies: "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "2.22.7" - "@sentry/cli" "2.39.1" + "@sentry/babel-plugin-component-annotate" "3.1.2" + "@sentry/cli" "2.41.1" dotenv "^16.3.1" find-up "^5.0.0" glob "^9.3.2" magic-string "0.30.8" unplugin "1.0.1" -"@sentry/bundler-plugin-core@3.1.2": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.1.2.tgz#29e4e686c5893b41a0d98a1bef6f0315a610bd59" - integrity sha512-lqOCvmOPzKiQenIMhmm5/mwCntwFy0dPZbVD28Dnr3MXpT1rIBg1HXjfnqQWFlMRbL9haSsWiY/TQyR/6b30YA== +"@sentry/bundler-plugin-core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.2.0.tgz#023ec92530a35fbec7c7077b7a8be2e79f0f9dd5" + integrity sha512-Q/ogVylue3XaFawyIxzuiic+7Dp4w63eJtRtVH8VBebNURyJ/re4GVoP1QNGccE1R243tXY1y2GiwqiJkAONOg== dependencies: "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "3.1.2" + "@sentry/babel-plugin-component-annotate" "3.2.0" "@sentry/cli" "2.41.1" dotenv "^16.3.1" find-up "^5.0.0" @@ -6639,95 +6639,41 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.39.1.tgz#75c338a53834b4cf72f57599f4c72ffb36cf0781" - integrity sha512-kiNGNSAkg46LNGatfNH5tfsmI/kCAaPA62KQuFZloZiemTNzhy9/6NJP8HZ/GxGs8GDMxic6wNrV9CkVEgFLJQ== - "@sentry/cli-darwin@2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.41.1.tgz#ca7e12bf1ad59bc2df35868ae98abc8869108efa" integrity sha512-7pS3pu/SuhE6jOn3wptstAg6B5nUP878O6s+2svT7b5fKNfYUi/6NPK6dAveh2Ca0rwVq40TO4YFJabWMgTpdQ== -"@sentry/cli-linux-arm64@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.39.1.tgz#27db44700c33fcb1e8966257020b43f8494373e6" - integrity sha512-5VbVJDatolDrWOgaffsEM7znjs0cR8bHt9Bq0mStM3tBolgAeSDHE89NgHggfZR+DJ2VWOy4vgCwkObrUD6NQw== - "@sentry/cli-linux-arm64@2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.41.1.tgz#948e8af8290418b1562db3531db08e69e39d74bb" integrity sha512-EzYCEnnENBnS5kpNW+2dBcrPZn1MVfywh2joGVQZTpmgDL5YFJ59VOd+K0XuEwqgFI8BSNI14KXZ75s4DD1/Vw== -"@sentry/cli-linux-arm@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.39.1.tgz#451683fa9a5a60b1359d104ec71334ed16f4b63c" - integrity sha512-DkENbxyRxUrfLnJLXTA4s5UL/GoctU5Cm4ER1eB7XN7p9WsamFJd/yf2KpltkjEyiTuplv0yAbdjl1KX3vKmEQ== - "@sentry/cli-linux-arm@2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.41.1.tgz#1e5fa971ae8dfb3ea5564c8503b4e635ae6aed8a" integrity sha512-wNUvquD6qjOCczvuBGf9OiD29nuQ6yf8zzfyPJa5Bdx1QXuteKsKb6HBrMwuIR3liyuu0duzHd+H/+p1n541Hg== -"@sentry/cli-linux-i686@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.39.1.tgz#9965a81f97a94e8b6d1d15589e43fee158e35201" - integrity sha512-pXWVoKXCRrY7N8vc9H7mETiV9ZCz+zSnX65JQCzZxgYrayQPJTc+NPRnZTdYdk5RlAupXaFicBI2GwOCRqVRkg== - "@sentry/cli-linux-i686@2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.41.1.tgz#3f01aff314f2ad8fd761f3e6e807a5ec09ae4eb4" integrity sha512-urpQCWrdYnSAsZY3udttuMV88wTJzKZL10xsrp7sjD/Hd+O6qSLVLkxebIlxts70jMLLFHYrQ2bkRg5kKuX6Fg== -"@sentry/cli-linux-x64@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.39.1.tgz#31fe008b02f92769543dc9919e2a5cbc4cda7889" - integrity sha512-IwayNZy+it7FWG4M9LayyUmG1a/8kT9+/IEm67sT5+7dkMIMcpmHDqL8rWcPojOXuTKaOBBjkVdNMBTXy0mXlA== - "@sentry/cli-linux-x64@2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.41.1.tgz#30dbf966a4b4c1721ffccd901dfcb6f967db073d" integrity sha512-ZqpYwHXAaK4MMEFlyaLYr6mJTmpy9qP6n30jGhLTW7kHKS3s6GPLCSlNmIfeClrInEt0963fM633ZRnXa04VPw== -"@sentry/cli-win32-i686@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.39.1.tgz#609e8790c49414011445e397130560c777850b35" - integrity sha512-NglnNoqHSmE+Dz/wHeIVRnV2bLMx7tIn3IQ8vXGO5HWA2f8zYJGktbkLq1Lg23PaQmeZLPGlja3gBQfZYSG10Q== - "@sentry/cli-win32-i686@2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.41.1.tgz#f88eeb5d2d4ee46c38d8616ae1eb484108ea71c2" integrity sha512-AuRimCeVsx99DIOr9cwdYBHk39tlmAuPDdy2r16iNzY0InXs4xOys4gGzM7N4vlFQvFkzuc778Su0HkfasgprA== -"@sentry/cli-win32-x64@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.39.1.tgz#1a874a5570c6d162b35d9d001c96e5389d07d2cb" - integrity sha512-xv0R2CMf/X1Fte3cMWie1NXuHmUyQPDBfCyIt6k6RPFPxAYUgcqgMPznYwVMwWEA1W43PaOkSn3d8ZylsDaETw== - "@sentry/cli-win32-x64@2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.41.1.tgz#eefd95a2aa184adb464334e265b55a9142070f6f" integrity sha512-6JcPvXGye61+wPp0xdzfc2YLE/Dcud8JdaK8VxLM3b/8+Em7E+UyliDu3uF8+YGUqizY5JYTd3fs17DC8DZhLw== -"@sentry/cli@2.39.1": - version "2.39.1" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.39.1.tgz#916bb5b7567ccf7fdf94ef6cf8a2b9ab78370d29" - integrity sha512-JIb3e9vh0+OmQ0KxmexMXg9oZsR/G7HMwxt5BUIKAXZ9m17Xll4ETXTRnRUBT3sf7EpNGAmlQk1xEmVN9pYZYQ== - dependencies: - https-proxy-agent "^5.0.0" - node-fetch "^2.6.7" - progress "^2.0.3" - proxy-from-env "^1.1.0" - which "^2.0.2" - optionalDependencies: - "@sentry/cli-darwin" "2.39.1" - "@sentry/cli-linux-arm" "2.39.1" - "@sentry/cli-linux-arm64" "2.39.1" - "@sentry/cli-linux-i686" "2.39.1" - "@sentry/cli-linux-x64" "2.39.1" - "@sentry/cli-win32-i686" "2.39.1" - "@sentry/cli-win32-x64" "2.39.1" - "@sentry/cli@2.41.1", "@sentry/cli@^2.36.1", "@sentry/cli@^2.41.1": version "2.41.1" resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.41.1.tgz#a9467ca3ff4acfcdedec1565c9ff726b93758d29" @@ -6763,14 +6709,13 @@ "@sentry/bundler-plugin-core" "2.22.6" unplugin "1.0.1" -"@sentry/webpack-plugin@2.22.7": - version "2.22.7" - resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-2.22.7.tgz#992c6c782c736f22e72eb318745e28cc24aabad7" - integrity sha512-j5h5LZHWDlm/FQCCmEghQ9FzYXwfZdlOf3FE/X6rK6lrtx0JCAkq+uhMSasoyP4XYKL4P4vRS6WFSos4jxf/UA== +"@sentry/vite-plugin@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-3.2.0.tgz#0785b6e04e0aed8a4d6b57a433a2da11c14e6cd0" + integrity sha512-IVBoAzZmpoX9+mnmIMq2ndxlFPoWMuYSE5Mek5zOWpYh+GbPxvkrxvM+vg0HeLH4r5v9Tm0FWcEZDgDIZqtoSg== dependencies: - "@sentry/bundler-plugin-core" "2.22.7" + "@sentry/bundler-plugin-core" "3.2.0" unplugin "1.0.1" - uuid "^9.0.0" "@sentry/webpack-plugin@3.1.2": version "3.1.2" @@ -7985,12 +7930,7 @@ dependencies: "@types/unist" "*" -"@types/history-4@npm:@types/history@4.7.8": - version "4.7.8" - resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" - integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== - -"@types/history-5@npm:@types/history@4.7.8": +"@types/history-4@npm:@types/history@4.7.8", "@types/history-5@npm:@types/history@4.7.8": version "4.7.8" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== @@ -27525,16 +27465,7 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -27637,14 +27568,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -27809,7 +27733,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -30445,16 +30368,7 @@ wrangler@^3.67.1: optionalDependencies: fsevents "~2.3.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@7.0.0, wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==