diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5d0364b7cda..6004a046f126 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -321,8 +321,10 @@ jobs: uses: ./.github/actions/restore-cache env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Run linter + - name: Lint source files run: yarn lint + - name: Validate ES5 builds + run: yarn validate:es5 job_circular_dep_check: name: Circular Dependency Check diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index bb414b553abd..9412da630775 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -77,5 +77,5 @@ jobs: working-directory: packages/browser-integration-tests env: CHANGED_TEST_PATHS: ${{ steps.changed.outputs.browser_integration_files }} - # Run 100 times when detecting changed test(s), else run all tests 5x - TEST_RUN_COUNT: ${{ steps.changed.outputs.browser_integration == 'true' && 100 || 5 }} + # Run 50 times when detecting changed test(s), else run all tests 5x + TEST_RUN_COUNT: ${{ steps.changed.outputs.browser_integration == 'true' && 50 || 5 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b48530e607..3d8824213ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.45.0 + +- build(cdn): Ensure ES5 bundles do not use non-ES5 code (#7550) +- feat(core): Add trace function (#7556) +- feat(hub): Make scope always defined on the hub (#7551) +- feat(replay): Add `replay_id` to transaction DSC (#7571) +- feat(replay): Capture fetch body size for replay events (#7524) +- feat(sveltekit): Add performance monitoring for client load (#7537) +- feat(sveltekit): Add performance monitoring to Sveltekit server handle (#7532) +- feat(sveltekit): Add SvelteKit routing instrumentation (#7565) +- fix(browser): Ensure keepalive flag is correctly set for parallel requests (#7553) +- fix(core): Ensure `ignoreErrors` only applies to error events (#7573) +- fix(node): Consider tracing error handler for process exit (#7558) +- fix(otel): Make sure we use correct hub on finish (#7577) +- fix(react): Handle case where error.cause already defined (#7557) +- fix(tracing): Account for case where startTransaction returns undefined (#7566) + ## 7.44.2 - fix(cdn): Fix ES5 CDN bundles (#7544) diff --git a/package.json b/package.json index 2296a10dfede..e6a54d7d032c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "link:yarn": "lerna exec yarn link", "lint": "lerna run lint", "lint:eslint": "lerna run lint:eslint", + "validate:es5": "lerna run validate:es5", "postpublish": "lerna run --stream --concurrency 1 postpublish", "test": "lerna run --ignore @sentry-internal/* test", "test:unit": "lerna run --ignore @sentry-internal/* test:unit", @@ -89,6 +90,7 @@ "chai": "^4.1.2", "codecov": "^3.6.5", "deepmerge": "^4.2.2", + "es-check": "7.1.0", "eslint": "7.32.0", "jest": "^27.5.1", "jest-environment-node": "^27.5.1", diff --git a/packages/browser-integration-tests/scripts/detectFlakyTests.ts b/packages/browser-integration-tests/scripts/detectFlakyTests.ts index 22977fa3ed83..af0a5c86a18e 100644 --- a/packages/browser-integration-tests/scripts/detectFlakyTests.ts +++ b/packages/browser-integration-tests/scripts/detectFlakyTests.ts @@ -6,52 +6,72 @@ import { promisify } from 'util'; const exec = promisify(childProcess.exec); async function run(): Promise { - let testPaths = getTestPaths(); - let failed = []; + let testPaths: string[] = []; - try { - const changedPaths: string[] = process.env.CHANGED_TEST_PATHS ? JSON.parse(process.env.CHANGED_TEST_PATHS) : []; + const changedPaths: string[] = process.env.CHANGED_TEST_PATHS ? JSON.parse(process.env.CHANGED_TEST_PATHS) : []; - if (changedPaths.length > 0) { - console.log(`Detected changed test paths: + if (changedPaths.length > 0) { + console.log(`Detected changed test paths: ${changedPaths.join('\n')} `); - testPaths = testPaths.filter(p => changedPaths.some(changedPath => changedPath.includes(p))); + testPaths = getTestPaths().filter(p => changedPaths.some(changedPath => changedPath.includes(p))); + if (testPaths.length === 0) { + console.log('Could not find matching tests, aborting...'); + process.exit(1); } - } catch { - console.log('Could not detect changed test paths, running all tests.'); } const cwd = path.join(__dirname, '../'); const runCount = parseInt(process.env.TEST_RUN_COUNT || '10'); - for (const testPath of testPaths) { - console.log(`Running test: ${testPath}`); - const start = Date.now(); + try { + await new Promise((resolve, reject) => { + const cp = childProcess.spawn( + `yarn playwright test ${ + testPaths.length ? testPaths.join(' ') : './suites' + } --browser='all' --reporter='line' --repeat-each ${runCount}`, + { shell: true, cwd }, + ); + + let error: Error | undefined; + + cp.stdout.on('data', data => { + console.log(data ? (data as object).toString() : ''); + }); - try { - await exec(`yarn playwright test ${testPath} --browser='all' --repeat-each ${runCount}`, { - cwd, + cp.stderr.on('data', data => { + console.log(data ? (data as object).toString() : ''); }); - const end = Date.now(); - console.log(` ☑️ Passed ${runCount} times, avg. duration ${Math.ceil((end - start) / runCount)}ms`); - } catch (error) { - logError(error); - failed.push(testPath); - } - } - console.log(''); - console.log(''); + cp.on('error', e => { + console.error(e); + error = e; + }); - if (failed.length > 0) { - console.error(`⚠️ ${failed.length} test(s) failed.`); + cp.on('close', status => { + const err = error || (status !== 0 ? new Error(`Process exited with status ${status}`) : undefined); + + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } catch (error) { + console.log(''); + console.log(''); + + console.error(`⚠️ Some tests failed.`); + console.error(error); process.exit(1); - } else { - console.log(`☑️ ${testPaths.length} test(s) passed.`); } + + console.log(''); + console.log(''); + console.log(`☑️ All tests passed.`); } function getTestPaths(): string[] { diff --git a/packages/browser-integration-tests/suites/replay/dsc/init.js b/packages/browser-integration-tests/suites/replay/dsc/init.js new file mode 100644 index 000000000000..c43f001779eb --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/dsc/init.js @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/browser'; +import { Integrations } from '@sentry/tracing'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + useCompression: false, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [new Integrations.BrowserTracing({ tracingOrigins: [/.*/] }), window.Replay], + environment: 'production', + tracesSampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, +}); + +Sentry.configureScope(scope => { + scope.setUser({ id: 'user123', segment: 'segmentB' }); + scope.setTransactionName('testTransactionDSC'); +}); diff --git a/packages/browser-integration-tests/suites/replay/dsc/test.ts b/packages/browser-integration-tests/suites/replay/dsc/test.ts new file mode 100644 index 000000000000..0819e9f7bf71 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/dsc/test.ts @@ -0,0 +1,33 @@ +import { expect } from '@playwright/test'; +import type { EventEnvelopeHeaders } from '@sentry/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeHeaderRequestParser, getFirstSentryEnvelopeRequest } from '../../../utils/helpers'; +import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../utils/replayHelpers'; + +sentryTest('should add replay_id to dsc of transactions', async ({ getLocalTestPath, page, browserName }) => { + // This is flaky on webkit, so skipping there... + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + const envHeader = await getFirstSentryEnvelopeRequest(page, url, envelopeHeaderRequestParser); + + await waitForReplayRunning(page); + const replay = await getReplaySnapshot(page); + + expect(replay.session?.id).toBeDefined(); + + expect(envHeader.trace).toBeDefined(); + expect(envHeader.trace).toEqual({ + environment: 'production', + user_segment: 'segmentB', + sample_rate: '1', + trace_id: expect.any(String), + public_key: 'public', + replay_id: replay.session?.id, + }); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts index 1ffeb360c650..27c429c9be98 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('parses response_body_size from Content-Length header if available', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -22,7 +26,17 @@ sentryTest('parses response_body_size from Content-Length header if available', }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -58,4 +72,20 @@ sentryTest('parses response_body_size from Content-Length header if available', url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 789, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts index 8248b4799480..31f8d65bc7e7 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('does not capture response_body_size without Content-Length header', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -22,7 +26,17 @@ sentryTest('does not capture response_body_size without Content-Length header', }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -57,4 +71,20 @@ sentryTest('does not capture response_body_size without Content-Length header', url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 29, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts index a293df49b366..d2c167110a8a 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -19,7 +23,17 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -60,4 +74,21 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 26, + responseBodySize: 24, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts index baac9005fd35..0f77394b6e5d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('captures request_body_size when body is sent', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -12,16 +16,23 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest await page.route('**/foo', route => { return route.fulfill({ status: 200, - body: JSON.stringify({ - userNames: ['John', 'Jane'], - }), headers: { 'Content-Type': 'application/json', }, }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -48,5 +59,32 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest expect(eventData.exception?.values).toHaveLength(1); expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + request_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); expect(eventData!.breadcrumbs![0].data!.request_body_size).toEqual(13); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 13, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts index b5f517f77352..4ee170939530 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest( 'parses response_body_size from Content-Length header if available', @@ -25,7 +29,17 @@ sentryTest( }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -65,5 +79,21 @@ sentryTest( url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 789, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }, ); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts index 9ea10831afab..9a9bd633c71f 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest( 'captures response_body_size without Content-Length header', @@ -25,7 +29,17 @@ sentryTest( }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -65,5 +79,21 @@ sentryTest( url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 29, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }, ); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts index 5142f2e6be82..0210283fea60 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestPath, page, browserName }) => { // These are a bit flaky on non-chromium browsers @@ -20,7 +24,17 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -63,4 +77,21 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 26, + responseBodySize: 24, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts index fd3cc426f9fd..470fe57c51ba 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('captures request_body_size when body is sent', async ({ getLocalTestPath, page, browserName }) => { // These are a bit flaky on non-chromium browsers @@ -13,9 +17,6 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest await page.route('**/foo', route => { return route.fulfill({ status: 200, - body: JSON.stringify({ - userNames: ['John', 'Jane'], - }), headers: { 'Content-Type': 'application/json', 'Content-Length': '', @@ -23,7 +24,17 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -52,5 +63,31 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest expect(eventData.exception?.values).toHaveLength(1); expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0].data!.request_body_size).toEqual(13); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 13, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/privacyInput/test.ts b/packages/browser-integration-tests/suites/replay/privacyInput/test.ts index f95e857d5637..dc071b9bf487 100644 --- a/packages/browser-integration-tests/suites/replay/privacyInput/test.ts +++ b/packages/browser-integration-tests/suites/replay/privacyInput/test.ts @@ -24,10 +24,34 @@ sentryTest( sentryTest.skip(); } + // We want to ensure to check the correct event payloads + const inputMutationSegmentIds: number[] = []; const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); - const reqPromise3 = waitForReplayRequest(page, 3); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const check = inputMutationSegmentIds.length === 0 && getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise2 = waitForReplayRequest(page, (event, res) => { + const check = + inputMutationSegmentIds.length === 1 && + inputMutationSegmentIds[0] < event.segment_id && + getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise3 = waitForReplayRequest(page, event => { + // This one should not have any input mutations + return inputMutationSegmentIds.length === 2 && inputMutationSegmentIds[1] < event.segment_id; + }); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -72,10 +96,34 @@ sentryTest( sentryTest.skip(); } + // We want to ensure to check the correct event payloads + const inputMutationSegmentIds: number[] = []; const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); - const reqPromise3 = waitForReplayRequest(page, 3); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const check = inputMutationSegmentIds.length === 0 && getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise2 = waitForReplayRequest(page, (event, res) => { + const check = + inputMutationSegmentIds.length === 1 && + inputMutationSegmentIds[0] < event.segment_id && + getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise3 = waitForReplayRequest(page, event => { + // This one should not have any input mutations + return inputMutationSegmentIds.length === 2 && inputMutationSegmentIds[1] < event.segment_id; + }); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ diff --git a/packages/browser-integration-tests/utils/replayHelpers.ts b/packages/browser-integration-tests/utils/replayHelpers.ts index cf21ce7b9c7b..bd7696ebd927 100644 --- a/packages/browser-integration-tests/utils/replayHelpers.ts +++ b/packages/browser-integration-tests/utils/replayHelpers.ts @@ -99,6 +99,16 @@ function isCustomSnapshot(event: RecordingEvent): event is RecordingEvent & { da return event.type === EventType.Custom; } +/** Wait for replay to be running & available. */ +export async function waitForReplayRunning(page: Page): Promise { + await page.waitForFunction(() => { + const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay; + const replay = replayIntegration._replay; + + return replay.isEnabled() && replay.session?.id !== undefined; + }); +} + /** * This returns the replay container (assuming it exists). * Note that due to how this works with playwright, this is a POJO copy of replay. diff --git a/packages/browser/package.json b/packages/browser/package.json index 5d93e763b309..fb2162449405 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -66,6 +66,7 @@ "lint": "run-s lint:prettier lint:eslint", "lint:eslint": "eslint . --format stylish", "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", + "validate:es5": "es-check es5 build/bundles/bundle.es5.js", "size:check": "run-p size:check:es5 size:check:es6", "size:check:es5": "cat build/bundles/bundle.min.js | gzip -9 | wc -c | awk '{$1=$1/1024; print \"ES5: \",$1,\"kB\";}'", "size:check:es6": "cat build/bundles/bundle.es6.min.js | gzip -9 | wc -c | awk '{$1=$1/1024; print \"ES6: \",$1,\"kB\";}'", diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 8cafc77a68c3..1d0bd091cf19 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -1,5 +1,5 @@ import type { Scope } from '@sentry/core'; -import { BaseClient, getEnvelopeEndpointWithUrlEncodedAuth, SDK_VERSION } from '@sentry/core'; +import { BaseClient, SDK_VERSION } from '@sentry/core'; import type { BrowserClientReplayOptions, ClientOptions, @@ -9,7 +9,7 @@ import type { Severity, SeverityLevel, } from '@sentry/types'; -import { createClientReportEnvelope, dsnToString, getSDKSource, logger, serializeEnvelope } from '@sentry/utils'; +import { createClientReportEnvelope, dsnToString, getSDKSource, logger } from '@sentry/utils'; import { eventFromException, eventFromMessage } from './eventbuilder'; import { WINDOW } from './helpers'; @@ -132,24 +132,7 @@ export class BrowserClient extends BaseClient { __DEBUG_BUILD__ && logger.log('Sending outcomes:', outcomes); - const url = getEnvelopeEndpointWithUrlEncodedAuth(this._dsn, this._options); const envelope = createClientReportEnvelope(outcomes, this._options.tunnel && dsnToString(this._dsn)); - - try { - const isRealNavigator = Object.prototype.toString.call(WINDOW && WINDOW.navigator) === '[object Navigator]'; - const hasSendBeacon = isRealNavigator && typeof WINDOW.navigator.sendBeacon === 'function'; - // Make sure beacon is not used if user configures custom transport options - if (hasSendBeacon && !this._options.transportOptions) { - // Prevent illegal invocations - https://xgwang.me/posts/you-may-not-know-beacon/#it-may-throw-error%2C-be-sure-to-catch - const sendBeacon = WINDOW.navigator.sendBeacon.bind(WINDOW.navigator); - sendBeacon(url, serializeEnvelope(envelope)); - } else { - // If beacon is not supported or if they are using the tunnel option - // use our regular transport to send client reports to Sentry. - void this._sendEnvelope(envelope); - } - } catch (e) { - __DEBUG_BUILD__ && logger.error(e); - } + void this._sendEnvelope(envelope); } } diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 59d814ac9412..dca32f05b35d 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -21,8 +21,8 @@ const INTEGRATIONS = { export { INTEGRATIONS as Integrations }; export { Replay } from '@sentry/replay'; -export { BrowserTracing } from '@sentry-internal/tracing'; -export { addTracingExtensions } from '@sentry/core'; +export { BrowserTracing, defaultRequestInstrumentationOptions } from '@sentry-internal/tracing'; +export { addTracingExtensions, getActiveTransaction } from '@sentry/core'; export { makeBrowserOfflineTransport } from './transports/offline'; export { onProfilingStartRouteTransaction } from './profiling/hubextensions'; export { BrowserProfilingIntegration } from './profiling/integration'; diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index 83b75c82ba39..e996b6f8277a 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -13,7 +13,14 @@ export function makeFetchTransport( options: BrowserTransportOptions, nativeFetch: FetchImpl = getNativeFetchImplementation(), ): Transport { + let pendingBodySize = 0; + let pendingCount = 0; + function makeRequest(request: TransportRequest): PromiseLike { + const requestSize = request.body.length; + pendingBodySize += requestSize; + pendingCount++; + const requestOptions: RequestInit = { body: request.body, method: 'POST', @@ -25,23 +32,31 @@ export function makeFetchTransport( // frequently sending events right before the user is switching pages (eg. whenfinishing navigation transactions). // Gotchas: // - `keepalive` isn't supported by Firefox - // - As per spec (https://fetch.spec.whatwg.org/#http-network-or-cache-fetch), a request with `keepalive: true` - // and a content length of > 64 kibibytes returns a network error. We will therefore only activate the flag when - // we're below that limit. - keepalive: request.body.length <= 65536, + // - As per spec (https://fetch.spec.whatwg.org/#http-network-or-cache-fetch): + // If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error. + // We will therefore only activate the flag when we're below that limit. + // There is also a limit of requests that can be open at the same time, so we also limit this to 15 + // See https://github.com/getsentry/sentry-javascript/pull/7553 for details + keepalive: pendingBodySize <= 60_000 && pendingCount < 15, ...options.fetchOptions, }; try { - return nativeFetch(options.url, requestOptions).then(response => ({ - statusCode: response.status, - headers: { - 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), - 'retry-after': response.headers.get('Retry-After'), - }, - })); + return nativeFetch(options.url, requestOptions).then(response => { + pendingBodySize -= requestSize; + pendingCount--; + return { + statusCode: response.status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + }; + }); } catch (e) { clearCachedFetchImplementation(); + pendingBodySize -= requestSize; + pendingCount--; return rejectedSyncPromise(e); } } diff --git a/packages/browser/test/unit/transports/fetch.test.ts b/packages/browser/test/unit/transports/fetch.test.ts index 53ed5c34e21b..e6b58eeaa110 100644 --- a/packages/browser/test/unit/transports/fetch.test.ts +++ b/packages/browser/test/unit/transports/fetch.test.ts @@ -16,6 +16,11 @@ const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, ]); +const LARGE_ERROR_ENVELOPE = createEnvelope( + { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, + [[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', message: 'x'.repeat(10 * 900) }] as EventItem], +); + class Headers { headers: { [key: string]: string } = {}; get(key: string) { @@ -107,4 +112,54 @@ describe('NewFetchTransport', () => { await expect(() => transport.send(ERROR_ENVELOPE)).not.toThrow(); expect(mockFetch).toHaveBeenCalledTimes(1); }); + + it('correctly sets keepalive flag', async () => { + const mockFetch = jest.fn(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve({}), + }), + ) as unknown as FetchImpl; + + const REQUEST_OPTIONS: RequestInit = { + referrerPolicy: 'strict-origin', + referrer: 'http://example.org', + }; + + const transport = makeFetchTransport( + { ...DEFAULT_FETCH_TRANSPORT_OPTIONS, fetchOptions: REQUEST_OPTIONS }, + mockFetch, + ); + + const promises: PromiseLike[] = []; + for (let i = 0; i < 30; i++) { + promises.push(transport.send(LARGE_ERROR_ENVELOPE)); + } + + await Promise.all(promises); + + for (let i = 1; i <= 30; i++) { + // After 7 requests, we hit the total limit of >64kb of size + // Starting there, keepalive should be false + const keepalive = i < 7; + expect(mockFetch).toHaveBeenNthCalledWith(i, expect.any(String), expect.objectContaining({ keepalive })); + } + + (mockFetch as jest.Mock).mockClear(); + + // Limit resets when requests have resolved + // Now try based on # of pending requests + const promises2 = []; + for (let i = 0; i < 20; i++) { + promises2.push(transport.send(ERROR_ENVELOPE)); + } + + await Promise.all(promises2); + + for (let i = 1; i <= 20; i++) { + const keepalive = i < 15; + expect(mockFetch).toHaveBeenNthCalledWith(i, expect.any(String), expect.objectContaining({ keepalive })); + } + }); }); diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index a6db2788aa52..80be9dc037bd 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -6,6 +6,7 @@ import type { ClientOptions, DataCategory, DsnComponents, + DynamicSamplingContext, Envelope, ErrorEvent, Event, @@ -378,6 +379,9 @@ export abstract class BaseClient implements Client { /** @inheritdoc */ public on(hook: 'beforeAddBreadcrumb', callback: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => void): void; + /** @inheritdoc */ + public on(hook: 'createDsc', callback: (dsc: DynamicSamplingContext) => void): void; + /** @inheritdoc */ public on(hook: string, callback: unknown): void { if (!this._hooks[hook]) { @@ -400,6 +404,9 @@ export abstract class BaseClient implements Client { /** @inheritdoc */ public emit(hook: 'beforeAddBreadcrumb', breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void; + /** @inheritdoc */ + public emit(hook: 'createDsc', dsc: DynamicSamplingContext): void; + /** @inheritdoc */ public emit(hook: string, ...rest: unknown[]): void { if (this._hooks[hook]) { diff --git a/packages/core/src/hub.ts b/packages/core/src/hub.ts index 203241a35389..f67ae3773325 100644 --- a/packages/core/src/hub.ts +++ b/packages/core/src/hub.ts @@ -56,7 +56,7 @@ const DEFAULT_BREADCRUMBS = 100; */ export interface Layer { client?: Client; - scope?: Scope; + scope: Scope; } /** @@ -87,7 +87,7 @@ export interface Carrier { */ export class Hub implements HubInterface { /** Is a {@link Layer}[] containing the client and scope */ - private readonly _stack: Layer[] = [{}]; + private readonly _stack: Layer[]; /** Contains the last event id of a captured event. */ private _lastEventId?: string; @@ -101,7 +101,7 @@ export class Hub implements HubInterface { * @param version number, higher number means higher priority. */ public constructor(client?: Client, scope: Scope = new Scope(), private readonly _version: number = API_VERSION) { - this.getStackTop().scope = scope; + this._stack = [{ scope }]; if (client) { this.bindClient(client); } @@ -166,7 +166,7 @@ export class Hub implements HubInterface { } /** Returns the scope of the top stack. */ - public getScope(): Scope | undefined { + public getScope(): Scope { return this.getStackTop().scope; } @@ -256,7 +256,7 @@ export class Hub implements HubInterface { public addBreadcrumb(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): void { const { scope, client } = this.getStackTop(); - if (!scope || !client) return; + if (!client) return; const { beforeBreadcrumb = null, maxBreadcrumbs = DEFAULT_BREADCRUMBS } = (client.getOptions && client.getOptions()) || {}; @@ -282,40 +282,35 @@ export class Hub implements HubInterface { * @inheritDoc */ public setUser(user: User | null): void { - const scope = this.getScope(); - if (scope) scope.setUser(user); + this.getScope().setUser(user); } /** * @inheritDoc */ public setTags(tags: { [key: string]: Primitive }): void { - const scope = this.getScope(); - if (scope) scope.setTags(tags); + this.getScope().setTags(tags); } /** * @inheritDoc */ public setExtras(extras: Extras): void { - const scope = this.getScope(); - if (scope) scope.setExtras(extras); + this.getScope().setExtras(extras); } /** * @inheritDoc */ public setTag(key: string, value: Primitive): void { - const scope = this.getScope(); - if (scope) scope.setTag(key, value); + this.getScope().setTag(key, value); } /** * @inheritDoc */ public setExtra(key: string, extra: Extra): void { - const scope = this.getScope(); - if (scope) scope.setExtra(key, extra); + this.getScope().setExtra(key, extra); } /** @@ -323,8 +318,7 @@ export class Hub implements HubInterface { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public setContext(name: string, context: { [key: string]: any } | null): void { - const scope = this.getScope(); - if (scope) scope.setContext(name, context); + this.getScope().setContext(name, context); } /** @@ -332,7 +326,7 @@ export class Hub implements HubInterface { */ public configureScope(callback: (scope: Scope) => void): void { const { scope, client } = this.getStackTop(); - if (scope && client) { + if (client) { callback(scope); } } @@ -395,17 +389,15 @@ export class Hub implements HubInterface { */ public endSession(): void { const layer = this.getStackTop(); - const scope = layer && layer.scope; - const session = scope && scope.getSession(); + const scope = layer.scope; + const session = scope.getSession(); if (session) { closeSession(session); } this._sendSessionUpdate(); // the session is over; take it off of the scope - if (scope) { - scope.setSession(); - } + scope.setSession(); } /** @@ -421,22 +413,20 @@ export class Hub implements HubInterface { const session = makeSession({ release, environment, - ...(scope && { user: scope.getUser() }), + user: scope.getUser(), ...(userAgent && { userAgent }), ...context, }); - if (scope) { - // End existing session if there's one - const currentSession = scope.getSession && scope.getSession(); - if (currentSession && currentSession.status === 'ok') { - updateSession(currentSession, { status: 'exited' }); - } - this.endSession(); - - // Afterwards we set the new session on the scope - scope.setSession(session); + // End existing session if there's one + const currentSession = scope.getSession && scope.getSession(); + if (currentSession && currentSession.status === 'ok') { + updateSession(currentSession, { status: 'exited' }); } + this.endSession(); + + // Afterwards we set the new session on the scope + scope.setSession(session); return session; } @@ -472,7 +462,7 @@ export class Hub implements HubInterface { * @param method The method to call on the client. * @param args Arguments to pass to the client function. */ - private _withClient(callback: (client: Client, scope: Scope | undefined) => void): void { + private _withClient(callback: (client: Client, scope: Scope) => void): void { const { scope, client } = this.getStackTop(); if (client) { callback(client, scope); diff --git a/packages/core/src/integrations/inboundfilters.ts b/packages/core/src/integrations/inboundfilters.ts index 047060ae4961..e790e3daf4b5 100644 --- a/packages/core/src/integrations/inboundfilters.ts +++ b/packages/core/src/integrations/inboundfilters.ts @@ -103,7 +103,8 @@ export function _shouldDropEvent(event: Event, options: Partial): boolean { - if (!ignoreErrors || !ignoreErrors.length) { + // If event.type, this is not an error + if (event.type || !ignoreErrors || !ignoreErrors.length) { return false; } diff --git a/packages/core/src/sdk.ts b/packages/core/src/sdk.ts index 7d2df2115ddd..5dc58a5fc944 100644 --- a/packages/core/src/sdk.ts +++ b/packages/core/src/sdk.ts @@ -28,9 +28,7 @@ export function initAndBind( } const hub = getCurrentHub(); const scope = hub.getScope(); - if (scope) { - scope.update(options.initialScope); - } + scope.update(options.initialScope); const client = new clientClass(options); hub.bindClient(client); diff --git a/packages/core/src/sessionflusher.ts b/packages/core/src/sessionflusher.ts index 9b4579ade486..4ef196819504 100644 --- a/packages/core/src/sessionflusher.ts +++ b/packages/core/src/sessionflusher.ts @@ -72,15 +72,13 @@ export class SessionFlusher implements SessionFlusherLike { return; } const scope = getCurrentHub().getScope(); - const requestSession = scope && scope.getRequestSession(); + const requestSession = scope.getRequestSession(); if (requestSession && requestSession.status) { this._incrementSessionStatusCount(requestSession.status, new Date()); // This is not entirely necessarily but is added as a safe guard to indicate the bounds of a request and so in // case captureRequestSession is called more than once to prevent double count - if (scope) { - scope.setRequestSession(undefined); - } + scope.setRequestSession(undefined); /* eslint-enable @typescript-eslint/no-unsafe-member-access */ } } diff --git a/packages/core/src/tracing/errors.ts b/packages/core/src/tracing/errors.ts index 3351f428fecf..8785bd86d448 100644 --- a/packages/core/src/tracing/errors.ts +++ b/packages/core/src/tracing/errors.ts @@ -29,3 +29,7 @@ function errorCallback(): void { activeTransaction.setStatus(status); } } + +// The function name will be lost when bundling but we need to be able to identify this listener later to maintain the +// node.js default exit behaviour +errorCallback.tag = 'sentry_tracingErrorCallback'; diff --git a/packages/core/src/tracing/hubextensions.ts b/packages/core/src/tracing/hubextensions.ts index b5518f95e04b..ad6858d41e4a 100644 --- a/packages/core/src/tracing/hubextensions.ts +++ b/packages/core/src/tracing/hubextensions.ts @@ -11,15 +11,13 @@ import { Transaction } from './transaction'; /** Returns all trace headers that are currently on the top scope. */ function traceHeaders(this: Hub): { [key: string]: string } { const scope = this.getScope(); - if (scope) { - const span = scope.getSpan(); - if (span) { - return { + const span = scope.getSpan(); + + return span + ? { 'sentry-trace': span.toTraceparent(), - }; - } - } - return {}; + } + : {}; } /** diff --git a/packages/core/src/tracing/idletransaction.ts b/packages/core/src/tracing/idletransaction.ts index f4604a312519..77b7d5317da1 100644 --- a/packages/core/src/tracing/idletransaction.ts +++ b/packages/core/src/tracing/idletransaction.ts @@ -346,10 +346,7 @@ export class IdleTransaction extends Transaction { */ function clearActiveTransaction(hub: Hub): void { const scope = hub.getScope(); - if (scope) { - const transaction = scope.getTransaction(); - if (transaction) { - scope.setSpan(undefined); - } + if (scope.getTransaction()) { + scope.setSpan(undefined); } } diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index fd4949257ceb..1afb556bce4d 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -6,3 +6,4 @@ export { extractTraceparentData, getActiveTransaction, stripUrlQueryAndFragment, // eslint-disable-next-line deprecation/deprecation export { SpanStatus } from './spanstatus'; export type { SpanStatusType } from './span'; +export { trace } from './trace'; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts new file mode 100644 index 000000000000..8e7844d23988 --- /dev/null +++ b/packages/core/src/tracing/trace.ts @@ -0,0 +1,68 @@ +import type { TransactionContext } from '@sentry/types'; +import { isThenable } from '@sentry/utils'; + +import { getCurrentHub } from '../hub'; +import type { Span } from './span'; + +/** + * Wraps a function with a transaction/span and finishes the span after the function is done. + * + * Note that if you have not enabled tracing extensions via `addTracingExtensions`, this function + * will not generate spans, and the `span` returned from the callback may be undefined. + * + * This function is meant to be used internally and may break at any time. Use at your own risk. + * + * @internal + * @private + */ +export function trace( + context: TransactionContext, + callback: (span?: Span) => T, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onError: (error: unknown) => void = () => {}, +): T { + const ctx = { ...context }; + // If a name is set and a description is not, set the description to the name. + if (ctx.name !== undefined && ctx.description === undefined) { + ctx.description = ctx.name; + } + + const hub = getCurrentHub(); + const scope = hub.getScope(); + + const parentSpan = scope.getSpan(); + const activeSpan = parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); + scope.setSpan(activeSpan); + + function finishAndSetSpan(): void { + activeSpan && activeSpan.finish(); + hub.getScope().setSpan(parentSpan); + } + + let maybePromiseResult: T; + try { + maybePromiseResult = callback(activeSpan); + } catch (e) { + activeSpan && activeSpan.setStatus('internal_error'); + onError(e); + finishAndSetSpan(); + throw e; + } + + if (isThenable(maybePromiseResult)) { + Promise.resolve(maybePromiseResult).then( + () => { + finishAndSetSpan(); + }, + e => { + activeSpan && activeSpan.setStatus('internal_error'); + onError(e); + finishAndSetSpan(); + }, + ); + } else { + finishAndSetSpan(); + } + + return maybePromiseResult; +} diff --git a/packages/core/src/tracing/transaction.ts b/packages/core/src/tracing/transaction.ts index eba498b7e654..9dcee22fb888 100644 --- a/packages/core/src/tracing/transaction.ts +++ b/packages/core/src/tracing/transaction.ts @@ -256,8 +256,7 @@ export class Transaction extends SpanClass implements TransactionInterface { const maybeSampleRate = this.metadata.sampleRate; const sample_rate = maybeSampleRate !== undefined ? maybeSampleRate.toString() : undefined; - const scope = hub.getScope(); - const { segment: user_segment } = (scope && scope.getUser()) || {}; + const { segment: user_segment } = hub.getScope().getUser() || {}; const source = this.metadata.source; @@ -277,6 +276,8 @@ export class Transaction extends SpanClass implements TransactionInterface { // Uncomment if we want to make DSC immutable // this._frozenDynamicSamplingContext = dsc; + client.emit && client.emit('createDsc', dsc); + return dsc; } } diff --git a/packages/core/src/tracing/utils.ts b/packages/core/src/tracing/utils.ts index 5dc64f98d400..624fff153270 100644 --- a/packages/core/src/tracing/utils.ts +++ b/packages/core/src/tracing/utils.ts @@ -21,7 +21,7 @@ export { TRACEPARENT_REGEXP, extractTraceparentData } from '@sentry/utils'; export function getActiveTransaction(maybeHub?: Hub): T | undefined { const hub = maybeHub || getCurrentHub(); const scope = hub.getScope(); - return scope && (scope.getTransaction() as T | undefined); + return scope.getTransaction() as T | undefined; } // so it can be used in manual instrumentation without necessitating a hard dependency on @sentry/utils diff --git a/packages/core/test/lib/integrations/inboundfilters.test.ts b/packages/core/test/lib/integrations/inboundfilters.test.ts index ff9aca20270a..7537f19d4d5d 100644 --- a/packages/core/test/lib/integrations/inboundfilters.test.ts +++ b/packages/core/test/lib/integrations/inboundfilters.test.ts @@ -177,6 +177,11 @@ const MALFORMED_EVENT: Event = { }, }; +const TRANSACTION_EVENT: Event = { + message: 'transaction message', + type: 'transaction', +}; + describe('InboundFilters', () => { describe('_isSentryError', () => { it('should work as expected', () => { @@ -202,6 +207,13 @@ describe('InboundFilters', () => { expect(eventProcessor(MESSAGE_EVENT, {})).toBe(null); }); + it('ignores transaction event for filtering', () => { + const eventProcessor = createInboundFiltersEventProcessor({ + ignoreErrors: ['transaction'], + }); + expect(eventProcessor(TRANSACTION_EVENT, {})).toBe(TRANSACTION_EVENT); + }); + it('string filter with exact match', () => { const eventProcessor = createInboundFiltersEventProcessor({ ignoreErrors: ['captureMessage'], diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts new file mode 100644 index 000000000000..064c41dc123a --- /dev/null +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -0,0 +1,189 @@ +import { addTracingExtensions, Hub, makeMain } from '../../../src'; +import { trace } from '../../../src/tracing'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +beforeAll(() => { + addTracingExtensions(); +}); + +const enum Type { + Sync = 'sync', + Async = 'async', +} + +let hub: Hub; +let client: TestClient; + +describe('trace', () => { + beforeEach(() => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 0.0 }); + client = new TestClient(options); + hub = new Hub(client); + makeMain(hub); + }); + + describe.each([ + // isSync, isError, callback, expectedReturnValue + [Type.Async, false, () => Promise.resolve('async good'), 'async good'], + [Type.Sync, false, () => 'sync good', 'sync good'], + [Type.Async, true, () => Promise.reject('async bad'), 'async bad'], + [ + Type.Sync, + true, + () => { + throw 'sync bad'; + }, + 'sync bad', + ], + ])('with %s callback and error %s', (_type, isError, callback, expected) => { + it('should return the same value as the callback', async () => { + try { + const result = await trace({ name: 'GET users/[id]' }, () => { + return callback(); + }); + expect(result).toEqual(expected); + } catch (e) { + expect(e).toEqual(expected); + } + }); + + it('should return the same value as the callback if transactions are undefined', async () => { + // @ts-ignore we are force overriding the transaction return to be undefined + // The `startTransaction` types are actually wrong - it can return undefined + // if tracingExtensions are not enabled + jest.spyOn(hub, 'startTransaction').mockReturnValue(undefined); + try { + const result = await trace({ name: 'GET users/[id]' }, () => { + return callback(); + }); + expect(result).toEqual(expected); + } catch (e) { + expect(e).toEqual(expected); + } + }); + + it('creates a transaction', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]' }, () => { + return callback(); + }); + } catch (e) { + // + } + expect(ref).toBeDefined(); + + expect(ref.name).toEqual('GET users/[id]'); + expect(ref.status).toEqual(isError ? 'internal_error' : undefined); + }); + + it('allows traceparent information to be overriden', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace( + { + name: 'GET users/[id]', + parentSampled: true, + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + }, + () => { + return callback(); + }, + ); + } catch (e) { + // + } + expect(ref).toBeDefined(); + + expect(ref.sampled).toEqual(true); + expect(ref.traceId).toEqual('12345678901234567890123456789012'); + expect(ref.parentSpanId).toEqual('1234567890123456'); + }); + + it('allows for transaction to be mutated', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]' }, span => { + if (span) { + span.op = 'http.server'; + } + return callback(); + }); + } catch (e) { + // + } + + expect(ref.op).toEqual('http.server'); + }); + + it('creates a span with correct description', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]', parentSampled: true }, () => { + return trace({ name: 'SELECT * from users' }, () => { + return callback(); + }); + }); + } catch (e) { + // + } + + expect(ref.spanRecorder.spans).toHaveLength(2); + expect(ref.spanRecorder.spans[1].description).toEqual('SELECT * from users'); + expect(ref.spanRecorder.spans[1].parentSpanId).toEqual(ref.spanId); + expect(ref.spanRecorder.spans[1].status).toEqual(isError ? 'internal_error' : undefined); + }); + + it('allows for span to be mutated', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]', parentSampled: true }, () => { + return trace({ name: 'SELECT * from users' }, childSpan => { + if (childSpan) { + childSpan.op = 'db.query'; + } + return callback(); + }); + }); + } catch (e) { + // + } + + expect(ref.spanRecorder.spans).toHaveLength(2); + expect(ref.spanRecorder.spans[1].op).toEqual('db.query'); + }); + + it('calls `onError` hook', async () => { + const onError = jest.fn(); + try { + await trace( + { name: 'GET users/[id]' }, + () => { + return callback(); + }, + onError, + ); + } catch (e) { + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(e); + } + expect(onError).toHaveBeenCalledTimes(isError ? 1 : 0); + }); + }); +}); diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/src/globals.d.ts b/packages/e2e-tests/test-applications/standard-frontend-react/src/globals.d.ts index 109dbcd55648..ffa61ca49acc 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/src/globals.d.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/src/globals.d.ts @@ -1,4 +1,5 @@ interface Window { recordedTransactions?: string[]; capturedExceptionId?: string; + sentryReplayId?: string; } diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx b/packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx index c6b8db266ac0..ef820ec794b3 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx +++ b/packages/e2e-tests/test-applications/standard-frontend-react/src/index.tsx @@ -14,6 +14,8 @@ import { import Index from './pages/Index'; import User from './pages/User'; +const replay = new Sentry.Replay(); + Sentry.init({ dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ @@ -26,11 +28,22 @@ Sentry.init({ matchRoutes, ), }), + replay, ], // We recommend adjusting this value in production, or using tracesSampler // for finer control tracesSampleRate: 1.0, release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, +}); + +Object.defineProperty(window, 'sentryReplayId', { + get() { + return replay['_replay'].session.id; + }, }); Sentry.addGlobalEventProcessor(event => { diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts index 795d610a4b08..fb2d291dd70d 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/tests/behaviour-test.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import axios, { AxiosError } from 'axios'; +import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; const EVENT_POLLING_TIMEOUT = 30_000; @@ -169,3 +170,85 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { expect(hadPageNavigationTransaction).toBe(true); }); + +test('Sends a Replay recording to Sentry', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/'); + + const replayId = await page.waitForFunction(() => { + return window.sentryReplayId; + }); + + // Wait for replay to be sent + + if (replayId === undefined) { + throw new Error("Application didn't set a replayId"); + } + + console.log(`Polling for replay with ID: ${replayId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + + // now fetch the first recording segment + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/recording-segments/?cursor=100%3A0%3A1`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return { + status: response.status, + data: response.data, + }; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toEqual({ + status: 200, + data: ReplayRecordingData, + }); +}); diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts new file mode 100644 index 000000000000..318fc368f7b9 --- /dev/null +++ b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts @@ -0,0 +1,225 @@ +import { expect } from '@playwright/test'; + +export const ReplayRecordingData = [ + [ + { type: 4, data: { href: 'http://localhost:3000/', width: 1280, height: 720 }, timestamp: expect.any(Number) }, + { + type: 2, + data: { + node: { + type: 0, + childNodes: [ + { type: 1, name: 'html', publicId: '', systemId: '', id: 2 }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { type: 2, tagName: 'meta', attributes: { charset: 'utf-8' }, childNodes: [], id: 5 }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'viewport', content: 'width=device-width,initial-scale=1' }, + childNodes: [], + id: 6, + }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'theme-color', content: '#000000' }, + childNodes: [], + id: 7, + }, + { + type: 2, + tagName: 'title', + attributes: {}, + childNodes: [{ type: 3, textContent: '***** ***', id: 9 }], + id: 8, + }, + ], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'noscript', + attributes: {}, + childNodes: [{ type: 3, textContent: '*** **** ** ****** ********** ** *** **** ****', id: 12 }], + id: 11, + }, + { type: 2, tagName: 'div', attributes: { id: 'root' }, childNodes: [], id: 13 }, + ], + id: 10, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { left: 0, top: 0 }, + }, + timestamp: expect.any(Number), + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'memory', + description: 'memory', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + memory: { + jsHeapSizeLimit: expect.any(Number), + totalJSHeapSize: expect.any(Number), + usedJSHeapSize: expect.any(Number), + }, + }, + }, + }, + }, + { + type: 3, + data: { + source: 0, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 13, + nextId: null, + node: { + type: 2, + tagName: 'a', + attributes: { id: 'navigation', href: 'http://localhost:3000/user/5' }, + childNodes: [], + id: 14, + }, + }, + { parentId: 14, nextId: null, node: { type: 3, textContent: '********', id: 15 } }, + { + parentId: 13, + nextId: 14, + node: { + type: 2, + tagName: 'input', + attributes: { type: 'button', id: 'exception-button', value: '******* *********' }, + childNodes: [], + id: 16, + }, + }, + ], + }, + timestamp: expect.any(Number), + }, + { + type: 3, + data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, + timestamp: expect.any(Number), + }, + ], + [ + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'navigation.navigate', + description: 'http://localhost:3000/', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { size: expect.any(Number), duration: expect.any(Number) }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'resource.script', + description: expect.stringMatching(/http:\/\/localhost:3000\/static\/js\/main.(\w+).js/), + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { size: expect.any(Number), encodedBodySize: expect.any(Number) }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'largest-contentful-paint', + description: 'largest-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { value: expect.any(Number), size: expect.any(Number), nodeId: 16 }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'memory', + description: 'memory', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + memory: { + jsHeapSizeLimit: expect.any(Number), + totalJSHeapSize: expect.any(Number), + usedJSHeapSize: expect.any(Number), + }, + }, + }, + }, + }, + ], +]; diff --git a/packages/ember/addon/instance-initializers/sentry-performance.ts b/packages/ember/addon/instance-initializers/sentry-performance.ts index ac3c08e46079..0001e6c5f97c 100644 --- a/packages/ember/addon/instance-initializers/sentry-performance.ts +++ b/packages/ember/addon/instance-initializers/sentry-performance.ts @@ -386,7 +386,7 @@ export async function instrumentForPerformance(appInstance: ApplicationInstance) // Maintaining backwards compatibility with config.browserTracingOptions, but passing it with Sentry options is preferred. const browserTracingOptions = config.browserTracingOptions || config.sentry.browserTracingOptions || {}; - const tracing = await import('@sentry/tracing'); + const { BrowserTracing } = await import('@sentry/browser'); const idleTimeout = config.transitionTimeout || 5000; @@ -394,7 +394,7 @@ export async function instrumentForPerformance(appInstance: ApplicationInstance) sentryConfig['integrations'] = [ ...existingIntegrations, - new tracing.Integrations.BrowserTracing({ + new BrowserTracing({ routingInstrumentation: (customStartTransaction, startTransactionOnPageLoad) => { const routerMain = appInstance.lookup('router:main'); let routerService = appInstance.lookup('service:router') as @@ -421,7 +421,7 @@ export async function instrumentForPerformance(appInstance: ApplicationInstance) ]; class FakeBrowserTracingClass { - static id = tracing.BROWSER_TRACING_INTEGRATION_ID; + static id = 'BrowserTracing'; public name = FakeBrowserTracingClass.id; setupOnce() { // noop - We're just faking this class for a lookup diff --git a/packages/ember/package.json b/packages/ember/package.json index 2ac858180668..5ba2b5dcfead 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -30,7 +30,6 @@ "dependencies": { "@embroider/macros": "^1.9.0", "@sentry/browser": "7.44.2", - "@sentry/tracing": "7.44.2", "@sentry/types": "7.44.2", "@sentry/utils": "7.44.2", "ember-auto-import": "^1.12.1 || ^2.4.3", diff --git a/packages/eslint-config-sdk/src/index.js b/packages/eslint-config-sdk/src/index.js index d09be9e2e67d..05ec68cff509 100644 --- a/packages/eslint-config-sdk/src/index.js +++ b/packages/eslint-config-sdk/src/index.js @@ -161,6 +161,9 @@ module.exports = { // All imports should be accounted for 'import/no-extraneous-dependencies': 'error', + + // Do not allow usage of functions we do not polyfill for ES5 + '@sentry-internal/sdk/no-unsupported-es6-methods': 'error', }, }, { diff --git a/packages/eslint-plugin-sdk/src/index.js b/packages/eslint-plugin-sdk/src/index.js index 31ac785abf5e..31d8e932d904 100644 --- a/packages/eslint-plugin-sdk/src/index.js +++ b/packages/eslint-plugin-sdk/src/index.js @@ -13,5 +13,6 @@ module.exports = { 'no-optional-chaining': require('./rules/no-optional-chaining'), 'no-nullish-coalescing': require('./rules/no-nullish-coalescing'), 'no-eq-empty': require('./rules/no-eq-empty'), + 'no-unsupported-es6-methods': require('./rules/no-unsupported-es6-methods'), }, }; diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsupported-es6-methods.js b/packages/eslint-plugin-sdk/src/rules/no-unsupported-es6-methods.js new file mode 100644 index 000000000000..85d32fb20e66 --- /dev/null +++ b/packages/eslint-plugin-sdk/src/rules/no-unsupported-es6-methods.js @@ -0,0 +1,35 @@ +'use strict'; + +/** + * Taken and adapted from https://github.com/nkt/eslint-plugin-es5/blob/master/src/rules/no-es6-methods.js + */ + +module.exports = { + meta: { + docs: { + description: 'Forbid methods added in ES6 which are not polyfilled by Sentry.', + }, + schema: [], + }, + create(context) { + return { + CallExpression(node) { + if (!node.callee || !node.callee.property) { + return; + } + const functionName = node.callee.property.name; + + const es6ArrayFunctions = ['copyWithin', 'values', 'fill']; + const es6StringFunctions = ['repeat']; + + const es6Functions = [].concat(es6ArrayFunctions, es6StringFunctions); + if (es6Functions.indexOf(functionName) > -1) { + context.report({ + node: node.callee.property, + message: `ES6 methods not allowed: ${functionName}`, + }); + } + }, + }; + }, +}; diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 1016f7cfa754..732c0836a08f 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -22,7 +22,6 @@ "dependencies": { "@sentry/core": "7.44.2", "@sentry/react": "7.44.2", - "@sentry/tracing": "7.44.2", "@sentry/types": "7.44.2", "@sentry/utils": "7.44.2", "@sentry/webpack-plugin": "1.19.0" diff --git a/packages/gatsby/src/index.ts b/packages/gatsby/src/index.ts index 6957f7337700..7c603d040693 100644 --- a/packages/gatsby/src/index.ts +++ b/packages/gatsby/src/index.ts @@ -1,4 +1,3 @@ export * from '@sentry/react'; -export { Integrations } from '@sentry/tracing'; export { init } from './sdk'; diff --git a/packages/gatsby/src/utils/integrations.ts b/packages/gatsby/src/utils/integrations.ts index 680ef61765cc..94ef28f21272 100644 --- a/packages/gatsby/src/utils/integrations.ts +++ b/packages/gatsby/src/utils/integrations.ts @@ -1,5 +1,5 @@ import { hasTracingEnabled } from '@sentry/core'; -import * as Tracing from '@sentry/tracing'; +import { BrowserTracing } from '@sentry/react'; import type { Integration } from '@sentry/types'; import type { GatsbyOptions } from './types'; @@ -31,11 +31,8 @@ export function getIntegrationsFromOptions(options: GatsbyOptions): UserIntegrat * @param isTracingEnabled Whether the user has enabled tracing. */ function getIntegrationsFromArray(userIntegrations: Integration[], isTracingEnabled: boolean): Integration[] { - if ( - isTracingEnabled && - !userIntegrations.some(integration => integration.name === Tracing.Integrations.BrowserTracing.name) - ) { - userIntegrations.push(new Tracing.Integrations.BrowserTracing()); + if (isTracingEnabled && !userIntegrations.some(integration => integration.name === BrowserTracing.name)) { + userIntegrations.push(new BrowserTracing()); } return userIntegrations; } diff --git a/packages/gatsby/test/gatsby-browser.test.ts b/packages/gatsby/test/gatsby-browser.test.ts index 00be51488d5f..b67305042c71 100644 --- a/packages/gatsby/test/gatsby-browser.test.ts +++ b/packages/gatsby/test/gatsby-browser.test.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { onClientEntry } from '../gatsby-browser'; +import { BrowserTracing } from '../src/index'; (global as any).__SENTRY_RELEASE__ = '683f3a6ab819d47d23abfca9a914c81f0524d35b'; (global as any).__SENTRY_DSN__ = 'https://examplePublicKey@o0.ingest.sentry.io/0'; @@ -20,11 +21,11 @@ global.console.warn = jest.fn(); global.console.error = jest.fn(); let tracingAddExtensionMethods = jest.fn(); -jest.mock('@sentry/tracing', () => { - const original = jest.requireActual('@sentry/tracing'); +jest.mock('@sentry/core', () => { + const original = jest.requireActual('@sentry/core'); return { ...original, - addExtensionMethods: (...args: any[]) => { + addTracingExtensions: (...args: any[]) => { tracingAddExtensionMethods(...args); }, }; @@ -140,8 +141,7 @@ describe('onClientEntry', () => { }); it('only defines a single `BrowserTracing` integration', () => { - const Tracing = jest.requireActual('@sentry/tracing'); - const integrations = [new Tracing.Integrations.BrowserTracing()]; + const integrations = [new BrowserTracing()]; onClientEntry(undefined, { tracesSampleRate: 0.5, integrations }); expect(sentryInit).toHaveBeenLastCalledWith( diff --git a/packages/gatsby/test/sdk.test.ts b/packages/gatsby/test/sdk.test.ts index 1c4342a13a4b..082fd771060b 100644 --- a/packages/gatsby/test/sdk.test.ts +++ b/packages/gatsby/test/sdk.test.ts @@ -1,5 +1,4 @@ -import { init, SDK_VERSION } from '@sentry/react'; -import { Integrations } from '@sentry/tracing'; +import { BrowserTracing, init, SDK_VERSION } from '@sentry/react'; import type { Integration } from '@sentry/types'; import { init as gatsbyInit } from '../src/sdk'; @@ -58,6 +57,8 @@ describe('Initialize React SDK', () => { }); }); +type TestArgs = [string, Integration[], GatsbyOptions, string[]]; + describe('Integrations from options', () => { afterEach(() => reactInit.mockClear()); @@ -65,72 +66,39 @@ describe('Integrations from options', () => { ['tracing disabled, no integrations', [], {}, []], ['tracing enabled, no integrations', [], { tracesSampleRate: 1 }, ['BrowserTracing']], [ - 'tracing disabled, with Integrations.BrowserTracing as an array', + 'tracing disabled, with BrowserTracing as an array', [], - { integrations: [new Integrations.BrowserTracing()] }, + { integrations: [new BrowserTracing()] }, ['BrowserTracing'], ], [ - 'tracing disabled, with Integrations.BrowserTracing as a function', + 'tracing disabled, with BrowserTracing as a function', [], { - integrations: () => [new Integrations.BrowserTracing()], + integrations: () => [new BrowserTracing()], }, ['BrowserTracing'], ], [ - 'tracing enabled, with Integrations.BrowserTracing as an array', + 'tracing enabled, with BrowserTracing as an array', [], - { tracesSampleRate: 1, integrations: [new Integrations.BrowserTracing()] }, + { tracesSampleRate: 1, integrations: [new BrowserTracing()] }, ['BrowserTracing'], ], [ - 'tracing enabled, with Integrations.BrowserTracing as a function', + 'tracing enabled, with BrowserTracing as a function', [], - { tracesSampleRate: 1, integrations: () => [new Integrations.BrowserTracing()] }, + { tracesSampleRate: 1, integrations: () => [new BrowserTracing()] }, ['BrowserTracing'], ], - [ - 'tracing enabled, with another integration as an array', - [], - { tracesSampleRate: 1, integrations: [new Integrations.Express()] }, - ['Express', 'BrowserTracing'], - ], - [ - 'tracing enabled, with another integration as a function', - [], - { tracesSampleRate: 1, integrations: () => [new Integrations.Express()] }, - ['Express', 'BrowserTracing'], - ], - [ - 'tracing disabled, with another integration as an array', - [], - { integrations: [new Integrations.Express()] }, - ['Express'], - ], - [ - 'tracing disabled, with another integration as a function', - [], - { integrations: () => [new Integrations.Express()] }, - ['Express'], - ], - [ - 'merges integrations with user integrations as a function', - [new Integrations.Mongo()], - { - tracesSampleRate: 1, - integrations: (defaultIntegrations: Integration[]): Integration[] => [ - ...defaultIntegrations, - new Integrations.Express(), - ], - }, - ['Mongo', 'Express', 'BrowserTracing'], - ], - ])('%s', (_testName, defaultIntegrations: Integration[], options: GatsbyOptions, expectedIntNames: string[]) => { - gatsbyInit(options); - const integrations: UserIntegrations = reactInit.mock.calls[0][0].integrations; - const arrIntegrations = Array.isArray(integrations) ? integrations : integrations(defaultIntegrations); - expect(arrIntegrations).toHaveLength(expectedIntNames.length); - arrIntegrations.map((integration, idx) => expect(integration.name).toStrictEqual(expectedIntNames[idx])); - }); + ] as TestArgs[])( + '%s', + (_testName, defaultIntegrations: Integration[], options: GatsbyOptions, expectedIntNames: string[]) => { + gatsbyInit(options); + const integrations: UserIntegrations = reactInit.mock.calls[0][0].integrations; + const arrIntegrations = Array.isArray(integrations) ? integrations : integrations(defaultIntegrations); + expect(arrIntegrations).toHaveLength(expectedIntNames.length); + arrIntegrations.map((integration, idx) => expect(integration.name).toStrictEqual(expectedIntNames[idx])); + }, + ); }); diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index f341b0609519..8b32553b1f9d 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -22,7 +22,6 @@ "@sentry/integrations": "7.44.2", "@sentry/node": "7.44.2", "@sentry/react": "7.44.2", - "@sentry/tracing": "7.44.2", "@sentry/types": "7.44.2", "@sentry/utils": "7.44.2", "@sentry/webpack-plugin": "1.20.0", diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 79dcd5219cd4..ece6bf78db6a 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -1,8 +1,13 @@ import { hasTracingEnabled } from '@sentry/core'; import { RewriteFrames } from '@sentry/integrations'; import type { BrowserOptions } from '@sentry/react'; -import { configureScope, init as reactInit, Integrations } from '@sentry/react'; -import { BrowserTracing, defaultRequestInstrumentationOptions } from '@sentry/tracing'; +import { + BrowserTracing, + configureScope, + defaultRequestInstrumentationOptions, + init as reactInit, + Integrations, +} from '@sentry/react'; import type { EventProcessor } from '@sentry/types'; import { addOrUpdateIntegration } from '@sentry/utils'; diff --git a/packages/nextjs/src/edge/edgeclient.ts b/packages/nextjs/src/edge/edgeclient.ts index a5b38d651aed..16aed66d1ca4 100644 --- a/packages/nextjs/src/edge/edgeclient.ts +++ b/packages/nextjs/src/edge/edgeclient.ts @@ -1,5 +1,5 @@ import type { Scope } from '@sentry/core'; -import { BaseClient, SDK_VERSION } from '@sentry/core'; +import { addTracingExtensions, BaseClient, SDK_VERSION } from '@sentry/core'; import type { ClientOptions, Event, EventHint, Severity, SeverityLevel } from '@sentry/types'; import { eventFromMessage, eventFromUnknownInput } from './eventbuilder'; @@ -28,6 +28,9 @@ export class EdgeClient extends BaseClient { version: SDK_VERSION, }; + // The Edge client always supports tracing + addTracingExtensions(); + super(options); } diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6f8cd2f42cc4..f7785aaa06d6 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,3 @@ -import '@sentry/tracing'; // Allow people to call tracing API methods without explicitly importing the tracing package. - import { getCurrentHub, getIntegrationsToSetup, initAndBind, Integrations as CoreIntegrations } from '@sentry/core'; import type { Options } from '@sentry/types'; import { diff --git a/packages/nextjs/src/server/utils/wrapperUtils.ts b/packages/nextjs/src/server/utils/wrapperUtils.ts index ae05b3f16b8d..9fa91fbbee5a 100644 --- a/packages/nextjs/src/server/utils/wrapperUtils.ts +++ b/packages/nextjs/src/server/utils/wrapperUtils.ts @@ -1,5 +1,4 @@ -import { captureException, getCurrentHub, startTransaction } from '@sentry/core'; -import { getActiveTransaction } from '@sentry/tracing'; +import { captureException, getActiveTransaction, getCurrentHub, startTransaction } from '@sentry/core'; import type { Transaction } from '@sentry/types'; import { baggageHeaderToDynamicSamplingContext, extractTraceparentData } from '@sentry/utils'; import * as domain from 'domain'; diff --git a/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts index 5bed5dca3135..37ef93bf30cc 100644 --- a/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts @@ -1,10 +1,10 @@ import { hasTracingEnabled } from '@sentry/core'; import { captureException, getCurrentHub, startTransaction } from '@sentry/node'; -import { extractTraceparentData } from '@sentry/tracing'; import type { Transaction } from '@sentry/types'; import { addExceptionMechanism, baggageHeaderToDynamicSamplingContext, + extractTraceparentData, isString, logger, objectify, diff --git a/packages/nextjs/src/server/wrapServerComponentWithSentry.ts b/packages/nextjs/src/server/wrapServerComponentWithSentry.ts index eab32207236c..12ff9ceb5f72 100644 --- a/packages/nextjs/src/server/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/server/wrapServerComponentWithSentry.ts @@ -1,4 +1,4 @@ -import { captureException, getCurrentHub, startTransaction } from '@sentry/core'; +import { addTracingExtensions, captureException, getCurrentHub, startTransaction } from '@sentry/core'; import { baggageHeaderToDynamicSamplingContext, extractTraceparentData } from '@sentry/utils'; import * as domain from 'domain'; @@ -11,6 +11,8 @@ export function wrapServerComponentWithSentry any> appDirComponent: F, context: ServerComponentContext, ): F { + addTracingExtensions(); + const { componentRoute, componentType } = context; // Even though users may define server components as async functions, for the client bundles diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 5bfb29434523..ed3bb666d58d 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,7 +1,6 @@ import { BaseClient, getCurrentHub } from '@sentry/core'; import * as SentryReact from '@sentry/react'; -import { WINDOW } from '@sentry/react'; -import { Integrations as TracingIntegrations } from '@sentry/tracing'; +import { BrowserTracing, WINDOW } from '@sentry/react'; import type { Integration } from '@sentry/types'; import type { UserIntegrationsFunction } from '@sentry/utils'; import { logger } from '@sentry/utils'; @@ -9,8 +8,6 @@ import { JSDOM } from 'jsdom'; import { init, Integrations, nextRouterInstrumentation } from '../src/client'; -const { BrowserTracing } = TracingIntegrations; - const reactInit = jest.spyOn(SentryReact, 'init'); const captureEvent = jest.spyOn(BaseClient.prototype, 'captureEvent'); const loggerLogSpy = jest.spyOn(logger, 'log'); diff --git a/packages/nextjs/test/config/withSentry.test.ts b/packages/nextjs/test/config/withSentry.test.ts index dfc06c9bcf7e..92315374836b 100644 --- a/packages/nextjs/test/config/withSentry.test.ts +++ b/packages/nextjs/test/config/withSentry.test.ts @@ -1,4 +1,5 @@ import * as hub from '@sentry/core'; +import { addTracingExtensions } from '@sentry/core'; import * as Sentry from '@sentry/node'; import type { Client, ClientOptions } from '@sentry/types'; import type { NextApiRequest, NextApiResponse } from 'next'; @@ -6,6 +7,10 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { withSentry } from '../../src/server'; import type { AugmentedNextApiResponse, NextApiHandler } from '../../src/server/types'; +// The wrap* functions require the hub to have tracing extensions. This is normally called by the NodeClient +// constructor but the client isn't used in these tests. +addTracingExtensions(); + const FLUSH_DURATION = 200; async function sleep(ms: number): Promise { diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index 68c598e9707f..444d45513ccb 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -1,4 +1,5 @@ import * as SentryCore from '@sentry/core'; +import { addTracingExtensions } from '@sentry/core'; import * as SentryNode from '@sentry/node'; import type { IncomingMessage, ServerResponse } from 'http'; @@ -6,6 +7,10 @@ import { wrapGetInitialPropsWithSentry, wrapGetServerSidePropsWithSentry } from const startTransactionSpy = jest.spyOn(SentryCore, 'startTransaction'); +// The wrap* functions require the hub to have tracing extensions. This is normally called by the NodeClient +// constructor but the client isn't used in these tests. +addTracingExtensions(); + describe('data-fetching function wrappers', () => { const route = '/tricks/[trickName]'; let req: IncomingMessage; diff --git a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts index 852ceb5628b4..cdc7cc4986e2 100644 --- a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts +++ b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts @@ -1,7 +1,12 @@ import * as coreSdk from '@sentry/core'; +import { addTracingExtensions } from '@sentry/core'; import { withEdgeWrapping } from '../../src/edge/utils/edgeWrapperUtils'; +// The wrap* functions require the hub to have tracing extensions. This is normally called by the EdgeClient +// constructor but the client isn't used in these tests. +addTracingExtensions(); + // @ts-ignore Request does not exist on type Global const origRequest = global.Request; // @ts-ignore Response does not exist on type Global diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts index 2ecbdf22a96e..08a91e0c5e11 100644 --- a/packages/nextjs/test/edge/withSentryAPI.test.ts +++ b/packages/nextjs/test/edge/withSentryAPI.test.ts @@ -2,6 +2,10 @@ import * as coreSdk from '@sentry/core'; import { wrapApiHandlerWithSentry } from '../../src/edge'; +// The wrap* functions require the hub to have tracing extensions. This is normally called by the EdgeClient +// constructor but the client isn't used in these tests. +coreSdk.addTracingExtensions(); + // @ts-ignore Request does not exist on type Global const origRequest = global.Request; // @ts-ignore Response does not exist on type Global diff --git a/packages/node-integration-tests/package.json b/packages/node-integration-tests/package.json index f1661102a963..e63a6e940dca 100644 --- a/packages/node-integration-tests/package.json +++ b/packages/node-integration-tests/package.json @@ -9,6 +9,7 @@ "scripts": { "clean": "rimraf -g **/node_modules", "prisma:init": "(cd suites/tracing/prisma-orm && ts-node ./setup.ts)", + "prisma:init:new": "(cd suites/tracing-new/prisma-orm && ts-node ./setup.ts)", "lint": "run-s lint:prettier lint:eslint", "lint:eslint": "eslint . --format stylish", "lint:prettier": "prettier --check \"{suites,utils}/**/*.ts\"", @@ -16,7 +17,7 @@ "fix:eslint": "eslint . --format stylish --fix", "fix:prettier": "prettier --write \"{suites,utils}/**/*.ts\"", "type-check": "tsc", - "pretest": "run-s --silent prisma:init", + "pretest": "run-s --silent prisma:init prisma:init:new", "test": "ts-node ./utils/run-tests.ts", "test:watch": "yarn test --watch" }, diff --git a/packages/node-integration-tests/suites/express/tracing/server.ts b/packages/node-integration-tests/suites/express/tracing/server.ts index faf5a50f95ed..e857621ad22e 100644 --- a/packages/node-integration-tests/suites/express/tracing/server.ts +++ b/packages/node-integration-tests/suites/express/tracing/server.ts @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/node'; -import * as Tracing from '@sentry/tracing'; import cors from 'cors'; import express from 'express'; @@ -8,7 +7,7 @@ const app = express(); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', - integrations: [new Sentry.Integrations.Http({ tracing: true }), new Tracing.Integrations.Express({ app })], + integrations: [new Sentry.Integrations.Http({ tracing: true }), new Sentry.Integrations.Express({ app })], tracesSampleRate: 1.0, }); diff --git a/packages/node-integration-tests/suites/tracing-new/apollo-graphql/scenario.ts b/packages/node-integration-tests/suites/tracing-new/apollo-graphql/scenario.ts new file mode 100644 index 000000000000..5bd8aa815cbe --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/apollo-graphql/scenario.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/node'; +import { ApolloServer, gql } from 'apollo-server'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [new Sentry.Integrations.GraphQL(), new Sentry.Integrations.Apollo()], +}); + +const typeDefs = gql` + type Query { + hello: String + } +`; + +const resolvers = { + Query: { + hello: () => { + return 'Hello world!'; + }, + }, +}; + +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +const transaction = Sentry.startTransaction({ name: 'test_transaction', op: 'transaction' }); + +Sentry.configureScope(scope => { + scope.setSpan(transaction); +}); + +void (async () => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await server.executeOperation({ + query: '{hello}', + }); + + transaction.finish(); +})(); diff --git a/packages/node-integration-tests/suites/tracing-new/apollo-graphql/test.ts b/packages/node-integration-tests/suites/tracing-new/apollo-graphql/test.ts new file mode 100644 index 000000000000..128f8a2f164b --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/apollo-graphql/test.ts @@ -0,0 +1,35 @@ +import { assertSentryTransaction, conditionalTest, TestEnv } from '../../../utils'; + +// Node 10 is not supported by `graphql-js` +// Ref: https://github.com/graphql/graphql-js/blob/main/package.json +conditionalTest({ min: 12 })('GraphQL/Apollo Tests', () => { + test('should instrument GraphQL and Apollo Server.', async () => { + const env = await TestEnv.init(__dirname); + const envelope = await env.getEnvelopeRequest({ envelopeType: 'transaction' }); + + expect(envelope).toHaveLength(3); + + const transaction = envelope[2]; + const parentSpanId = (transaction as any)?.contexts?.trace?.span_id; + const graphqlSpanId = (transaction as any)?.spans?.[0].span_id; + + expect(parentSpanId).toBeDefined(); + expect(graphqlSpanId).toBeDefined(); + + assertSentryTransaction(transaction, { + transaction: 'test_transaction', + spans: [ + { + description: 'execute', + op: 'graphql.execute', + parent_span_id: parentSpanId, + }, + { + description: 'Query.hello', + op: 'graphql.resolve', + parent_span_id: graphqlSpanId, + }, + ], + }); + }); +}); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/scenario.ts new file mode 100644 index 000000000000..31d7356765e9 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/scenario.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; +import { MongoClient } from 'mongodb'; + +// suppress logging of the mongo download +global.console.log = () => null; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations()], +}); + +const client = new MongoClient(process.env.MONGO_URL || '', { + useUnifiedTopology: true, +}); + +async function run(): Promise { + const transaction = Sentry.startTransaction({ + name: 'Test Transaction', + op: 'transaction', + }); + + Sentry.configureScope(scope => { + scope.setSpan(transaction); + }); + + try { + await client.connect(); + + const database = client.db('admin'); + const collection = database.collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.findOne({ title: 'South Park' }); + + await collection.find({ title: 'South Park' }).toArray(); + } finally { + if (transaction) transaction.finish(); + await client.close(); + } +} + +void run(); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts new file mode 100644 index 000000000000..5664aac9422b --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts @@ -0,0 +1,85 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; + +import { assertSentryTransaction, conditionalTest, TestEnv } from '../../../../utils'; + +// This test can take longer. +jest.setTimeout(15000); + +conditionalTest({ min: 12 })('MongoDB Test', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 10000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + }); + + test('should auto-instrument `mongodb` package.', async () => { + const env = await TestEnv.init(__dirname); + const envelope = await env.getEnvelopeRequest({ envelopeType: 'transaction' }); + + expect(envelope).toHaveLength(3); + + assertSentryTransaction(envelope[2], { + transaction: 'Test Transaction', + spans: [ + { + data: { + collectionName: 'movies', + dbName: 'admin', + namespace: 'admin.movies', + doc: '{"title":"Rick and Morty"}', + }, + description: 'insertOne', + op: 'db', + }, + { + data: { + collectionName: 'movies', + dbName: 'admin', + namespace: 'admin.movies', + query: '{"title":"Back to the Future"}', + }, + description: 'findOne', + op: 'db', + }, + { + data: { + collectionName: 'movies', + dbName: 'admin', + namespace: 'admin.movies', + filter: '{"title":"Back to the Future"}', + update: '{"$set":{"title":"South Park"}}', + }, + description: 'updateOne', + op: 'db', + }, + { + data: { + collectionName: 'movies', + dbName: 'admin', + namespace: 'admin.movies', + query: '{"title":"South Park"}', + }, + description: 'findOne', + op: 'db', + }, + { + data: { + collectionName: 'movies', + dbName: 'admin', + namespace: 'admin.movies', + query: '{"title":"South Park"}', + }, + description: 'find', + op: 'db', + }, + ], + }); + }); +}); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/scenario.ts new file mode 100644 index 000000000000..0f576cb793aa --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/scenario.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import mysql from 'mysql'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations()], +}); + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +connection.connect(function (err: unknown) { + if (err) { + return; + } +}); + +const transaction = Sentry.startTransaction({ + op: 'transaction', + name: 'Test Transaction', +}); + +Sentry.configureScope(scope => { + scope.setSpan(transaction); +}); + +connection.query('SELECT 1 + 1 AS solution', function () { + connection.query('SELECT NOW()', ['1', '2'], () => { + if (transaction) transaction.finish(); + connection.end(); + }); +}); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/test.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/test.ts new file mode 100644 index 000000000000..3b96f2cafec0 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/test.ts @@ -0,0 +1,23 @@ +import { assertSentryTransaction, TestEnv } from '../../../../utils'; + +test('should auto-instrument `mysql` package.', async () => { + const env = await TestEnv.init(__dirname); + const envelope = await env.getEnvelopeRequest({ envelopeType: 'transaction' }); + + expect(envelope).toHaveLength(3); + + assertSentryTransaction(envelope[2], { + transaction: 'Test Transaction', + spans: [ + { + description: 'SELECT 1 + 1 AS solution', + op: 'db', + }, + + { + description: 'SELECT NOW()', + op: 'db', + }, + ], + }); +}); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/scenario.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/scenario.ts new file mode 100644 index 000000000000..a7859fd562a3 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/scenario.ts @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/node'; +import pg from 'pg'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations()], +}); + +const transaction = Sentry.startTransaction({ + op: 'transaction', + name: 'Test Transaction', +}); + +Sentry.configureScope(scope => { + scope.setSpan(transaction); +}); + +const client = new pg.Client(); +client.query('SELECT * FROM foo where bar ilike "baz%"', ['a', 'b'], () => + client.query('SELECT * FROM bazz', () => { + client.query('SELECT NOW()', () => transaction.finish()); + }), +); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/test.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/test.ts new file mode 100644 index 000000000000..edfa67cee9d7 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/test.ts @@ -0,0 +1,54 @@ +import { assertSentryTransaction, TestEnv } from '../../../../utils'; + +class PgClient { + // https://node-postgres.com/api/client#clientquery + public query(_text: unknown, values: unknown, callback?: () => void) { + if (typeof callback === 'function') { + callback(); + return; + } + + if (typeof values === 'function') { + values(); + return; + } + + return Promise.resolve(); + } +} + +beforeAll(() => { + jest.mock('pg', () => { + return { + Client: PgClient, + native: { + Client: PgClient, + }, + }; + }); +}); + +test('should auto-instrument `pg` package.', async () => { + const env = await TestEnv.init(__dirname); + const envelope = await env.getEnvelopeRequest({ envelopeType: 'transaction' }); + + expect(envelope).toHaveLength(3); + + assertSentryTransaction(envelope[2], { + transaction: 'Test Transaction', + spans: [ + { + description: 'SELECT * FROM foo where bar ilike "baz%"', + op: 'db', + }, + { + description: 'SELECT * FROM bazz', + op: 'db', + }, + { + description: 'SELECT NOW()', + op: 'db', + }, + ], + }); +}); diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/docker-compose.yml b/packages/node-integration-tests/suites/tracing-new/prisma-orm/docker-compose.yml new file mode 100644 index 000000000000..45caa4bb3179 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.9' + +services: + db: + image: postgres:13 + restart: always + container_name: integration-tests-prisma + ports: + - '5433:5432' + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/package.json b/packages/node-integration-tests/suites/tracing-new/prisma-orm/package.json new file mode 100644 index 000000000000..f8b24d7d0465 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/package.json @@ -0,0 +1,22 @@ +{ + "name": "sentry-prisma-test", + "version": "1.0.0", + "description": "", + "main": "index.js", + "engines": { + "node": ">=12" + }, + "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" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@prisma/client": "3.12.0", + "prisma": "^3.12.0" + } +} diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/migrations/migration_lock.toml b/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000000..fbffa92c2bb7 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/migrations/sentry_test/migration.sql b/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/migrations/sentry_test/migration.sql new file mode 100644 index 000000000000..8619aaceb2b0 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/migrations/sentry_test/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "email" TEXT NOT NULL, + "name" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/schema.prisma b/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/schema.prisma new file mode 100644 index 000000000000..4363c97738ee --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/prisma/schema.prisma @@ -0,0 +1,15 @@ +datasource db { + url = "postgresql://prisma:prisma@localhost:5433/tests" + provider = "postgresql" +} + +generator client { + provider = "prisma-client-js" +} + +model User { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + email String @unique + name String? +} diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/scenario.ts b/packages/node-integration-tests/suites/tracing-new/prisma-orm/scenario.ts new file mode 100644 index 000000000000..0eb40d9c83ee --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/scenario.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { PrismaClient } from '@prisma/client'; +import * as Sentry from '@sentry/node'; +import { randomBytes } from 'crypto'; + +const client = new PrismaClient(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [new Sentry.Integrations.Prisma({ client })], +}); + +async function run(): Promise { + const transaction = Sentry.startTransaction({ + name: 'Test Transaction', + op: 'transaction', + }); + + Sentry.configureScope(scope => { + scope.setSpan(transaction); + }); + + try { + await client.user.create({ + data: { + name: 'Tilda', + email: `tilda_${randomBytes(4).toString('hex')}@sentry.io`, + }, + }); + + await client.user.findMany(); + + await client.user.deleteMany({ + where: { + email: { + contains: 'sentry.io', + }, + }, + }); + } finally { + if (transaction) transaction.finish(); + } +} + +void run(); diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/setup.ts b/packages/node-integration-tests/suites/tracing-new/prisma-orm/setup.ts new file mode 100755 index 000000000000..3c40d12f7337 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/setup.ts @@ -0,0 +1,16 @@ +import { parseSemver } from '@sentry/utils'; +import { execSync } from 'child_process'; + +const NODE_VERSION = parseSemver(process.versions.node); + +if (NODE_VERSION.major && NODE_VERSION.major < 12) { + // eslint-disable-next-line no-console + console.warn(`Skipping Prisma tests on Node: ${NODE_VERSION.major}`); + process.exit(0); +} + +try { + execSync('yarn && yarn setup'); +} catch (_) { + process.exit(1); +} diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/test.ts b/packages/node-integration-tests/suites/tracing-new/prisma-orm/test.ts new file mode 100644 index 000000000000..e3393f5fe2f8 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/test.ts @@ -0,0 +1,17 @@ +import { assertSentryTransaction, conditionalTest, TestEnv } from '../../../utils'; + +conditionalTest({ min: 12 })('Prisma ORM Integration', () => { + test('should instrument Prisma client for tracing.', async () => { + const env = await TestEnv.init(__dirname); + const envelope = await env.getEnvelopeRequest({ envelopeType: 'transaction' }); + + assertSentryTransaction(envelope[2], { + transaction: 'Test Transaction', + spans: [ + { description: 'User create', op: 'db.sql.prisma' }, + { description: 'User findMany', op: 'db.sql.prisma' }, + { description: 'User deleteMany', op: 'db.sql.prisma' }, + ], + }); + }); +}); diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/yarn.lock b/packages/node-integration-tests/suites/tracing-new/prisma-orm/yarn.lock new file mode 100644 index 000000000000..d228adebd621 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/yarn.lock @@ -0,0 +1,27 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@prisma/client@3.12.0": + version "3.12.0" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.12.0.tgz#a0eb49ffea5c128dd11dffb896d7139a60073d12" + integrity sha512-4NEQjUcWja/NVBvfuDFscWSk1/rXg3+wj+TSkqXCb1tKlx/bsUE00rxsvOvGg7VZ6lw1JFpGkwjwmsOIc4zvQw== + dependencies: + "@prisma/engines-version" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" + +"@prisma/engines-version@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980": + version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#829ca3d9d0d92555f44644606d4edfd45b2f5886" + integrity sha512-o+jo8d7ZEiVpcpNWUDh3fj2uPQpBxl79XE9ih9nkogJbhw6P33274SHnqheedZ7PyvPIK/mvU8MLNYgetgXPYw== + +"@prisma/engines@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980": + version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#e52e364084c4d05278f62768047b788665e64a45" + integrity sha512-zULjkN8yhzS7B3yeEz4aIym4E2w1ChrV12i14pht3ePFufvsAvBSoZ+tuXMvfSoNTgBS5E4bolRzLbMmbwkkMQ== + +prisma@^3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.12.0.tgz#9675e0e72407122759d3eadcb6d27cdccd3497bd" + integrity sha512-ltCMZAx1i0i9xuPM692Srj8McC665h6E5RqJom999sjtVSccHSD8Z+HSdBN2183h9PJKvC5dapkn78dd0NWMBg== + dependencies: + "@prisma/engines" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" diff --git a/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/scenario.ts b/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/scenario.ts new file mode 100644 index 000000000000..a6197e5ab743 --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/scenario.ts @@ -0,0 +1,26 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import * as Sentry from '@sentry/node'; +import * as http from 'http'; + +Sentry.addTracingExtensions(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + tracePropagationTargets: [/\/v0/, 'v1'], + integrations: [new Sentry.Integrations.Http({ tracing: true })], +}); + +const transaction = Sentry.startTransaction({ name: 'test_transaction' }); + +Sentry.configureScope(scope => { + scope.setSpan(transaction); +}); + +http.get('http://match-this-url.com/api/v0'); +http.get('http://match-this-url.com/api/v1'); +http.get('http://dont-match-this-url.com/api/v2'); +http.get('http://dont-match-this-url.com/api/v3'); + +transaction.finish(); diff --git a/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/test.ts b/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/test.ts new file mode 100644 index 000000000000..1209c59da46a --- /dev/null +++ b/packages/node-integration-tests/suites/tracing-new/tracePropagationTargets/test.ts @@ -0,0 +1,42 @@ +import nock from 'nock'; + +import { runScenario, TestEnv } from '../../../utils'; + +test('HttpIntegration should instrument correct requests when tracePropagationTargets option is provided', async () => { + const match1 = nock('http://match-this-url.com') + .get('/api/v0') + .matchHeader('baggage', val => typeof val === 'string') + .matchHeader('sentry-trace', val => typeof val === 'string') + .reply(200); + + const match2 = nock('http://match-this-url.com') + .get('/api/v1') + .matchHeader('baggage', val => typeof val === 'string') + .matchHeader('sentry-trace', val => typeof val === 'string') + .reply(200); + + const match3 = nock('http://dont-match-this-url.com') + .get('/api/v2') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const match4 = nock('http://dont-match-this-url.com') + .get('/api/v3') + .matchHeader('baggage', val => val === undefined) + .matchHeader('sentry-trace', val => val === undefined) + .reply(200); + + const env = await TestEnv.init(__dirname); + await runScenario(env.url); + + env.server.close(); + nock.cleanAll(); + + await new Promise(resolve => env.server.close(resolve)); + + expect(match1.isDone()).toBe(true); + expect(match2.isDone()).toBe(true); + expect(match3.isDone()).toBe(true); + expect(match4.isDone()).toBe(true); +}); diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index e15b119aa31e..8e4b96002ae0 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -6,5 +6,6 @@ module.exports = { rules: { '@sentry-internal/sdk/no-optional-chaining': 'off', '@sentry-internal/sdk/no-nullish-coalescing': 'off', + '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', }, }; diff --git a/packages/node/package.json b/packages/node/package.json index 7386be3912bb..fd197f539739 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -19,13 +19,13 @@ "@sentry/core": "7.44.2", "@sentry/types": "7.44.2", "@sentry/utils": "7.44.2", + "@sentry-internal/tracing": "7.44.2", "cookie": "^0.4.1", "https-proxy-agent": "^5.0.0", "lru_map": "^0.3.3", "tslib": "^1.9.3" }, "devDependencies": { - "@sentry/tracing": "7.44.2", "@types/cookie": "0.3.2", "@types/express": "^4.17.14", "@types/lru-cache": "^5.1.0", diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 0b3a925d775e..d0d0ae7424be 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1,5 +1,5 @@ import type { Scope } from '@sentry/core'; -import { BaseClient, SDK_VERSION, SessionFlusher } from '@sentry/core'; +import { addTracingExtensions, BaseClient, SDK_VERSION, SessionFlusher } from '@sentry/core'; import type { Event, EventHint, Severity, SeverityLevel } from '@sentry/types'; import { logger, resolvedSyncPromise } from '@sentry/utils'; import * as os from 'os'; @@ -40,6 +40,9 @@ export class NodeClient extends BaseClient { ...options.transportOptions, }; + // The Node client always supports tracing + addTracingExtensions(); + super(options); } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 92d3cfcf835f..7f0d923e4ae2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -30,6 +30,7 @@ export { captureMessage, configureScope, createTransport, + getActiveTransaction, getHubFromCarrier, getCurrentHub, Hub, @@ -45,6 +46,7 @@ export { setUser, withScope, } from '@sentry/core'; +export { autoDiscoverNodePerformanceMonitoringIntegrations } from './tracing'; export { NodeClient } from './client'; export { makeNodeTransport } from './transports'; @@ -57,10 +59,12 @@ import * as domain from 'domain'; import * as Handlers from './handlers'; import * as NodeIntegrations from './integrations'; +import * as TracingIntegrations from './tracing/integrations'; const INTEGRATIONS = { ...CoreIntegrations, ...NodeIntegrations, + ...TracingIntegrations, }; export { INTEGRATIONS as Integrations, Handlers }; diff --git a/packages/node/src/integrations/onuncaughtexception.ts b/packages/node/src/integrations/onuncaughtexception.ts index 2d10ae61d696..b4f99b419fd4 100644 --- a/packages/node/src/integrations/onuncaughtexception.ts +++ b/packages/node/src/integrations/onuncaughtexception.ts @@ -8,6 +8,10 @@ import { logAndExitProcess } from './utils/errorhandling'; type OnFatalErrorHandler = (firstError: Error, secondError?: Error) => void; +type TaggedListener = NodeJS.UncaughtExceptionListener & { + tag?: string; +}; + // CAREFUL: Please think twice before updating the way _options looks because the Next.js SDK depends on it in `index.server.ts` interface OnUncaughtExceptionOptions { // TODO(v8): Evaluate whether we should switch the default behaviour here. @@ -95,18 +99,20 @@ export class OnUncaughtException implements Integration { // exit behaviour of the SDK accordingly: // - If other listeners are attached, do not exit. // - If the only listener attached is ours, exit. - const userProvidedListenersCount = global.process - .listeners('uncaughtException') - .reduce((acc, listener) => { - if ( - listener.name === 'domainUncaughtExceptionClear' || // as soon as we're using domains this listener is attached by node itself - listener === this.handler // filter the handler we registered ourselves) - ) { - return acc; - } else { - return acc + 1; - } - }, 0); + const userProvidedListenersCount = ( + global.process.listeners('uncaughtException') as TaggedListener[] + ).reduce((acc, listener) => { + if ( + // There are 3 listeners we ignore: + listener.name === 'domainUncaughtExceptionClear' || // as soon as we're using domains this listener is attached by node itself + (listener.tag && listener.tag === 'sentry_tracingErrorCallback') || // the handler we register for tracing + listener === this.handler // the handler we register in this integration + ) { + return acc; + } else { + return acc + 1; + } + }, 0); const processWouldExit = userProvidedListenersCount === 0; const shouldApplyFatalHandlingLogic = this._options.exitEvenIfOtherHandlersAreRegistered || processWouldExit; diff --git a/packages/node/src/tracing/index.ts b/packages/node/src/tracing/index.ts new file mode 100644 index 000000000000..15c4e2889b3f --- /dev/null +++ b/packages/node/src/tracing/index.ts @@ -0,0 +1,24 @@ +import { lazyLoadedNodePerformanceMonitoringIntegrations } from '@sentry-internal/tracing'; +import type { Integration } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +/** + * Automatically detects and returns integrations that will work with your dependencies. + */ +export function autoDiscoverNodePerformanceMonitoringIntegrations(): Integration[] { + const loadedIntegrations = lazyLoadedNodePerformanceMonitoringIntegrations + .map(tryLoad => { + try { + return tryLoad(); + } catch (_) { + return undefined; + } + }) + .filter(integration => !!integration) as Integration[]; + + if (loadedIntegrations.length === 0) { + logger.warn('Performance monitoring integrations could not be automatically loaded.'); + } + + return loadedIntegrations; +} diff --git a/packages/node/src/tracing/integrations.ts b/packages/node/src/tracing/integrations.ts new file mode 100644 index 000000000000..a37bf6bfd494 --- /dev/null +++ b/packages/node/src/tracing/integrations.ts @@ -0,0 +1 @@ +export { Apollo, Express, GraphQL, Mongo, Mysql, Postgres, Prisma } from '@sentry-internal/tracing'; diff --git a/packages/node/test/handlers.test.ts b/packages/node/test/handlers.test.ts index 03cfba80ade4..028cdff98af1 100644 --- a/packages/node/test/handlers.test.ts +++ b/packages/node/test/handlers.test.ts @@ -1,5 +1,5 @@ import * as sentryCore from '@sentry/core'; -import { Transaction } from '@sentry/tracing'; +import { Transaction } from '@sentry/core'; import type { Event } from '@sentry/types'; import { SentryError } from '@sentry/utils'; import * as http from 'http'; diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index 446aa4ec0f82..7d9a4e45226b 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -1,9 +1,8 @@ +import type { Span, Transaction } from '@sentry/core'; import * as sentryCore from '@sentry/core'; -import { Hub } from '@sentry/core'; -import type { Span, Transaction } from '@sentry/tracing'; -import { addExtensionMethods, TRACEPARENT_REGEXP } from '@sentry/tracing'; +import { addTracingExtensions, Hub } from '@sentry/core'; import type { TransactionContext } from '@sentry/types'; -import { logger, parseSemver } from '@sentry/utils'; +import { logger, parseSemver, TRACEPARENT_REGEXP } from '@sentry/utils'; import * as http from 'http'; import * as https from 'https'; import * as HttpsProxyAgent from 'https-proxy-agent'; @@ -34,7 +33,7 @@ describe('tracing', () => { ...customOptions, }); const hub = new Hub(new NodeClient(options)); - addExtensionMethods(); + addTracingExtensions(); hub.configureScope(scope => scope.setUser({ @@ -227,7 +226,7 @@ describe('tracing', () => { } function createTransactionAndPutOnScope(hub: Hub) { - addExtensionMethods(); + addTracingExtensions(); const transaction = hub.startTransaction({ name: 'dogpark' }); hub.getScope()?.setSpan(transaction); return transaction; diff --git a/packages/opentelemetry-node/README.md b/packages/opentelemetry-node/README.md index 551684a75c26..b75ca0ab0ef8 100644 --- a/packages/opentelemetry-node/README.md +++ b/packages/opentelemetry-node/README.md @@ -66,10 +66,9 @@ const sdk = new opentelemetry.NodeSDK({ // Sentry config spanProcessor: new SentrySpanProcessor(), + textMapPropagator: new SentryPropagator(), }); -otelApi.propagation.setGlobalPropagator(new SentryPropagator()); - sdk.start(); ``` diff --git a/packages/opentelemetry-node/package.json b/packages/opentelemetry-node/package.json index 84391b502c63..b19a608ef2e3 100644 --- a/packages/opentelemetry-node/package.json +++ b/packages/opentelemetry-node/package.json @@ -17,7 +17,6 @@ }, "dependencies": { "@sentry/core": "7.44.2", - "@sentry/tracing": "7.44.2", "@sentry/types": "7.44.2", "@sentry/utils": "7.44.2" }, diff --git a/packages/opentelemetry-node/src/index.ts b/packages/opentelemetry-node/src/index.ts index 501c93432b44..752c2cfaa0aa 100644 --- a/packages/opentelemetry-node/src/index.ts +++ b/packages/opentelemetry-node/src/index.ts @@ -1,4 +1,2 @@ -import '@sentry/tracing'; - export { SentrySpanProcessor } from './spanprocessor'; export { SentryPropagator } from './propagator'; diff --git a/packages/opentelemetry-node/src/spanprocessor.ts b/packages/opentelemetry-node/src/spanprocessor.ts index 08d14e9fa671..773c68ebf1ff 100644 --- a/packages/opentelemetry-node/src/spanprocessor.ts +++ b/packages/opentelemetry-node/src/spanprocessor.ts @@ -2,8 +2,7 @@ import type { Context } from '@opentelemetry/api'; import { SpanKind, trace } from '@opentelemetry/api'; import type { Span as OtelSpan, SpanProcessor as OtelSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core'; -import { Transaction } from '@sentry/tracing'; +import { addGlobalEventProcessor, addTracingExtensions, getCurrentHub, Transaction } from '@sentry/core'; import type { DynamicSamplingContext, Span as SentrySpan, TraceparentData, TransactionContext } from '@sentry/types'; import { isString, logger } from '@sentry/utils'; @@ -23,6 +22,8 @@ export const SENTRY_SPAN_PROCESSOR_MAP: Map = */ export class SentrySpanProcessor implements OtelSpanProcessor { public constructor() { + addTracingExtensions(); + addGlobalEventProcessor(event => { const otelSpan = trace && trace.getActiveSpan && (trace.getActiveSpan() as OtelSpan | undefined); if (!otelSpan) { diff --git a/packages/opentelemetry-node/src/utils/map-otel-status.ts b/packages/opentelemetry-node/src/utils/map-otel-status.ts index 968150852e6e..8fdc0e09ac8b 100644 --- a/packages/opentelemetry-node/src/utils/map-otel-status.ts +++ b/packages/opentelemetry-node/src/utils/map-otel-status.ts @@ -1,6 +1,6 @@ import type { Span as OtelSpan } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import type { SpanStatusType as SentryStatus } from '@sentry/tracing'; +import type { SpanStatusType as SentryStatus } from '@sentry/core'; // canonicalCodesHTTPMap maps some HTTP codes to Sentry's span statuses. See possible mapping in https://develop.sentry.dev/sdk/event-payloads/span/ const canonicalCodesHTTPMap: Record = { diff --git a/packages/opentelemetry-node/test/propagator.test.ts b/packages/opentelemetry-node/test/propagator.test.ts index f1914653dd0d..d5222e3103d4 100644 --- a/packages/opentelemetry-node/test/propagator.test.ts +++ b/packages/opentelemetry-node/test/propagator.test.ts @@ -7,8 +7,7 @@ import { TraceFlags, } from '@opentelemetry/api'; import { suppressTracing } from '@opentelemetry/core'; -import { Hub, makeMain } from '@sentry/core'; -import { addExtensionMethods, Transaction } from '@sentry/tracing'; +import { addTracingExtensions, Hub, makeMain, Transaction } from '@sentry/core'; import type { TransactionContext } from '@sentry/types'; import { @@ -21,7 +20,7 @@ import { SentryPropagator } from '../src/propagator'; import { SENTRY_SPAN_PROCESSOR_MAP } from '../src/spanprocessor'; beforeAll(() => { - addExtensionMethods(); + addTracingExtensions(); }); describe('SentryPropagator', () => { diff --git a/packages/opentelemetry-node/test/spanprocessor.test.ts b/packages/opentelemetry-node/test/spanprocessor.test.ts index 4e3c975d00cf..94abd327314c 100644 --- a/packages/opentelemetry-node/test/spanprocessor.test.ts +++ b/packages/opentelemetry-node/test/spanprocessor.test.ts @@ -4,10 +4,9 @@ import { Resource } from '@opentelemetry/resources'; import type { Span as OtelSpan } from '@opentelemetry/sdk-trace-base'; import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import { SemanticAttributes, SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; -import { createTransport, Hub, makeMain } from '@sentry/core'; +import type { SpanStatusType } from '@sentry/core'; +import { addTracingExtensions, createTransport, Hub, makeMain, Span as SentrySpan, Transaction } from '@sentry/core'; import { NodeClient } from '@sentry/node'; -import type { SpanStatusType } from '@sentry/tracing'; -import { addExtensionMethods, Span as SentrySpan, Transaction } from '@sentry/tracing'; import { resolvedSyncPromise } from '@sentry/utils'; import { SENTRY_SPAN_PROCESSOR_MAP, SentrySpanProcessor } from '../src/spanprocessor'; @@ -24,7 +23,7 @@ const DEFAULT_NODE_CLIENT_OPTIONS = { // Integration Test of SentrySpanProcessor beforeAll(() => { - addExtensionMethods(); + addTracingExtensions(); }); describe('SentrySpanProcessor', () => { diff --git a/packages/overhead-metrics/.eslintrc.cjs b/packages/overhead-metrics/.eslintrc.cjs index 1cbdd66daa88..8046df7df92c 100644 --- a/packages/overhead-metrics/.eslintrc.cjs +++ b/packages/overhead-metrics/.eslintrc.cjs @@ -10,6 +10,7 @@ module.exports = { 'import/no-unresolved': 'off', '@sentry-internal/sdk/no-optional-chaining': 'off', '@sentry-internal/sdk/no-nullish-coalescing': 'off', + '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', 'jsdoc/require-jsdoc': 'off', }, }, diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index 1553028195a2..96a88cf31e73 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -66,6 +66,25 @@ const INITIAL_STATE = { eventId: null, }; +function setCause(error: Error & { cause?: Error }, cause: Error): void { + const seenErrors = new WeakMap(); + + function recurse(error: Error & { cause?: Error }, cause: Error): void { + // If we've already seen the error, there is a recursive loop somewhere in the error's + // cause chain. Let's just bail out then to prevent a stack overflow. + if (seenErrors.has(error)) { + return; + } + if (error.cause) { + seenErrors.set(error, true); + return recurse(error.cause, cause); + } + error.cause = cause; + } + + recurse(error, cause); +} + /** * A ErrorBoundary component that logs errors to Sentry. Requires React >= 16. * NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the @@ -93,7 +112,7 @@ class ErrorBoundary extends React.Component; } -const TestApp: React.FC = ({ children, ...props }) => { +interface TestAppProps extends ErrorBoundaryProps { + errorComp?: JSX.Element; +} + +const TestApp: React.FC = ({ children, errorComp, ...props }) => { + // eslint-disable-next-line no-param-reassign + const customErrorComp = errorComp || ; const [isError, setError] = React.useState(false); return ( = ({ children, ...props }) => { } }} > - {isError ? : children} + {isError ? customErrorComp : children}