diff --git a/CHANGELOG.md b/CHANGELOG.md index 724c0730ee83..d2612400a12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.72.0 + +### Important Changes + +- **feat(node): App Not Responding with stack traces (#9079)** + +This release introduces support for Application Not Responding (ANR) errors for Node.js applications. +These errors are triggered when the Node.js main thread event loop of an application is blocked for more than five seconds. +The Node SDK reports ANR errors as Sentry events and can optionally attach a stacktrace of the blocking code to the ANR event. + +To enable ANR detection, import and use the `enableANRDetection` function from the `@sentry/node` package before you run the rest of your application code. +Any event loop blocking before calling `enableANRDetection` will not be detected by the SDK. + +Example (ESM): + +```ts +import * as Sentry from "@sentry/node"; + +Sentry.init({ + dsn: "___PUBLIC_DSN___", + tracesSampleRate: 1.0, +}); + +await Sentry.enableANRDetection({ captureStackTrace: true }); +// Function that runs your app +runApp(); +``` + +Example (CJS): + +```ts +const Sentry = require("@sentry/node"); + +Sentry.init({ + dsn: "___PUBLIC_DSN___", + tracesSampleRate: 1.0, +}); + +Sentry.enableANRDetection({ captureStackTrace: true }).then(() => { + // Function that runs your app + runApp(); +}); +``` + +### Other Changes + +- fix(nextjs): Filter `RequestAsyncStorage` locations by locations that webpack will resolve (#9114) +- fix(replay): Ensure `replay_id` is not captured when session is expired (#9109) + ## 7.71.0 - feat(bun): Instrument Bun.serve (#9080) diff --git a/lerna.json b/lerna.json index d26f0d0014ff..b3782d4b6201 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "7.71.0", + "version": "7.72.0", "npmClient": "yarn" } diff --git a/package.json b/package.json index 6ae7e4f1d2cf..54d5991acd46 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "packages/ember", "packages/eslint-config-sdk", "packages/eslint-plugin-sdk", + "packages/feedback", "packages/gatsby", "packages/hub", "packages/integrations", diff --git a/packages/angular-ivy/package.json b/packages/angular-ivy/package.json index 503b376433d9..3a23231e3994 100644 --- a/packages/angular-ivy/package.json +++ b/packages/angular-ivy/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular-ivy", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Angular with full Ivy Support", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular-ivy", @@ -21,9 +21,9 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/browser": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/angular/package.json b/packages/angular/package.json index ef3b8be03f39..ef4af30ce3db 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,9 +21,9 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/browser": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/browser-integration-tests/package.json b/packages/browser-integration-tests/package.json index f409c3fcb5f1..9ff1e605f170 100644 --- a/packages/browser-integration-tests/package.json +++ b/packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "7.71.0", + "version": "7.72.0", "main": "index.js", "license": "MIT", "engines": { diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts index f049267a51c1..c4da79faa1d9 100644 --- a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts @@ -84,12 +84,13 @@ sentryTest( await reqErrorPromise; expect(callsToSentry).toEqual(2); - await page.evaluate(async () => { - const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay; - await replayIntegration.flush(); - }); - - const req0 = await reqPromise0; + const [req0] = await Promise.all([ + reqPromise0, + page.evaluate(async () => { + const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay; + await replayIntegration.flush(); + }), + ]); // 2 errors, 1 flush await reqErrorPromise; @@ -226,12 +227,13 @@ sentryTest( await reqErrorPromise; expect(callsToSentry).toEqual(2); - await page.evaluate(async () => { - const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay; - await replayIntegration.flush({ continueRecording: false }); - }); - - const req0 = await reqPromise0; + const [req0] = await Promise.all([ + reqPromise0, + page.evaluate(async () => { + const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay; + await replayIntegration.flush({ continueRecording: false }); + }), + ]); // 2 errors, 1 flush await reqErrorPromise; @@ -346,9 +348,12 @@ sentryTest( // Error sample rate is now at 1.0, this error should create a replay const reqErrorPromise1 = waitForErrorRequest(page); - await page.click('#error2'); - // 1 unsampled error, 1 sampled error -> 1 flush - const req0 = await reqPromise0; + const [req0] = await Promise.all([ + // 1 unsampled error, 1 sampled error -> 1 flush + reqPromise0, + page.click('#error2'), + ]); + const reqError1 = await reqErrorPromise1; const errorEvent1 = envelopeRequestParser(reqError1); expect(callsToSentry).toEqual(3); diff --git a/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts b/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts index c0ceed092995..13353bf1fb6c 100644 --- a/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts +++ b/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts @@ -10,8 +10,6 @@ sentryTest('should capture console messages in replay', async ({ getLocalTestPat sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -20,10 +18,11 @@ sentryTest('should capture console messages in replay', async ({ getLocalTestPat }); }); + const reqPromise0 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([page.goto(url), reqPromise0]); const reqPromise1 = waitForReplayRequest( page, @@ -38,11 +37,10 @@ sentryTest('should capture console messages in replay', async ({ getLocalTestPat await page.click('[data-log]'); // Sometimes this doesn't seem to trigger, so we trigger it twice to be sure... - await page.click('[data-log]'); - + const [req1] = await Promise.all([reqPromise1, page.click('[data-log]')]); await forceFlushReplay(); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'console')).toEqual( expect.arrayContaining([ @@ -65,8 +63,6 @@ sentryTest('should capture very large console logs', async ({ getLocalTestPath, sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -75,10 +71,11 @@ sentryTest('should capture very large console logs', async ({ getLocalTestPath, }); }); + const reqPromise0 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([page.goto(url), reqPromise0]); const reqPromise1 = waitForReplayRequest( page, @@ -90,14 +87,10 @@ sentryTest('should capture very large console logs', async ({ getLocalTestPath, 5_000, ); - await page.click('[data-log-large]'); - - // Sometimes this doesn't seem to trigger, so we trigger it twice to be sure... - await page.click('[data-log-large]'); - + const [req1] = await Promise.all([reqPromise1, page.click('[data-log-large]')]); await forceFlushReplay(); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'console')).toEqual( expect.arrayContaining([ diff --git a/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts b/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts index 15e9ab60dc3c..4bb4c45ae978 100644 --- a/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts @@ -50,26 +50,25 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - await page.click('#go-background'); - await new Promise(resolve => setTimeout(resolve, 1000)); + await Promise.all([ + page.goto(url), + page.click('#go-background'), + new Promise(resolve => setTimeout(resolve, 1000)), + ]); expect(callsToSentry).toEqual(0); - await page.click('#error'); - const req0 = await reqPromise0; + const [req0] = await Promise.all([reqPromise0, page.click('#error')]); expect(callsToSentry).toEqual(2); // 1 error, 1 replay event - await page.click('#go-background'); - const req1 = await reqPromise1; - await reqErrorPromise; + const [req1] = await Promise.all([reqPromise1, page.click('#go-background'), reqErrorPromise]); expect(callsToSentry).toEqual(3); // 1 error, 2 replay events await page.click('#log'); - await page.click('#go-background'); - const req2 = await reqPromise2; + + const [req2] = await Promise.all([reqPromise2, page.click('#go-background')]); const event0 = getReplayEvent(req0); const content0 = getReplayRecordingContent(req0); diff --git a/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts b/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts index b7d0e558b844..efbd3384ec20 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts @@ -10,8 +10,6 @@ sentryTest( sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -22,26 +20,17 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - const res0 = await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page); - - void page.click('#button-add'); + const [res0] = await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); await forceFlushReplay(); - const res1 = await reqPromise1; - const reqPromise2 = waitForReplayRequest(page); - - void page.click('#button-modify'); + const [res1] = await Promise.all([waitForReplayRequest(page), page.click('#button-add')]); await forceFlushReplay(); - const res2 = await reqPromise2; - const reqPromise3 = waitForReplayRequest(page); + const [res2] = await Promise.all([waitForReplayRequest(page), page.click('#button-modify')]); + await forceFlushReplay(); - void page.click('#button-remove'); + const [res3] = await Promise.all([waitForReplayRequest(page), page.click('#button-remove')]); await forceFlushReplay(); - const res3 = await reqPromise3; const replayData0 = getReplayRecordingContent(res0); const replayData1 = getReplayRecordingContent(res1); diff --git a/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts b/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts index eb144fa012ef..4c10e9db0436 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts @@ -15,8 +15,6 @@ sentryTest( sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -25,23 +23,24 @@ sentryTest( }); }); + const reqPromise0 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - const res0 = await reqPromise0; + const [res0] = await Promise.all([reqPromise0, page.goto(url)]); + await forceFlushReplay(); const reqPromise1 = waitForReplayRequest(page); - void page.click('#button-add'); + const [res1] = await Promise.all([reqPromise1, page.click('#button-add')]); await forceFlushReplay(); - const res1 = await reqPromise1; // replay should be stopped due to mutation limit let replay = await getReplaySnapshot(page); expect(replay.session).toBe(undefined); expect(replay._isEnabled).toBe(false); - void page.click('#button-modify'); + await page.click('#button-modify'); await forceFlushReplay(); await page.click('#button-remove'); diff --git a/packages/browser-integration-tests/suites/replay/maxReplayDuration/test.ts b/packages/browser-integration-tests/suites/replay/maxReplayDuration/test.ts index a40387159bfc..2890f58691b5 100644 --- a/packages/browser-integration-tests/suites/replay/maxReplayDuration/test.ts +++ b/packages/browser-integration-tests/suites/replay/maxReplayDuration/test.ts @@ -11,9 +11,6 @@ sentryTest('keeps track of max duration across reloads', async ({ getLocalTestPa sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -22,33 +19,42 @@ sentryTest('keeps track of max duration across reloads', async ({ getLocalTestPa }); }); + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); await new Promise(resolve => setTimeout(resolve, MAX_REPLAY_DURATION / 2)); - await page.reload(); - await page.click('#button1'); + await Promise.all([page.reload(), page.click('#button1')]); // After the second reload, we should have a new session (because we exceeded max age) const reqPromise3 = waitForReplayRequest(page, 0); await new Promise(resolve => setTimeout(resolve, MAX_REPLAY_DURATION / 2 + 100)); - void page.click('#button1'); - await page.evaluate(`Object.defineProperty(document, 'visibilityState', { + const [req0, req1] = await Promise.all([ + reqPromise0, + reqPromise1, + page.click('#button1'), + page.evaluate( + `Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { return 'hidden'; }, }); - document.dispatchEvent(new Event('visibilitychange'));`); - const replayEvent0 = getReplayEvent(await reqPromise0); + document.dispatchEvent(new Event('visibilitychange'));`, + ), + ]); + + const replayEvent0 = getReplayEvent(req0); expect(replayEvent0).toEqual(getExpectedReplayEvent({})); - const replayEvent1 = getReplayEvent(await reqPromise1); + const replayEvent1 = getReplayEvent(req1); expect(replayEvent1).toEqual( getExpectedReplayEvent({ segment_id: 1, diff --git a/packages/browser-integration-tests/suites/replay/multiple-pages/test.ts b/packages/browser-integration-tests/suites/replay/multiple-pages/test.ts index 956397c84a49..4b04a61995a4 100644 --- a/packages/browser-integration-tests/suites/replay/multiple-pages/test.ts +++ b/packages/browser-integration-tests/suites/replay/multiple-pages/test.ts @@ -56,8 +56,7 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - const req0 = await reqPromise0; + const [req0] = await Promise.all([reqPromise0, page.goto(url)]); const replayEvent0 = getReplayEvent(req0); const recording0 = getReplayRecordingContent(req0); @@ -65,9 +64,8 @@ sentryTest( expect(normalize(recording0.fullSnapshots)).toMatchSnapshot('seg-0-snap-full'); expect(recording0.incrementalSnapshots.length).toEqual(0); - await page.click('#go-background'); + const [req1] = await Promise.all([reqPromise1, page.click('#go-background')]); - const req1 = await reqPromise1; const replayEvent1 = getReplayEvent(req1); const recording1 = getReplayRecordingContent(req1); @@ -97,9 +95,8 @@ sentryTest( // ----------------------------------------------------------------------------------------- // Test page reload - await page.reload(); + const [req2] = await Promise.all([reqPromise2, page.reload()]); - const req2 = await reqPromise2; const replayEvent2 = getReplayEvent(req2); const recording2 = getReplayRecordingContent(req2); @@ -107,9 +104,8 @@ sentryTest( expect(normalize(recording2.fullSnapshots)).toMatchSnapshot('seg-2-snap-full'); expect(recording2.incrementalSnapshots.length).toEqual(0); - await page.click('#go-background'); + const [req3] = await Promise.all([reqPromise3, page.click('#go-background')]); - const req3 = await reqPromise3; const replayEvent3 = getReplayEvent(req3); const recording3 = getReplayRecordingContent(req3); @@ -137,9 +133,8 @@ sentryTest( // ----------------------------------------------------------------------------------------- // Test subsequent link navigation to another page - await page.click('a'); + const [req4] = await Promise.all([reqPromise4, page.click('a')]); - const req4 = await reqPromise4; const replayEvent4 = getReplayEvent(req4); const recording4 = getReplayRecordingContent(req4); @@ -161,9 +156,8 @@ sentryTest( expect(normalize(recording4.fullSnapshots)).toMatchSnapshot('seg-4-snap-full'); expect(recording4.incrementalSnapshots.length).toEqual(0); - await page.click('#go-background'); + const [req5] = await Promise.all([reqPromise5, page.click('#go-background')]); - const req5 = await reqPromise5; const replayEvent5 = getReplayEvent(req5); const recording5 = getReplayRecordingContent(req5); @@ -207,9 +201,8 @@ sentryTest( // ----------------------------------------------------------------------------------------- // Test subsequent navigation without a page reload (i.e. SPA navigation) - await page.click('#spa-navigation'); + const [req6] = await Promise.all([reqPromise6, page.click('#spa-navigation')]); - const req6 = await reqPromise6; const replayEvent6 = getReplayEvent(req6); const recording6 = getReplayRecordingContent(req6); @@ -231,9 +224,8 @@ sentryTest( expect(recording6.fullSnapshots.length).toEqual(0); expect(normalize(recording6.incrementalSnapshots)).toMatchSnapshot('seg-6-snap-incremental'); - await page.click('#go-background'); + const [req7] = await Promise.all([reqPromise7, page.click('#go-background')]); - const req7 = await reqPromise7; const replayEvent7 = getReplayEvent(req7); const recording7 = getReplayRecordingContent(req7); @@ -279,9 +271,8 @@ sentryTest( // // ----------------------------------------------------------------------------------------- // // And just to finish this off, let's go back to the index page - await page.click('a'); + const [req8] = await Promise.all([reqPromise8, page.click('a')]); - const req8 = await reqPromise8; const replayEvent8 = getReplayEvent(req8); const recording8 = getReplayRecordingContent(req8); @@ -293,9 +284,8 @@ sentryTest( expect(normalize(recording8.fullSnapshots)).toMatchSnapshot('seg-8-snap-full'); expect(recording8.incrementalSnapshots.length).toEqual(0); - await page.click('#go-background'); + const [req9] = await Promise.all([reqPromise9, page.click('#go-background')]); - const req9 = await reqPromise9; const replayEvent9 = getReplayEvent(req9); const recording9 = getReplayRecordingContent(req9); diff --git a/packages/browser-integration-tests/suites/replay/requests/test.ts b/packages/browser-integration-tests/suites/replay/requests/test.ts index ba1d6ec5d0a2..52d78a144787 100644 --- a/packages/browser-integration-tests/suites/replay/requests/test.ts +++ b/packages/browser-integration-tests/suites/replay/requests/test.ts @@ -10,9 +10,6 @@ sentryTest('replay recording should contain fetch request span', async ({ getLoc sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -29,15 +26,16 @@ sentryTest('replay recording should contain fetch request span', async ({ getLoc }); }); + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - await page.click('#go-background'); - const { performanceSpans: spans0 } = getReplayRecordingContent(await reqPromise0); + const [req0] = await Promise.all([reqPromise0, page.goto(url), page.click('#go-background')]); + + const { performanceSpans: spans0 } = getReplayRecordingContent(req0); - const receivedResponse = page.waitForResponse('https://example.com'); - await page.click('#fetch'); - await receivedResponse; + await Promise.all([page.waitForResponse('https://example.com'), page.click('#fetch')]); const { performanceSpans: spans1 } = getReplayRecordingContent(await reqPromise1); @@ -50,9 +48,6 @@ sentryTest('replay recording should contain XHR request span', async ({ getLocal sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -69,15 +64,16 @@ sentryTest('replay recording should contain XHR request span', async ({ getLocal }); }); + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - await page.click('#go-background'); - const { performanceSpans: spans0 } = getReplayRecordingContent(await reqPromise0); + const [req0] = await Promise.all([reqPromise0, page.goto(url), page.click('#go-background')]); + + const { performanceSpans: spans0 } = getReplayRecordingContent(req0); - const receivedResponse = page.waitForResponse('https://example.com'); - await page.click('#xhr'); - await receivedResponse; + await Promise.all([page.waitForResponse('https://example.com'), page.click('#xhr')]); const { performanceSpans: spans1 } = getReplayRecordingContent(await reqPromise1); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts index 59bfb2ea26e8..8074f34a40fb 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts @@ -35,8 +35,6 @@ import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -47,18 +45,19 @@ import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); - }); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }), - await page.click(`#${id}`); + page.click(`#${id}`), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); @@ -92,8 +91,6 @@ import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -104,18 +101,18 @@ import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); - - await page.click(`#${id}`); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click(`#${id}`), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { diff --git a/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts index 1a88d992714e..aef218a63cae 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/disable/test.ts @@ -8,8 +8,6 @@ sentryTest('does not capture slow click when slowClickTimeout === 0', async ({ g sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -20,18 +18,18 @@ sentryTest('does not capture slow click when slowClickTimeout === 0', async ({ g const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#mutationButton'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click('#mutationButton'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { diff --git a/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts index 15e891b22e52..f80789d46a78 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/ignore/test.ts @@ -8,8 +8,6 @@ sentryTest('click is ignored on ignoreSelectors', async ({ getLocalTestUrl, page sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -20,18 +18,18 @@ sentryTest('click is ignored on ignoreSelectors', async ({ getLocalTestUrl, page const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#mutationIgnoreButton'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click('#mutationIgnoreButton'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { @@ -60,8 +58,6 @@ sentryTest('click is ignored on div', async ({ getLocalTestUrl, page }) => { sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -72,18 +68,19 @@ sentryTest('click is ignored on div', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), - await page.click('#mutationDiv'); + await page.click('#mutationDiv'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { diff --git a/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts index cdb099d82a18..a21327058a81 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts @@ -13,8 +13,6 @@ sentryTest('captures multi click when not detecting slow click', async ({ getLoc sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -25,18 +23,18 @@ sentryTest('captures multi click when not detecting slow click', async ({ getLoc const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.multiClick'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#mutationButtonImmediately', { clickCount: 4 }); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.multiClick'); + }), + page.click('#mutationButtonImmediately', { clickCount: 4 }), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick'); @@ -65,15 +63,16 @@ sentryTest('captures multi click when not detecting slow click', async ({ getLoc // When this has been flushed, the timeout has exceeded - so add a new click now, which should trigger another multi click - const reqPromise2 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); - - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.multiClick'); - }); + const [req2] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#mutationButtonImmediately', { clickCount: 3 }); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.multiClick'); + }), + page.click('#mutationButtonImmediately', { clickCount: 3 }), + ]); - const { breadcrumbs: breadcrumbb2 } = getCustomRecordingEvents(await reqPromise2); + const { breadcrumbs: breadcrumbb2 } = getCustomRecordingEvents(req2); const slowClickBreadcrumbs2 = breadcrumbb2.filter(breadcrumb => breadcrumb.category === 'ui.multiClick'); @@ -106,8 +105,6 @@ sentryTest('captures multiple multi clicks', async ({ getLocalTestUrl, page, for sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -118,8 +115,7 @@ sentryTest('captures multiple multi clicks', async ({ getLocalTestUrl, page, for const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); let multiClickBreadcrumbCount = 0; diff --git a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index cb5b9160d13b..196719783229 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -8,8 +8,6 @@ sentryTest('mutation after threshold results in slow click', async ({ forceFlush sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -20,19 +18,20 @@ sentryTest('mutation after threshold results in slow click', async ({ forceFlush const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); await forceFlushReplay(); - await reqPromise0; - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); - }); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }), - await page.click('#mutationButton'); + page.click('#mutationButton'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); @@ -69,8 +68,6 @@ sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -81,18 +78,18 @@ sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - void page.click('#mutationButton', { clickCount: 4 }); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }), + page.click('#mutationButton', { clickCount: 4 }), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); const multiClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick'); @@ -131,8 +128,6 @@ sentryTest('immediate mutation does not trigger slow click', async ({ forceFlush sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -143,19 +138,19 @@ sentryTest('immediate mutation does not trigger slow click', async ({ forceFlush const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); await forceFlushReplay(); - await reqPromise0; - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); - - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#mutationButtonImmediately'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click('#mutationButtonImmediately'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { @@ -183,8 +178,6 @@ sentryTest('inline click handler does not trigger slow click', async ({ forceFlu sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -195,19 +188,19 @@ sentryTest('inline click handler does not trigger slow click', async ({ forceFlu const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); await forceFlushReplay(); - await reqPromise0; - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); - - await page.click('#mutationButtonInline'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click('#mutationButtonInline'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { @@ -235,8 +228,6 @@ sentryTest('mouseDown events are considered', async ({ getLocalTestUrl, page }) sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -247,18 +238,18 @@ sentryTest('mouseDown events are considered', async ({ getLocalTestUrl, page }) const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#mouseDownButton'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click('#mouseDownButton'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { diff --git a/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts index a8e59752fc4a..1f5b672ff659 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts @@ -8,8 +8,6 @@ sentryTest('immediate scroll does not trigger slow click', async ({ getLocalTest sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -20,18 +18,18 @@ sentryTest('immediate scroll does not trigger slow click', async ({ getLocalTest const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#scrollButton'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }), + page.click('#scrollButton'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); expect(breadcrumbs).toEqual([ { @@ -59,8 +57,6 @@ sentryTest('late scroll triggers slow click', async ({ getLocalTestUrl, page }) sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -71,18 +67,18 @@ sentryTest('late scroll triggers slow click', async ({ getLocalTestUrl, page }) const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#scrollLateButton'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }), + page.click('#scrollLateButton'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts index ed53bc2d5fb4..6e6a1f13e3b6 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts @@ -8,8 +8,6 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -20,18 +18,18 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; - - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); - }); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - await page.click('#mutationButtonLate'); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }), + page.click('#mutationButtonLate'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); @@ -65,8 +63,6 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page } sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -77,18 +73,19 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page } const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const [req1] = await Promise.all([ + waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); - }); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }), - await page.click('#consoleLogButton'); + page.click('#consoleLogButton'), + ]); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const { breadcrumbs } = getCustomRecordingEvents(req1); const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts index 4c9401234ea2..98ca0cc4c2a0 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/windowOpen/test.ts @@ -8,8 +8,6 @@ sentryTest('window.open() is considered for slow click', async ({ getLocalTestUr sentryTest.skip(); } - const reqPromise0 = waitForReplayRequest(page, 0); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ status: 200, @@ -20,8 +18,7 @@ sentryTest('window.open() is considered for slow click', async ({ getLocalTestUr const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]); const reqPromise1 = waitForReplayRequest(page, (event, res) => { const { breadcrumbs } = getCustomRecordingEvents(res); diff --git a/packages/browser/package.json b/packages/browser/package.json index 05c72d4d428e..0af472e94821 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -23,15 +23,15 @@ "access": "public" }, "dependencies": { - "@sentry-internal/tracing": "7.71.0", - "@sentry/core": "7.71.0", - "@sentry/replay": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry-internal/tracing": "7.72.0", + "@sentry/core": "7.72.0", + "@sentry/replay": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { - "@sentry-internal/integration-shims": "7.71.0", + "@sentry-internal/integration-shims": "7.72.0", "@types/md5": "2.1.33", "btoa": "^1.2.1", "chai": "^4.1.2", diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 5027c4f0f1a4..4c0ace57547b 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -127,6 +127,7 @@ export class BrowserClient extends BaseClient { return; } + // This is really the only place where we want to check for a DSN and only send outcomes then if (!this._dsn) { __DEBUG_BUILD__ && logger.log('No dsn provided, will not send outcomes'); return; diff --git a/packages/bun/package.json b/packages/bun/package.json index d6d88eac35a0..8bd80df82622 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -23,10 +23,10 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/node": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0" + "@sentry/core": "7.72.0", + "@sentry/node": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0" }, "devDependencies": { "bun-types": "latest" diff --git a/packages/core/package.json b/packages/core/package.json index a938c8522b05..2ebd703881d5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "7.71.0", + "version": "7.72.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", @@ -23,8 +23,8 @@ "access": "public" }, "dependencies": { - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "scripts": { diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 1a15c4bc37bd..c811c4f827a3 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -127,7 +127,7 @@ export abstract class BaseClient implements Client { if (options.dsn) { this._dsn = makeDsn(options.dsn); } else { - __DEBUG_BUILD__ && logger.warn('No DSN provided, client will not do anything.'); + __DEBUG_BUILD__ && logger.warn('No DSN provided, client will not send events.'); } if (this._dsn) { @@ -216,11 +216,6 @@ export abstract class BaseClient implements Client { * @inheritDoc */ public captureSession(session: Session): void { - if (!this._isEnabled()) { - __DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture session.'); - return; - } - if (!(typeof session.release === 'string')) { __DEBUG_BUILD__ && logger.warn('Discarded session because of missing or non-string release'); } else { @@ -297,8 +292,8 @@ export abstract class BaseClient implements Client { /** * Sets up the integrations */ - public setupIntegrations(): void { - if (this._isEnabled() && !this._integrationsInitialized) { + public setupIntegrations(forceInitialize?: boolean): void { + if ((forceInitialize && !this._integrationsInitialized) || (this._isEnabled() && !this._integrationsInitialized)) { this._integrations = setupIntegrations(this, this._options.integrations); this._integrationsInitialized = true; } @@ -338,23 +333,21 @@ export abstract class BaseClient implements Client { public sendEvent(event: Event, hint: EventHint = {}): void { this.emit('beforeSendEvent', event, hint); - if (this._dsn) { - let env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel); - - for (const attachment of hint.attachments || []) { - env = addItemToEnvelope( - env, - createAttachmentEnvelopeItem( - attachment, - this._options.transportOptions && this._options.transportOptions.textEncoder, - ), - ); - } + let env = createEventEnvelope(event, this._dsn, this._options._metadata, this._options.tunnel); - const promise = this._sendEnvelope(env); - if (promise) { - promise.then(sendResponse => this.emit('afterSendEvent', event, sendResponse), null); - } + for (const attachment of hint.attachments || []) { + env = addItemToEnvelope( + env, + createAttachmentEnvelopeItem( + attachment, + this._options.transportOptions && this._options.transportOptions.textEncoder, + ), + ); + } + + const promise = this._sendEnvelope(env); + if (promise) { + promise.then(sendResponse => this.emit('afterSendEvent', event, sendResponse), null); } } @@ -362,10 +355,8 @@ export abstract class BaseClient implements Client { * @inheritDoc */ public sendSession(session: Session | SessionAggregates): void { - if (this._dsn) { - const env = createSessionEnvelope(session, this._dsn, this._options._metadata, this._options.tunnel); - void this._sendEnvelope(env); - } + const env = createSessionEnvelope(session, this._dsn, this._options._metadata, this._options.tunnel); + void this._sendEnvelope(env); } /** @@ -531,9 +522,9 @@ export abstract class BaseClient implements Client { }); } - /** Determines whether this SDK is enabled and a valid Dsn is present. */ + /** Determines whether this SDK is enabled and a transport is present. */ protected _isEnabled(): boolean { - return this.getOptions().enabled !== false && this._dsn !== undefined; + return this.getOptions().enabled !== false && this._transport !== undefined; } /** @@ -635,10 +626,6 @@ export abstract class BaseClient implements Client { const options = this.getOptions(); const { sampleRate } = options; - if (!this._isEnabled()) { - return rejectedSyncPromise(new SentryError('SDK not enabled, will not capture event.', 'log')); - } - const isTransaction = isTransactionEvent(event); const isError = isErrorEvent(event); const eventType = event.type || 'error'; @@ -738,9 +725,9 @@ export abstract class BaseClient implements Client { * @inheritdoc */ protected _sendEnvelope(envelope: Envelope): PromiseLike | void { - if (this._transport && this._dsn) { - this.emit('beforeEnvelope', envelope); + this.emit('beforeEnvelope', envelope); + if (this._isEnabled() && this._transport) { return this._transport.send(envelope).then(null, reason => { __DEBUG_BUILD__ && logger.error('Error while sending event:', reason); }); diff --git a/packages/core/src/envelope.ts b/packages/core/src/envelope.ts index 5e79d1707d67..9ec29c9d2a7e 100644 --- a/packages/core/src/envelope.ts +++ b/packages/core/src/envelope.ts @@ -36,7 +36,7 @@ function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { /** Creates an envelope from a Session */ export function createSessionEnvelope( session: Session | SessionAggregates, - dsn: DsnComponents, + dsn?: DsnComponents, metadata?: SdkMetadata, tunnel?: string, ): SessionEnvelope { @@ -44,7 +44,7 @@ export function createSessionEnvelope( const envelopeHeaders = { sent_at: new Date().toISOString(), ...(sdkInfo && { sdk: sdkInfo }), - ...(!!tunnel && { dsn: dsnToString(dsn) }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }; const envelopeItem: SessionItem = @@ -58,7 +58,7 @@ export function createSessionEnvelope( */ export function createEventEnvelope( event: Event, - dsn: DsnComponents, + dsn?: DsnComponents, metadata?: SdkMetadata, tunnel?: string, ): EventEnvelope { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 21f7cab37505..81fcd7c3af8d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -51,6 +51,7 @@ export { FunctionToString, InboundFilters } from './integrations'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; export { hasTracingEnabled } from './utils/hasTracingEnabled'; +export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { DEFAULT_ENVIRONMENT } from './constants'; export { ModuleMetadata } from './integrations/metadata'; import * as Integrations from './integrations'; diff --git a/packages/core/src/integrations/inboundfilters.ts b/packages/core/src/integrations/inboundfilters.ts index 37ea8bac06e9..9c6e8c9a546a 100644 --- a/packages/core/src/integrations/inboundfilters.ts +++ b/packages/core/src/integrations/inboundfilters.ts @@ -1,4 +1,4 @@ -import type { Event, EventProcessor, Hub, Integration, StackFrame } from '@sentry/types'; +import type { Client, Event, EventHint, Integration, StackFrame } from '@sentry/types'; import { getEventDescription, logger, stringMatchesSomePattern } from '@sentry/utils'; // "Script error." is hard coded into browsers for errors that it can't read. @@ -48,23 +48,15 @@ export class InboundFilters implements Integration { /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (processor: EventProcessor) => void, getCurrentHub: () => Hub): void { - const eventProcess: EventProcessor = (event: Event) => { - const hub = getCurrentHub(); - if (hub) { - const self = hub.getIntegration(InboundFilters); - if (self) { - const client = hub.getClient(); - const clientOptions = client ? client.getOptions() : {}; - const options = _mergeOptions(self._options, clientOptions); - return _shouldDropEvent(event, options) ? null : event; - } - } - return event; - }; + public setupOnce(_addGlobaleventProcessor: unknown, _getCurrentHub: unknown): void { + // noop + } - eventProcess.id = this.name; - addGlobalEventProcessor(eventProcess); + /** @inheritDoc */ + public processEvent(event: Event, _eventHint: EventHint, client: Client): Event | null { + const clientOptions = client.getOptions(); + const options = _mergeOptions(this._options, clientOptions); + return _shouldDropEvent(event, options) ? null : event; } } diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts new file mode 100644 index 000000000000..0256e3cf7835 --- /dev/null +++ b/packages/core/src/utils/isSentryRequestUrl.ts @@ -0,0 +1,29 @@ +import type { DsnComponents, Hub } from '@sentry/types'; + +/** + * Checks whether given url points to Sentry server + * @param url url to verify + */ +export function isSentryRequestUrl(url: string, hub: Hub): boolean { + const client = hub.getClient(); + const dsn = client && client.getDsn(); + const tunnel = client && client.getOptions().tunnel; + + return checkDsn(url, dsn) || checkTunnel(url, tunnel); +} + +function checkTunnel(url: string, tunnel: string | undefined): boolean { + if (!tunnel) { + return false; + } + + return removeTrailingSlash(url) === removeTrailingSlash(tunnel); +} + +function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { + return dsn ? url.includes(dsn.host) : false; +} + +function removeTrailingSlash(str: string): string { + return str[str.length - 1] === '/' ? str.slice(0, -1) : str; +} diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 689e10d1e77e..ebd3ef4e0b91 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -1 +1 @@ -export const SDK_VERSION = '7.71.0'; +export const SDK_VERSION = '7.72.0'; diff --git a/packages/core/test/lib/base.test.ts b/packages/core/test/lib/base.test.ts index dd0906921fa3..27e34a1402d4 100644 --- a/packages/core/test/lib/base.test.ts +++ b/packages/core/test/lib/base.test.ts @@ -398,30 +398,6 @@ describe('BaseClient', () => { }); describe('captureEvent() / prepareEvent()', () => { - test('skips when disabled', () => { - expect.assertions(1); - - const options = getDefaultTestClientOptions({ enabled: false, dsn: PUBLIC_DSN }); - const client = new TestClient(options); - const scope = new Scope(); - - client.captureEvent({}, undefined, scope); - - expect(TestClient.instance!.event).toBeUndefined(); - }); - - test('skips without a Dsn', () => { - expect.assertions(1); - - const options = getDefaultTestClientOptions({}); - const client = new TestClient(options); - const scope = new Scope(); - - client.captureEvent({}, undefined, scope); - - expect(TestClient.instance!.event).toBeUndefined(); - }); - test.each([ ['`Error` instance', new Error('Will I get caught twice?')], ['plain object', { 'Will I': 'get caught twice?' }], @@ -1616,9 +1592,9 @@ describe('BaseClient', () => { test('close', async () => { jest.useRealTimers(); - expect.assertions(2); + expect.assertions(4); - const { makeTransport, delay } = makeFakeTransport(300); + const { makeTransport, delay, getSentCount } = makeFakeTransport(300); const client = new TestClient( getDefaultTestClientOptions({ @@ -1630,9 +1606,12 @@ describe('BaseClient', () => { expect(client.captureMessage('test')).toBeTruthy(); await client.close(delay); + expect(getSentCount()).toBe(1); + expect(client.captureMessage('test')).toBeTruthy(); + await client.close(delay); // Sends after close shouldn't work anymore - expect(client.captureMessage('test')).toBeFalsy(); + expect(getSentCount()).toBe(1); }); test('multiple concurrent flush calls should just work', async () => { @@ -1798,18 +1777,6 @@ describe('BaseClient', () => { expect(TestClient.instance!.session).toEqual(session); }); - - test('skips when disabled', () => { - expect.assertions(1); - - const options = getDefaultTestClientOptions({ enabled: false, dsn: PUBLIC_DSN }); - const client = new TestClient(options); - const session = makeSession({ release: 'test' }); - - client.captureSession(session); - - expect(TestClient.instance!.session).toBeUndefined(); - }); }); describe('recordDroppedEvent()/_clearOutcomes()', () => { diff --git a/packages/core/test/lib/integrations/inboundfilters.test.ts b/packages/core/test/lib/integrations/inboundfilters.test.ts index 9888a59eedff..8e42c5bc29ee 100644 --- a/packages/core/test/lib/integrations/inboundfilters.test.ts +++ b/packages/core/test/lib/integrations/inboundfilters.test.ts @@ -2,6 +2,9 @@ import type { Event, EventProcessor } from '@sentry/types'; import type { InboundFiltersOptions } from '../../../src/integrations/inboundfilters'; import { InboundFilters } from '../../../src/integrations/inboundfilters'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const PUBLIC_DSN = 'https://username@domain/123'; /** * Creates an instance of the InboundFilters integration and returns @@ -25,30 +28,22 @@ function createInboundFiltersEventProcessor( options: Partial = {}, clientOptions: Partial = {}, ): EventProcessor { - const eventProcessors: EventProcessor[] = []; - const inboundFiltersInstance = new InboundFilters(options); - - function addGlobalEventProcessor(processor: EventProcessor): void { - eventProcessors.push(processor); - expect(eventProcessors).toHaveLength(1); - } - - function getCurrentHub(): any { - return { - getIntegration(_integration: any): any { - // pretend integration is enabled - return inboundFiltersInstance; - }, - getClient(): any { - return { - getOptions: () => clientOptions, - }; - }, - }; - } - - inboundFiltersInstance.setupOnce(addGlobalEventProcessor, getCurrentHub); - return eventProcessors[0]; + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + ...clientOptions, + defaultIntegrations: false, + integrations: [new InboundFilters(options)], + }), + ); + + client.setupIntegrations(); + + const eventProcessors = client['_eventProcessors']; + const eventProcessor = eventProcessors.find(processor => processor.id === 'InboundFilters'); + + expect(eventProcessor).toBeDefined(); + return eventProcessor!; } // Fixtures diff --git a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts new file mode 100644 index 000000000000..b1671b9410e8 --- /dev/null +++ b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts @@ -0,0 +1,26 @@ +import type { Hub } from '@sentry/types'; + +import { isSentryRequestUrl } from '../../../src'; + +describe('isSentryRequestUrl', () => { + it.each([ + ['', 'sentry-dsn.com', '', false], + ['http://sentry-dsn.com/my-url', 'sentry-dsn.com', '', true], + ['http://sentry-dsn.com', 'sentry-dsn.com', '', true], + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200', true], + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200/', true], + ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200', true], + ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200', false], + ])('works with url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string, expected: boolean) => { + const hub = { + getClient: () => { + return { + getOptions: () => ({ tunnel }), + getDsn: () => ({ host: dsn }), + }; + }, + } as unknown as Hub; + + expect(isSentryRequestUrl(url, hub)).toBe(expected); + }); +}); diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index d51bec48a36b..4719cd281669 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "7.71.0", + "version": "7.72.0", "license": "MIT", "private": true, "scripts": { diff --git a/packages/ember/package.json b/packages/ember/package.json index a178f1a905ce..dde774f2f979 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -34,9 +34,9 @@ }, "dependencies": { "@embroider/macros": "^1.9.0", - "@sentry/browser": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/browser": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "ember-auto-import": "^1.12.1 || ^2.4.3", "ember-cli-babel": "^7.26.11", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index d47c7d56fe85..bb3cb6bb59e5 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-config-sdk", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -19,8 +19,8 @@ "access": "public" }, "dependencies": { - "@sentry-internal/eslint-plugin-sdk": "7.71.0", - "@sentry-internal/typescript": "7.71.0", + "@sentry-internal/eslint-plugin-sdk": "7.72.0", + "@sentry-internal/typescript": "7.72.0", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint-config-prettier": "^6.11.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index 49923e36643a..4bc52c394670 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-plugin-sdk", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/.eslintignore b/packages/feedback/.eslintignore new file mode 100644 index 000000000000..c76c6c2d64d1 --- /dev/null +++ b/packages/feedback/.eslintignore @@ -0,0 +1,6 @@ +node_modules/ +build/ +demo/build/ +# TODO: Check if we can re-introduce linting in demo +demo +metrics diff --git a/packages/feedback/.eslintrc.js b/packages/feedback/.eslintrc.js new file mode 100644 index 000000000000..4f69827ac50b --- /dev/null +++ b/packages/feedback/.eslintrc.js @@ -0,0 +1,42 @@ +// Note: All paths are relative to the directory in which eslint is being run, rather than the directory where this file +// lives + +// ESLint config docs: https://eslint.org/docs/user-guide/configuring/ + +module.exports = { + extends: ['../../.eslintrc.js'], + overrides: [ + { + files: ['src/**/*.ts'], + rules: { + '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', + }, + }, + { + files: ['jest.setup.ts', 'jest.config.ts'], + parserOptions: { + project: ['tsconfig.test.json'], + }, + rules: { + 'no-console': 'off', + }, + }, + { + files: ['test/**/*.ts'], + + rules: { + // most of these errors come from `new Promise(process.nextTick)` + '@typescript-eslint/unbound-method': 'off', + // TODO: decide if we want to enable this again after the migration + // We can take the freedom to be a bit more lenient with tests + '@typescript-eslint/no-floating-promises': 'off', + }, + }, + { + files: ['src/types/deprecated.ts'], + rules: { + '@typescript-eslint/naming-convention': 'off', + }, + }, + ], +}; diff --git a/packages/feedback/.gitignore b/packages/feedback/.gitignore new file mode 100644 index 000000000000..363d3467c6fa --- /dev/null +++ b/packages/feedback/.gitignore @@ -0,0 +1,4 @@ +node_modules +/*.tgz +.eslintcache +build diff --git a/packages/feedback/CONTRIBUTING.md b/packages/feedback/CONTRIBUTING.md new file mode 100644 index 000000000000..829930e2b05e --- /dev/null +++ b/packages/feedback/CONTRIBUTING.md @@ -0,0 +1,4 @@ +## Updating the rrweb dependency + +When [updating the `rrweb` dependency](https://github.com/getsentry/sentry-javascript/blob/a493aa6a46555b944c8d896a2164bcd8b11caaf5/packages/replay/package.json?plain=1#LL55), +please be aware that [`@sentry/replay`'s README.md](https://github.com/getsentry/sentry-javascript/blob/a493aa6a46555b944c8d896a2164bcd8b11caaf5/packages/replay/README.md?plain=1#LL204) also needs to be updated. diff --git a/packages/feedback/LICENSE b/packages/feedback/LICENSE new file mode 100644 index 000000000000..4ac873d49f33 --- /dev/null +++ b/packages/feedback/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2022 Sentry (https://sentry.io) and individual contributors. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/feedback/MIGRATION.md b/packages/feedback/MIGRATION.md new file mode 100644 index 000000000000..ba6326939970 --- /dev/null +++ b/packages/feedback/MIGRATION.md @@ -0,0 +1,149 @@ +# End of Replay Beta + +Sentry Replay is now out of Beta. This means that the usual stability guarantees apply. + +Because of experimentation and rapid iteration, during the Beta period some bugs and problems came up which have since been fixed/improved. +We **strongly** recommend anyone using Replay in a version before 7.39.0 to update to 7.39.0 or newer, in order to prevent running Replay with known problems that have since been fixed. + +Below you can find a list of relevant replay issues that have been resolved until 7.39.0: + +## New features / improvements + +- Remove `autoplay` attribute from audio/video tags ([#59](https://github.com/getsentry/rrweb/pull/59)) +- Exclude fetching scripts that use `` ([#52](https://github.com/getsentry/rrweb/pull/52)) +- With maskAllText, mask the attributes: placeholder, title, `aria-label` +- Lower the flush max delay from 15 seconds to 5 seconds (#6761) +- Stop recording when retry fails (#6765) +- Stop without retry when receiving bad API response (#6773) +- Send client_report when replay sending fails (#7093) +- Stop recording when hitting a rate limit (#7018) +- Allow Replay to be used in Electron renderers with nodeIntegration enabled (#6644) +- Do not renew session in error mode (#6948) +- Remove default sample rates for replay (#6878) +- Add `flush` method to integration (#6776) +- Improve compression worker & fallback behavior (#6988, #6936, #6827) +- Improve error handling (#7087, #7094, #7010, getsentry/rrweb#16, #6856) +- Add more default block filters (#7233) + +## Fixes + +- Fix masking inputs on change when `maskAllInputs:false` ([#61](https://github.com/getsentry/rrweb/pull/61)) +- More robust `rootShadowHost` check ([#50](https://github.com/getsentry/rrweb/pull/50)) +- Fix duplicated textarea value ([#62](https://github.com/getsentry/rrweb/pull/62)) +- Handle removed attributes ([#65](https://github.com/getsentry/rrweb/pull/65)) +- Change LCP calculation (#7187, #7225) +- Fix debounced flushes not respecting `maxWait` (#7207, #7208) +- Fix svgs not getting unblocked (#7132) +- Fix missing fetch/xhr requests (#7134) +- Fix feature detection of PerformanceObserver (#7029) +- Fix `checkoutEveryNms` (#6722) +- Fix incorrect uncompressed recording size due to encoding (#6740) +- Ensure dropping replays works (#6522) +- Envelope send should be awaited in try/catch (#6625) +- Improve handling of `maskAllText` selector (#6637) + +# Upgrading Replay from 7.34.0 to 7.35.0 - #6645 + +This release will remove the ability to change the default rrweb recording options (outside of privacy options). The following are the new configuration values all replays will use: +`slimDOMOptions: 'all'` - Removes `script`, comments, `favicon`, whitespace in `head`, and a few `meta` tags in `head` +`recordCanvas: false` - This option did not do anything as playback of recorded canvas means we would have to remove the playback sandbox (which is a security concern). +`inlineStylesheet: true` - Inlines styles into the recording itself instead of attempting to fetch it remotely. This means that styles in the replay will reflect the styles at the time of recording and not the current styles of the remote stylesheet. +`collectFonts: true` - Attempts to load custom fonts. +`inlineImages: false` - Does not inline images to recording and instead loads the asset remotely. During playback, images may not load due to CORS (add sentry.io as an origin). + +Additionally, we have streamlined the privacy options. The following table lists the deprecated value, and what it is replaced by: + +| deprecated key | replaced by | description | +| ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| maskInputOptions | mask | Use CSS selectors in `mask` in order to mask all inputs of a certain type. For example, `input[type="address"]` | +| blockSelector | block | The selector(s) can be moved directly in the `block` array. | +| blockClass | block | Convert the class name to a CSS selector and add to `block` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | +| maskClass | mask | Convert the class name to a CSS selector and add to `mask` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | +| maskSelector | mask | The selector(s) can be moved directly in the `mask` array. | +| ignoreClass | ignore | Convert the class name to a CSS selector and add to `ignore` array. For example, `first-name` becomes `.first-name`. Regexes can be moved as-is. | + +# Upgrading Replay from 7.31.0 to 7.32.0 + +In 7.32.0, we have removed the default values for the replay sample rates. +Previously, they were: + +* `replaysSessionSampleRate: 0.1` +* `replaysOnErrorSampleRate: 1.0` + +Now, you have to explicitly set the sample rates, otherwise they default to 0. + +# Upgrading Replay from 0.6.x to 7.24.0 + +The Sentry Replay integration was moved to the Sentry JavaScript SDK monorepo. Hence we're jumping from version 0.x to the monorepo's 7.x version which is shared across all JS SDK packages. + +## Replay sample rates are defined on top level (https://github.com/getsentry/sentry-javascript/issues/6351) + +Instead of defining the sample rates on the integration like this: + +```js +Sentry.init({ + dsn: '__DSN__', + integrations: [ + new Replay({ + sessionSampleRate: 0.1, + errorSampleRate: 1.0, + }) + ], + // ... +}); +``` + +They are now defined on the top level of the SDK: + +```js +Sentry.init({ + dsn: '__DSN__', + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + new Replay({ + // other replay config still goes in here + }) + ], +}); +``` + +Note that the sample rate options inside of `new Replay({})` have been deprecated and will be removed in a future update. + +## Removed deprecated options (https://github.com/getsentry/sentry-javascript/pull/6370) + +Two options, which have been deprecated for some time, have been removed: + +* `replaysSamplingRate` - instead use `sessionSampleRate` +* `captureOnlyOnError` - instead use `errorSampleRate` + +## New NPM package structure (https://github.com/getsentry/sentry-javascript/issues/6280) + +The internal structure of the npm package has changed. This is unlikely to affect you, unless you have imported something from e.g.: + +```js +import something from '@sentry/replay/submodule'; +``` + +If you only imported from `@sentry/replay`, this will not affect you. + +## Changed type name from `IEventBuffer` to `EventBuffer` (https://github.com/getsentry/sentry-javascript/pull/6416) + +It is highly unlikely to affect anybody, but the type `IEventBuffer` was renamed to `EventBuffer` for consistency. +Unless you manually imported this and used it somewhere in your codebase, this will not affect you. + +## Session object is now a plain object (https://github.com/getsentry/sentry-javascript/pull/6417) + +The `Session` object exported from Replay is now a plain object, instead of a class. +This should not affect you unless you specifically accessed this class & did custom things with it. + +## Reduce public API of Replay integration (https://github.com/getsentry/sentry-javascript/pull/6407) + +The result of `new Replay()` now has a much more limited public API. Only the following methods are exposed: + +```js +const replay = new Replay(); + +replay.start(); +replay.stop(); +``` diff --git a/packages/feedback/README.md b/packages/feedback/README.md new file mode 100644 index 000000000000..3d835d1838e5 --- /dev/null +++ b/packages/feedback/README.md @@ -0,0 +1,100 @@ +

+ + Sentry + +

+ +# Sentry Feedback + +[![npm version](https://img.shields.io/npm/v/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) +[![npm dm](https://img.shields.io/npm/dm/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) +[![npm dt](https://img.shields.io/npm/dt/@sentry/feedback.svg)](https://www.npmjs.com/package/@sentry/feedback) + +## Pre-requisites + +`@sentry/feedback` requires Node 12+, and browsers newer than IE11. + +## Installation + +Feedback can be imported from `@sentry/browser`, or a respective SDK package like `@sentry/react` or `@sentry/vue`. +You don't need to install anything in order to use Feedback. The minimum version that includes Feedback is <>. + +For details on using Feedback when using Sentry via the CDN bundles, see [CDN bundle](#loading-feedback-as-a-cdn-bundle). + +## Setup + +To set up the integration, add the following to your Sentry initialization. Several options are supported and passable via the integration constructor. +See the [configuration section](#configuration) below for more details. + +```javascript +import * as Sentry from '@sentry/browser'; +// or e.g. import * as Sentry from '@sentry/react'; + +Sentry.init({ + dsn: '__DSN__', + integrations: [ + new Sentry.Feedback({ + // Additional SDK configuration goes in here, for example: + // See below for all available options + }) + ], + // ... +}); +``` + +### Lazy loading Feedback + +Feedback will start automatically when you add the integration. +If you do not want to start Feedback immediately (e.g. if you want to lazy-load it), +you can also use `addIntegration` to load it later: + +```js +import * as Sentry from "@sentry/react"; +import { BrowserClient } from "@sentry/browser"; + +Sentry.init({ + // Do not load it initially + integrations: [] +}); + +// Sometime later +const { Feedback } = await import('@sentry/browser'); +const client = Sentry.getCurrentHub().getClient(); + +// Client can be undefined +client?.addIntegration(new Feedback()); +``` + +### Identifying Users + +If you have only followed the above instructions to setup session feedbacks, you will only see IP addresses in Sentry's UI. In order to associate a user identity to a session feedback, use [`setUser`](https://docs.sentry.io/platforms/javascript/enriching-events/identify-user/). + +```javascript +import * as Sentry from "@sentry/browser"; + +Sentry.setUser({ email: "jane.doe@example.com" }); +``` + +## Loading Feedback as a CDN Bundle + +As an alternative to the NPM package, you can use Feedback as a CDN bundle. +Please refer to the [Feedback installation guide](https://docs.sentry.io/platforms/javascript/session-feedback/#install) for CDN bundle instructions. + + +## Configuration + +### General Integration Configuration + +The following options can be configured as options to the integration, in `new Feedback({})`: + +| key | type | default | description | +| --------- | ------- | ------- | ----------- | +| tbd | boolean | `true` | tbd | + + + +## Manually Sending Feedback Data + +Connect your own feedback UI to Sentry's You can use `feedback.flush()` to immediately send all currently captured feedback data. +When Feedback is currently in buffering mode, this will send up to the last 60 seconds of feedback data, +and also continue sending afterwards, similar to when an error happens & is recorded. diff --git a/packages/feedback/jest.config.ts b/packages/feedback/jest.config.ts new file mode 100644 index 000000000000..90a3cf471f8d --- /dev/null +++ b/packages/feedback/jest.config.ts @@ -0,0 +1,17 @@ +import type { Config } from '@jest/types'; +import { jsWithTs as jsWithTsPreset } from 'ts-jest/presets'; + +export default async (): Promise => { + return { + ...jsWithTsPreset, + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.test.json', + }, + __DEBUG_BUILD__: true, + }, + setupFilesAfterEnv: ['./jest.setup.ts'], + testEnvironment: 'jsdom', + testMatch: ['/test/**/*(*.)@(spec|test).ts'], + }; +}; diff --git a/packages/feedback/jest.setup.ts b/packages/feedback/jest.setup.ts new file mode 100644 index 000000000000..093c97dcdce4 --- /dev/null +++ b/packages/feedback/jest.setup.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { getCurrentHub } from '@sentry/core'; +import type { ReplayRecordingData, Transport } from '@sentry/types'; +import { TextEncoder } from 'util'; + +import type { ReplayContainer, Session } from './src/types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).TextEncoder = TextEncoder; + +type MockTransport = jest.MockedFunction; + +jest.mock('./src/util/isBrowser', () => { + return { + isBrowser: () => true, + }; +}); + +type EnvelopeHeader = { + event_id: string; + sent_at: string; + sdk: { + name: string; + version?: string; + }; +}; + +type ReplayEventHeader = { type: 'replay_event' }; +type ReplayEventPayload = Record; +type RecordingHeader = { type: 'replay_recording'; length: number }; +type RecordingPayloadHeader = Record; +type SentReplayExpected = { + envelopeHeader?: EnvelopeHeader; + replayEventHeader?: ReplayEventHeader; + replayEventPayload?: ReplayEventPayload; + recordingHeader?: RecordingHeader; + recordingPayloadHeader?: RecordingPayloadHeader; + recordingData?: ReplayRecordingData; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const toHaveSameSession = function (received: jest.Mocked, expected: undefined | Session) { + const pass = this.equals(received.session?.id, expected?.id) as boolean; + + const options = { + isNot: this.isNot, + promise: this.promise, + }; + + return { + pass, + message: () => + `${this.utils.matcherHint( + 'toHaveSameSession', + undefined, + undefined, + options, + )}\n\n${this.utils.printDiffOrStringify(expected, received.session, 'Expected', 'Received')}`, + }; +}; + +type Result = { + passed: boolean; + key: string; + expectedVal: SentReplayExpected[keyof SentReplayExpected]; + actualVal: SentReplayExpected[keyof SentReplayExpected]; +}; +type Call = [ + EnvelopeHeader, + [ + [ReplayEventHeader | undefined, ReplayEventPayload | undefined], + [RecordingHeader | undefined, RecordingPayloadHeader | undefined], + ], +]; +type CheckCallForSentReplayResult = { pass: boolean; call: Call | undefined; results: Result[] }; + +function checkCallForSentReplay( + call: Call | undefined, + expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, +): CheckCallForSentReplayResult { + const envelopeHeader = call?.[0]; + const envelopeItems = call?.[1] || [[], []]; + const [[replayEventHeader, replayEventPayload], [recordingHeader, recordingPayload] = []] = envelopeItems; + + // @ts-ignore recordingPayload is always a string in our tests + const [recordingPayloadHeader, recordingData] = recordingPayload?.split('\n') || []; + + const actualObj: Required = { + // @ts-ignore Custom envelope + envelopeHeader: envelopeHeader, + // @ts-ignore Custom envelope + replayEventHeader: replayEventHeader, + // @ts-ignore Custom envelope + replayEventPayload: replayEventPayload, + // @ts-ignore Custom envelope + recordingHeader: recordingHeader, + recordingPayloadHeader: recordingPayloadHeader && JSON.parse(recordingPayloadHeader), + recordingData, + }; + + const isObjectContaining = expected && 'sample' in expected && 'inverse' in expected; + const expectedObj = isObjectContaining + ? (expected as { sample: SentReplayExpected }).sample + : (expected as SentReplayExpected); + + if (isObjectContaining) { + console.warn('`expect.objectContaining` is unnecessary when using the `toHaveSentReplay` matcher'); + } + + const results = expected + ? Object.keys(expectedObj) + .map(key => { + const actualVal = actualObj[key as keyof SentReplayExpected]; + const expectedVal = expectedObj[key as keyof SentReplayExpected]; + const passed = !expectedVal || this.equals(actualVal, expectedVal); + + return { passed, key, expectedVal, actualVal }; + }) + .filter(({ passed }) => !passed) + : []; + + const pass = Boolean(call && (!expected || results.length === 0)); + + return { + pass, + call, + results, + }; +} + +/** + * Only want calls that send replay events, i.e. ignore error events + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getReplayCalls(calls: any[][][]): any[][][] { + return calls + .map(call => { + const arg = call[0]; + if (arg.length !== 2) { + return []; + } + + if (!arg[1][0].find(({ type }: { type: string }) => ['replay_event', 'replay_recording'].includes(type))) { + return []; + } + + return [arg]; + }) + .filter(Boolean); +} + +/** + * Checks all calls to `fetch` and ensures a replay was uploaded by + * checking the `fetch()` request's body. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const toHaveSentReplay = function ( + _received: jest.Mocked, + expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, +) { + const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; + + let result: CheckCallForSentReplayResult; + + const expectedKeysLength = expected + ? ('sample' in expected ? Object.keys(expected.sample) : Object.keys(expected)).length + : 0; + + const replayCalls = getReplayCalls(calls); + + for (const currentCall of replayCalls) { + result = checkCallForSentReplay.call(this, currentCall[0], expected); + if (result.pass) { + break; + } + + // stop on the first call where any of the expected obj passes + if (result.results.length < expectedKeysLength) { + break; + } + } + + // @ts-ignore use before assigned + const { results, call, pass } = result; + + const options = { + isNot: this.isNot, + promise: this.promise, + }; + + return { + pass, + message: () => + !call + ? pass + ? 'Expected Replay to not have been sent, but a request was attempted' + : 'Expected Replay to have been sent, but a request was not attempted' + : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results + .map(({ key, expectedVal, actualVal }: Result) => + this.utils.printDiffOrStringify( + expectedVal, + actualVal, + `Expected (key: ${key})`, + `Received (key: ${key})`, + ), + ) + .join('\n')}`, + }; +}; + +/** + * Checks the last call to `fetch` and ensures a replay was uploaded by + * checking the `fetch()` request's body. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const toHaveLastSentReplay = function ( + _received: jest.Mocked, + expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, +) { + const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; + const replayCalls = getReplayCalls(calls); + + const lastCall = replayCalls[calls.length - 1]?.[0]; + + const { results, call, pass } = checkCallForSentReplay.call(this, lastCall, expected); + + const options = { + isNot: this.isNot, + promise: this.promise, + }; + + return { + pass, + message: () => + !call + ? pass + ? 'Expected Replay to not have been sent, but a request was attempted' + : 'Expected Replay to have last been sent, but a request was not attempted' + : `${this.utils.matcherHint('toHaveSentReplay', undefined, undefined, options)}\n\n${results + .map(({ key, expectedVal, actualVal }: Result) => + this.utils.printDiffOrStringify( + expectedVal, + actualVal, + `Expected (key: ${key})`, + `Received (key: ${key})`, + ), + ) + .join('\n')}`, + }; +}; + +expect.extend({ + toHaveSameSession, + toHaveSentReplay, + toHaveLastSentReplay, +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface AsymmetricMatchers { + toHaveSentReplay(expected?: SentReplayExpected): void; + toHaveLastSentReplay(expected?: SentReplayExpected): void; + toHaveSameSession(expected: undefined | Session): void; + } + interface Matchers { + toHaveSentReplay(expected?: SentReplayExpected): R; + toHaveLastSentReplay(expected?: SentReplayExpected): R; + toHaveSameSession(expected: undefined | Session): R; + } + } +} diff --git a/packages/feedback/package.json b/packages/feedback/package.json new file mode 100644 index 000000000000..b88412947698 --- /dev/null +++ b/packages/feedback/package.json @@ -0,0 +1,67 @@ +{ + "name": "@sentry-internal/feedback", + "version": "7.70.0", + "description": "User feedback for Sentry", + "main": "build/npm/cjs/index.js", + "module": "build/npm/esm/index.js", + "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { + "build/npm/types/index.d.ts": [ + "build/npm/types-ts3.8/index.d.ts" + ] + } + }, + "sideEffects": false, + "scripts": { + "build": "run-p build:transpile build:types build:bundle", + "build:transpile": "rollup -c rollup.npm.config.js", + "build:bundle": "rollup -c rollup.bundle.config.js", + "build:dev": "run-p build:transpile build:types", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch build:bundle:watch build:types:watch", + "build:dev:watch": "run-p build:transpile:watch build:types:watch", + "build:transpile:watch": "yarn build:transpile --watch", + "build:bundle:watch": "yarn build:bundle --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "ts-node ../../scripts/prepack.ts --bundles && npm pack ./build/npm", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build sentry-replay-*.tgz", + "fix": "run-s fix:eslint fix:prettier", + "fix:eslint": "eslint . --format stylish --fix", + "fix:prettier": "prettier --write \"{src,test,scripts}/**/*.ts\"", + "lint": "run-s lint:prettier lint:eslint", + "lint:eslint": "eslint . --format stylish", + "lint:prettier": "prettier --check \"{src,test,scripts}/**/*.ts\"", + "test": "jest", + "test:watch": "jest --watch", + "yalc:publish": "ts-node ../../scripts/prepack.ts --bundles && yalc publish ./build/npm --push" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/sentry-javascript.git" + }, + "author": "Sentry", + "license": "MIT", + "bugs": { + "url": "https://github.com/getsentry/sentry-javascript/issues" + }, + "homepage": "https://docs.sentry.io/platforms/javascript/", + "devDependencies": { + "@babel/core": "^7.17.5", + "tslib": "^2.4.1 || ^1.9.3" + }, + "dependencies": { + "@sentry/core": "7.70.0", + "@sentry/types": "7.70.0", + "@sentry/utils": "7.70.0" + }, + "engines": { + "node": ">=12" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/feedback/rollup.bundle.config.js b/packages/feedback/rollup.bundle.config.js new file mode 100644 index 000000000000..75f240f85822 --- /dev/null +++ b/packages/feedback/rollup.bundle.config.js @@ -0,0 +1,13 @@ +import { makeBaseBundleConfig, makeBundleConfigVariants } from '../../rollup/index.js'; + +const baseBundleConfig = makeBaseBundleConfig({ + bundleType: 'addon', + entrypoints: ['src/index.ts'], + jsVersion: 'es6', + licenseTitle: '@sentry/replay', + outputFileBase: () => 'bundles/replay', +}); + +const builds = makeBundleConfigVariants(baseBundleConfig); + +export default builds; diff --git a/packages/feedback/rollup.npm.config.js b/packages/feedback/rollup.npm.config.js new file mode 100644 index 000000000000..c3c2db72bebf --- /dev/null +++ b/packages/feedback/rollup.npm.config.js @@ -0,0 +1,16 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + hasBundles: true, + packageSpecificConfig: { + output: { + // set exports to 'named' or 'auto' so that rollup doesn't warn + exports: 'named', + // set preserveModules to false because for Replay we actually want + // to bundle everything into one file. + preserveModules: false, + }, + }, + }), +); diff --git a/packages/feedback/scripts/craft-pre-release.sh b/packages/feedback/scripts/craft-pre-release.sh new file mode 100644 index 000000000000..bae7c3246cdb --- /dev/null +++ b/packages/feedback/scripts/craft-pre-release.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -eux +OLD_VERSION="${1}" +NEW_VERSION="${2}" + +# Do not tag and commit changes made by "npm version" +export npm_config_git_tag_version=false +npm version "${NEW_VERSION}" diff --git a/packages/feedback/scripts/repl.ts b/packages/feedback/scripts/repl.ts new file mode 100644 index 000000000000..e94a9797d1a1 --- /dev/null +++ b/packages/feedback/scripts/repl.ts @@ -0,0 +1,276 @@ +/* eslint:disable: no-console */ +import * as fs from 'fs'; +import inquirer from 'inquirer'; +import { EventEmitter } from 'node:events'; +import * as path from 'path'; +import type { Frame} from 'playwright'; +import {chromium} from 'playwright'; +// import puppeteer from 'puppeteer'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const emitter = new EventEmitter(); + +function getCode(): string { + const bundlePath = path.resolve(__dirname, '../replay/build/bundles/replay.debug.min.js'); + return fs.readFileSync(bundlePath, 'utf8'); +} + +void (async () => { + const code = getCode(); + + async function injectRecording(frame: Frame) { + await frame.evaluate((rrwebCode: string) => { + const win = window; + // @ts-expect-error global + if (win.__IS_RECORDING__) return; + // @ts-expect-error global + win.__IS_RECORDING__ = true; + + (async () => { + function loadScript(code: string) { + const s = document.createElement('script'); + const r = false; + s.type = 'text/javascript'; + s.innerHTML = code; + if (document.head) { + document.head.append(s); + } else { + requestAnimationFrame(() => { + document.head.append(s); + }); + } + } + loadScript(rrwebCode); + + // @ts-expect-error global + win.__replay = new Sentry.Replay({ + blockAllMedia: false, + maskAllText: false, + useCompression: false, + mutationBreadcrumbLimit: 250, + }) + // @ts-expect-error global + Sentry.init({ + debug: true, + dsn: '', + environment: 'repl', + tracesSampleRate: 1.0, + replaysSessionSampleRate: 1.0, + integrations: [ + // @ts-expect-error global + win.__replay + // new BrowserTracing({ + // tracingOrigins: ["localhost:3000", "localhost", /^\//], + // }), + ], + }) + })(); + }, code); + } + + await start('https://react-redux.realworld.io'); + + // const fakeGoto = async (page, url) => { + // const intercept = async (request) => { + // await request.respond({ + // status: 200, + // contentType: 'text/html', + // body: ' ', // non-empty string or page will load indefinitely + // }); + // }; + // await page.setRequestInterception(true); + // page.on('request', intercept); + // await page.goto(url); + // await page.setRequestInterception(false); + // page.off('request', intercept); + // }; + + async function start(defaultURL: string) { + let { url } = await inquirer.prompt([ + { + type: 'input', + name: 'url', + message: `Enter the url you want to record, e.g [${defaultURL}]: `, + }, + ]); + + if (url === '') { + url = defaultURL; + } + + console.log(`Going to open ${url}...`); + await record(url); + console.log('Ready to record. You can do any interaction on the page.'); + + // const { shouldReplay } = await inquirer.prompt([ + // { + // type: 'list', + // choices: [ + // { name: 'Start replay (default)', value: 'default' }, + // { + // name: 'Start replay on original url (helps when experiencing CORS issues)', + // value: 'replayWithFakeURL', + // }, + // { name: 'Skip replay', value: false }, + // ], + // name: 'shouldReplay', + // message: 'Once you want to finish the recording, choose the following to start replay: ', + // }, + // ]); + + emitter.emit('done'); + + /** + * not needed atm as we always save to Sentry + */ + // const { shouldStore } = await inquirer.prompt([ + // { + // type: 'confirm', + // name: 'shouldStore', + // message: 'Persistently store these recorded events?', + // }, + // ]); + + // if (shouldStore) { + // saveEvents(); + // } + + const { shouldRecordAnother } = await inquirer.prompt([ + { + type: 'confirm', + name: 'shouldRecordAnother', + message: 'Record another one?', + }, + ]); + + if (shouldRecordAnother) { + start(url); + } else { + process.exit(); + } + } + + async function record(url: string) { + const browser = await chromium.launch({ + headless: false, + args: [ + '--start-maximized', + '--ignore-certificate-errors', + '--no-sandbox', + '--auto-open-devtools-for-tabs', + ], + }); + const context = await browser.newContext({ + viewport: { + width: 1600, + height: 900, + }, + }); + const page = await context.newPage(); + + // await page.exposeFunction('_replLog', (event) => { + // events.push(event); + // }); + + page.on('framenavigated', async (frame: Frame) => { + await injectRecording(frame); + }); + + await page.goto(url, { + waitUntil: 'domcontentloaded', + timeout: 300000, + }); + + emitter.once('done', async () => { + await context.close(); + await browser.close(); + console.log('go to sentry to view this replay'); + // if (shouldReplay) { + // await replay(url, shouldReplay === 'replayWithFakeURL'); + // } + }); + } + + // async function replay(url, useSpoofedUrl) { + // const browser = await puppeteer.launch({ + // headless: false, + // defaultViewport: { + // width: 1600, + // height: 900, + // }, + // args: ['--start-maximized', '--no-sandbox'], + // }); + // const page = await browser.newPage(); + // if (useSpoofedUrl) { + // await fakeGoto(page, url); + // } else { + // await page.goto('about:blank'); + // } + // + // await page.addStyleTag({ + // path: path.resolve(__dirname, '../dist/rrweb.css'), + // }); + // await page.evaluate(`${code} + // const events = ${JSON.stringify(events)}; + // const replayer = new rrweb.Replayer(events, { + // UNSAFE_replayCanvas: true + // }); + // replayer.play(); + // `); + // } + +// function saveEvents() { +// const tempFolder = path.join(__dirname, '../temp'); +// console.log(tempFolder); +// +// if (!fs.existsSync(tempFolder)) { +// fs.mkdirSync(tempFolder); +// } +// const time = new Date() +// .toISOString() +// .replace(/[-|:]/g, '_') +// .replace(/\..+/, ''); +// const fileName = `replay_${time}.html`; +// const content = ` +// +// +// +// +// +// +// Record @${time} +// +// +// +// +// +// +// +// `; +// const savePath = path.resolve(tempFolder, fileName); +// fs.writeFileSync(savePath, content); +// console.log(`Saved at ${savePath}`); +// } + + process + .on('uncaughtException', (error) => { + console.error(error); + }) + .on('unhandledRejection', (error) => { + console.error(error); + }); +})(); diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts new file mode 100644 index 000000000000..5d4adc0af6e2 --- /dev/null +++ b/packages/feedback/src/index.ts @@ -0,0 +1 @@ +export type { SendFeedbackData } from './types' diff --git a/packages/feedback/src/types/feedback.ts b/packages/feedback/src/types/feedback.ts new file mode 100644 index 000000000000..ee779869fb19 --- /dev/null +++ b/packages/feedback/src/types/feedback.ts @@ -0,0 +1,23 @@ +import type {Event} from '@sentry/types'; + +/** + * NOTE: These types are still considered Beta and subject to change. + * @hidden + */ +export interface FeedbackEvent extends Event { + feedback: { + contact_email: string; + message: string; + replay_id: string; + url: string; + }; + // TODO: Add this event type to Event + // type: 'feedback_event'; +} + +export interface SendFeedbackData { + message: string, + email: string, + replay_id: string, + url: string, +} diff --git a/packages/feedback/src/types/index.ts b/packages/feedback/src/types/index.ts new file mode 100644 index 000000000000..3c6cb93bb7b3 --- /dev/null +++ b/packages/feedback/src/types/index.ts @@ -0,0 +1 @@ +export * from './feedback'; diff --git a/packages/feedback/src/util/prepareFeedbackEvent.ts b/packages/feedback/src/util/prepareFeedbackEvent.ts new file mode 100644 index 000000000000..c174cd3f4c1c --- /dev/null +++ b/packages/feedback/src/util/prepareFeedbackEvent.ts @@ -0,0 +1,45 @@ +import type {Scope} from '@sentry/core'; +import {prepareEvent} from '@sentry/core'; +import type { Client, FeedbackEvent } from '@sentry/types'; +// import type { FeedbackEvent } from '../types'; + +/** + * Prepare a feedback event & enrich it with the SDK metadata. + */ +export async function prepareFeedbackEvent({ + client, + scope, + event, +}: { + client: Client; + event: FeedbackEvent; + scope: Scope; +}): Promise { + const preparedEvent = (await prepareEvent( + client.getOptions(), + event, + {integrations: []}, + scope + )) as FeedbackEvent | null; + + // If e.g. a global event processor returned null + if (!preparedEvent) { + return null; + } + + // This normally happens in browser client "_prepareEvent" + // but since we do not use this private method from the client, but rather the plain import + // we need to do this manually. + preparedEvent.platform = preparedEvent.platform || 'javascript'; + + // extract the SDK name because `client._prepareEvent` doesn't add it to the event + const metadata = client.getSdkMetadata && client.getSdkMetadata(); + const {name, version} = (metadata && metadata.sdk) || {}; + + preparedEvent.sdk = { + ...preparedEvent.sdk, + name: name || 'sentry.javascript.unknown', + version: version || '0.0.0', + }; + return preparedEvent; +} diff --git a/packages/feedback/src/util/sendFeedbackRequest.ts b/packages/feedback/src/util/sendFeedbackRequest.ts new file mode 100644 index 000000000000..db911f42a546 --- /dev/null +++ b/packages/feedback/src/util/sendFeedbackRequest.ts @@ -0,0 +1,115 @@ +import { getCurrentHub } from '@sentry/core'; +import { dsnToString } from '@sentry/utils'; + +import type { SendFeedbackData } from '../types'; +import { prepareFeedbackEvent } from './prepareFeedbackEvent'; + +/** + * Send feedback using `fetch()` + */ +export async function sendFeedbackRequest({ + message, + email, + replay_id, + url, +}: SendFeedbackData): Promise { + const hub = getCurrentHub(); + + if (!hub) { + return null; + } + + const client = hub.getClient(); + const scope = hub.getScope(); + const transport = client && client.getTransport(); + const dsn = client && client.getDsn(); + + if (!client || !transport || !dsn) { + return null; + } + + const baseEvent = { + feedback: { + contact_email: email, + message, + replay_id, + url, + }, + // type: 'feedback_event', + }; + + const feedbackEvent = await prepareFeedbackEvent({ + scope, + client, + event: baseEvent, + }); + + if (!feedbackEvent) { + // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions + // client.recordDroppedEvent('event_processor', 'feedback', baseEvent); + return null; + } + + /* + For reference, the fully built event looks something like this: + { + "data": { + "dist": "abc123", + "environment": "production", + "feedback": { + "contact_email": "colton.allen@sentry.io", + "message": "I really like this user-feedback feature!", + "replay_id": "ec3b4dc8b79f417596f7a1aa4fcca5d2", + "url": "https://docs.sentry.io/platforms/javascript/" + }, + "id": "1ffe0775ac0f4417aed9de36d9f6f8dc", + "platform": "javascript", + "release": "version@1.3", + "request": { + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" + } + }, + "sdk": { + "name": "sentry.javascript.react", + "version": "6.18.1" + }, + "tags": { + "key": "value" + }, + "timestamp": "2023-08-31T14:10:34.954048", + "user": { + "email": "username@example.com", + "id": "123", + "ip_address": "127.0.0.1", + "name": "user", + "username": "user2270129" + } + } + } + */ + + // Prevent this data (which, if it exists, was used in earlier steps in the processing pipeline) from being sent to + // sentry. (Note: Our use of this property comes and goes with whatever we might be debugging, whatever hacks we may + // have temporarily added, etc. Even if we don't happen to be using it at some point in the future, let's not get rid + // of this `delete`, lest we miss putting it back in the next time the property is in use.) + delete feedbackEvent.sdkProcessingMetadata; + + try { + const path = 'https://sentry.io/api/0/feedback/'; + const response = await fetch(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `DSN ${dsnToString(dsn)}`, + }, + body: JSON.stringify(feedbackEvent), + }); + if (!response.ok) { + return null; + } + return response; + } catch (err) { + return null; + } +} diff --git a/packages/feedback/test/index.ts b/packages/feedback/test/index.ts new file mode 100644 index 000000000000..ed4b82a6c780 --- /dev/null +++ b/packages/feedback/test/index.ts @@ -0,0 +1,4 @@ +export * from './mocks/mockRrweb'; // XXX: Needs to happen before `mockSdk` or importing Replay! +export * from './mocks/mockSdk'; + +export const BASE_TIMESTAMP = new Date('2020-02-02 00:00:00').getTime(); // 1580619600000 diff --git a/packages/feedback/tsconfig.json b/packages/feedback/tsconfig.json new file mode 100644 index 000000000000..f8f54556da93 --- /dev/null +++ b/packages/feedback/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "esnext" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/feedback/tsconfig.test.json b/packages/feedback/tsconfig.test.json new file mode 100644 index 000000000000..ad87caa06c48 --- /dev/null +++ b/packages/feedback/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*.ts", "jest.config.ts", "jest.setup.ts"], + + "compilerOptions": { + "types": ["node", "jest"], + "esModuleInterop": true, + "allowJs": true, + "noImplicitAny": true, + "noImplicitThis": false, + "strictNullChecks": true, + "strictPropertyInitialization": false + } +} diff --git a/packages/feedback/tsconfig.types.json b/packages/feedback/tsconfig.types.json new file mode 100644 index 000000000000..374fd9bc9364 --- /dev/null +++ b/packages/feedback/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/npm/types" + } +} diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index c2825dbac6f2..964e9baebff5 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -27,10 +27,10 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/react": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/react": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "@sentry/webpack-plugin": "1.19.0" }, "peerDependencies": { diff --git a/packages/hub/package.json b/packages/hub/package.json index 9b8ecd3277b5..bc338053fc3d 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/hub", - "version": "7.71.0", + "version": "7.72.0", "description": "Sentry hub which handles global state managment.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/hub", @@ -23,9 +23,9 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "scripts": { diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index f6afcd27f48d..4176326d8d31 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "7.71.0", + "version": "7.72.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -43,7 +43,7 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/types": "7.71.0" + "@sentry/types": "7.72.0" }, "engines": { "node": ">=12" diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 6328bf6306e6..01d1d566840f 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/integrations", - "version": "7.71.0", + "version": "7.72.0", "description": "Pluggable integrations that can be used to enhance JS SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/integrations", @@ -23,13 +23,14 @@ } }, "dependencies": { - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "localforage": "^1.8.1", "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { - "@sentry/browser": "7.71.0", + "@sentry/browser": "7.72.0", "chai": "^4.1.2" }, "scripts": { diff --git a/packages/integrations/src/httpclient.ts b/packages/integrations/src/httpclient.ts index 5492116d7722..6ceb832fc1ff 100644 --- a/packages/integrations/src/httpclient.ts +++ b/packages/integrations/src/httpclient.ts @@ -1,3 +1,4 @@ +import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; import type { Event as SentryEvent, EventProcessor, @@ -345,22 +346,6 @@ export class HttpClient implements Integration { ); } - /** - * Checks whether given url points to Sentry server - * - * @param url url to verify - */ - private _isSentryRequest(url: string): boolean { - const client = this._getCurrentHub && this._getCurrentHub().getClient(); - - if (!client) { - return false; - } - - const dsn = client.getDsn(); - return dsn ? url.includes(dsn.host) : false; - } - /** * Checks whether to capture given response as an event * @@ -368,7 +353,11 @@ export class HttpClient implements Integration { * @param url response url */ private _shouldCaptureResponse(status: number, url: string): boolean { - return this._isInGivenStatusRanges(status) && this._isInGivenRequestTargets(url) && !this._isSentryRequest(url); + return ( + this._isInGivenStatusRanges(status) && + this._isInGivenRequestTargets(url) && + !isSentryRequestUrl(url, getCurrentHub()) + ); } /** diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index cef2b9bc9e3d..5f95d5d73584 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -25,13 +25,13 @@ }, "dependencies": { "@rollup/plugin-commonjs": "24.0.0", - "@sentry/core": "7.71.0", - "@sentry/integrations": "7.71.0", - "@sentry/node": "7.71.0", - "@sentry/react": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", - "@sentry/vercel-edge": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/integrations": "7.72.0", + "@sentry/node": "7.72.0", + "@sentry/react": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", + "@sentry/vercel-edge": "7.72.0", "@sentry/webpack-plugin": "1.20.0", "chalk": "3.0.0", "rollup": "2.78.0", diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 7fc882794e5c..346f56a91cbb 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -189,7 +189,7 @@ export default function wrappingLoader( } templateCode = templateCode.replace( /__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g, - '@sentry/nextjs/build/esm/config/templates/requestAsyncStorageShim.js', + '@sentry/nextjs/esm/config/templates/requestAsyncStorageShim.js', ); } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index dc8a77a4494c..ffe7091fa74a 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -126,7 +126,7 @@ export function constructWebpackConfigFunction( pageExtensionRegex, excludeServerRoutes: userSentryOptions.excludeServerRoutes, sentryConfigFilePath: getUserConfigFilePath(projectDir, runtime), - nextjsRequestAsyncStorageModulePath: getRequestAsyncLocalStorageModuleLocation(rawNewConfig.resolve?.modules), + nextjsRequestAsyncStorageModulePath: getRequestAsyncStorageModuleLocation(rawNewConfig.resolve?.modules), }; const normalizeLoaderResourcePath = (resourcePath: string): string => { @@ -977,30 +977,39 @@ function addValueInjectionLoader( ); } -function getRequestAsyncLocalStorageModuleLocation(modules: string[] | undefined): string | undefined { - if (modules === undefined) { +function getRequestAsyncStorageModuleLocation( + webpackResolvableModuleLocations: string[] | undefined, +): string | undefined { + if (webpackResolvableModuleLocations === undefined) { return undefined; } - try { - // Original location of that module - // https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts - const location = 'next/dist/client/components/request-async-storage'; - require.resolve(location, { paths: modules }); - return location; - } catch { - // noop - } + const absoluteWebpackResolvableModuleLocations = webpackResolvableModuleLocations.map(m => path.resolve(m)); + const moduleIsWebpackResolvable = (moduleId: string): boolean => { + let requireResolveLocation: string; + try { + // This will throw if the location is not resolvable at all. + // We provide a `paths` filter in order to maximally limit the potential locations to the locations webpack would check. + requireResolveLocation = require.resolve(moduleId, { paths: webpackResolvableModuleLocations }); + } catch { + return false; + } - try { + // Since the require.resolve approach still looks in "global" node_modules locations like for example "/user/lib/node" + // we further need to filter by locations that start with the locations that webpack would check for. + return absoluteWebpackResolvableModuleLocations.some(resolvableModuleLocation => + requireResolveLocation.startsWith(resolvableModuleLocation), + ); + }; + + const potentialRequestAsyncStorageLocations = [ + // Original location of RequestAsyncStorage + // https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts + 'next/dist/client/components/request-async-storage', // Introduced in Next.js 13.4.20 // https://github.com/vercel/next.js/blob/e1bc270830f2fc2df3542d4ef4c61b916c802df3/packages/next/src/client/components/request-async-storage.external.ts - const location = 'next/dist/client/components/request-async-storage.external'; - require.resolve(location, { paths: modules }); - return location; - } catch { - // noop - } + 'next/dist/client/components/request-async-storage.external', + ]; - return undefined; + return potentialRequestAsyncStorageLocations.find(potentialLocation => moduleIsWebpackResolvable(potentialLocation)); } diff --git a/packages/node-experimental/package.json b/packages/node-experimental/package.json index 6ba4849b58d8..9146e163a42d 100644 --- a/packages/node-experimental/package.json +++ b/packages/node-experimental/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-experimental", - "version": "7.71.0", + "version": "7.72.0", "description": "Experimental version of a Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-experimental", @@ -36,14 +36,15 @@ "@opentelemetry/instrumentation-mysql2": "~0.34.1", "@opentelemetry/instrumentation-nestjs-core": "~0.33.1", "@opentelemetry/instrumentation-pg": "~0.36.1", + "@opentelemetry/resources": "~1.17.0", "@opentelemetry/sdk-trace-base": "~1.17.0", "@opentelemetry/semantic-conventions": "~1.17.0", "@prisma/instrumentation": "~5.3.1", - "@sentry/core": "7.71.0", - "@sentry/node": "7.71.0", - "@sentry/opentelemetry-node": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0" + "@sentry/core": "7.72.0", + "@sentry/node": "7.72.0", + "@sentry/opentelemetry-node": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index d2a3e35397be..6a4b8766a242 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -3,7 +3,7 @@ import { SpanKind } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { hasTracingEnabled, Transaction } from '@sentry/core'; +import { hasTracingEnabled, isSentryRequestUrl, Transaction } from '@sentry/core'; import { getCurrentHub } from '@sentry/node'; import { _INTERNAL_getSentrySpan } from '@sentry/opentelemetry-node'; import type { EventProcessor, Hub, Integration } from '@sentry/types'; @@ -11,6 +11,7 @@ import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; import type { NodeExperimentalClient, OtelSpan } from '../types'; import { getRequestSpanData } from '../utils/getRequestSpanData'; +import { getRequestUrl } from '../utils/getRequestUrl'; interface TracingOptions { /** @@ -93,8 +94,8 @@ export class Http implements Integration { instrumentations: [ new HttpInstrumentation({ ignoreOutgoingRequestHook: request => { - const host = request.host || request.hostname; - return isSentryHost(host); + const url = getRequestUrl(request); + return url ? isSentryRequestUrl(url, getCurrentHub()) : false; }, ignoreIncomingRequestHook: request => { @@ -224,11 +225,3 @@ function getHttpUrl(attributes: Attributes): string | undefined { const url = attributes[SemanticAttributes.HTTP_URL]; return typeof url === 'string' ? url : undefined; } - -/** - * Checks whether given host points to Sentry server - */ -function isSentryHost(host: string | undefined): boolean { - const dsn = getCurrentHub().getClient()?.getDsn(); - return dsn && host ? host.includes(dsn.host) : false; -} diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index b58eec81880e..3ed0e2ab2b2b 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -1,6 +1,8 @@ import { diag, DiagLogLevel } from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; import { AlwaysOnSampler, BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { getCurrentHub } from '@sentry/core'; +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; +import { getCurrentHub, SDK_VERSION } from '@sentry/core'; import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry-node'; import { logger } from '@sentry/utils'; @@ -28,6 +30,11 @@ export function initOtel(): () => void { // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new AlwaysOnSampler(), + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'node-experimental', + [SemanticResourceAttributes.SERVICE_NAMESPACE]: 'sentry', + [SemanticResourceAttributes.SERVICE_VERSION]: SDK_VERSION, + }), }); provider.addSpanProcessor(new SentrySpanProcessor()); diff --git a/packages/node-experimental/src/utils/getRequestUrl.ts b/packages/node-experimental/src/utils/getRequestUrl.ts new file mode 100644 index 000000000000..1e4dcfb71232 --- /dev/null +++ b/packages/node-experimental/src/utils/getRequestUrl.ts @@ -0,0 +1,15 @@ +import type { RequestOptions } from 'http'; + +/** Build a full URL from request options. */ +export function getRequestUrl(requestOptions: RequestOptions): string { + const protocol = requestOptions.protocol || ''; + const hostname = requestOptions.hostname || requestOptions.host || ''; + // Don't log standard :80 (http) and :443 (https) ports to reduce the noise + // Also don't add port if the hostname already includes a port + const port = + !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) + ? '' + : `:${requestOptions.port}`; + const path = requestOptions.path ? requestOptions.path : '/'; + return `${protocol}//${hostname}${port}${path}`; +} diff --git a/packages/node-experimental/test/utils/getRequestUrl.test.ts b/packages/node-experimental/test/utils/getRequestUrl.test.ts new file mode 100644 index 000000000000..caa92aa10a59 --- /dev/null +++ b/packages/node-experimental/test/utils/getRequestUrl.test.ts @@ -0,0 +1,20 @@ +import type { RequestOptions } from 'http'; + +import { getRequestUrl } from '../../src/utils/getRequestUrl'; + +describe('getRequestUrl', () => { + it.each([ + [{ protocol: 'http:', hostname: 'localhost', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', host: 'localhost:80', port: 80 }, 'http://localhost/'], + [{ protocol: 'http:', hostname: 'localhost', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'http:', host: 'localhost:3000', port: 3000 }, 'http://localhost:3000/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443 }, 'https://localhost/'], + [{ protocol: 'https:', hostname: 'localhost', port: 443, path: '/my-path' }, 'https://localhost/my-path'], + [ + { protocol: 'https:', hostname: 'www.example.com', port: 443, path: '/my-path' }, + 'https://www.example.com/my-path', + ], + ])('works with %s', (input: RequestOptions, expected: string | undefined) => { + expect(getRequestUrl(input)).toBe(expected); + }); +}); diff --git a/packages/node-integration-tests/package.json b/packages/node-integration-tests/package.json index cc79346d53e8..ca2ad321f14c 100644 --- a/packages/node-integration-tests/package.json +++ b/packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "7.71.0", + "version": "7.72.0", "license": "MIT", "engines": { "node": ">=10" diff --git a/packages/node-integration-tests/suites/anr/scenario.js b/packages/node-integration-tests/suites/anr/scenario.js new file mode 100644 index 000000000000..3abadc09b9c3 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/scenario.js @@ -0,0 +1,31 @@ +const crypto = require('crypto'); + +const Sentry = require('@sentry/node'); + +// close both processes after 5 seconds +setTimeout(() => { + process.exit(); +}, 5000); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + beforeSend: event => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(event)); + }, +}); + +Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true }).then(() => { + function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } + } + + setTimeout(() => { + longWork(); + }, 1000); +}); diff --git a/packages/node-integration-tests/suites/anr/scenario.mjs b/packages/node-integration-tests/suites/anr/scenario.mjs new file mode 100644 index 000000000000..ba9c8623da7e --- /dev/null +++ b/packages/node-integration-tests/suites/anr/scenario.mjs @@ -0,0 +1,31 @@ +import * as crypto from 'crypto'; + +import * as Sentry from '@sentry/node'; + +// close both processes after 5 seconds +setTimeout(() => { + process.exit(); +}, 5000); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + beforeSend: event => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(event)); + }, +}); + +await Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true }); + +function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } +} + +setTimeout(() => { + longWork(); +}, 1000); diff --git a/packages/node-integration-tests/suites/anr/test.ts b/packages/node-integration-tests/suites/anr/test.ts new file mode 100644 index 000000000000..ec820dca9c62 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/test.ts @@ -0,0 +1,57 @@ +import type { Event } from '@sentry/node'; +import { parseSemver } from '@sentry/utils'; +import * as childProcess from 'child_process'; +import * as path from 'path'; + +const NODE_VERSION = parseSemver(process.versions.node).major || 0; + +describe('should report ANR when event loop blocked', () => { + test('CJS', done => { + // The stack trace is different when node < 12 + const testFramesDetails = NODE_VERSION >= 12; + + expect.assertions(testFramesDetails ? 6 : 4); + + const testScriptPath = path.resolve(__dirname, 'scenario.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { + const event = JSON.parse(stdout) as Event; + + expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); + expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); + expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); + expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); + + if (testFramesDetails) { + expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); + expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + } + + done(); + }); + }); + + test('ESM', done => { + if (NODE_VERSION < 14) { + done(); + return; + } + + expect.assertions(6); + + const testScriptPath = path.resolve(__dirname, 'scenario.mjs'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { + const event = JSON.parse(stdout) as Event; + + expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); + expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); + expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); + expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); + expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); + expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + + done(); + }); + }); +}); diff --git a/packages/node/package.json b/packages/node/package.json index e63e3a021ddf..2afe7392d1e4 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Node.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -23,10 +23,10 @@ "access": "public" }, "dependencies": { - "@sentry-internal/tracing": "7.71.0", - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry-internal/tracing": "7.72.0", + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "cookie": "^0.5.0", "https-proxy-agent": "^5.0.0", "lru_map": "^0.3.3", diff --git a/packages/node/src/anr/debugger.ts b/packages/node/src/anr/debugger.ts new file mode 100644 index 000000000000..4d4a2799fa64 --- /dev/null +++ b/packages/node/src/anr/debugger.ts @@ -0,0 +1,95 @@ +import type { StackFrame } from '@sentry/types'; +import { dropUndefinedKeys, filenameIsInApp } from '@sentry/utils'; +import type { Debugger } from 'inspector'; + +import { getModuleFromFilename } from '../module'; +import { createWebSocketClient } from './websocket'; + +/** + * Converts Debugger.CallFrame to Sentry StackFrame + */ +function callFrameToStackFrame( + frame: Debugger.CallFrame, + filenameFromScriptId: (id: string) => string | undefined, +): StackFrame { + const filename = filenameFromScriptId(frame.location.scriptId)?.replace(/^file:\/\//, ''); + + // CallFrame row/col are 0 based, whereas StackFrame are 1 based + const colno = frame.location.columnNumber ? frame.location.columnNumber + 1 : undefined; + const lineno = frame.location.lineNumber ? frame.location.lineNumber + 1 : undefined; + + return dropUndefinedKeys({ + filename, + module: getModuleFromFilename(filename), + function: frame.functionName || '?', + colno, + lineno, + in_app: filename ? filenameIsInApp(filename) : undefined, + }); +} + +// The only messages we care about +type DebugMessage = + | { + method: 'Debugger.scriptParsed'; + params: Debugger.ScriptParsedEventDataType; + } + | { method: 'Debugger.paused'; params: Debugger.PausedEventDataType }; + +/** + * Wraps a websocket connection with the basic logic of the Node debugger protocol. + * @param url The URL to connect to + * @param onMessage A callback that will be called with each return message from the debugger + * @returns A function that can be used to send commands to the debugger + */ +async function webSocketDebugger( + url: string, + onMessage: (message: DebugMessage) => void, +): Promise<(method: string, params?: unknown) => void> { + let id = 0; + const webSocket = await createWebSocketClient(url); + + webSocket.on('message', (data: Buffer) => { + const message = JSON.parse(data.toString()) as DebugMessage; + onMessage(message); + }); + + return (method: string, params?: unknown) => { + webSocket.send(JSON.stringify({ id: id++, method, params })); + }; +} + +/** + * Captures stack traces from the Node debugger. + * @param url The URL to connect to + * @param callback A callback that will be called with the stack frames + * @returns A function that triggers the debugger to pause and capture a stack trace + */ +export async function captureStackTrace(url: string, callback: (frames: StackFrame[]) => void): Promise<() => void> { + // Collect scriptId -> url map so we can look up the filenames later + const scripts = new Map(); + + const sendCommand = await webSocketDebugger(url, message => { + if (message.method === 'Debugger.scriptParsed') { + scripts.set(message.params.scriptId, message.params.url); + } else if (message.method === 'Debugger.paused') { + // copy the frames + const callFrames = [...message.params.callFrames]; + // and resume immediately! + sendCommand('Debugger.resume'); + sendCommand('Debugger.disable'); + + const frames = callFrames + .map(frame => callFrameToStackFrame(frame, id => scripts.get(id))) + // Sentry expects the frames to be in the opposite order + .reverse(); + + callback(frames); + } + }); + + return () => { + sendCommand('Debugger.enable'); + sendCommand('Debugger.pause'); + }; +} diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts new file mode 100644 index 000000000000..2d546447ddd7 --- /dev/null +++ b/packages/node/src/anr/index.ts @@ -0,0 +1,245 @@ +import type { Event, StackFrame } from '@sentry/types'; +import { logger } from '@sentry/utils'; +import { fork } from 'child_process'; +import * as inspector from 'inspector'; + +import { addGlobalEventProcessor, captureEvent, flush } from '..'; +import { captureStackTrace } from './debugger'; + +const DEFAULT_INTERVAL = 50; +const DEFAULT_HANG_THRESHOLD = 5000; + +/** + * A node.js watchdog timer + * @param pollInterval The interval that we expect to get polled at + * @param anrThreshold The threshold for when we consider ANR + * @param callback The callback to call for ANR + * @returns A function to call to reset the timer + */ +function watchdogTimer(pollInterval: number, anrThreshold: number, callback: () => void): () => void { + let lastPoll = process.hrtime(); + let triggered = false; + + setInterval(() => { + const [seconds, nanoSeconds] = process.hrtime(lastPoll); + const diffMs = Math.floor(seconds * 1e3 + nanoSeconds / 1e6); + + if (triggered === false && diffMs > pollInterval + anrThreshold) { + triggered = true; + callback(); + } + + if (diffMs < pollInterval + anrThreshold) { + triggered = false; + } + }, 20); + + return () => { + lastPoll = process.hrtime(); + }; +} + +interface Options { + /** + * The app entry script. This is used to run the same script as the child process. + * + * Defaults to `process.argv[1]`. + */ + entryScript: string; + /** + * Interval to send heartbeat messages to the child process. + * + * Defaults to 50ms. + */ + pollInterval: number; + /** + * Threshold in milliseconds to trigger an ANR event. + * + * Defaults to 5000ms. + */ + anrThreshold: number; + /** + * Whether to capture a stack trace when the ANR event is triggered. + * + * Defaults to `false`. + * + * This uses the node debugger which enables the inspector API and opens the required ports. + */ + captureStackTrace: boolean; + /** + * Log debug information. + */ + debug: boolean; +} + +function sendEvent(blockedMs: number, frames?: StackFrame[]): void { + const event: Event = { + level: 'error', + exception: { + values: [ + { + type: 'ApplicationNotResponding', + value: `Application Not Responding for at least ${blockedMs} ms`, + stacktrace: { frames }, + mechanism: { + // This ensures the UI doesn't say 'Crashed in' for the stack trace + type: 'ANR', + }, + }, + ], + }, + }; + + captureEvent(event); + + void flush(3000).then(() => { + // We only capture one event to avoid spamming users with errors + process.exit(); + }); +} + +function startChildProcess(options: Options): void { + function log(message: string, err?: unknown): void { + if (options.debug) { + if (err) { + logger.log(`[ANR] ${message}`, err); + } else { + logger.log(`[ANR] ${message}`); + } + } + } + + try { + const env = { ...process.env }; + + if (options.captureStackTrace) { + inspector.open(); + env.SENTRY_INSPECT_URL = inspector.url(); + } + + const child = fork(options.entryScript, { + env, + stdio: options.debug ? 'inherit' : 'ignore', + }); + // The child process should not keep the main process alive + child.unref(); + + const timer = setInterval(() => { + try { + // message the child process to tell it the main event loop is still running + child.send('ping'); + } catch (_) { + // + } + }, options.pollInterval); + + const end = (err: unknown): void => { + clearInterval(timer); + log('Child process ended', err); + }; + + child.on('error', end); + child.on('disconnect', end); + child.on('exit', end); + } catch (e) { + log('Failed to start child process', e); + } +} + +function handleChildProcess(options: Options): void { + function log(message: string): void { + if (options.debug) { + logger.log(`[ANR child process] ${message}`); + } + } + + log('Started'); + + addGlobalEventProcessor(event => { + // Strip sdkProcessingMetadata from all child process events to remove trace info + delete event.sdkProcessingMetadata; + event.tags = { + ...event.tags, + 'process.name': 'ANR', + }; + return event; + }); + + let debuggerPause: Promise<() => void> | undefined; + + // if attachStackTrace is enabled, we'll have a debugger url to connect to + if (process.env.SENTRY_INSPECT_URL) { + log('Connecting to debugger'); + + debuggerPause = captureStackTrace(process.env.SENTRY_INSPECT_URL, frames => { + log('Capturing event with stack frames'); + sendEvent(options.anrThreshold, frames); + }); + } + + async function watchdogTimeout(): Promise { + log('Watchdog timeout'); + const pauseAndCapture = await debuggerPause; + + if (pauseAndCapture) { + log('Pausing debugger to capture stack trace'); + pauseAndCapture(); + } else { + log('Capturing event'); + sendEvent(options.anrThreshold); + } + } + + const ping = watchdogTimer(options.pollInterval, options.anrThreshold, watchdogTimeout); + + process.on('message', () => { + ping(); + }); +} + +/** + * **Note** This feature is still in beta so there may be breaking changes in future releases. + * + * Starts a child process that detects Application Not Responding (ANR) errors. + * + * It's important to await on the returned promise before your app code to ensure this code does not run in the ANR + * child process. + * + * ```js + * import { init, enableAnrDetection } from '@sentry/node'; + * + * init({ dsn: "__DSN__" }); + * + * // with ESM + Node 14+ + * await enableAnrDetection({ captureStackTrace: true }); + * runApp(); + * + * // with CJS or Node 10+ + * enableAnrDetection({ captureStackTrace: true }).then(() => { + * runApp(); + * }); + * ``` + */ +export function enableAnrDetection(options: Partial): Promise { + const isChildProcess = !!process.send; + + const anrOptions: Options = { + entryScript: options.entryScript || process.argv[1], + pollInterval: options.pollInterval || DEFAULT_INTERVAL, + anrThreshold: options.anrThreshold || DEFAULT_HANG_THRESHOLD, + captureStackTrace: !!options.captureStackTrace, + debug: !!options.debug, + }; + + if (isChildProcess) { + handleChildProcess(anrOptions); + // In the child process, the promise never resolves which stops the app code from running + return new Promise(() => { + // Never resolve + }); + } else { + startChildProcess(anrOptions); + // In the main process, the promise resolves immediately + return Promise.resolve(); + } +} diff --git a/packages/node/src/anr/websocket.ts b/packages/node/src/anr/websocket.ts new file mode 100644 index 000000000000..9faa90bcfd1c --- /dev/null +++ b/packages/node/src/anr/websocket.ts @@ -0,0 +1,359 @@ +/* eslint-disable no-bitwise */ +/** + * A simple WebSocket client implementation copied from Rome before being modified for our use: + * https://github.com/jeremyBanks/rome/tree/b034dd22d5f024f87c50eef2872e22b3ad48973a/packages/%40romejs/codec-websocket + * + * Original license: + * + * MIT License + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { EventEmitter } from 'events'; +import * as http from 'http'; +import type { Socket } from 'net'; +import * as url from 'url'; + +type BuildFrameOpts = { + opcode: number; + fin: boolean; + data: Buffer; +}; + +type Frame = { + fin: boolean; + opcode: number; + mask: undefined | Buffer; + payload: Buffer; + payloadLength: number; +}; + +const OPCODES = { + CONTINUATION: 0, + TEXT: 1, + BINARY: 2, + TERMINATE: 8, + PING: 9, + PONG: 10, +}; + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function isCompleteFrame(frame: Frame): boolean { + return Buffer.byteLength(frame.payload) >= frame.payloadLength; +} + +function unmaskPayload(payload: Buffer, mask: undefined | Buffer, offset: number): Buffer { + if (mask === undefined) { + return payload; + } + + for (let i = 0; i < payload.length; i++) { + payload[i] ^= mask[(offset + i) & 3]; + } + + return payload; +} + +function buildFrame(opts: BuildFrameOpts): Buffer { + const { opcode, fin, data } = opts; + + let offset = 6; + let dataLength = data.length; + + if (dataLength >= 65_536) { + offset += 8; + dataLength = 127; + } else if (dataLength > 125) { + offset += 2; + dataLength = 126; + } + + const head = Buffer.allocUnsafe(offset); + + head[0] = fin ? opcode | 128 : opcode; + head[1] = dataLength; + + if (dataLength === 126) { + head.writeUInt16BE(data.length, 2); + } else if (dataLength === 127) { + head.writeUInt32BE(0, 2); + head.writeUInt32BE(data.length, 6); + } + + const mask = crypto.randomBytes(4); + head[1] |= 128; + head[offset - 4] = mask[0]; + head[offset - 3] = mask[1]; + head[offset - 2] = mask[2]; + head[offset - 1] = mask[3]; + + const masked = Buffer.alloc(dataLength); + for (let i = 0; i < dataLength; ++i) { + masked[i] = data[i] ^ mask[i & 3]; + } + + return Buffer.concat([head, masked]); +} + +function parseFrame(buffer: Buffer): Frame { + const firstByte = buffer.readUInt8(0); + const isFinalFrame: boolean = Boolean((firstByte >>> 7) & 1); + const opcode: number = firstByte & 15; + + const secondByte: number = buffer.readUInt8(1); + const isMasked: boolean = Boolean((secondByte >>> 7) & 1); + + // Keep track of our current position as we advance through the buffer + let currentOffset = 2; + let payloadLength = secondByte & 127; + if (payloadLength > 125) { + if (payloadLength === 126) { + payloadLength = buffer.readUInt16BE(currentOffset); + currentOffset += 2; + } else if (payloadLength === 127) { + const leftPart = buffer.readUInt32BE(currentOffset); + currentOffset += 4; + + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + + // if payload length is greater than this number. + if (leftPart >= Number.MAX_SAFE_INTEGER) { + throw new Error('Unsupported WebSocket frame: payload length > 2^53 - 1'); + } + + const rightPart = buffer.readUInt32BE(currentOffset); + currentOffset += 4; + + payloadLength = leftPart * Math.pow(2, 32) + rightPart; + } else { + throw new Error('Unknown payload length'); + } + } + + // Get the masking key if one exists + let mask; + if (isMasked) { + mask = buffer.slice(currentOffset, currentOffset + 4); + currentOffset += 4; + } + + const payload = unmaskPayload(buffer.slice(currentOffset), mask, 0); + + return { + fin: isFinalFrame, + opcode, + mask, + payload, + payloadLength, + }; +} + +function createKey(key: string): string { + return crypto.createHash('sha1').update(`${key}${GUID}`).digest('base64'); +} + +class WebSocketInterface extends EventEmitter { + private _alive: boolean; + private _incompleteFrame: undefined | Frame; + private _unfinishedFrame: undefined | Frame; + private _socket: Socket; + + public constructor(socket: Socket) { + super(); + // When a frame is set here then any additional continuation frames payloads will be appended + this._unfinishedFrame = undefined; + + // When a frame is set here, all additional chunks will be appended until we reach the correct payloadLength + this._incompleteFrame = undefined; + + this._socket = socket; + this._alive = true; + + socket.on('data', buff => { + this._addBuffer(buff); + }); + + socket.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ECONNRESET') { + this.emit('close'); + } else { + this.emit('error'); + } + }); + + socket.on('close', () => { + this.end(); + }); + } + + public end(): void { + if (!this._alive) { + return; + } + + this._alive = false; + this.emit('close'); + this._socket.end(); + } + + public send(buff: string): void { + this._sendFrame({ + opcode: OPCODES.TEXT, + fin: true, + data: Buffer.from(buff), + }); + } + + private _sendFrame(frameOpts: BuildFrameOpts): void { + this._socket.write(buildFrame(frameOpts)); + } + + private _completeFrame(frame: Frame): void { + // If we have an unfinished frame then only allow continuations + const { _unfinishedFrame: unfinishedFrame } = this; + if (unfinishedFrame !== undefined) { + if (frame.opcode === OPCODES.CONTINUATION) { + unfinishedFrame.payload = Buffer.concat([ + unfinishedFrame.payload, + unmaskPayload(frame.payload, unfinishedFrame.mask, unfinishedFrame.payload.length), + ]); + + if (frame.fin) { + this._unfinishedFrame = undefined; + this._completeFrame(unfinishedFrame); + } + return; + } else { + // Silently ignore the previous frame... + this._unfinishedFrame = undefined; + } + } + + if (frame.fin) { + if (frame.opcode === OPCODES.PING) { + this._sendFrame({ + opcode: OPCODES.PONG, + fin: true, + data: frame.payload, + }); + } else { + // Trim off any excess payload + let excess; + if (frame.payload.length > frame.payloadLength) { + excess = frame.payload.slice(frame.payloadLength); + frame.payload = frame.payload.slice(0, frame.payloadLength); + } + + this.emit('message', frame.payload); + + if (excess !== undefined) { + this._addBuffer(excess); + } + } + } else { + this._unfinishedFrame = frame; + } + } + + private _addBufferToIncompleteFrame(incompleteFrame: Frame, buff: Buffer): void { + incompleteFrame.payload = Buffer.concat([ + incompleteFrame.payload, + unmaskPayload(buff, incompleteFrame.mask, incompleteFrame.payload.length), + ]); + + if (isCompleteFrame(incompleteFrame)) { + this._incompleteFrame = undefined; + this._completeFrame(incompleteFrame); + } + } + + private _addBuffer(buff: Buffer): void { + // Check if we're still waiting for the rest of a payload + const { _incompleteFrame: incompleteFrame } = this; + if (incompleteFrame !== undefined) { + this._addBufferToIncompleteFrame(incompleteFrame, buff); + return; + } + + const frame = parseFrame(buff); + + if (isCompleteFrame(frame)) { + // Frame has been completed! + this._completeFrame(frame); + } else { + this._incompleteFrame = frame; + } + } +} + +/** + * Creates a WebSocket client + */ +export async function createWebSocketClient(rawUrl: string): Promise { + const parts = url.parse(rawUrl); + + return new Promise((resolve, reject) => { + const key = crypto.randomBytes(16).toString('base64'); + const digest = createKey(key); + + const req = http.request({ + hostname: parts.hostname, + port: parts.port, + path: parts.path, + method: 'GET', + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': key, + 'Sec-WebSocket-Version': '13', + }, + }); + + req.on('response', (res: http.IncomingMessage) => { + if (res.statusCode && res.statusCode >= 400) { + process.stderr.write(`Unexpected HTTP code: ${res.statusCode}\n`); + res.pipe(process.stderr); + } else { + res.pipe(process.stderr); + } + }); + + req.on('upgrade', (res: http.IncomingMessage, socket: Socket) => { + if (res.headers['sec-websocket-accept'] !== digest) { + socket.end(); + reject(new Error(`Digest mismatch ${digest} !== ${res.headers['sec-websocket-accept']}`)); + return; + } + + const client = new WebSocketInterface(socket); + resolve(client); + }); + + req.on('error', err => { + reject(err); + }); + + req.end(); + }); +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index c7d93ef16463..503f2749ea29 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -71,6 +71,7 @@ export { defaultIntegrations, init, defaultStackParser, getSentryRelease } from export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from './requestdata'; export { deepReadDirSync } from './utils'; export { getModuleFromFilename } from './module'; +export { enableAnrDetection } from './anr'; import { Integrations as CoreIntegrations } from '@sentry/core'; diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 95b68e2c8eb3..1f4b5c9c9224 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,5 +1,5 @@ import type { Hub } from '@sentry/core'; -import { getCurrentHub, getDynamicSamplingContextFromClient } from '@sentry/core'; +import { getCurrentHub, getDynamicSamplingContextFromClient, isSentryRequestUrl } from '@sentry/core'; import type { DynamicSamplingContext, EventProcessor, @@ -21,7 +21,7 @@ import { LRUMap } from 'lru_map'; import type { NodeClient } from '../client'; import { NODE_VERSION } from '../nodeVersion'; import type { RequestMethod, RequestMethodArgs, RequestOptions } from './utils/http'; -import { cleanSpanDescription, extractRawUrl, extractUrl, isSentryRequest, normalizeRequestArgs } from './utils/http'; +import { cleanSpanDescription, extractRawUrl, extractUrl, normalizeRequestArgs } from './utils/http'; interface TracingOptions { /** @@ -238,7 +238,7 @@ function _createWrappedRequestMethodFactory( const requestUrl = extractUrl(requestOptions); // we don't want to record requests to Sentry as either breadcrumbs or spans, so just use the original method - if (isSentryRequest(requestUrl)) { + if (isSentryRequestUrl(requestUrl, getCurrentHub())) { return originalRequestMethod.apply(httpModule, requestArgs); } diff --git a/packages/node/src/integrations/undici/index.ts b/packages/node/src/integrations/undici/index.ts index 1cc51ab1fb70..25888780a30c 100644 --- a/packages/node/src/integrations/undici/index.ts +++ b/packages/node/src/integrations/undici/index.ts @@ -1,4 +1,4 @@ -import { getCurrentHub, getDynamicSamplingContextFromClient } from '@sentry/core'; +import { getCurrentHub, getDynamicSamplingContextFromClient, isSentryRequestUrl } from '@sentry/core'; import type { EventProcessor, Integration, Span } from '@sentry/types'; import { dynamicRequire, @@ -12,7 +12,6 @@ import { LRUMap } from 'lru_map'; import type { NodeClient } from '../../client'; import { NODE_VERSION } from '../../nodeVersion'; -import { isSentryRequest } from '../utils/http'; import type { DiagnosticsChannel, RequestCreateMessage, @@ -138,7 +137,7 @@ export class Undici implements Integration { const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - if (isSentryRequest(stringUrl) || request.__sentry_span__ !== undefined) { + if (isSentryRequestUrl(stringUrl, hub) || request.__sentry_span__ !== undefined) { return; } @@ -198,7 +197,7 @@ export class Undici implements Integration { const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - if (isSentryRequest(stringUrl)) { + if (isSentryRequestUrl(stringUrl, hub)) { return; } @@ -238,7 +237,7 @@ export class Undici implements Integration { const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - if (isSentryRequest(stringUrl)) { + if (isSentryRequestUrl(stringUrl, hub)) { return; } diff --git a/packages/node/src/integrations/undici/types.ts b/packages/node/src/integrations/undici/types.ts index f56e708f456c..d885984671bf 100644 --- a/packages/node/src/integrations/undici/types.ts +++ b/packages/node/src/integrations/undici/types.ts @@ -1,6 +1,7 @@ // Vendored from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/5a94716c6788f654aea7999a5fc28f4f1e7c48ad/types/node/diagnostics_channel.d.ts import type { Span } from '@sentry/core'; +import type { URL } from 'url'; // License: // This project is licensed under the MIT license. @@ -224,7 +225,7 @@ export interface UndiciRequest { method?: string; path: string; headers: string; - addHeader(key: string, value: string): Request; + addHeader(key: string, value: string): RequestWithSentry; } export interface UndiciResponse { diff --git a/packages/node/src/integrations/utils/http.ts b/packages/node/src/integrations/utils/http.ts index d49b01ac8275..fc2e6a1e88bd 100644 --- a/packages/node/src/integrations/utils/http.ts +++ b/packages/node/src/integrations/utils/http.ts @@ -1,19 +1,9 @@ -import { getCurrentHub } from '@sentry/core'; import type * as http from 'http'; import type * as https from 'https'; import { URL } from 'url'; import { NODE_VERSION } from '../../nodeVersion'; -/** - * Checks whether given url points to Sentry server - * @param url url to verify - */ -export function isSentryRequest(url: string): boolean { - const dsn = getCurrentHub().getClient()?.getDsn(); - return dsn ? url.includes(dsn.host) : false; -} - /** * Assembles a URL that's passed to the users to filter on. * It can include raw (potentially PII containing) data, which we'll allow users to access to filter @@ -24,11 +14,7 @@ export function isSentryRequest(url: string): boolean { */ // TODO (v8): This function should include auth, query and fragment (it's breaking, so we need to wait for v8) export function extractRawUrl(requestOptions: RequestOptions): string { - const protocol = requestOptions.protocol || ''; - const hostname = requestOptions.hostname || requestOptions.host || ''; - // Don't log standard :80 (http) and :443 (https) ports to reduce the noise - const port = - !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 ? '' : `:${requestOptions.port}`; + const { protocol, hostname, port } = parseRequestOptions(requestOptions); const path = requestOptions.path ? requestOptions.path : '/'; return `${protocol}//${hostname}${port}${path}`; } @@ -40,13 +26,10 @@ export function extractRawUrl(requestOptions: RequestOptions): string { * @returns Fully-formed URL */ export function extractUrl(requestOptions: RequestOptions): string { - const protocol = requestOptions.protocol || ''; - const hostname = requestOptions.hostname || requestOptions.host || ''; - // Don't log standard :80 (http) and :443 (https) ports to reduce the noise - const port = - !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 ? '' : `:${requestOptions.port}`; - // do not include search or hash in span descriptions, per https://develop.sentry.dev/sdk/data-handling/#structuring-data + const { protocol, hostname, port } = parseRequestOptions(requestOptions); + const path = requestOptions.pathname || '/'; + // always filter authority, see https://develop.sentry.dev/sdk/data-handling/#structuring-data const authority = requestOptions.auth ? redactAuthority(requestOptions.auth) : ''; @@ -168,6 +151,21 @@ export function normalizeRequestArgs( requestOptions = urlToOptions(requestArgs[0]); } else { requestOptions = requestArgs[0]; + + try { + const parsed = new URL( + requestOptions.path || '', + `${requestOptions.protocol || 'http:'}//${requestOptions.hostname}`, + ); + requestOptions = { + pathname: parsed.pathname, + search: parsed.search, + hash: parsed.hash, + ...requestOptions, + }; + } catch (e) { + // ignore + } } // if the options were given separately from the URL, fold them in @@ -206,3 +204,20 @@ export function normalizeRequestArgs( return [requestOptions]; } } + +function parseRequestOptions(requestOptions: RequestOptions): { + protocol: string; + hostname: string; + port: string; +} { + const protocol = requestOptions.protocol || ''; + const hostname = requestOptions.hostname || requestOptions.host || ''; + // Don't log standard :80 (http) and :443 (https) ports to reduce the noise + // Also don't add port if the hostname already includes a port + const port = + !requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 || /^(.*):(\d+)$/.test(hostname) + ? '' + : `:${requestOptions.port}`; + + return { protocol, hostname, port }; +} diff --git a/packages/node/src/requestdata.ts b/packages/node/src/requestdata.ts index a0d5aed926a9..bc07fcf92f8b 100644 --- a/packages/node/src/requestdata.ts +++ b/packages/node/src/requestdata.ts @@ -320,11 +320,5 @@ function extractQueryParams(req: PolymorphicRequest): string | Record { if (d2done) { done(); } - }); + }, 0); }); runWithAsyncContext(() => { @@ -131,7 +131,7 @@ describe('domains', () => { if (d1done) { done(); } - }); + }, 0); }); }); }); diff --git a/packages/node/test/async/hooks.test.ts b/packages/node/test/async/hooks.test.ts index a08271230579..ad477e03d477 100644 --- a/packages/node/test/async/hooks.test.ts +++ b/packages/node/test/async/hooks.test.ts @@ -130,7 +130,7 @@ conditionalTest({ min: 12 })('async_hooks', () => { if (d2done) { done(); } - }); + }, 0); }); runWithAsyncContext(() => { @@ -142,7 +142,7 @@ conditionalTest({ min: 12 })('async_hooks', () => { if (d1done) { done(); } - }); + }, 0); }); }); }); diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index bb162789f0be..02c13b544b01 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -298,6 +298,25 @@ describe('tracing', () => { expect(spans[1].data['http.fragment']).toEqual('learn-more'); }); + it('fills in span data from http.RequestOptions object', () => { + nock('http://dogs.are.great').get('/spaniel?tail=wag&cute=true#learn-more').reply(200); + + const transaction = createTransactionOnScope(); + const spans = (transaction as unknown as Span).spanRecorder?.spans as Span[]; + + http.request({ method: 'GET', host: 'dogs.are.great', path: '/spaniel?tail=wag&cute=true#learn-more' }); + + expect(spans.length).toEqual(2); + + // our span is at index 1 because the transaction itself is at index 0 + expect(spans[1].description).toEqual('GET http://dogs.are.great/spaniel'); + expect(spans[1].op).toEqual('http.client'); + expect(spans[1].data['http.method']).toEqual('GET'); + expect(spans[1].data.url).toEqual('http://dogs.are.great/spaniel'); + expect(spans[1].data['http.query']).toEqual('tail=wag&cute=true'); + expect(spans[1].data['http.fragment']).toEqual('learn-more'); + }); + it.each([ ['user:pwd', '[Filtered]:[Filtered]@'], ['user:', '[Filtered]:@'], diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index bf45a09f2d71..5fc0658105eb 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -4,6 +4,6 @@ "include": ["src/**/*"], "compilerOptions": { - // package-specific options + "lib": ["es6"] } } diff --git a/packages/opentelemetry-node/package.json b/packages/opentelemetry-node/package.json index 55201e9d4e04..63909ac4a674 100644 --- a/packages/opentelemetry-node/package.json +++ b/packages/opentelemetry-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry-node", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for OpenTelemetry Node.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry-node", @@ -23,9 +23,9 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0" + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0" }, "peerDependencies": { "@opentelemetry/api": "1.x", @@ -39,7 +39,7 @@ "@opentelemetry/sdk-trace-base": "^1.17.0", "@opentelemetry/sdk-trace-node": "^1.17.0", "@opentelemetry/semantic-conventions": "^1.17.0", - "@sentry/node": "7.71.0" + "@sentry/node": "7.72.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/opentelemetry-node/src/utils/isSentryRequest.ts b/packages/opentelemetry-node/src/utils/isSentryRequest.ts index b02e3b4cb588..5b285bb0ec68 100644 --- a/packages/opentelemetry-node/src/utils/isSentryRequest.ts +++ b/packages/opentelemetry-node/src/utils/isSentryRequest.ts @@ -1,6 +1,6 @@ import type { Span as OtelSpan } from '@opentelemetry/sdk-trace-base'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { getCurrentHub } from '@sentry/core'; +import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; /** * @@ -16,14 +16,5 @@ export function isSentryRequestSpan(otelSpan: OtelSpan): boolean { return false; } - return isSentryRequestUrl(httpUrl.toString()); -} - -/** - * Checks whether given url points to Sentry server - * @param url url to verify - */ -function isSentryRequestUrl(url: string): boolean { - const dsn = getCurrentHub().getClient()?.getDsn(); - return dsn ? url.includes(dsn.host) : false; + return isSentryRequestUrl(httpUrl.toString(), getCurrentHub()); } diff --git a/packages/overhead-metrics/package.json b/packages/overhead-metrics/package.json index afda49ff9d4e..c0549f521fe3 100644 --- a/packages/overhead-metrics/package.json +++ b/packages/overhead-metrics/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "7.71.0", + "version": "7.72.0", "name": "@sentry-internal/overhead-metrics", "main": "index.js", "author": "Sentry", diff --git a/packages/react/package.json b/packages/react/package.json index 3b27498be5f4..1d795329d66d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -23,9 +23,9 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/browser": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "hoist-non-react-statics": "^3.3.2", "tslib": "^2.4.1 || ^1.9.3" }, diff --git a/packages/remix/package.json b/packages/remix/package.json index 14dbdbcf2afe..11143797d7a7 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -28,11 +28,11 @@ }, "dependencies": { "@sentry/cli": "2.20.5", - "@sentry/core": "7.71.0", - "@sentry/node": "7.71.0", - "@sentry/react": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/node": "7.72.0", + "@sentry/react": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "glob": "^10.3.4", "tslib": "^2.4.1 || ^1.9.3", "yargs": "^17.6.0" diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 415b443f1c2d..8ddca56b3654 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "7.71.0", + "version": "7.72.0", "description": "Worker for @sentry/replay", "main": "build/npm/esm/index.js", "module": "build/npm/esm/index.js", diff --git a/packages/replay/package.json b/packages/replay/package.json index c306f9bfc1df..43a511742a0d 100644 --- a/packages/replay/package.json +++ b/packages/replay/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/replay", - "version": "7.71.0", + "version": "7.72.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -53,16 +53,16 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.17.5", - "@sentry-internal/replay-worker": "7.71.0", + "@sentry-internal/replay-worker": "7.72.0", "@sentry-internal/rrweb": "1.108.0", "@sentry-internal/rrweb-snapshot": "1.108.0", "jsdom-worker": "^0.2.1", "tslib": "^2.4.1 || ^1.9.3" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0" + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0" }, "engines": { "node": ">=12" diff --git a/packages/replay/src/coreHandlers/handleGlobalEvent.ts b/packages/replay/src/coreHandlers/handleGlobalEvent.ts index c2e134a86acb..f69e8d975417 100644 --- a/packages/replay/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay/src/coreHandlers/handleGlobalEvent.ts @@ -35,6 +35,12 @@ export function handleGlobalEventListener( return event; } + // Ensure we do not add replay_id if the session is expired + const isSessionActive = replay.checkAndHandleExpiredSession(); + if (!isSessionActive) { + return event; + } + // Unless `captureExceptions` is enabled, we want to ignore errors coming from rrweb // As there can be a bunch of stuff going wrong in internals there, that we don't want to bubble up to users if (isRrwebError(event, hint) && !replay.getOptions()._experiments.captureExceptions) { diff --git a/packages/replay/src/util/shouldFilterRequest.ts b/packages/replay/src/util/shouldFilterRequest.ts index 7d66cf31d780..fcfd75b1b048 100644 --- a/packages/replay/src/util/shouldFilterRequest.ts +++ b/packages/replay/src/util/shouldFilterRequest.ts @@ -1,4 +1,4 @@ -import { getCurrentHub } from '@sentry/core'; +import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; import type { ReplayContainer } from '../types'; @@ -12,14 +12,5 @@ export function shouldFilterRequest(replay: ReplayContainer, url: string): boole return false; } - return _isSentryRequest(url); -} - -/** - * Checks wether a given URL belongs to the configured Sentry DSN. - */ -function _isSentryRequest(url: string): boolean { - const client = getCurrentHub().getClient(); - const dsn = client && client.getDsn(); - return dsn ? url.includes(dsn.host) : false; + return isSentryRequestUrl(url, getCurrentHub()); } diff --git a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts index dbe919b18079..d4357eb4a6ea 100644 --- a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -1,9 +1,10 @@ import type { Event } from '@sentry/types'; import type { Replay as ReplayIntegration } from '../../../src'; -import { REPLAY_EVENT_NAME } from '../../../src/constants'; +import { REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants'; import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent'; import type { ReplayContainer } from '../../../src/replay'; +import { makeSession } from '../../../src/session/Session'; import { Error } from '../../fixtures/error'; import { Transaction } from '../../fixtures/transaction'; import { resetSdkMock } from '../../mocks/resetSdkMock'; @@ -102,6 +103,32 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { ); }); + it('does not add replayId if replay session is expired', async () => { + const transaction = Transaction(); + const error = Error(); + + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'session', + }); + + expect(handleGlobalEventListener(replay)(transaction, {})).toEqual( + expect.objectContaining({ + tags: expect.not.objectContaining({ replayId: expect.anything() }), + }), + ); + expect(handleGlobalEventListener(replay)(error, {})).toEqual( + expect.objectContaining({ + tags: expect.not.objectContaining({ replayId: expect.anything() }), + }), + ); + }); + it('tags errors and transactions with replay id for session samples', async () => { let integration: ReplayIntegration; ({ replay, integration } = await resetSdkMock({})); diff --git a/packages/serverless/package.json b/packages/serverless/package.json index 56f2b16ac9ab..36fccfe5b8c2 100644 --- a/packages/serverless/package.json +++ b/packages/serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/serverless", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for various serverless solutions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/serverless", @@ -23,10 +23,10 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/node": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/node": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "@types/aws-lambda": "^8.10.62", "@types/express": "^4.17.14", "tslib": "^2.4.1 || ^1.9.3" diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 9dd09b4d9ea8..7b09302668ca 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -23,9 +23,9 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/browser": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "magic-string": "^0.30.0", "tslib": "^2.4.1 || ^1.9.3" }, diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 838be212ab5b..628c44109aeb 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -20,13 +20,13 @@ "@sveltejs/kit": "1.x" }, "dependencies": { - "@sentry-internal/tracing": "7.71.0", - "@sentry/core": "7.71.0", - "@sentry/integrations": "7.71.0", - "@sentry/node": "7.71.0", - "@sentry/svelte": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry-internal/tracing": "7.72.0", + "@sentry/core": "7.72.0", + "@sentry/integrations": "7.72.0", + "@sentry/node": "7.72.0", + "@sentry/svelte": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "@sentry/vite-plugin": "^0.6.1", "magicast": "0.2.8", "sorcery": "0.11.0" diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index eb0276b7f95d..23528dcf6870 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -296,7 +296,7 @@ describe('handleSentry', () => { } catch (e) { expect(mockCaptureException).toBeCalledTimes(1); expect(addEventProcessorSpy).toBeCalledTimes(1); - expect(mockAddExceptionMechanism).toBeCalledTimes(1); + expect(mockAddExceptionMechanism).toBeCalledTimes(2); expect(mockAddExceptionMechanism).toBeCalledWith( {}, { handled: false, type: 'sveltekit', data: { function: 'handle' } }, diff --git a/packages/tracing-internal/package.json b/packages/tracing-internal/package.json index 331ad0acf258..bb552b36ddd4 100644 --- a/packages/tracing-internal/package.json +++ b/packages/tracing-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/tracing", - "version": "7.71.0", + "version": "7.72.0", "description": "Sentry Internal Tracing Package", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tracing-internal", @@ -23,9 +23,9 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { diff --git a/packages/tracing/package.json b/packages/tracing/package.json index 23540cd16585..fc4588774588 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tracing", - "version": "7.71.0", + "version": "7.72.0", "description": "Sentry Performance Monitoring Package", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tracing", @@ -23,14 +23,14 @@ "access": "public" }, "dependencies": { - "@sentry-internal/tracing": "7.71.0" + "@sentry-internal/tracing": "7.72.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "7.71.0", - "@sentry/browser": "7.71.0", - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry-internal/integration-shims": "7.72.0", + "@sentry/browser": "7.72.0", + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "@types/express": "^4.17.14" }, "scripts": { diff --git a/packages/types/package.json b/packages/types/package.json index 7bad21fd8598..60f9e6634435 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/types", - "version": "7.71.0", + "version": "7.72.0", "description": "Types for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/types", diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 1b7b78066f0c..8aeabaa6cc8d 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -149,7 +149,7 @@ export interface Client { addIntegration?(integration: Integration): void; /** This is an internal function to setup all integrations that should run on the client */ - setupIntegrations(): void; + setupIntegrations(forceInitialize?: boolean): void; /** Creates an {@link Event} from all inputs to `captureException` and non-primitive inputs to `captureMessage`. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/types/src/feedback.ts b/packages/types/src/feedback.ts new file mode 100644 index 000000000000..d13b59595c8e --- /dev/null +++ b/packages/types/src/feedback.ts @@ -0,0 +1,16 @@ +import type {Event} from './event'; + +/** + * NOTE: These types are still considered Beta and subject to change. + * @hidden + */ +export interface FeedbackEvent extends Event { + feedback: { + contact_email: string; + message: string; + replay_id: string; + url: string; + }; + // TODO: Add this event type to Event + // type: 'feedback_event'; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8a93681aa938..556dde26662c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -69,6 +69,7 @@ export type { Profile, } from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; +export type { FeedbackEvent } from './feedback'; export type { QueryParams, Request, SanitizedRequestData } from './request'; export type { Runtime } from './runtime'; export type { CaptureContext, Scope, ScopeContext } from './scope'; diff --git a/packages/types/src/mechanism.ts b/packages/types/src/mechanism.ts index 0f2adf98ed24..9d3dc86e7382 100644 --- a/packages/types/src/mechanism.ts +++ b/packages/types/src/mechanism.ts @@ -13,7 +13,7 @@ export interface Mechanism { * it hits the global error/rejection handlers, whether through explicit handling by the user or auto instrumentation. * Converted to a tag on ingest and used in various ways in the UI. */ - handled: boolean; + handled?: boolean; /** * Arbitrary data to be associated with the mechanism (for example, errors coming from event handlers include the diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 198ede03242e..d84559b0f852 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/typescript", - "version": "7.71.0", + "version": "7.72.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/utils/package.json b/packages/utils/package.json index c3aeb68e1cb8..27fe6ca6bd80 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/utils", - "version": "7.71.0", + "version": "7.72.0", "description": "Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/utils", @@ -23,7 +23,7 @@ "access": "public" }, "dependencies": { - "@sentry/types": "7.71.0", + "@sentry/types": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index e91aefdbab5b..e249564eca51 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -234,14 +234,14 @@ export function createEventEnvelopeHeaders( event: Event, sdkInfo: SdkInfo | undefined, tunnel: string | undefined, - dsn: DsnComponents, + dsn?: DsnComponents, ): EventEnvelopeHeaders { const dynamicSamplingContext = event.sdkProcessingMetadata && event.sdkProcessingMetadata.dynamicSamplingContext; return { event_id: event.event_id as string, sent_at: new Date().toISOString(), ...(sdkInfo && { sdk: sdkInfo }), - ...(!!tunnel && { dsn: dsnToString(dsn) }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), ...(dynamicSamplingContext && { trace: dropUndefinedKeys({ ...dynamicSamplingContext }), }), diff --git a/packages/utils/src/node-stack-trace.ts b/packages/utils/src/node-stack-trace.ts index 00b02b0fee35..43db209a5fc5 100644 --- a/packages/utils/src/node-stack-trace.ts +++ b/packages/utils/src/node-stack-trace.ts @@ -25,6 +25,29 @@ import type { StackLineParserFn } from '@sentry/types'; export type GetModuleFn = (filename: string | undefined) => string | undefined; +/** + * Does this filename look like it's part of the app code? + */ +export function filenameIsInApp(filename: string, isNative: boolean = false): boolean { + const isInternal = + isNative || + (filename && + // It's not internal if it's an absolute linux path + !filename.startsWith('/') && + // It's not internal if it's an absolute windows path + !filename.includes(':\\') && + // It's not internal if the path is starting with a dot + !filename.startsWith('.') && + // It's not internal if the frame has a protocol. In node, this is usually the case if the file got pre-processed with a bundler like webpack + !filename.match(/^[a-zA-Z]([a-zA-Z0-9.\-+])*:\/\//)); // Schema from: https://stackoverflow.com/a/3641782 + + // in_app is all that's not an internal Node function or a module within node_modules + // note that isNative appears to return true even for node core libraries + // see https://github.com/getsentry/raven-node/issues/176 + + return !isInternal && filename !== undefined && !filename.includes('node_modules/'); +} + /** Node Stack line parser */ // eslint-disable-next-line complexity export function node(getModule?: GetModuleFn): StackLineParserFn { @@ -84,31 +107,13 @@ export function node(getModule?: GetModuleFn): StackLineParserFn { filename = lineMatch[5]; } - const isInternal = - isNative || - (filename && - // It's not internal if it's an absolute linux path - !filename.startsWith('/') && - // It's not internal if it's an absolute windows path - !filename.includes(':\\') && - // It's not internal if the path is starting with a dot - !filename.startsWith('.') && - // It's not internal if the frame has a protocol. In node, this is usually the case if the file got pre-processed with a bundler like webpack - !filename.match(/^[a-zA-Z]([a-zA-Z0-9.\-+])*:\/\//)); // Schema from: https://stackoverflow.com/a/3641782 - - // in_app is all that's not an internal Node function or a module within node_modules - // note that isNative appears to return true even for node core libraries - // see https://github.com/getsentry/raven-node/issues/176 - - const in_app = !isInternal && filename !== undefined && !filename.includes('node_modules/'); - return { filename, module: getModule ? getModule(filename) : undefined, function: functionName, lineno: parseInt(lineMatch[3], 10) || undefined, colno: parseInt(lineMatch[4], 10) || undefined, - in_app, + in_app: filenameIsInApp(filename, isNative), }; } diff --git a/packages/utils/src/stacktrace.ts b/packages/utils/src/stacktrace.ts index ac9f2159221d..917b46daa5d1 100644 --- a/packages/utils/src/stacktrace.ts +++ b/packages/utils/src/stacktrace.ts @@ -1,7 +1,9 @@ import type { StackFrame, StackLineParser, StackParser } from '@sentry/types'; import type { GetModuleFn } from './node-stack-trace'; -import { node } from './node-stack-trace'; +import { filenameIsInApp, node } from './node-stack-trace'; + +export { filenameIsInApp }; const STACKTRACE_FRAME_LIMIT = 50; // Used to sanitize webpack (error: *) wrapped stack errors diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index f47e92caf491..df919dc92156 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "7.71.0", + "version": "7.72.0", "description": "Offical Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -23,9 +23,9 @@ "access": "public" }, "dependencies": { - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { diff --git a/packages/vue/package.json b/packages/vue/package.json index 4f660ff7686a..52eef27d71dc 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "7.71.0", + "version": "7.72.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -23,10 +23,10 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "7.71.0", - "@sentry/core": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/browser": "7.72.0", + "@sentry/core": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "peerDependencies": { diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 05f342218b70..f70635bf5e2c 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "7.71.0", + "version": "7.72.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -23,9 +23,9 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "7.71.0", - "@sentry/types": "7.71.0", - "@sentry/utils": "7.71.0", + "@sentry/browser": "7.72.0", + "@sentry/types": "7.72.0", + "@sentry/utils": "7.72.0", "tslib": "^2.4.1 || ^1.9.3" }, "scripts": { diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index c07f9cd7ca16..a45b18ac4e96 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,4 +1,4 @@ -import type { Event, EventProcessor, Hub, Integration, StackFrame } from '@sentry/types'; +import type { Event, Integration, StackFrame } from '@sentry/types'; import { patchWebAssembly } from './patchWebAssembly'; import { getImage, getImages } from './registry'; @@ -49,26 +49,27 @@ export class Wasm implements Integration { /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void { + public setupOnce(_addGlobaleventProcessor: unknown, _getCurrentHub: unknown): void { patchWebAssembly(); + } - addGlobalEventProcessor((event: Event) => { - let haveWasm = false; + /** @inheritDoc */ + public processEvent(event: Event): Event { + let haveWasm = false; - if (event.exception && event.exception.values) { - event.exception.values.forEach(exception => { - if (exception?.stacktrace?.frames) { - haveWasm = haveWasm || patchFrames(exception.stacktrace.frames); - } - }); - } + if (event.exception && event.exception.values) { + event.exception.values.forEach(exception => { + if (exception?.stacktrace?.frames) { + haveWasm = haveWasm || patchFrames(exception.stacktrace.frames); + } + }); + } - if (haveWasm) { - event.debug_meta = event.debug_meta || {}; - event.debug_meta.images = [...(event.debug_meta.images || []), ...getImages()]; - } + if (haveWasm) { + event.debug_meta = event.debug_meta || {}; + event.debug_meta.images = [...(event.debug_meta.images || []), ...getImages()]; + } - return event; - }); + return event; } } diff --git a/yarn.lock b/yarn.lock index 774b6abcc4dd..25082dd4c5c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3993,7 +3993,7 @@ "@opentelemetry/core" "1.15.2" "@opentelemetry/semantic-conventions" "1.15.2" -"@opentelemetry/resources@1.17.0": +"@opentelemetry/resources@1.17.0", "@opentelemetry/resources@~1.17.0": version "1.17.0" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.17.0.tgz#ee29144cfd7d194c69698c8153dbadec7fe6819f" integrity sha512-+u0ciVnj8lhuL/qGRBPeVYvk7fL+H/vOddfvmOeJaA1KC+5/3UED1c9KoZQlRsNT5Kw1FaK8LkY2NVLYfOVZQw==