From df4f4ab8734192cef2f04e2b672c76274da1dfdb Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 19 Jun 2023 10:49:47 -0400 Subject: [PATCH 01/28] feat(replay): Do not capture replays < 5 seconds (GA) (#8277) --- .../suites/replay/bufferMode/test.ts | 19 +- .../suites/replay/fileInput/test.ts | 7 +- .../largeMutations/defaultOptions/test.ts | 4 - .../largeMutations/mutationLimit/test.ts | 3 - .../suites/replay/slowClick/mutation/test.ts | 94 +- .../suites/replay/throttleBreadcrumbs/test.ts | 13 +- .../tests/fixtures/ReplayRecordingData.ts | 22 - packages/replay/src/replay.ts | 7 + packages/replay/src/types/replay.ts | 2 +- .../replay/src/util/handleRecordingEmit.ts | 33 +- .../coreHandlers/handleAfterSendEvent.test.ts | 6 +- .../errorSampleRate-delayFlush.test.ts | 865 ------------------ .../test/integration/errorSampleRate.test.ts | 217 +++-- 13 files changed, 222 insertions(+), 1070 deletions(-) delete mode 100644 packages/replay/test/integration/errorSampleRate-delayFlush.test.ts diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts index a9a9dbbe86e5..4785c1a5b158 100644 --- a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts @@ -25,7 +25,6 @@ sentryTest( let errorEventId: string | undefined; const reqPromise0 = waitForReplayRequest(page, 0); const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); const reqErrorPromise = waitForErrorRequest(page); await page.route('https://dsn.ingest.sentry.io/**/*', route => { @@ -101,8 +100,7 @@ sentryTest( // Switches to session mode and then goes to background const req1 = await reqPromise1; - const req2 = await reqPromise2; - expect(callsToSentry).toBeGreaterThanOrEqual(5); + expect(callsToSentry).toBeGreaterThanOrEqual(4); const event0 = getReplayEvent(req0); const content0 = getReplayRecordingContent(req0); @@ -110,9 +108,6 @@ sentryTest( const event1 = getReplayEvent(req1); const content1 = getReplayRecordingContent(req1); - const event2 = getReplayEvent(req2); - const content2 = getReplayRecordingContent(req2); - expect(event0).toEqual( getExpectedReplayEvent({ error_ids: [errorEventId!], @@ -157,17 +152,7 @@ sentryTest( // From switching to session mode expect(content1.fullSnapshots).toHaveLength(1); - - expect(event2).toEqual( - getExpectedReplayEvent({ - replay_type: 'buffer', // although we're in session mode, we still send 'buffer' as replay_type - segment_id: 2, - urls: [], - }), - ); - - expect(content2.fullSnapshots).toHaveLength(0); - expect(content2.breadcrumbs).toEqual(expect.arrayContaining([expectedClickBreadcrumb])); + expect(content1.breadcrumbs).toEqual(expect.arrayContaining([expectedClickBreadcrumb])); }, ); diff --git a/packages/browser-integration-tests/suites/replay/fileInput/test.ts b/packages/browser-integration-tests/suites/replay/fileInput/test.ts index 685c626ec470..e0827538ba56 100644 --- a/packages/browser-integration-tests/suites/replay/fileInput/test.ts +++ b/packages/browser-integration-tests/suites/replay/fileInput/test.ts @@ -25,7 +25,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -39,7 +38,7 @@ sentryTest( await page.goto(url); - await reqPromise0; + const res = await reqPromise0; await page.setInputFiles('#file-input', { name: 'file.csv', @@ -49,9 +48,7 @@ sentryTest( await forceFlushReplay(); - const res1 = await reqPromise1; - - const snapshots = getIncrementalRecordingSnapshots(res1).filter(isInputMutation); + const snapshots = getIncrementalRecordingSnapshots(res).filter(isInputMutation); expect(snapshots).toEqual([]); }, 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 29d0f3ada164..c0d8e8234da8 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts @@ -11,7 +11,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise0b = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -24,10 +23,7 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await forceFlushReplay(); const res0 = await reqPromise0; - await reqPromise0b; - // A second request is sent right after initial snapshot, we want to wait for that to settle before we continue const reqPromise1 = waitForReplayRequest(page); 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 84f0113263d7..b826daafe6b4 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts @@ -16,7 +16,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise0b = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -30,8 +29,6 @@ sentryTest( await page.goto(url); const res0 = await reqPromise0; - await reqPromise0b; - // A second request is sent right after initial snapshot, we want to wait for that to settle before we continue const reqPromise1 = waitForReplayRequest(page); 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 deb394ebac2d..bab50e12938c 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; -sentryTest('mutation after threshold results in slow click', async ({ getLocalTestUrl, page }) => { +sentryTest('mutation after threshold results in slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -21,6 +21,7 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); + await forceFlushReplay(); await reqPromise0; const reqPromise1 = waitForReplayRequest(page, (event, res) => { @@ -125,59 +126,63 @@ sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100); }); -sentryTest('immediate mutation does not trigger slow click', async ({ browserName, getLocalTestUrl, page }) => { - // This test seems to only be flakey on firefox - if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { - sentryTest.skip(); - } - - const reqPromise0 = waitForReplayRequest(page, 0); - - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), +sentryTest( + 'immediate mutation does not trigger slow click', + async ({ forceFlushReplay, browserName, getLocalTestUrl, page }) => { + // This test seems to only be flakey on firefox + if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); }); - }); - const url = await getLocalTestUrl({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await page.goto(url); + await forceFlushReplay(); + await reqPromise0; - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); - - await page.click('#mutationButtonImmediately'); - - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); - expect(breadcrumbs).toEqual([ - { - category: 'ui.click', - data: { - node: { - attributes: { - id: 'mutationButtonImmediately', + await page.click('#mutationButtonImmediately'); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + expect(breadcrumbs).toEqual([ + { + category: 'ui.click', + data: { + node: { + attributes: { + id: 'mutationButtonImmediately', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* ******** ***********', }, - id: expect.any(Number), - tagName: 'button', - textContent: '******* ******** ***********', + nodeId: expect.any(Number), }, - nodeId: expect.any(Number), + message: 'body > button#mutationButtonImmediately', + timestamp: expect.any(Number), + type: 'default', }, - message: 'body > button#mutationButtonImmediately', - timestamp: expect.any(Number), - type: 'default', - }, - ]); -}); + ]); + }, +); -sentryTest('inline click handler does not trigger slow click', async ({ getLocalTestUrl, page }) => { +sentryTest('inline click handler does not trigger slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -195,6 +200,7 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); + await forceFlushReplay(); await reqPromise0; const reqPromise1 = waitForReplayRequest(page, (event, res) => { diff --git a/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts b/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts index 17f4210624a0..e025c90a77e0 100644 --- a/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts +++ b/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts @@ -26,16 +26,19 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); - await reqPromise0; + await forceFlushReplay(); + const res0 = getCustomRecordingEvents(await reqPromise0); await page.click('[data-console]'); await forceFlushReplay(); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const res1 = getCustomRecordingEvents(await reqPromise1); - // 1 click breadcrumb + 1 throttled breadcrumb is why console logs are less - // than throttle limit - expect(breadcrumbs.length).toBe(THROTTLE_LIMIT); + const breadcrumbs = [...res0.breadcrumbs, ...res1.breadcrumbs]; + const spans = [...res0.performanceSpans, ...res1.performanceSpans]; expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'replay.throttled').length).toBe(1); + // replay.throttled breadcrumb does *not* use the throttledAddEvent as we + // alwants want that breadcrumb to be present in replay + expect(breadcrumbs.length + spans.length).toBe(THROTTLE_LIMIT + 1); }, ); diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts index a22694a64304..0da2e1b2e327 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts @@ -95,26 +95,6 @@ export const ReplayRecordingData = [ }, timestamp: expect.any(Number), }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'memory', - description: 'memory', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - memory: { - jsHeapSizeLimit: expect.any(Number), - totalJSHeapSize: expect.any(Number), - usedJSHeapSize: expect.any(Number), - }, - }, - }, - }, - }, { type: 3, data: { @@ -155,8 +135,6 @@ export const ReplayRecordingData = [ data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, timestamp: expect.any(Number), }, - ], - [ { type: 5, timestamp: expect.any(Number), diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index acb2980e608c..8fab410a0c34 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -547,6 +547,13 @@ export class ReplayContainer implements ReplayContainerInterface { return this.flushImmediate(); } + /** + * Flush using debounce flush + */ + public flush(): Promise { + return this._debouncedFlush() as Promise; + } + /** * Always flush via `_debouncedFlush` so that we do not have flushes triggered * from calling both `flush` and `_debouncedFlush`. Otherwise, there could be diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts index f058b2c9011a..e2cfddd0f525 100644 --- a/packages/replay/src/types/replay.ts +++ b/packages/replay/src/types/replay.ts @@ -194,7 +194,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { _experiments: Partial<{ captureExceptions: boolean; traceInternals: boolean; - delayFlushOnCheckout: number; }>; } @@ -438,6 +437,7 @@ export interface ReplayContainer { stopRecording(): boolean; sendBufferedReplayOrFlush(options?: SendBufferedReplayOptions): Promise; conditionalFlush(): Promise; + flush(): Promise; flushImmediate(): Promise; cancelFlush(): void; triggerUserActivity(): void; diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index cc7c87afed48..987f589412ed 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -80,41 +80,12 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa } } - const options = replay.getOptions(); - - // TODO: We want this as an experiment so that we can test - // internally and create metrics before making this the default - if (options._experiments.delayFlushOnCheckout) { + if (replay.recordingMode === 'session') { // If the full snapshot is due to an initial load, we will not have // a previous session ID. In this case, we want to buffer events // for a set amount of time before flushing. This can help avoid // capturing replays of users that immediately close the window. - // TODO: We should check `recordingMode` here and do nothing if it's - // buffer, instead of checking inside of timeout, this will make our - // tests a bit cleaner as we will need to wait on the delay in order to - // do nothing. - setTimeout(() => replay.conditionalFlush(), options._experiments.delayFlushOnCheckout); - - // Cancel any previously debounced flushes to ensure there are no [near] - // simultaneous flushes happening. The latter request should be - // insignificant in this case, so wait for additional user interaction to - // trigger a new flush. - // - // This can happen because there's no guarantee that a recording event - // happens first. e.g. a mouse click can happen and trigger a debounced - // flush before the checkout. - replay.cancelFlush(); - - return true; - } - - // Flush immediately so that we do not miss the first segment, otherwise - // it can prevent loading on the UI. This will cause an increase in short - // replays (e.g. opening and closing a tab quickly), but these can be - // filtered on the UI. - if (replay.recordingMode === 'session') { - // We want to ensure the worker is ready, as otherwise we'd always send the first event uncompressed - void replay.flushImmediate(); + void replay.flush(); } return true; diff --git a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts index 1e59a4f7eef0..cd367a2d04f5 100644 --- a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -148,9 +148,13 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { jest.runAllTimers(); await new Promise(process.nextTick); - // Send twice, one for the error & one right after for the session conversion + expect(mockSend).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + await new Promise(process.nextTick); expect(mockSend).toHaveBeenCalledTimes(2); + // This is removed now, because it has been converted to a "session" session expect(Array.from(replay.getContext().errorIds)).toEqual([]); expect(replay.isEnabled()).toBe(true); diff --git a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts b/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts deleted file mode 100644 index f691d8e953c1..000000000000 --- a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts +++ /dev/null @@ -1,865 +0,0 @@ -import { captureException, getCurrentHub } from '@sentry/core'; - -import { - BUFFER_CHECKOUT_TIME, - DEFAULT_FLUSH_MIN_DELAY, - MAX_SESSION_LIFE, - REPLAY_SESSION_KEY, - SESSION_IDLE_EXPIRE_DURATION, - WINDOW, -} from '../../src/constants'; -import type { ReplayContainer } from '../../src/replay'; -import { clearSession } from '../../src/session/clearSession'; -import { addEvent } from '../../src/util/addEvent'; -import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; -import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'; -import type { RecordMock } from '../index'; -import { BASE_TIMESTAMP } from '../index'; -import { resetSdkMock } from '../mocks/resetSdkMock'; -import type { DomHandler } from '../types'; -import { useFakeTimers } from '../utils/use-fake-timers'; - -useFakeTimers(); - -async function advanceTimers(time: number) { - jest.advanceTimersByTime(time); - await new Promise(process.nextTick); -} - -async function waitForBufferFlush() { - await new Promise(process.nextTick); - await new Promise(process.nextTick); -} - -async function waitForFlush() { - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); -} - -describe('Integration | errorSampleRate with delayed flush', () => { - let replay: ReplayContainer; - let mockRecord: RecordMock; - let domHandler: DomHandler; - - beforeEach(async () => { - ({ mockRecord, domHandler, replay } = await resetSdkMock({ - replayOptions: { - stickySession: true, - _experiments: { - delayFlushOnCheckout: DEFAULT_FLUSH_MIN_DELAY, - }, - }, - sentryOptions: { - replaysSessionSampleRate: 0.0, - replaysOnErrorSampleRate: 1.0, - }, - })); - }); - - afterEach(async () => { - clearSession(replay); - replay.stop(); - }); - - it('uploads a replay when `Sentry.captureException` is called and continues recording', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - // Does not capture on mouse click - domHandler({ - name: 'click', - }); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - await waitForFlush(); - - // This is from when we stop recording and start a session recording - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), - }); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - - // Check that click will get captured - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingData: JSON.stringify([ - { - type: 5, - timestamp: BASE_TIMESTAMP + 10000 + 80, - data: { - tag: 'breadcrumb', - payload: { - timestamp: (BASE_TIMESTAMP + 10000 + 80) / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - }); - - it('manually flushes replay and does not continue to record', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - // Does not capture on mouse click - domHandler({ - name: 'click', - }); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - replay.sendBufferedReplayOrFlush({ continueRecording: false }); - - await waitForBufferFlush(); - - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - // Check that click will not get captured - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - // This is still the last replay sent since we passed `continueRecording: - // false`. - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - }); - - // This tests a regression where we were calling flush indiscriminantly in `stop()` - it('does not upload a replay event if error is not sampled', async () => { - // We are trying to replicate the case where error rate is 0 and session - // rate is > 0, we can't set them both to 0 otherwise - // `_loadAndCheckSession` is not called when initializing the plugin. - replay.stop(); - replay['_options']['errorSampleRate'] = 0; - replay['_loadAndCheckSession'](); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_EXPIRE_DURATION]ms', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not send a replay if user hides the tab and comes back within 60 seconds', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - // User comes back before `SESSION_IDLE_EXPIRE_DURATION` elapses - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 100); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event when document becomes hidden', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - jest.advanceTimersByTime(ELAPSED); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - addEvent(replay, TEST_EVENT); - - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event if 5 seconds have elapsed since the last replay event occurred', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event if 15 seconds have elapsed since the last replay upload', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - // Fire a new event every 4 seconds, 4 times - [...Array(4)].forEach(() => { - mockRecord._emitter(TEST_EVENT); - jest.advanceTimersByTime(4000); - }); - - // We are at time = +16seconds now (relative to BASE_TIMESTAMP) - // The next event should cause an upload immediately - mockRecord._emitter(TEST_EVENT); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - // There should also not be another attempt at an upload 5 seconds after the last replay event - await waitForFlush(); - expect(replay).not.toHaveLastSentReplay(); - - // Let's make sure it continues to work - mockRecord._emitter(TEST_EVENT); - await waitForFlush(); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - }); - - // When the error session records as a normal session, we want to stop - // recording after the session ends. Otherwise, we get into a state where the - // new session is a session type replay (this could conflict with the session - // sample rate of 0.0), or an error session that has no errors. Instead we - // simply stop the session replay completely and wait for a new page load to - // resample. - it.each([ - ['MAX_SESSION_LIFE', MAX_SESSION_LIFE], - ['SESSION_IDLE_DURATION', SESSION_IDLE_EXPIRE_DURATION], - ])( - 'stops replay if session had an error and exceeds %s and does not start a new session thereafter', - async (_label, waitTime) => { - expect(replay.session?.shouldRefresh).toBe(true); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - await waitForFlush(); - - // segment_id is 1 because it sends twice on error - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - expect(replay.session?.shouldRefresh).toBe(false); - - // Idle for given time - jest.advanceTimersByTime(waitTime + 1); - await new Promise(process.nextTick); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // We stop recording after 15 minutes of inactivity in error mode - - // still no new replay sent - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(false); - - domHandler({ - name: 'click', - }); - - // Remains disabled! - expect(replay.isEnabled()).toBe(false); - }, - ); - - it.each([ - ['MAX_SESSION_LIFE', MAX_SESSION_LIFE], - ['SESSION_IDLE_EXPIRE_DURATION', SESSION_IDLE_EXPIRE_DURATION], - ])('continues buffering replay if session had no error and exceeds %s', async (_label, waitTime) => { - expect(replay).not.toHaveLastSentReplay(); - - // Idle for given time - jest.advanceTimersByTime(waitTime + 1); - await new Promise(process.nextTick); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // still no new replay sent - expect(replay).not.toHaveLastSentReplay(); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - // should still react to errors later on - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('session'); - expect(replay.session?.sampled).toBe('buffer'); - expect(replay.session?.shouldRefresh).toBe(false); - }); - - // Should behave the same as above test - it('stops replay if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and does not start a new session thereafter', async () => { - // Idle for 15 minutes - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // We stop recording after SESSION_IDLE_EXPIRE_DURATION of inactivity in error mode - expect(replay).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - // should still react to errors later on - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('session'); - expect(replay.session?.sampled).toBe('buffer'); - expect(replay.session?.shouldRefresh).toBe(false); - }); - - it('has the correct timestamps with deferred root event and last replay update', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - - expect(replay).toHaveSentReplay({ - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - ]), - replayEventPayload: expect.objectContaining({ - replay_start_timestamp: BASE_TIMESTAMP / 1000, - // the exception happens roughly 10 seconds after BASE_TIMESTAMP - // (advance timers + waiting for flush after the checkout) and - // extra time is likely due to async of `addMemoryEntry()` - - timestamp: (BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + DEFAULT_FLUSH_MIN_DELAY + 40) / 1000, - error_ids: [expect.any(String)], - trace_ids: [], - urls: ['http://localhost/'], - replay_id: expect.any(String), - }), - recordingPayloadHeader: { segment_id: 0 }, - }); - }); - - it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { - const ELAPSED = BUFFER_CHECKOUT_TIME; - const TICK = 20; - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - // add a mock performance event - replay.performanceEvents.push(PerformanceEntryResource()); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.advanceTimersByTime(ELAPSED); - - // in production, this happens at a time interval - // session started time should be updated to this current timestamp - mockRecord.takeFullSnapshot(true); - const optionsEvent = createOptionsEvent(replay); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - // See comments in `handleRecordingEmit.ts`, we perform a setTimeout into a - // noop when it can be skipped altogether - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY + TICK + TICK); - - // Does not capture mouse click - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - // Make sure the old performance event is thrown out - replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000, - }), - recordingData: JSON.stringify([ - { - data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + ELAPSED + TICK, - type: 2, - }, - optionsEvent, - ]), - }); - }); - - it('stops replay when user goes idle', async () => { - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay(); - - // Flush from calling `stopRecording` - await waitForFlush(); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - expect(replay).not.toHaveLastSentReplay(); - - // Go idle - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - - expect(replay).not.toHaveLastSentReplay(); - - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - }); - - it('stops replay when session exceeds max length after latest captured error', async () => { - const sessionId = replay.session?.id; - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - jest.advanceTimersByTime(2 * MAX_SESSION_LIFE); - - captureException(new Error('testing')); - - // Flush due to exception - await new Promise(process.nextTick); - await waitForFlush(); - - expect(replay.session?.id).toBe(sessionId); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - }); - - // This comes from `startRecording()` in `sendBufferedReplayOrFlush()` - await waitForFlush(); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([ - { - data: { - isCheckout: true, - }, - timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40, - type: 2, - }, - ]), - }); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - jest.advanceTimersByTime(MAX_SESSION_LIFE); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - - // Once the session is stopped after capturing a replay already - // (buffer-mode), another error will not trigger a new replay - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not stop replay based on earliest event in buffer', async () => { - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay(); - - // Flush from calling `stopRecording` - await waitForFlush(); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - expect(replay).not.toHaveLastSentReplay(); - - const TICKS = 80; - - // We advance time so that we are on the border of expiring, taking into - // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The - // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has - // happened, and for the next two that will happen. The first following - // `waitForFlush` does not expire session, but the following one will. - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(true); - - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(true); - - // It's hard to test, but if we advance the below time less 1 ms, it should - // be enabled, but we can't trigger a session check via flush without - // incurring another DEFAULT_FLUSH_MIN_DELAY timeout. - jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY); - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - }); -}); - -/** - * This is testing a case that should only happen with error-only sessions. - * Previously we had assumed that loading a session from session storage meant - * that the session was not new. However, this is not the case with error-only - * sampling since we can load a saved session that did not have an error (and - * thus no replay was created). - */ -it('sends a replay after loading the session from storage', async () => { - // Pretend that a session is already saved before loading replay - WINDOW.sessionStorage.setItem( - REPLAY_SESSION_KEY, - `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"buffer","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`, - ); - const { mockRecord, replay, integration } = await resetSdkMock({ - replayOptions: { - stickySession: true, - _experiments: { - delayFlushOnCheckout: DEFAULT_FLUSH_MIN_DELAY, - }, - }, - sentryOptions: { - replaysOnErrorSampleRate: 1.0, - }, - autoStart: false, - }); - integration['_initialize'](); - const optionsEvent = createOptionsEvent(replay); - - jest.runAllTimers(); - - await new Promise(process.nextTick); - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - // 2 ticks to send replay from an error - await waitForBufferFlush(); - - // Buffered events before error - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - ]), - }); - - // `startRecording()` after switching to session mode to continue recording - await waitForFlush(); - - // Latest checkout when we call `startRecording` again after uploading segment - // after an error occurs (e.g. when we switch to session replay recording) - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 }, - ]), - }); -}); diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index ea1825dd8429..fe3049f9704f 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -26,6 +26,15 @@ async function advanceTimers(time: number) { await new Promise(process.nextTick); } +async function waitForBufferFlush() { + await new Promise(process.nextTick); + await new Promise(process.nextTick); +} + +async function waitForFlush() { + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); +} + describe('Integration | errorSampleRate', () => { let replay: ReplayContainer; let mockRecord: RecordMock; @@ -66,11 +75,9 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', @@ -96,48 +103,35 @@ describe('Integration | errorSampleRate', () => { ]), }); + await waitForFlush(); + // This is from when we stop recording and start a session recording expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 1 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 }, - ]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), }); jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - // New checkout when we call `startRecording` again after uploading segment - // after an error occurs - expect(replay).toHaveLastSentReplay({ - recordingData: JSON.stringify([ - { - data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, - type: 2, - }, - ]), - }); - // Check that click will get captured domHandler({ name: 'click', }); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([ { type: 5, - timestamp: BASE_TIMESTAMP + 10000 + 60, + timestamp: BASE_TIMESTAMP + 10000 + 80, data: { tag: 'breadcrumb', payload: { - timestamp: (BASE_TIMESTAMP + 10000 + 60) / 1000, + timestamp: (BASE_TIMESTAMP + 10000 + 80) / 1000, type: 'default', category: 'ui.click', message: '', @@ -167,9 +161,7 @@ describe('Integration | errorSampleRate', () => { replay.sendBufferedReplayOrFlush({ continueRecording: false }); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, @@ -202,8 +194,8 @@ describe('Integration | errorSampleRate', () => { domHandler({ name: 'click', }); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + + await waitForFlush(); // This is still the last replay sent since we passed `continueRecording: // false`. @@ -353,12 +345,12 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); // There should also not be another attempt at an upload 5 seconds after the last replay event - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); // Let's make sure it continues to work mockRecord._emitter(TEST_EVENT); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await waitForFlush(); jest.runAllTimers(); await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); @@ -380,9 +372,16 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); + + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'buffer', + }), + }); + + await waitForFlush(); // segment_id is 1 because it sends twice on error expect(replay).toHaveLastSentReplay({ @@ -462,9 +461,7 @@ describe('Integration | errorSampleRate', () => { name: 'click', }); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); expect(replay.isEnabled()).toBe(true); @@ -474,23 +471,12 @@ describe('Integration | errorSampleRate', () => { // should still react to errors later on captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay.session?.id).toBe(oldSessionId); - // Flush of buffered events - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - // Checkout from `startRecording` expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, + recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), @@ -546,7 +532,7 @@ describe('Integration | errorSampleRate', () => { // `startRecording` full checkout expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, + recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), @@ -602,6 +588,7 @@ describe('Integration | errorSampleRate', () => { it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { const ELAPSED = BUFFER_CHECKOUT_TIME; + const TICK = 20; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); @@ -624,25 +611,25 @@ describe('Integration | errorSampleRate', () => { jest.runAllTimers(); await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.runAllTimers(); - await new Promise(process.nextTick); + await waitForBufferFlush(); - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 40); + expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + TICK + TICK); // Does not capture mouse click expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ // Make sure the old performance event is thrown out - replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000, + replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + ELAPSED + 20, + timestamp: BASE_TIMESTAMP + ELAPSED + TICK, type: 2, }, optionsEvent, @@ -664,12 +651,13 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay).toHaveLastSentReplay(); + // Flush from calling `stopRecording` + await waitForFlush(); + // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); @@ -684,8 +672,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); @@ -711,12 +698,29 @@ describe('Integration | errorSampleRate', () => { // Flush due to exception await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); + expect(replay.session?.id).toBe(sessionId); - expect(replay).toHaveLastSentReplay(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + }); + + // This comes from `startRecording()` in `sendBufferedReplayOrFlush()` + await waitForFlush(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + recordingData: JSON.stringify([ + { + data: { + isCheckout: true, + }, + timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40, + type: 2, + }, + ]), + }); - // Now wait after session expires - should re-start into buffering mode + // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); @@ -732,7 +736,7 @@ describe('Integration | errorSampleRate', () => { expect(replay.isEnabled()).toBe(false); // Once the session is stopped after capturing a replay already - // (buffer-mode), another error should trigger a new replay + // (buffer-mode), another error will not trigger a new replay captureException(new Error('testing')); await new Promise(process.nextTick); @@ -740,6 +744,73 @@ describe('Integration | errorSampleRate', () => { await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); }); + + it('does not stop replay based on earliest event in buffer', async () => { + jest.setSystemTime(BASE_TIMESTAMP); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 }; + mockRecord._emitter(TEST_EVENT); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).not.toHaveLastSentReplay(); + + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); + + await waitForBufferFlush(); + + expect(replay).toHaveLastSentReplay(); + + // Flush from calling `stopRecording` + await waitForFlush(); + + // Now wait after session expires - should stop recording + mockRecord.takeFullSnapshot.mockClear(); + (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); + + expect(replay).not.toHaveLastSentReplay(); + + const TICKS = 80; + + // We advance time so that we are on the border of expiring, taking into + // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The + // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has + // happened, and for the next two that will happen. The first following + // `waitForFlush` does not expire session, but the following one will. + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS); + await new Promise(process.nextTick); + + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + // It's hard to test, but if we advance the below time less 1 ms, it should + // be enabled, but we can't trigger a session check via flush without + // incurring another DEFAULT_FLUSH_MIN_DELAY timeout. + jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY); + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(false); + }); }); /** @@ -749,7 +820,7 @@ describe('Integration | errorSampleRate', () => { * sampling since we can load a saved session that did not have an error (and * thus no replay was created). */ -it('sends a replay after loading the session multiple times', async () => { +it('sends a replay after loading the session from storage', async () => { // Pretend that a session is already saved before loading replay WINDOW.sessionStorage.setItem( REPLAY_SESSION_KEY, @@ -765,7 +836,6 @@ it('sends a replay after loading the session multiple times', async () => { autoStart: false, }); integration['_initialize'](); - const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); @@ -778,10 +848,10 @@ it('sends a replay after loading the session multiple times', async () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + // 2 ticks to send replay from an error + await waitForBufferFlush(); + // Buffered events before error expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ @@ -791,10 +861,13 @@ it('sends a replay after loading the session multiple times', async () => { ]), }); + // `startRecording()` after switching to session mode to continue recording + await waitForFlush(); + // Latest checkout when we call `startRecording` again after uploading segment // after an error occurs (e.g. when we switch to session replay recording) expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5040, type: 2 }]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), }); }); From 50ead2493dcc64f1a54fd54a6b3a6909e3005c43 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 19 Jun 2023 16:54:36 +0200 Subject: [PATCH 02/28] test(e2e): Add test for Next.js middleware (#8350) --- .../nextjs-app-dir/middleware.ts | 15 +++++ .../nextjs-app-dir/pages/api/edge-endpoint.ts | 18 +++++- .../pages/api/endpoint-behind-middleware.ts | 9 +++ .../pages/api/error-edge-endpoint.ts | 5 ++ .../nextjs-app-dir/tests/edge-route.test.ts | 56 +++++++++++++++++++ .../nextjs-app-dir/tests/middleware.test.ts | 53 ++++++++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts create mode 100644 packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts create mode 100644 packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts create mode 100644 packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts create mode 100644 packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts new file mode 100644 index 000000000000..bb9db27b50d7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware'], +}; diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts index d8af89f2e9d5..b2b2dfdf4fc3 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts @@ -1,3 +1,17 @@ -export const config = { runtime: 'edge' }; +export const config = { + runtime: 'edge', +}; -export default () => new Response('Hello world!'); +export default async function handler() { + return new Response( + JSON.stringify({ + name: 'Jim Halpert', + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }, + ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts new file mode 100644 index 000000000000..2ca75a33ba7e --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts @@ -0,0 +1,9 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +type Data = { + name: string; +}; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ name: 'John Doe' }); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts new file mode 100644 index 000000000000..043112494c23 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts @@ -0,0 +1,5 @@ +export const config = { runtime: 'edge' }; + +export default () => { + throw new Error('Edge Route Error'); +}; diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts new file mode 100644 index 000000000000..8f73764a919f --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { waitForTransaction, waitForError } from '../../../test-utils/event-proxy-server'; + +test('Should create a transaction for edge routes', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/edge-endpoint' && transactionEvent?.contexts?.trace?.status === 'ok' + ); + }); + + const response = await request.get('/api/edge-endpoint'); + expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' }); + + const edgerouteTransaction = await edgerouteTransactionPromise; + + expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok'); + expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Should create a transaction with error status for faulty edge routes', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && + transactionEvent?.contexts?.trace?.status === 'internal_error' + ); + }); + + request.get('/api/error-edge-endpoint').catch(() => { + // Noop + }); + + const edgerouteTransaction = await edgerouteTransactionPromise; + + expect(edgerouteTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server'); + expect(edgerouteTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Should record exceptions for faulty edge routes', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error'; + }); + + request.get('/api/error-edge-endpoint').catch(() => { + // Noop + }); + + expect(await errorEventPromise).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts new file mode 100644 index 000000000000..268a55f1f481 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import { waitForTransaction, waitForError } from '../../../test-utils/event-proxy-server'; + +test('Should create a transaction for middleware', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'ok'; + }); + + const response = await request.get('/api/endpoint-behind-middleware'); + expect(await response.json()).toStrictEqual({ name: 'John Doe' }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Should create a transaction with error status for faulty middleware', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'middleware' && transactionEvent?.contexts?.trace?.status === 'internal_error' + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('middleware.nextjs'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('edge'); +}); + +test('Records exceptions happening in middleware', async ({ request }) => { + test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode."); + + const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + expect(await errorEventPromise).toBeDefined(); +}); From 9d8464b0a6b8d72782d7b74a1a4ad7355abf2d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kry=C5=A1tof=20Wold=C5=99ich?= <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 20 Jun 2023 11:19:18 +0200 Subject: [PATCH 03/28] chore(profiling): Move profiling types to types and cache to utils (#8270) --- packages/browser/src/profiling/cache.ts | 72 +----------------- .../browser/src/profiling/jsSelfProfiling.ts | 60 --------------- packages/browser/src/profiling/utils.ts | 10 +-- packages/hub/package.json | 3 +- packages/types/src/index.ts | 10 +++ packages/types/src/profiling.ts | 76 +++++++++++++++++++ packages/utils/src/cache.ts | 68 +++++++++++++++++ packages/utils/src/index.ts | 1 + 8 files changed, 162 insertions(+), 138 deletions(-) create mode 100644 packages/types/src/profiling.ts create mode 100644 packages/utils/src/cache.ts diff --git a/packages/browser/src/profiling/cache.ts b/packages/browser/src/profiling/cache.ts index ee62538e60cb..34b5da5d12fa 100644 --- a/packages/browser/src/profiling/cache.ts +++ b/packages/browser/src/profiling/cache.ts @@ -1,72 +1,4 @@ import type { Event } from '@sentry/types'; +import { makeFifoCache } from '@sentry/utils'; -/** - * Creates a cache that evicts keys in fifo order - * @param size {Number} - */ -export function makeProfilingCache( - size: number, -): { - get: (key: Key) => Value | undefined; - add: (key: Key, value: Value) => void; - delete: (key: Key) => boolean; - clear: () => void; - size: () => number; -} { - // Maintain a fifo queue of keys, we cannot rely on Object.keys as the browser may not support it. - let evictionOrder: Key[] = []; - let cache: Record = {}; - - return { - add(key: Key, value: Value) { - while (evictionOrder.length >= size) { - // shift is O(n) but this is small size and only happens if we are - // exceeding the cache size so it should be fine. - const evictCandidate = evictionOrder.shift(); - - if (evictCandidate !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete cache[evictCandidate]; - } - } - - // in case we have a collision, delete the old key. - if (cache[key]) { - this.delete(key); - } - - evictionOrder.push(key); - cache[key] = value; - }, - clear() { - cache = {}; - evictionOrder = []; - }, - get(key: Key): Value | undefined { - return cache[key]; - }, - size() { - return evictionOrder.length; - }, - // Delete cache key and return true if it existed, false otherwise. - delete(key: Key): boolean { - if (!cache[key]) { - return false; - } - - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete cache[key]; - - for (let i = 0; i < evictionOrder.length; i++) { - if (evictionOrder[i] === key) { - evictionOrder.splice(i, 1); - break; - } - } - - return true; - }, - }; -} - -export const PROFILING_EVENT_CACHE = makeProfilingCache(20); +export const PROFILING_EVENT_CACHE = makeFifoCache(20); diff --git a/packages/browser/src/profiling/jsSelfProfiling.ts b/packages/browser/src/profiling/jsSelfProfiling.ts index af9ebada8cbf..efa4a0a0a0bc 100644 --- a/packages/browser/src/profiling/jsSelfProfiling.ts +++ b/packages/browser/src/profiling/jsSelfProfiling.ts @@ -53,63 +53,3 @@ declare global { export interface RawThreadCpuProfile extends JSSelfProfile { profile_id: string; } -export interface ThreadCpuProfile { - samples: { - stack_id: number; - thread_id: string; - elapsed_since_start_ns: string; - }[]; - stacks: number[][]; - frames: { - function: string; - file: string | undefined; - line: number | undefined; - column: number | undefined; - }[]; - thread_metadata: Record; - queue_metadata?: Record; -} - -export interface SentryProfile { - event_id: string; - version: string; - os: { - name: string; - version: string; - build_number: string; - }; - runtime: { - name: string; - version: string; - }; - device: { - architecture: string; - is_emulator: boolean; - locale: string; - manufacturer: string; - model: string; - }; - timestamp: string; - release: string; - environment: string; - platform: string; - profile: ThreadCpuProfile; - debug_meta?: { - images: { - debug_id: string; - image_addr: string; - code_file: string; - type: string; - image_size: number; - image_vmaddr: string; - }[]; - }; - transactions: { - name: string; - trace_id: string; - id: string; - active_thread_id: string; - relative_start_ns: string; - relative_end_ns: string; - }[]; -} diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 9fc068f7c0ee..7b2e1b60e848 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -6,19 +6,15 @@ import type { EventEnvelope, EventEnvelopeHeaders, EventItem, + Profile as SentryProfile, SdkInfo, SdkMetadata, + ThreadCpuProfile, } from '@sentry/types'; import { createEnvelope, dropUndefinedKeys, dsnToString, logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers'; -import type { - JSSelfProfile, - JSSelfProfileStack, - RawThreadCpuProfile, - SentryProfile, - ThreadCpuProfile, -} from './jsSelfProfiling'; +import type { JSSelfProfile, JSSelfProfileStack, RawThreadCpuProfile } from './jsSelfProfiling'; const MS_TO_NS = 1e6; // Use 0 as main thread id which is identical to threadId in node:worker_threads diff --git a/packages/hub/package.json b/packages/hub/package.json index ca34143369db..bc795aa6bee5 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -40,7 +40,8 @@ "lint:eslint": "eslint . --format stylish", "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "yalc:publish": "ts-node ../../scripts/prepack.ts && yalc publish build --push" }, "volta": { "extends": "../../package.json" diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 1da1778d2012..ccabae59a995 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,6 +49,16 @@ export type { ExtractedNodeRequestData, HttpHeaderValue, Primitive, WorkerLocati export type { ClientOptions, Options } from './options'; export type { Package } from './package'; export type { PolymorphicEvent, PolymorphicRequest } from './polymorphics'; +export type { + ThreadId, + FrameId, + StackId, + ThreadCpuSample, + ThreadCpuStack, + ThreadCpuFrame, + ThreadCpuProfile, + Profile, +} from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; export type { QueryParams, Request, SanitizedRequestData } from './request'; export type { Runtime } from './runtime'; diff --git a/packages/types/src/profiling.ts b/packages/types/src/profiling.ts new file mode 100644 index 000000000000..1b6dac566901 --- /dev/null +++ b/packages/types/src/profiling.ts @@ -0,0 +1,76 @@ +export type ThreadId = string; +export type FrameId = number; +export type StackId = number; + +export interface ThreadCpuSample { + stack_id: StackId; + thread_id: ThreadId; + elapsed_since_start_ns: string; +} + +export type ThreadCpuStack = FrameId[]; + +export type ThreadCpuFrame = { + function: string; + file?: string; + line?: number; + column?: number; +}; + +export interface ThreadCpuProfile { + samples: ThreadCpuSample[]; + stacks: ThreadCpuStack[]; + frames: ThreadCpuFrame[]; + thread_metadata: Record; + queue_metadata?: Record; +} + +export interface Profile { + event_id: string; + version: string; + os: { + name: string; + version: string; + build_number?: string; + }; + runtime: { + name: string; + version: string; + }; + device: { + architecture: string; + is_emulator: boolean; + locale: string; + manufacturer: string; + model: string; + }; + timestamp: string; + release: string; + environment: string; + platform: string; + profile: ThreadCpuProfile; + debug_meta?: { + images: { + debug_id: string; + image_addr: string; + code_file: string; + type: string; + image_size: number; + image_vmaddr: string; + }[]; + }; + transaction?: { + name: string; + id: string; + trace_id: string; + active_thread_id: string; + }; + transactions?: { + name: string; + id: string; + trace_id: string; + active_thread_id: string; + relative_start_ns: string; + relative_end_ns: string; + }[]; +} diff --git a/packages/utils/src/cache.ts b/packages/utils/src/cache.ts new file mode 100644 index 000000000000..412970e77c76 --- /dev/null +++ b/packages/utils/src/cache.ts @@ -0,0 +1,68 @@ +/** + * Creates a cache that evicts keys in fifo order + * @param size {Number} + */ +export function makeFifoCache( + size: number, +): { + get: (key: Key) => Value | undefined; + add: (key: Key, value: Value) => void; + delete: (key: Key) => boolean; + clear: () => void; + size: () => number; +} { + // Maintain a fifo queue of keys, we cannot rely on Object.keys as the browser may not support it. + let evictionOrder: Key[] = []; + let cache: Record = {}; + + return { + add(key: Key, value: Value) { + while (evictionOrder.length >= size) { + // shift is O(n) but this is small size and only happens if we are + // exceeding the cache size so it should be fine. + const evictCandidate = evictionOrder.shift(); + + if (evictCandidate !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete cache[evictCandidate]; + } + } + + // in case we have a collision, delete the old key. + if (cache[key]) { + this.delete(key); + } + + evictionOrder.push(key); + cache[key] = value; + }, + clear() { + cache = {}; + evictionOrder = []; + }, + get(key: Key): Value | undefined { + return cache[key]; + }, + size() { + return evictionOrder.length; + }, + // Delete cache key and return true if it existed, false otherwise. + delete(key: Key): boolean { + if (!cache[key]) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete cache[key]; + + for (let i = 0; i < evictionOrder.length; i++) { + if (evictionOrder[i] === key) { + evictionOrder.splice(i, 1); + break; + } + } + + return true; + }, + }; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c5631559a9aa..6b9426c22149 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -28,3 +28,4 @@ export * from './ratelimit'; export * from './baggage'; export * from './url'; export * from './userIntegrations'; +export * from './cache'; From c8686ffaee0844d86d6684df742dd2bf45792aae Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 20 Jun 2023 14:22:36 +0200 Subject: [PATCH 04/28] fix(core): Only start spans in `trace` if tracing is enabled (#8357) In our (still internal) `trace` function, we don't yet check if `hasTracingEnabled` returns `true` before trying to start a span or a transaction. This leads to the following problematic UX path that was discovered [here](https://github.com/getsentry/sentry-javascript/discussions/5838#discussioncomment-6217855): 1. Users don't set `tracesSampleRate` (or `tracesSampler`), causing some SDKs (NextJS, SvelteKit) to **not** add the `BrowserTracing` integration, which in turn means that tracing extension methods are not added/registered. 2. Users or, more likely, other parts of our SDK (e.g. SvelteKit's wrappers/handlers) call `trace` which will still try to start a span/txn. 3. Users will get a console warning that tracing extension methods were not installed and they should manually call `addExtensionMethods` which is completely misleading in this case. This fix makes `trace` check for `hasTracingEnabled()` in which case it will not start a span but just invoke the error callback if an error occurred. --- packages/core/src/tracing/trace.ts | 16 ++++++++--- packages/core/test/lib/tracing/trace.test.ts | 28 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 8e7844d23988..2864377bfc04 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -2,13 +2,15 @@ import type { TransactionContext } from '@sentry/types'; import { isThenable } from '@sentry/utils'; import { getCurrentHub } from '../hub'; +import { hasTracingEnabled } from '../utils/hasTracingEnabled'; import type { Span } from './span'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. * - * Note that if you have not enabled tracing extensions via `addTracingExtensions`, this function - * will not generate spans, and the `span` returned from the callback may be undefined. + * Note that if you have not enabled tracing extensions via `addTracingExtensions` + * or you didn't set `tracesSampleRate`, this function will not generate spans + * and the `span` returned from the callback will be undefined. * * This function is meant to be used internally and may break at any time. Use at your own risk. * @@ -31,7 +33,15 @@ export function trace( const scope = hub.getScope(); const parentSpan = scope.getSpan(); - const activeSpan = parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); + + function getActiveSpan(): Span | undefined { + if (!hasTracingEnabled()) { + return undefined; + } + return parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); + } + + const activeSpan = getActiveSpan(); scope.setSpan(activeSpan); function finishAndSetSpan(): void { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 064c41dc123a..bff1c425c2a0 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -185,5 +185,33 @@ describe('trace', () => { } expect(onError).toHaveBeenCalledTimes(isError ? 1 : 0); }); + + it("doesn't create spans but calls onError if tracing is disabled", async () => { + const options = getDefaultTestClientOptions({ + /* we don't set tracesSampleRate or tracesSampler */ + }); + client = new TestClient(options); + hub = new Hub(client); + makeMain(hub); + + const startTxnSpy = jest.spyOn(hub, 'startTransaction'); + + const onError = jest.fn(); + try { + await trace( + { name: 'GET users/[id]' }, + () => { + return callback(); + }, + onError, + ); + } catch (e) { + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(e); + } + expect(onError).toHaveBeenCalledTimes(isError ? 1 : 0); + + expect(startTxnSpy).not.toHaveBeenCalled(); + }); }); }); From 365c75085e85af615958d28c0202c587fce129bb Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 20 Jun 2023 16:26:55 +0200 Subject: [PATCH 05/28] fix(angular): Stop routing spans on navigation cancel and error events (#8369) Previously, in our Angular routing instrumentation, we only stopped routing spans when a navigation ended successfully. This was because we only listened to [`NavigationEnd`](https://angular.io/api/router/NavigationEnd) router events. However, the Angular router emits other events in unsuccessful routing attempts: * a routing error (e.g. unknown route) emits [`NavigationError`](https://angular.io/api/router/NavigationCancel) * a cancelled navigation (e.g. due to a route guard that rejected/returned false) emits [`NavigationCancel`](https://angular.io/api/router/NavigationCancel) This fix adjusts our instrumentation to also listen to these events and to stop the span accordingly. --- packages/angular/src/tracing.ts | 6 ++- packages/angular/test/tracing.test.ts | 62 ++++++++++++++++++++++++++- packages/angular/test/utils/index.ts | 22 +++++++--- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/angular/src/tracing.ts b/packages/angular/src/tracing.ts index b206d7fe429d..f2e79adfe9b0 100644 --- a/packages/angular/src/tracing.ts +++ b/packages/angular/src/tracing.ts @@ -5,7 +5,7 @@ import type { ActivatedRouteSnapshot, Event, RouterState } from '@angular/router // Duplicated import to work around a TypeScript bug where it'd complain that `Router` isn't imported as a type. // We need to import it as a value to satisfy Angular dependency injection. So: // eslint-disable-next-line @typescript-eslint/consistent-type-imports, import/no-duplicates -import { Router } from '@angular/router'; +import { NavigationCancel, NavigationError, Router } from '@angular/router'; // eslint-disable-next-line import/no-duplicates import { NavigationEnd, NavigationStart, ResolveEnd } from '@angular/router'; import { getCurrentHub, WINDOW } from '@sentry/browser'; @@ -131,7 +131,9 @@ export class TraceService implements OnDestroy { ); public navEnd$: Observable = this._router.events.pipe( - filter(event => event instanceof NavigationEnd), + filter( + event => event instanceof NavigationEnd || event instanceof NavigationCancel || event instanceof NavigationError, + ), tap(() => { if (this._routingSpan) { runOutsideAngular(() => { diff --git a/packages/angular/test/tracing.test.ts b/packages/angular/test/tracing.test.ts index 0afef2771add..a3375518466a 100644 --- a/packages/angular/test/tracing.test.ts +++ b/packages/angular/test/tracing.test.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import type { ActivatedRouteSnapshot } from '@angular/router'; +import type { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; import type { Hub } from '@sentry/types'; import { instrumentAngularRouting, TraceClassDecorator, TraceDirective, TraceMethodDecorator } from '../src'; @@ -185,6 +185,66 @@ describe('Angular Tracing', () => { env.destroy(); }); + it('finishes routing span on navigation error', async () => { + const customStartTransaction = jest.fn(defaultStartTransaction); + + const env = await TestEnv.setup({ + customStartTransaction, + routes: [ + { + path: '', + component: AppComponent, + }, + ], + useTraceService: true, + }); + + const finishMock = jest.fn(); + transaction.startChild = jest.fn(() => ({ + finish: finishMock, + })); + + await env.navigateInAngular('/somewhere'); + + expect(finishMock).toHaveBeenCalledTimes(1); + + env.destroy(); + }); + + it('finishes routing span on navigation cancel', async () => { + const customStartTransaction = jest.fn(defaultStartTransaction); + + class CanActivateGuard implements CanActivate { + canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): boolean { + return false; + } + } + + const env = await TestEnv.setup({ + customStartTransaction, + routes: [ + { + path: 'cancel', + component: AppComponent, + canActivate: [CanActivateGuard], + }, + ], + useTraceService: true, + additionalProviders: [{ provide: CanActivateGuard, useClass: CanActivateGuard }], + }); + + const finishMock = jest.fn(); + transaction.startChild = jest.fn(() => ({ + finish: finishMock, + })); + + await env.navigateInAngular('/cancel'); + + expect(finishMock).toHaveBeenCalledTimes(1); + + env.destroy(); + }); + describe('URL parameterization', () => { it.each([ [ diff --git a/packages/angular/test/utils/index.ts b/packages/angular/test/utils/index.ts index b15ad2028560..daa23155d931 100644 --- a/packages/angular/test/utils/index.ts +++ b/packages/angular/test/utils/index.ts @@ -1,3 +1,4 @@ +import type { Provider } from '@angular/core'; import { Component, NgModule } from '@angular/core'; import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; @@ -47,6 +48,7 @@ export class TestEnv { startTransactionOnPageLoad?: boolean; startTransactionOnNavigation?: boolean; useTraceService?: boolean; + additionalProviders?: Provider[]; }): Promise { instrumentAngularRouting( conf.customStartTransaction || jest.fn(), @@ -60,14 +62,16 @@ export class TestEnv { TestBed.configureTestingModule({ imports: [AppModule, RouterTestingModule.withRoutes(routes)], declarations: [...(conf.components || []), AppComponent], - providers: useTraceService + providers: (useTraceService ? [ { provide: TraceService, deps: [Router], }, + ...(conf.additionalProviders || []), ] - : [], + : [] + ).concat(...(conf.additionalProviders || [])), }); const router: Router = TestBed.inject(Router); @@ -80,10 +84,16 @@ export class TestEnv { public async navigateInAngular(url: string): Promise { return new Promise(resolve => { return this.fixture.ngZone?.run(() => { - void this.router.navigateByUrl(url).then(() => { - this.fixture.detectChanges(); - resolve(); - }); + void this.router + .navigateByUrl(url) + .then(() => { + this.fixture.detectChanges(); + resolve(); + }) + .catch(() => { + this.fixture.detectChanges(); + resolve(); + }); }); }); } From e5e6a6bd8ab2bbec59879971595a7248fa132826 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 20 Jun 2023 16:27:53 +0200 Subject: [PATCH 06/28] fix(angular): Filter out `TryCatch` integration by default (#8367) The `TryCatch` default integration interferes with the `SentryErrorHander` error handler of the Angular(-Ivy) SDKs by catching certain errors too early, before the Angular SDK-specific error handler can catch them. This caused missing data on the event in some or duplicated errors in other cases. This fix filters out the `TryCatch` by default, as long as users didn't set `defaultIntegrations` in their SDK init. Therefore, it makes the previously provided [workaround](https://github.com/getsentry/sentry-javascript/issues/5417#issuecomment-1453407097) obsolete. --- packages/angular-ivy/src/sdk.ts | 14 +++++++++++++- packages/angular/src/sdk.ts | 14 +++++++++++++- packages/angular/test/sdk.test.ts | 31 ++++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/angular-ivy/src/sdk.ts b/packages/angular-ivy/src/sdk.ts index d16d16009ca0..fcbbbce399d0 100644 --- a/packages/angular-ivy/src/sdk.ts +++ b/packages/angular-ivy/src/sdk.ts @@ -1,6 +1,6 @@ import { VERSION } from '@angular/core'; import type { BrowserOptions } from '@sentry/browser'; -import { init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; +import { defaultIntegrations, init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; import { logger } from '@sentry/utils'; import { IS_DEBUG_BUILD } from './flags'; @@ -21,6 +21,18 @@ export function init(options: BrowserOptions): void { version: SDK_VERSION, }; + // Filter out TryCatch integration as it interferes with our Angular `ErrorHandler`: + // TryCatch would catch certain errors before they reach the `ErrorHandler` and thus provide a + // lower fidelity error than what `SentryErrorHandler` (see errorhandler.ts) would provide. + // see: + // - https://github.com/getsentry/sentry-javascript/issues/5417#issuecomment-1453407097 + // - https://github.com/getsentry/sentry-javascript/issues/2744 + if (options.defaultIntegrations === undefined) { + options.defaultIntegrations = defaultIntegrations.filter(integration => { + return integration.name !== 'TryCatch'; + }); + } + checkAndSetAngularVersion(); browserInit(options); } diff --git a/packages/angular/src/sdk.ts b/packages/angular/src/sdk.ts index 4afce6259c2b..e50cece043d0 100755 --- a/packages/angular/src/sdk.ts +++ b/packages/angular/src/sdk.ts @@ -1,6 +1,6 @@ import { VERSION } from '@angular/core'; import type { BrowserOptions } from '@sentry/browser'; -import { init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; +import { defaultIntegrations, init as browserInit, SDK_VERSION, setContext } from '@sentry/browser'; import { logger } from '@sentry/utils'; import { IS_DEBUG_BUILD } from './flags'; @@ -21,6 +21,18 @@ export function init(options: BrowserOptions): void { version: SDK_VERSION, }; + // Filter out TryCatch integration as it interferes with our Angular `ErrorHandler`: + // TryCatch would catch certain errors before they reach the `ErrorHandler` and thus provide a + // lower fidelity error than what `SentryErrorHandler` (see errorhandler.ts) would provide. + // see: + // - https://github.com/getsentry/sentry-javascript/issues/5417#issuecomment-1453407097 + // - https://github.com/getsentry/sentry-javascript/issues/2744 + if (options.defaultIntegrations === undefined) { + options.defaultIntegrations = defaultIntegrations.filter(integration => { + return integration.name !== 'TryCatch'; + }); + } + checkAndSetAngularVersion(); browserInit(options); } diff --git a/packages/angular/test/sdk.test.ts b/packages/angular/test/sdk.test.ts index bf5ecabb0ac5..0a7244d424f7 100644 --- a/packages/angular/test/sdk.test.ts +++ b/packages/angular/test/sdk.test.ts @@ -1,6 +1,6 @@ import * as SentryBrowser from '@sentry/browser'; -import { init } from '../src/sdk'; +import { defaultIntegrations, init } from '../src/index'; describe('init', () => { it('sets the Angular version (if available) in the global scope', () => { @@ -13,4 +13,33 @@ describe('init', () => { expect(setContextSpy).toHaveBeenCalledTimes(1); expect(setContextSpy).toHaveBeenCalledWith('angular', { version: 10 }); }); + + describe('filtering out the `TryCatch` integration', () => { + const browserInitSpy = jest.spyOn(SentryBrowser, 'init'); + + beforeEach(() => { + browserInitSpy.mockClear(); + }); + + it('filters if `defaultIntegrations` is not set', () => { + init({}); + + expect(browserInitSpy).toHaveBeenCalledTimes(1); + + const options = browserInitSpy.mock.calls[0][0] || {}; + expect(options.defaultIntegrations).not.toContainEqual(expect.objectContaining({ name: 'TryCatch' })); + }); + + it.each([false as const, defaultIntegrations])( + "doesn't filter if `defaultIntegrations` is set to %s", + defaultIntegrations => { + init({ defaultIntegrations }); + + expect(browserInitSpy).toHaveBeenCalledTimes(1); + + const options = browserInitSpy.mock.calls[0][0] || {}; + expect(options.defaultIntegrations).toEqual(defaultIntegrations); + }, + ); + }); }); From b84c23f6a43014396a8d89de827b1c1ad17bd737 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 21 Jun 2023 09:16:54 +0200 Subject: [PATCH 07/28] test(e2e): Ensure sveltekit E2E test work with prereleases (#8372) Since @sentry/sveltekit depends on @sentry/vite-plugin, which in turn depens on `@sentry/node@^7.19.0` & `@sentry/tracing@^7.19.0`, this fails to install in E2E tests for pre-release versions (e.g. `7.57.0-beta.0`), as the prerelease does not satisfy the range `^7.19.0`. So we override this to `*` to ensure this works as expected. --- packages/e2e-tests/test-applications/sveltekit/package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/e2e-tests/test-applications/sveltekit/package.json b/packages/e2e-tests/test-applications/sveltekit/package.json index ab1a2c9cac2e..003868af5ab8 100644 --- a/packages/e2e-tests/test-applications/sveltekit/package.json +++ b/packages/e2e-tests/test-applications/sveltekit/package.json @@ -27,5 +27,11 @@ "vite": "^4.2.0", "wait-port": "1.0.4" }, + "pnpm": { + "overrides": { + "@sentry/node": "*", + "@sentry/tracing": "*" + } + }, "type": "module" } From 4fb043e875a50611f77ff7c1904b8e0c0fce0e2c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 21 Jun 2023 09:49:14 +0200 Subject: [PATCH 08/28] fix(nextjs): Inject init calls via loader instead of via entrypoints (#8368) --- .github/workflows/build.yml | 2 +- .../src/config/loaders/wrappingLoader.ts | 18 +-- packages/nextjs/src/config/webpack.ts | 42 +---- .../webpack/constructWebpackConfig.test.ts | 149 ------------------ 4 files changed, 18 insertions(+), 193 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 990361d6ae7c..3bc95b171d94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -424,7 +424,7 @@ jobs: name: Nextjs (Node ${{ matrix.node }}) Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_nextjs == 'true' || github.event_name != 'pull_request' - timeout-minutes: 15 + timeout-minutes: 25 runs-on: ubuntu-20.04 strategy: fail-fast: false diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 3157d41df71f..e4d58c579420 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -201,21 +201,21 @@ export default function wrappingLoader( } else { templateCode = templateCode.replace(/__COMPONENT_TYPE__/g, 'Unknown'); } - - // We check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute, - // however we can only create relative paths to the sentry config from absolute paths.Examples where this could possibly be non - absolute are virtual modules. - if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) { - const sentryConfigImportPath = path - .relative(path.dirname(this.resourcePath), sentryConfigFilePath) // Absolute paths do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133 - .replace(/\\/g, '/'); - templateCode = `import "${sentryConfigImportPath}";\n`.concat(templateCode); - } } else if (wrappingTargetKind === 'middleware') { templateCode = middlewareWrapperTemplateCode; } else { throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`); } + // We check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute, + // however we can only create relative paths to the sentry config from absolute paths.Examples where this could possibly be non - absolute are virtual modules. + if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) { + const sentryConfigImportPath = path + .relative(path.dirname(this.resourcePath), sentryConfigFilePath) // Absolute paths do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133 + .replace(/\\/g, '/'); + templateCode = `import "${sentryConfigImportPath}";\n`.concat(templateCode); + } + // Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand. templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME); diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 0bb42f98b7ec..7237d8ce24be 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -1,7 +1,7 @@ /* eslint-disable complexity */ /* eslint-disable max-lines */ import { getSentryRelease } from '@sentry/node'; -import { arrayify, dropUndefinedKeys, escapeStringForRegex, logger, stringMatchesSomePattern } from '@sentry/utils'; +import { arrayify, dropUndefinedKeys, escapeStringForRegex, logger } from '@sentry/utils'; import { default as SentryWebpackPlugin } from '@sentry/webpack-plugin'; import * as chalk from 'chalk'; import * as fs from 'fs'; @@ -441,7 +441,7 @@ async function addSentryToEntryProperty( // inject into all entry points which might contain user's code for (const entryPointName in newEntryProperty) { - if (shouldAddSentryToEntryPoint(entryPointName, runtime, userSentryOptions.excludeServerRoutes ?? [])) { + if (shouldAddSentryToEntryPoint(entryPointName, runtime)) { addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject); } else { if ( @@ -589,39 +589,13 @@ function checkWebpackPluginOverrides( * @param excludeServerRoutes A list of excluded serverside entrypoints provided by the user * @returns `true` if sentry code should be injected, and `false` otherwise */ -function shouldAddSentryToEntryPoint( - entryPointName: string, - runtime: 'node' | 'browser' | 'edge', - excludeServerRoutes: Array, -): boolean { - // On the server side, by default we inject the `Sentry.init()` code into every page (with a few exceptions). - if (runtime === 'node') { - // User-specified pages to skip. (Note: For ease of use, `excludeServerRoutes` is specified in terms of routes, - // which don't have the `pages` prefix.) - const entryPointRoute = entryPointName.replace(/^pages/, ''); - if (stringMatchesSomePattern(entryPointRoute, excludeServerRoutes, true)) { - return false; - } - - // This expression will implicitly include `pages/_app` which is called for all serverside routes and pages - // regardless whether or not the user has a`_app` file. - return entryPointName.startsWith('pages/'); - } else if (runtime === 'browser') { - return ( - // entrypoint for `/pages` pages - this is included on all clientside pages - // It's important that we inject the SDK into this file and not into 'main' because in 'main' - // some important Next.js code (like the setup code for getCongig()) is located and some users - // may need this code inside their Sentry configs - entryPointName === 'pages/_app' || +function shouldAddSentryToEntryPoint(entryPointName: string, runtime: 'node' | 'browser' | 'edge'): boolean { + return ( + runtime === 'browser' && + (entryPointName === 'pages/_app' || // entrypoint for `/app` pages - entryPointName === 'main-app' - ); - } else { - // User-specified pages to skip. (Note: For ease of use, `excludeServerRoutes` is specified in terms of routes, - // which don't have the `pages` prefix.) - const entryPointRoute = entryPointName.replace(/^pages/, ''); - return !stringMatchesSomePattern(entryPointRoute, excludeServerRoutes, true); - } + entryPointName === 'main-app') + ); } /** diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 01f89bed1077..86bb2d03d3fd 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -7,10 +7,7 @@ import { CLIENT_SDK_CONFIG_FILE, clientBuildContext, clientWebpackConfig, - EDGE_SDK_CONFIG_FILE, - edgeBuildContext, exportedNextConfig, - SERVER_SDK_CONFIG_FILE, serverBuildContext, serverWebpackConfig, userNextConfig, @@ -88,74 +85,15 @@ describe('constructWebpackConfigFunction()', () => { }); describe('webpack `entry` property config', () => { - const serverConfigFilePath = `./${SERVER_SDK_CONFIG_FILE}`; const clientConfigFilePath = `./${CLIENT_SDK_CONFIG_FILE}`; - const edgeConfigFilePath = `./${EDGE_SDK_CONFIG_FILE}`; - - it('handles various entrypoint shapes', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - // original entrypoint value is a string - // (was 'private-next-pages/_error.js') - 'pages/_error': [serverConfigFilePath, 'private-next-pages/_error.js'], - - // original entrypoint value is a string array - // (was ['./node_modules/smellOVision/index.js', 'private-next-pages/sniffTour.js']) - 'pages/sniffTour': [ - serverConfigFilePath, - './node_modules/smellOVision/index.js', - 'private-next-pages/sniffTour.js', - ], - - // original entrypoint value is an object containing a string `import` value - // (was { import: 'private-next-pages/api/simulator/dogStats/[name].js' }) - 'pages/api/simulator/dogStats/[name]': { - import: [serverConfigFilePath, 'private-next-pages/api/simulator/dogStats/[name].js'], - }, - - // original entrypoint value is an object containing a string array `import` value - // (was { import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/simulator/leaderboard.js'] }) - 'pages/simulator/leaderboard': { - import: [ - serverConfigFilePath, - './node_modules/dogPoints/converter.js', - 'private-next-pages/simulator/leaderboard.js', - ], - }, - - // original entrypoint value is an object containg properties besides `import` - // (was { import: 'private-next-pages/api/tricks/[trickName].js', dependOn: 'treats', }) - 'pages/api/tricks/[trickName]': { - import: [serverConfigFilePath, 'private-next-pages/api/tricks/[trickName].js'], - dependOn: 'treats', // untouched - }, - }), - ); - }); it('injects user config file into `_app` in server bundle and in the client bundle', async () => { - const finalServerWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); const finalClientWebpackConfig = await materializeFinalWebpackConfig({ exportedNextConfig, incomingWebpackConfig: clientWebpackConfig, incomingWebpackBuildContext: clientBuildContext, }); - expect(finalServerWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/_app': expect.arrayContaining([serverConfigFilePath]), - }), - ); expect(finalClientWebpackConfig.entry).toEqual( expect.objectContaining({ 'pages/_app': expect.arrayContaining([clientConfigFilePath]), @@ -163,68 +101,6 @@ describe('constructWebpackConfigFunction()', () => { ); }); - it('injects user config file into `_error` in server bundle but not client bundle', async () => { - const finalServerWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - const finalClientWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: clientWebpackConfig, - incomingWebpackBuildContext: clientBuildContext, - }); - - expect(finalServerWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/_error': expect.arrayContaining([serverConfigFilePath]), - }), - ); - expect(finalClientWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/_error': expect.not.arrayContaining([clientConfigFilePath]), - }), - ); - }); - - it('injects user config file into both API routes and non-API routes', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/api/simulator/dogStats/[name]': { - import: expect.arrayContaining([serverConfigFilePath]), - }, - - 'pages/api/tricks/[trickName]': expect.objectContaining({ - import: expect.arrayContaining([serverConfigFilePath]), - }), - - 'pages/simulator/leaderboard': { - import: expect.arrayContaining([serverConfigFilePath]), - }, - }), - ); - }); - - it('injects user config file into API middleware', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: edgeBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - middleware: [edgeConfigFilePath, 'private-next-pages/middleware.js'], - }), - ); - }); - it('does not inject anything into non-_app pages during client build', async () => { const finalWebpackConfig = await materializeFinalWebpackConfig({ exportedNextConfig, @@ -244,30 +120,5 @@ describe('constructWebpackConfigFunction()', () => { simulatorBundle: './src/simulator/index.ts', }); }); - - it('does not inject into routes included in `excludeServerRoutes`', async () => { - const nextConfigWithExcludedRoutes = { - ...exportedNextConfig, - sentry: { - excludeServerRoutes: [/simulator/], - }, - }; - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig: nextConfigWithExcludedRoutes, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/simulator/leaderboard': { - import: expect.not.arrayContaining([serverConfigFilePath]), - }, - 'pages/api/simulator/dogStats/[name]': { - import: expect.not.arrayContaining([serverConfigFilePath]), - }, - }), - ); - }); }); }); From da2487eec0466b8db5cada2575e6f53297a7dcf6 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Wed, 21 Jun 2023 11:44:52 -0700 Subject: [PATCH 09/28] fix(replay): Mark ui.slowClickDetected `clickCount` as optional (#8376) This field was added as part of v7.56.0, but `SlowClickFrameData` was created before that. Having `clickCount` be required breaks backwards compatibility, it should be optional. --- packages/replay/src/types/replayFrame.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/replay/src/types/replayFrame.ts b/packages/replay/src/types/replayFrame.ts index 379dbab91605..f3fb594829d3 100644 --- a/packages/replay/src/types/replayFrame.ts +++ b/packages/replay/src/types/replayFrame.ts @@ -91,7 +91,7 @@ interface SlowClickFrameData extends ClickFrameData { route?: string; timeAfterClickMs: number; endReason: string; - clickCount: number; + clickCount?: number; } export interface SlowClickFrame extends BaseBreadcrumbFrame { category: 'ui.slowClickDetected'; From ee4e37ef4dc2549daa8accfcd4a4a00221f8f4ea Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 22 Jun 2023 13:30:48 +0200 Subject: [PATCH 10/28] fix(sveltekit): Only instrument SvelteKit `fetch` if the SDK client is valid (#8381) In the client-side SvelteKit fetch instrumentation, our previous type cast when retrieving the SDK client was wrong, causing us to not guard the fetch instrumentation correctly if the client was undefined. This fix adds an undefined check. fixes #8290 --- packages/sveltekit/src/client/load.ts | 25 +++++++++++---- packages/sveltekit/test/client/load.test.ts | 34 ++++++++++++++++++--- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/sveltekit/src/client/load.ts b/packages/sveltekit/src/client/load.ts index 1996523346e2..bbc184b6d3a0 100644 --- a/packages/sveltekit/src/client/load.ts +++ b/packages/sveltekit/src/client/load.ts @@ -3,7 +3,7 @@ import type { BaseClient } from '@sentry/core'; import { getCurrentHub, trace } from '@sentry/core'; import type { Breadcrumbs, BrowserTracing } from '@sentry/svelte'; import { captureException } from '@sentry/svelte'; -import type { ClientOptions, SanitizedRequestData } from '@sentry/types'; +import type { Client, ClientOptions, SanitizedRequestData } from '@sentry/types'; import { addExceptionMechanism, addNonEnumerableProperty, @@ -122,12 +122,14 @@ type SvelteKitFetch = LoadEvent['fetch']; * @returns a proxy of SvelteKit's fetch implementation */ function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch { - const client = getCurrentHub().getClient() as BaseClient; + const client = getCurrentHub().getClient(); - const browserTracingIntegration = - client.getIntegrationById && (client.getIntegrationById('BrowserTracing') as BrowserTracing | undefined); - const breadcrumbsIntegration = - client.getIntegrationById && (client.getIntegrationById('Breadcrumbs') as Breadcrumbs | undefined); + if (!isValidClient(client)) { + return originalFetch; + } + + const browserTracingIntegration = client.getIntegrationById('BrowserTracing') as BrowserTracing | undefined; + const breadcrumbsIntegration = client.getIntegrationById('Breadcrumbs') as Breadcrumbs | undefined; const browserTracingOptions = browserTracingIntegration && browserTracingIntegration.options; @@ -270,3 +272,14 @@ function addFetchBreadcrumb( }, ); } + +type MaybeClientWithGetIntegrationsById = + | (Client & { getIntegrationById?: BaseClient['getIntegrationById'] }) + | undefined; + +type ClientWithGetIntegrationById = Required & + Exclude; + +function isValidClient(client: MaybeClientWithGetIntegrationsById): client is ClientWithGetIntegrationById { + return !!client && typeof client.getIntegrationById === 'function'; +} diff --git a/packages/sveltekit/test/client/load.test.ts b/packages/sveltekit/test/client/load.test.ts index b07e0c12108f..65b4cc1da3b1 100644 --- a/packages/sveltekit/test/client/load.test.ts +++ b/packages/sveltekit/test/client/load.test.ts @@ -52,6 +52,12 @@ const mockedGetIntegrationById = vi.fn(id => { return undefined; }); +const mockedGetClient = vi.fn(() => { + return { + getIntegrationById: mockedGetIntegrationById, + }; +}); + vi.mock('@sentry/core', async () => { const original = (await vi.importActual('@sentry/core')) as any; return { @@ -62,11 +68,7 @@ vi.mock('@sentry/core', async () => { }, getCurrentHub: () => { return { - getClient: () => { - return { - getIntegrationById: mockedGetIntegrationById, - }; - }, + getClient: mockedGetClient, getScope: () => { return { getSpan: () => { @@ -427,6 +429,28 @@ describe('wrapLoadWithSentry', () => { }); }); + it.each([ + ['is undefined', undefined], + ["doesn't have a `getClientById` method", {}], + ])("doesn't instrument fetch if the client %s", async (_, client) => { + // @ts-expect-error: we're mocking the client + mockedGetClient.mockImplementationOnce(() => client); + + async function load(_event: Parameters[0]): Promise> { + return { + msg: 'hi', + }; + } + const wrappedLoad = wrapLoadWithSentry(load); + + const originalFetch = MOCK_LOAD_ARGS.fetch; + await wrappedLoad(MOCK_LOAD_ARGS); + + expect(MOCK_LOAD_ARGS.fetch).toStrictEqual(originalFetch); + + expect(mockTrace).toHaveBeenCalledTimes(1); + }); + it('adds an exception mechanism', async () => { const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => { void callback({}, { event_id: 'fake-event-id' }); From 54e091e7004c6b79cd94acc2d8a32c65632dbbd6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 22 Jun 2023 14:37:44 +0200 Subject: [PATCH 11/28] fix(serverless): Export `autoDiscoverNodePerformanceMonitoringIntegrations` from SDK (#8382) When refactoring our `@sentry/tracing` package and exporting tracing related integrations from SDK packages, we apparently forgot to export `autoDiscoverNodePerformanceMonitoringIntegrations` from the Serverless SDK. This fix re-adds the export. --- packages/serverless/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/serverless/src/index.ts b/packages/serverless/src/index.ts index 284aef1331af..f6ae3a2b9184 100644 --- a/packages/serverless/src/index.ts +++ b/packages/serverless/src/index.ts @@ -15,6 +15,7 @@ export { Scope, addBreadcrumb, addGlobalEventProcessor, + autoDiscoverNodePerformanceMonitoringIntegrations, captureEvent, captureException, captureMessage, From 9fd50169b5286c56340e08b7d075c15baae01833 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 22 Jun 2023 17:04:24 +0100 Subject: [PATCH 12/28] test(react): Add tests for `react-router` `createHashRouter` usage. (#8362) --- .../react-create-hash-router/.gitignore | 29 ++ .../react-create-hash-router/.npmrc | 2 + .../react-create-hash-router/package.json | 53 ++++ .../playwright.config.ts | 70 +++++ .../public/index.html | 24 ++ .../react-create-hash-router/src/globals.d.ts | 5 + .../react-create-hash-router/src/index.tsx | 78 ++++++ .../src/pages/Index.tsx | 24 ++ .../src/pages/User.tsx | 7 + .../src/react-app-env.d.ts | 1 + .../react-create-hash-router/test-recipe.json | 19 ++ .../tests/behaviour-test.spec.ts | 256 ++++++++++++++++++ .../tests/fixtures/ReplayRecordingData.ts | 243 +++++++++++++++++ .../react-create-hash-router/tsconfig.json | 20 ++ 14 files changed, 831 insertions(+) create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/.gitignore create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/.npmrc create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/package.json create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/public/index.html create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts create mode 100644 packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore b/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc b/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/package.json b/packages/e2e-tests/test-applications/react-create-hash-router/package.json new file mode 100644 index 000000000000..bac46c9562d0 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -0,0 +1,53 @@ +{ + "name": "react-create-hash-router-test", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "*", + "@testing-library/jest-dom": "5.14.1", + "@testing-library/react": "13.0.0", + "@testing-library/user-event": "13.2.1", + "@types/jest": "27.0.1", + "@types/node": "16.7.13", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^6.4.1", + "react-scripts": "5.0.1", + "typescript": "4.4.2", + "web-vitals": "2.1.0" + }, + "scripts": { + "build": "react-scripts build", + "start": "serve -s build", + "test": "playwright test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@playwright/test": "1.26.1", + "axios": "1.1.2", + "serve": "14.0.1" + }, + "volta": { + "node": "16.19.0", + "yarn": "1.22.19" + } +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts new file mode 100644 index 000000000000..a24d7bc1c742 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/playwright.config.ts @@ -0,0 +1,70 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 60 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'pnpm start', + port: Number(process.env.BASE_PORT) + Number(process.env.PORT_MODULO), + env: { + PORT: String(Number(process.env.BASE_PORT) + Number(process.env.PORT_MODULO)), + }, + }, +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html b/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts b/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx new file mode 100644 index 000000000000..aef574bce3c4 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import * as Sentry from '@sentry/react'; +import { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + RouterProvider, + createHashRouter, +} from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = new Sentry.Replay(); + +Sentry.init({ + // environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + ), + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, +}); + +Object.defineProperty(window, 'sentryReplayId', { + get() { + return replay['_replay'].session.id; + }, +}); + +Sentry.addGlobalEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +const sentryCreateHashRouter = Sentry.wrapCreateBrowserRouter(createHashRouter); + +const router = sentryCreateHashRouter([ + { + path: '/', + element: , + }, + { + path: '/user/:id', + element: , + }, +]); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + +root.render(); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx new file mode 100644 index 000000000000..2f683c63ed84 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as Sentry from '@sentry/react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts b/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json b/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json new file mode 100644 index 000000000000..7955a96ea1d0 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/test-recipe.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../test-recipe-schema.json", + "testApplicationName": "react-create-hash-router", + "buildCommand": "pnpm install && npx playwright install && pnpm build", + "tests": [ + { + "testName": "Playwright tests", + "testCommand": "pnpm test" + } + ], + "canaryVersions": [ + { + "dependencyOverrides": { + "react": "latest", + "react-dom": "latest" + } + } + ] +} diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts new file mode 100644 index 000000000000..c57beb61c8db --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tests/behaviour-test.spec.ts @@ -0,0 +1,256 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; +import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; + +const EVENT_POLLING_TIMEOUT = 30_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends an exception to Sentry', async ({ page }) => { + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); + const exceptionEventId = await exceptionIdHandle.jsonValue(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends a pageload transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageLoadTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'pageload') { + expect(response.data.title).toBe('/'); + hadPageLoadTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageLoadTransaction).toBe(true); +}); + +test('Sends a navigation transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + // Give pageload transaction time to finish + page.waitForTimeout(4000); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageNavigationTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'navigation') { + expect(response.data.title).toBe('/user/:id'); + hadPageNavigationTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageNavigationTransaction).toBe(true); +}); + +test('Sends a Replay recording to Sentry', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto('/'); + + const replayId = await page.waitForFunction(() => { + return window.sentryReplayId; + }); + + // Wait for replay to be sent + + if (replayId === undefined) { + throw new Error("Application didn't set a replayId"); + } + + console.log(`Polling for replay with ID: ${replayId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + + // now fetch the first recording segment + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/replays/${replayId}/recording-segments/?cursor=100%3A0%3A1`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return { + status: response.status, + data: response.data, + }; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toEqual({ + status: 200, + data: ReplayRecordingData, + }); +}); diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts new file mode 100644 index 000000000000..0da2e1b2e327 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts @@ -0,0 +1,243 @@ +import { expect } from '@playwright/test'; + +export const ReplayRecordingData = [ + [ + { + type: 4, + data: { href: expect.stringMatching(/http:\/\/localhost:\d+\//), width: 1280, height: 720 }, + timestamp: expect.any(Number), + }, + { + data: { + payload: { + blockAllMedia: true, + errorSampleRate: 0, + maskAllInputs: true, + maskAllText: true, + networkCaptureBodies: true, + networkDetailHasUrls: false, + networkRequestHasHeaders: true, + networkResponseHasHeaders: true, + sessionSampleRate: 1, + useCompression: false, + useCompressionOption: true, + }, + tag: 'options', + }, + timestamp: expect.any(Number), + type: 5, + }, + { + type: 2, + data: { + node: { + type: 0, + childNodes: [ + { type: 1, name: 'html', publicId: '', systemId: '', id: 2 }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { type: 2, tagName: 'meta', attributes: { charset: 'utf-8' }, childNodes: [], id: 5 }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'viewport', content: 'width=device-width,initial-scale=1' }, + childNodes: [], + id: 6, + }, + { + type: 2, + tagName: 'meta', + attributes: { name: 'theme-color', content: '#000000' }, + childNodes: [], + id: 7, + }, + { + type: 2, + tagName: 'title', + attributes: {}, + childNodes: [{ type: 3, textContent: '***** ***', id: 9 }], + id: 8, + }, + ], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'noscript', + attributes: {}, + childNodes: [{ type: 3, textContent: '*** **** ** ****** ********** ** *** **** ****', id: 12 }], + id: 11, + }, + { type: 2, tagName: 'div', attributes: { id: 'root' }, childNodes: [], id: 13 }, + ], + id: 10, + }, + ], + id: 3, + }, + ], + id: 1, + }, + initialOffset: { left: 0, top: 0 }, + }, + timestamp: expect.any(Number), + }, + { + type: 3, + data: { + source: 0, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 13, + nextId: null, + node: { + type: 2, + tagName: 'a', + attributes: { id: 'navigation', href: expect.stringMatching(/http:\/\/localhost:\d+\/user\/5/) }, + childNodes: [], + id: 14, + }, + }, + { parentId: 14, nextId: null, node: { type: 3, textContent: '********', id: 15 } }, + { + parentId: 13, + nextId: 14, + node: { + type: 2, + tagName: 'input', + attributes: { type: 'button', id: 'exception-button', value: '******* *********' }, + childNodes: [], + id: 16, + }, + }, + ], + }, + timestamp: expect.any(Number), + }, + { + type: 3, + data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, + timestamp: expect.any(Number), + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'navigation.navigate', + description: expect.stringMatching(/http:\/\/localhost:\d+\//), + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + decodedBodySize: expect.any(Number), + encodedBodySize: expect.any(Number), + duration: expect.any(Number), + domInteractive: expect.any(Number), + domContentLoadedEventEnd: expect.any(Number), + domContentLoadedEventStart: expect.any(Number), + loadEventStart: expect.any(Number), + loadEventEnd: expect.any(Number), + domComplete: expect.any(Number), + redirectCount: expect.any(Number), + size: expect.any(Number), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'resource.script', + description: expect.stringMatching(/http:\/\/localhost:\d+\/static\/js\/main.(\w+).js/), + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + decodedBodySize: expect.any(Number), + encodedBodySize: expect.any(Number), + size: expect.any(Number), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'largest-contentful-paint', + description: 'largest-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { value: expect.any(Number), size: expect.any(Number), nodeId: 16 }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'paint', + description: 'first-contentful-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'memory', + description: 'memory', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + memory: { + jsHeapSizeLimit: expect.any(Number), + totalJSHeapSize: expect.any(Number), + usedJSHeapSize: expect.any(Number), + }, + }, + }, + }, + }, + ], +]; diff --git a/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json b/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json new file mode 100644 index 000000000000..c8df41dcf4b5 --- /dev/null +++ b/packages/e2e-tests/test-applications/react-create-hash-router/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": ["src", "tests"] +} From fc7ef13f359a0e4bb0b96a1762d68ff4ea30c3d5 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 23 Jun 2023 10:06:31 +0200 Subject: [PATCH 13/28] fix(tracing): Instrument Prisma client in constructor of integration (#8383) --- .github/workflows/build.yml | 1 + .../src/node/integrations/prisma.ts | 56 +++++++++---------- .../test/integrations/node/prisma.test.ts | 35 +++++------- 3 files changed, 41 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3bc95b171d94..b8e3fcafcb34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,6 +89,7 @@ jobs: - 'scripts/**' - 'packages/core/**' - 'packages/tracing/**' + - 'packages/tracing-internal/**' - 'packages/utils/**' - 'packages/types/**' - 'packages/integrations/**' diff --git a/packages/tracing-internal/src/node/integrations/prisma.ts b/packages/tracing-internal/src/node/integrations/prisma.ts index 20a84aa36aaf..ab6fd1f1ae5b 100644 --- a/packages/tracing-internal/src/node/integrations/prisma.ts +++ b/packages/tracing-internal/src/node/integrations/prisma.ts @@ -1,7 +1,6 @@ -import type { Hub } from '@sentry/core'; -import { trace } from '@sentry/core'; -import type { EventProcessor, Integration } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { getCurrentHub, trace } from '@sentry/core'; +import type { Integration } from '@sentry/types'; +import { addNonEnumerableProperty, logger } from '@sentry/utils'; import { shouldDisableAutoInstrumentation } from './utils/node-utils'; @@ -36,6 +35,7 @@ type PrismaMiddleware = ( ) => Promise; interface PrismaClient { + _sentryInstrumented?: boolean; $use: (cb: PrismaMiddleware) => void; } @@ -55,17 +55,30 @@ export class Prisma implements Integration { */ public name: string = Prisma.id; - /** - * Prisma ORM Client Instance - */ - private readonly _client?: PrismaClient; - /** * @inheritDoc */ public constructor(options: { client?: unknown } = {}) { - if (isValidPrismaClient(options.client)) { - this._client = options.client; + // We instrument the PrismaClient inside the constructor and not inside `setupOnce` because in some cases of server-side + // bundling (Next.js) multiple Prisma clients can be instantiated, even though users don't intend to. When instrumenting + // in setupOnce we can only ever instrument one client. + // https://github.com/getsentry/sentry-javascript/issues/7216#issuecomment-1602375012 + // In the future we might explore providing a dedicated PrismaClient middleware instead of this hack. + if (isValidPrismaClient(options.client) && !options.client._sentryInstrumented) { + addNonEnumerableProperty(options.client as any, '_sentryInstrumented', true); + + options.client.$use((params, next: (params: PrismaMiddlewareParams) => Promise) => { + if (shouldDisableAutoInstrumentation(getCurrentHub)) { + return next(params); + } + + const action = params.action; + const model = params.model; + return trace( + { name: model ? `${model} ${action}` : action, op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + () => next(params), + ); + }); } else { __DEBUG_BUILD__ && logger.warn( @@ -77,24 +90,7 @@ export class Prisma implements Integration { /** * @inheritDoc */ - public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - if (!this._client) { - __DEBUG_BUILD__ && logger.error('PrismaIntegration is missing a Prisma Client Instance'); - return; - } - - if (shouldDisableAutoInstrumentation(getCurrentHub)) { - __DEBUG_BUILD__ && logger.log('Prisma Integration is skipped because of instrumenter configuration.'); - return; - } - - this._client.$use((params, next: (params: PrismaMiddlewareParams) => Promise) => { - const action = params.action; - const model = params.model; - return trace( - { name: model ? `${model} ${action}` : action, op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, - () => next(params), - ); - }); + public setupOnce(): void { + // Noop - here for backwards compatibility } } diff --git a/packages/tracing/test/integrations/node/prisma.test.ts b/packages/tracing/test/integrations/node/prisma.test.ts index 3096401ec43a..61c0e5fb07f6 100644 --- a/packages/tracing/test/integrations/node/prisma.test.ts +++ b/packages/tracing/test/integrations/node/prisma.test.ts @@ -1,7 +1,6 @@ /* eslint-disable deprecation/deprecation */ -/* eslint-disable @typescript-eslint/unbound-method */ -import { Hub, Scope } from '@sentry/core'; -import { logger } from '@sentry/utils'; +import * as sentryCore from '@sentry/core'; +import { Hub } from '@sentry/core'; import { Integrations } from '../../../src'; import { getTestClient } from '../../testutils'; @@ -38,21 +37,15 @@ class PrismaClient { } describe('setupOnce', function () { - const Client: PrismaClient = new PrismaClient(); - - beforeAll(() => { - new Integrations.Prisma({ client: Client }).setupOnce( - () => undefined, - () => new Hub(undefined, new Scope()), - ); - }); - beforeEach(() => { mockTrace.mockClear(); + mockTrace.mockReset(); }); it('should add middleware with $use method correctly', done => { - void Client.user.create()?.then(() => { + const prismaClient = new PrismaClient(); + new Integrations.Prisma({ client: prismaClient }); + void prismaClient.user.create()?.then(() => { expect(mockTrace).toHaveBeenCalledTimes(1); expect(mockTrace).toHaveBeenLastCalledWith( { name: 'user create', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, @@ -62,18 +55,18 @@ describe('setupOnce', function () { }); }); - it("doesn't attach when using otel instrumenter", () => { - const loggerLogSpy = jest.spyOn(logger, 'log'); + it("doesn't trace when using otel instrumenter", done => { + const prismaClient = new PrismaClient(); + new Integrations.Prisma({ client: prismaClient }); const client = getTestClient({ instrumenter: 'otel' }); const hub = new Hub(client); - const integration = new Integrations.Prisma({ client: Client }); - integration.setupOnce( - () => {}, - () => hub, - ); + jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub); - expect(loggerLogSpy).toBeCalledWith('Prisma Integration is skipped because of instrumenter configuration.'); + void prismaClient.user.create()?.then(() => { + expect(mockTrace).not.toHaveBeenCalled(); + done(); + }); }); }); From 00641e2a5be791f1ee4e935e30015a6bf00959e7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 23 Jun 2023 13:09:05 +0200 Subject: [PATCH 14/28] ref(replay): More graceful `sessionStorage` check (#8394) It seems in some environments `sessionStorage` my be unset to `null` or similar. To be extra careful, we can guard for existence as well there. Fixes https://github.com/getsentry/sentry-javascript/issues/8392 --- packages/replay/src/session/clearSession.ts | 5 ++--- packages/replay/src/session/fetchSession.ts | 5 ++--- packages/replay/src/session/saveSession.ts | 4 ++-- packages/replay/src/util/hasSessionStorage.ts | 6 ++++++ 4 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 packages/replay/src/util/hasSessionStorage.ts diff --git a/packages/replay/src/session/clearSession.ts b/packages/replay/src/session/clearSession.ts index d084764c2fb9..78f50255e363 100644 --- a/packages/replay/src/session/clearSession.ts +++ b/packages/replay/src/session/clearSession.ts @@ -1,5 +1,6 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/types'; +import { hasSessionStorage } from '../util/hasSessionStorage'; /** * Removes the session from Session Storage and unsets session in replay instance @@ -13,9 +14,7 @@ export function clearSession(replay: ReplayContainer): void { * Deletes a session from storage */ function deleteSession(): void { - const hasSessionStorage = 'sessionStorage' in WINDOW; - - if (!hasSessionStorage) { + if (!hasSessionStorage()) { return; } diff --git a/packages/replay/src/session/fetchSession.ts b/packages/replay/src/session/fetchSession.ts index 4b4b1eccf530..3e89a9cbd049 100644 --- a/packages/replay/src/session/fetchSession.ts +++ b/packages/replay/src/session/fetchSession.ts @@ -1,14 +1,13 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; import type { Session } from '../types'; +import { hasSessionStorage } from '../util/hasSessionStorage'; import { makeSession } from './Session'; /** * Fetches a session from storage */ export function fetchSession(): Session | null { - const hasSessionStorage = 'sessionStorage' in WINDOW; - - if (!hasSessionStorage) { + if (!hasSessionStorage()) { return null; } diff --git a/packages/replay/src/session/saveSession.ts b/packages/replay/src/session/saveSession.ts index 8f75d0ab50ed..d868fd6ea8a1 100644 --- a/packages/replay/src/session/saveSession.ts +++ b/packages/replay/src/session/saveSession.ts @@ -1,12 +1,12 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; import type { Session } from '../types'; +import { hasSessionStorage } from '../util/hasSessionStorage'; /** * Save a session to session storage. */ export function saveSession(session: Session): void { - const hasSessionStorage = 'sessionStorage' in WINDOW; - if (!hasSessionStorage) { + if (!hasSessionStorage()) { return; } diff --git a/packages/replay/src/util/hasSessionStorage.ts b/packages/replay/src/util/hasSessionStorage.ts new file mode 100644 index 000000000000..f242df101c25 --- /dev/null +++ b/packages/replay/src/util/hasSessionStorage.ts @@ -0,0 +1,6 @@ +import { WINDOW } from '../constants'; + +/** If sessionStorage is available. */ +export function hasSessionStorage(): boolean { + return 'sessionStorage' in WINDOW && !!WINDOW.sessionStorage; +} From 52174851d7a8fe612268302c6bb8bdf44a31de44 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 23 Jun 2023 14:13:22 +0200 Subject: [PATCH 15/28] feat(browser): Better event name handling for non-Error objects (#8374) This PR adjusts the exception name generation for non-error objects in the browser SDK. ref https://github.com/getsentry/sentry-javascript/issues/7941 --- .../captureException/classInstance/subject.js | 6 ++++ .../captureException/classInstance/test.ts | 21 +++++++++++ .../{empty_obj => emptyObj}/subject.js | 0 .../{empty_obj => emptyObj}/test.ts | 2 +- .../captureException/errorEvent/init.js | 8 +++++ .../captureException/errorEvent/subject.js | 5 +++ .../captureException/errorEvent/test.ts | 25 +++++++++++++ .../captureException/event/subject.js | 1 + .../public-api/captureException/event/test.ts | 21 +++++++++++ .../captureException/plainObject/subject.js | 4 +++ .../captureException/plainObject/test.ts | 21 +++++++++++ .../{simple_error => simpleError}/subject.js | 0 .../{simple_error => simpleError}/test.ts | 0 .../subject.js | 0 .../{undefined_arg => undefinedArg}/test.ts | 0 packages/browser/src/eventbuilder.ts | 36 +++++++++++++++++-- .../test/integration/suites/onerror.js | 4 +-- .../suites/onunhandledrejection.js | 6 ++-- .../browser/test/unit/eventbuilder.test.ts | 19 ++++++++++ .../browser/tsconfig.test-integration.json | 12 +++++++ packages/browser/tsconfig.test.json | 3 +- 21 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/classInstance/subject.js create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/classInstance/test.ts rename packages/browser-integration-tests/suites/public-api/captureException/{empty_obj => emptyObj}/subject.js (100%) rename packages/browser-integration-tests/suites/public-api/captureException/{empty_obj => emptyObj}/test.ts (91%) create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/errorEvent/init.js create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/errorEvent/subject.js create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/errorEvent/test.ts create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/event/subject.js create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/event/test.ts create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/plainObject/subject.js create mode 100644 packages/browser-integration-tests/suites/public-api/captureException/plainObject/test.ts rename packages/browser-integration-tests/suites/public-api/captureException/{simple_error => simpleError}/subject.js (100%) rename packages/browser-integration-tests/suites/public-api/captureException/{simple_error => simpleError}/test.ts (100%) rename packages/browser-integration-tests/suites/public-api/captureException/{undefined_arg => undefinedArg}/subject.js (100%) rename packages/browser-integration-tests/suites/public-api/captureException/{undefined_arg => undefinedArg}/test.ts (100%) create mode 100644 packages/browser/tsconfig.test-integration.json diff --git a/packages/browser-integration-tests/suites/public-api/captureException/classInstance/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/subject.js new file mode 100644 index 000000000000..d2d2b96a87fe --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/subject.js @@ -0,0 +1,6 @@ +class MyTestClass { + prop1 = 'value1'; + prop2 = 2; +} + +Sentry.captureException(new MyTestClass()); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/classInstance/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/test.ts new file mode 100644 index 000000000000..3a8865ec3672 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/classInstance/test.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an POJO', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'Object captured as exception with keys: prop1, prop2', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/empty_obj/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/emptyObj/subject.js similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/empty_obj/subject.js rename to packages/browser-integration-tests/suites/public-api/captureException/emptyObj/subject.js diff --git a/packages/browser-integration-tests/suites/public-api/captureException/empty_obj/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/emptyObj/test.ts similarity index 91% rename from packages/browser-integration-tests/suites/public-api/captureException/empty_obj/test.ts rename to packages/browser-integration-tests/suites/public-api/captureException/emptyObj/test.ts index 6ce86bfe7aeb..fa6b1dcb1562 100644 --- a/packages/browser-integration-tests/suites/public-api/captureException/empty_obj/test.ts +++ b/packages/browser-integration-tests/suites/public-api/captureException/emptyObj/test.ts @@ -12,7 +12,7 @@ sentryTest('should capture an empty object', async ({ getLocalTestPath, page }) expect(eventData.exception?.values).toHaveLength(1); expect(eventData.exception?.values?.[0]).toMatchObject({ type: 'Error', - value: 'Non-Error exception captured with keys: [object has no keys]', + value: 'Object captured as exception with keys: [object has no keys]', mechanism: { type: 'generic', handled: true, diff --git a/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/init.js b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/init.js new file mode 100644 index 000000000000..3796a084234a --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: false, +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/subject.js new file mode 100644 index 000000000000..207f9d1d58f6 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/subject.js @@ -0,0 +1,5 @@ +window.addEventListener('error', function (event) { + Sentry.captureException(event); +}); + +window.thisDoesNotExist(); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/test.ts new file mode 100644 index 000000000000..dbcaaf24a1cf --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/errorEvent/test.ts @@ -0,0 +1,25 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an ErrorEvent', async ({ getLocalTestPath, page, browserName }) => { + // On Firefox, the ErrorEvent has the `error` property and thus is handled separately + if (browserName === 'firefox') { + sentryTest.skip(); + } + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'ErrorEvent', + value: 'Event `ErrorEvent` captured as exception with message `Script error.`', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/event/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/event/subject.js new file mode 100644 index 000000000000..b5855af22829 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/event/subject.js @@ -0,0 +1 @@ +Sentry.captureException(new Event('custom')); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/event/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/event/test.ts new file mode 100644 index 000000000000..65c46a776731 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/event/test.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an Event', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Event', + value: 'Event `Event` (type=custom) captured as exception', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/plainObject/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/subject.js new file mode 100644 index 000000000000..ea827971bed4 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/subject.js @@ -0,0 +1,4 @@ +Sentry.captureException({ + prop1: 'value1', + prop2: 2, +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/plainObject/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/test.ts new file mode 100644 index 000000000000..e81fe0125906 --- /dev/null +++ b/packages/browser-integration-tests/suites/public-api/captureException/plainObject/test.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers'; + +sentryTest('should capture an class instance', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'Object captured as exception with keys: prop1, prop2', + mechanism: { + type: 'generic', + handled: true, + }, + }); +}); diff --git a/packages/browser-integration-tests/suites/public-api/captureException/simple_error/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/simpleError/subject.js similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/simple_error/subject.js rename to packages/browser-integration-tests/suites/public-api/captureException/simpleError/subject.js diff --git a/packages/browser-integration-tests/suites/public-api/captureException/simple_error/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/simpleError/test.ts similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/simple_error/test.ts rename to packages/browser-integration-tests/suites/public-api/captureException/simpleError/test.ts diff --git a/packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/subject.js b/packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/subject.js similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/subject.js rename to packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/subject.js diff --git a/packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/test.ts b/packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/test.ts similarity index 100% rename from packages/browser-integration-tests/suites/public-api/captureException/undefined_arg/test.ts rename to packages/browser-integration-tests/suites/public-api/captureException/undefinedArg/test.ts diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index 4db58b9b7b43..6acc1080c56f 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -14,6 +14,8 @@ import { resolvedSyncPromise, } from '@sentry/utils'; +type Prototype = { constructor: (...args: unknown[]) => unknown }; + /** * This function creates an exception from a JavaScript Error */ @@ -55,9 +57,7 @@ export function eventFromPlainObject( values: [ { type: isEvent(exception) ? exception.constructor.name : isUnhandledRejection ? 'UnhandledRejection' : 'Error', - value: `Non-Error ${ - isUnhandledRejection ? 'promise rejection' : 'exception' - } captured with keys: ${extractExceptionKeysForMessage(exception)}`, + value: getNonErrorObjectExceptionValue(exception, { isUnhandledRejection }), }, ], }, @@ -283,3 +283,33 @@ export function eventFromString( return event; } + +function getNonErrorObjectExceptionValue( + exception: Record, + { isUnhandledRejection }: { isUnhandledRejection?: boolean }, +): string { + const keys = extractExceptionKeysForMessage(exception); + const captureType = isUnhandledRejection ? 'promise rejection' : 'exception'; + + // Some ErrorEvent instances do not have an `error` property, which is why they are not handled before + // We still want to try to get a decent message for these cases + if (isErrorEvent(exception)) { + return `Event \`ErrorEvent\` captured as ${captureType} with message \`${exception.message}\``; + } + + if (isEvent(exception)) { + const className = getObjectClassName(exception); + return `Event \`${className}\` (type=${exception.type}) captured as ${captureType}`; + } + + return `Object captured as ${captureType} with keys: ${keys}`; +} + +function getObjectClassName(obj: unknown): string | undefined | void { + try { + const prototype: Prototype | null = Object.getPrototypeOf(obj); + return prototype ? prototype.constructor.name : undefined; + } catch (e) { + // ignore errors here + } +} diff --git a/packages/browser/test/integration/suites/onerror.js b/packages/browser/test/integration/suites/onerror.js index d5a7f155d723..3db1a755061a 100644 --- a/packages/browser/test/integration/suites/onerror.js +++ b/packages/browser/test/integration/suites/onerror.js @@ -61,7 +61,7 @@ describe('window.onerror', function () { } else { assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error exception captured with keys: error, somekey' + 'Object captured as exception with keys: error, somekey' ); } assert.equal(summary.events[0].exception.values[0].stacktrace.frames.length, 1); // always 1 because thrown objects can't provide > 1 frame @@ -119,7 +119,7 @@ describe('window.onerror', function () { assert.equal(summary.events[0].exception.values[0].type, 'Error'); assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error exception captured with keys: otherKey, type' + 'Object captured as exception with keys: otherKey, type' ); assert.deepEqual(summary.events[0].extra.__serialized__, { type: 'error', diff --git a/packages/browser/test/integration/suites/onunhandledrejection.js b/packages/browser/test/integration/suites/onunhandledrejection.js index d32708eb88cd..f9095d7c7333 100644 --- a/packages/browser/test/integration/suites/onunhandledrejection.js +++ b/packages/browser/test/integration/suites/onunhandledrejection.js @@ -77,7 +77,7 @@ describe('window.onunhandledrejection', function () { // non-error rejections don't provide stacktraces so we can skip that assertion assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error promise rejection captured with keys: currentTarget, isTrusted, target, type' + 'Event `Event` (type=unhandledrejection) captured as promise rejection' ); assert.equal(summary.events[0].exception.values[0].type, 'Event'); assert.equal(summary.events[0].exception.values[0].mechanism.handled, false); @@ -144,7 +144,7 @@ describe('window.onunhandledrejection', function () { // non-error rejections don't provide stacktraces so we can skip that assertion assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error promise rejection captured with keys: a, b, c' + 'Object captured as promise rejection with keys: a, b, c' ); assert.equal(summary.events[0].exception.values[0].type, 'UnhandledRejection'); assert.equal(summary.events[0].exception.values[0].mechanism.handled, false); @@ -172,7 +172,7 @@ describe('window.onunhandledrejection', function () { // non-error rejections don't provide stacktraces so we can skip that assertion assert.equal( summary.events[0].exception.values[0].value, - 'Non-Error promise rejection captured with keys: a, b, c, d, e' + 'Object captured as promise rejection with keys: a, b, c, d, e' ); assert.equal(summary.events[0].exception.values[0].type, 'UnhandledRejection'); assert.equal(summary.events[0].exception.values[0].mechanism.handled, false); diff --git a/packages/browser/test/unit/eventbuilder.test.ts b/packages/browser/test/unit/eventbuilder.test.ts index ac9b564e99e0..d7a2ab712959 100644 --- a/packages/browser/test/unit/eventbuilder.test.ts +++ b/packages/browser/test/unit/eventbuilder.test.ts @@ -23,6 +23,11 @@ jest.mock('@sentry/core', () => { }; }); +class MyTestClass { + prop1 = 'hello'; + prop2 = 2; +} + afterEach(() => { jest.resetAllMocks(); }); @@ -61,4 +66,18 @@ describe('eventFromPlainObject', () => { }, }); }); + + it.each([ + ['empty object', {}, 'Object captured as exception with keys: [object has no keys]'], + ['pojo', { prop1: 'hello', prop2: 2 }, 'Object captured as exception with keys: prop1, prop2'], + ['Custom Class', new MyTestClass(), 'Object captured as exception with keys: prop1, prop2'], + ['Event', new Event('custom'), 'Event `Event` (type=custom) captured as exception'], + ['MouseEvent', new MouseEvent('click'), 'Event `MouseEvent` (type=click) captured as exception'], + ] as [string, Record, string][])( + 'has correct exception value for %s', + (_name, exception, expected) => { + const actual = eventFromPlainObject(defaultStackParser, exception); + expect(actual.exception?.values?.[0]?.value).toEqual(expected); + }, + ); }); diff --git a/packages/browser/tsconfig.test-integration.json b/packages/browser/tsconfig.test-integration.json new file mode 100644 index 000000000000..06a946007c8b --- /dev/null +++ b/packages/browser/tsconfig.test-integration.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/integration/**/*"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node", "mocha", "chai", "sinon"] + + // other package-specific, test-specific options + } +} diff --git a/packages/browser/tsconfig.test.json b/packages/browser/tsconfig.test.json index 03bd27bbfdda..9bdd2aa76dab 100644 --- a/packages/browser/tsconfig.test.json +++ b/packages/browser/tsconfig.test.json @@ -2,10 +2,11 @@ "extends": "./tsconfig.json", "include": ["test/**/*"], + "exclude": ["test/integration/**/*"], "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["node", "mocha", "chai", "sinon", "jest"] + "types": ["node", "jest"] // other package-specific, test-specific options } From a34581d2893fb96624bac7f023c89c8c79e962c6 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 23 Jun 2023 08:21:41 -0400 Subject: [PATCH 16/28] feat(eslint): Add `no-return-await` rule to eslint config (#8388) Add https://eslint.org/docs/latest/rules/no-return-await to eslint config to remove usage of uneeded async/await calls. This helps reduce microtasks being generated, which can help reduce memory pressure caused by the SDK. The downside of removing `return await` is that stacktraces get slightly worse for async errors that use these methods, as we no longer pause execution on return for the engine to grab context on, but instead just pass through the promise, but I think it's worth it for this to be the default, and for us to opt-in to the better stacktraces if need be. --- packages/browser-integration-tests/utils/helpers.ts | 2 +- packages/browser-integration-tests/utils/replayHelpers.ts | 4 ++-- packages/e2e-tests/test-utils/event-proxy-server.ts | 4 ++-- packages/eslint-config-sdk/src/index.js | 3 +++ .../nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts | 4 ++-- .../src/client/wrapDocumentGetInitialPropsWithSentry.ts | 4 ++-- .../nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts | 4 ++-- packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts | 4 ++-- .../nextjs/src/client/wrapGetServerSidePropsWithSentry.ts | 4 ++-- packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts | 4 ++-- .../nextjs/src/common/devErrorSymbolicationEventProcessor.ts | 2 +- packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts | 4 ++-- packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts | 2 +- packages/nextjs/src/server/wrapApiHandlerWithSentry.ts | 4 ++-- .../src/server/wrapDocumentGetInitialPropsWithSentry.ts | 4 ++-- packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts | 2 +- packages/replay/src/util/sendReplay.ts | 2 +- packages/replay/test/integration/flush.test.ts | 4 ++-- packages/sveltekit/src/vite/svelteConfig.ts | 2 +- packages/utils/test/buildPolyfills/originals.js | 4 ++-- 20 files changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/browser-integration-tests/utils/helpers.ts b/packages/browser-integration-tests/utils/helpers.ts index 296b2fcdba91..525877e9763f 100644 --- a/packages/browser-integration-tests/utils/helpers.ts +++ b/packages/browser-integration-tests/utils/helpers.ts @@ -268,7 +268,7 @@ async function injectScriptAndGetEvents(page: Page, url: string, scriptPath: str await page.goto(url); await runScriptInSandbox(page, scriptPath); - return await getSentryEvents(page); + return getSentryEvents(page); } export { diff --git a/packages/browser-integration-tests/utils/replayHelpers.ts b/packages/browser-integration-tests/utils/replayHelpers.ts index ec332edea74d..bf070b395cf6 100644 --- a/packages/browser-integration-tests/utils/replayHelpers.ts +++ b/packages/browser-integration-tests/utils/replayHelpers.ts @@ -132,14 +132,14 @@ export async function waitForReplayRunning(page: Page): Promise { * Note that due to how this works with playwright, this is a POJO copy of replay. * This means that we cannot access any methods on it, and also not mutate it in any way. */ -export async function getReplaySnapshot(page: Page): Promise<{ +export function getReplaySnapshot(page: Page): Promise<{ _isPaused: boolean; _isEnabled: boolean; _context: InternalEventContext; session: Session | undefined; recordingMode: ReplayRecordingMode; }> { - return await page.evaluate(() => { + return page.evaluate(() => { const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay; const replay = replayIntegration._replay; diff --git a/packages/e2e-tests/test-utils/event-proxy-server.ts b/packages/e2e-tests/test-utils/event-proxy-server.ts index c61e20d4081d..b32910480f38 100644 --- a/packages/e2e-tests/test-utils/event-proxy-server.ts +++ b/packages/e2e-tests/test-utils/event-proxy-server.ts @@ -243,7 +243,7 @@ async function registerCallbackServerPort(serverName: string, port: string): Pro await writeFile(tmpFilePath, port, { encoding: 'utf8' }); } -async function retrieveCallbackServerPort(serverName: string): Promise { +function retrieveCallbackServerPort(serverName: string): Promise { const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); - return await readFile(tmpFilePath, 'utf8'); + return readFile(tmpFilePath, 'utf8'); } diff --git a/packages/eslint-config-sdk/src/index.js b/packages/eslint-config-sdk/src/index.js index 05ec68cff509..c070195ed083 100644 --- a/packages/eslint-config-sdk/src/index.js +++ b/packages/eslint-config-sdk/src/index.js @@ -261,5 +261,8 @@ module.exports = { 'array-callback-return': ['error', { allowImplicit: true }], quotes: ['error', 'single', { avoidEscape: true }], + + // Remove uncessary usages of async await to prevent extra micro-tasks + 'no-return-await': 'error', }, }; diff --git a/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts index 8c757be6a3c8..e5f8c40847ff 100644 --- a/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapAppGetInitialPropsWithSentry.ts @@ -8,8 +8,8 @@ type AppGetInitialProps = (typeof App)['getInitialProps']; */ export function wrapAppGetInitialPropsWithSentry(origAppGetInitialProps: AppGetInitialProps): AppGetInitialProps { return new Proxy(origAppGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts index 0af40a1f3f84..20669a0af9f6 100644 --- a/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapDocumentGetInitialPropsWithSentry.ts @@ -10,8 +10,8 @@ export function wrapDocumentGetInitialPropsWithSentry( origDocumentGetInitialProps: DocumentGetInitialProps, ): DocumentGetInitialProps { return new Proxy(origDocumentGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts index 605efa58eff9..ab32a2bf93cc 100644 --- a/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapErrorGetInitialPropsWithSentry.ts @@ -11,8 +11,8 @@ export function wrapErrorGetInitialPropsWithSentry( origErrorGetInitialProps: ErrorGetInitialProps, ): ErrorGetInitialProps { return new Proxy(origErrorGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts b/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts index 1fbbd8707063..37004f04bc6e 100644 --- a/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapGetInitialPropsWithSentry.ts @@ -8,8 +8,8 @@ type GetInitialProps = Required['getInitialProps']; */ export function wrapGetInitialPropsWithSentry(origGetInitialProps: GetInitialProps): GetInitialProps { return new Proxy(origGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts b/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts index 2235016856f4..50450c053a15 100644 --- a/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapGetServerSidePropsWithSentry.ts @@ -6,8 +6,8 @@ import type { GetServerSideProps } from 'next'; */ export function wrapGetServerSidePropsWithSentry(origGetServerSideProps: GetServerSideProps): GetServerSideProps { return new Proxy(origGetServerSideProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts b/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts index 735a3cd8a936..3b99737bcf20 100644 --- a/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts +++ b/packages/nextjs/src/client/wrapGetStaticPropsWithSentry.ts @@ -8,8 +8,8 @@ type Props = { [key: string]: unknown }; */ export function wrapGetStaticPropsWithSentry(origGetStaticProps: GetStaticProps): GetStaticProps { return new Proxy(origGetStaticProps, { - apply: async (wrappingTarget, thisArg, args: Parameters>) => { - return await wrappingTarget.apply(thisArg, args); + apply: (wrappingTarget, thisArg, args: Parameters>) => { + return wrappingTarget.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 7e3fd8baae24..d516780c0257 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -116,7 +116,7 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev const frames = stackTraceParser.parse(hint.originalException.stack); const resolvedFrames = await Promise.all( - frames.map(async frame => await resolveStackFrame(frame, hint.originalException as Error)), + frames.map(frame => resolveStackFrame(frame, hint.originalException as Error)), ); if (event.exception?.values?.[0].stacktrace?.frames) { diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index ef228abc40e9..ebfdbf5fdf74 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -11,7 +11,7 @@ export function wrapApiHandlerWithSentry( parameterizedRoute: string, ): (...params: Parameters) => Promise> { return new Proxy(handler, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { const req = args[0]; const activeSpan = !!getCurrentHub().getScope()?.getSpan(); @@ -25,7 +25,7 @@ export function wrapApiHandlerWithSentry( mechanismFunctionName: 'wrapApiHandlerWithSentry', }); - return await wrappedHandler.apply(thisArg, args); + return wrappedHandler.apply(thisArg, args); }, }); } diff --git a/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts index 18c16f1a4198..831a50eb8629 100644 --- a/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/edge/wrapMiddlewareWithSentry.ts @@ -11,7 +11,7 @@ export function wrapMiddlewareWithSentry( middleware: H, ): (...params: Parameters) => Promise> { return new Proxy(middleware, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { return withEdgeWrapping(wrappingTarget, { spanDescription: 'middleware', spanOp: 'middleware.nextjs', diff --git a/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts index 3ebf97d4b614..9aecbd9a6c6b 100644 --- a/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/server/wrapApiHandlerWithSentry.ts @@ -26,7 +26,7 @@ import { autoEndTransactionOnResponseEnd, finishTransaction, flushQueue } from ' */ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameterizedRoute: string): NextApiHandler { return new Proxy(apiHandler, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { // eslint-disable-next-line deprecation/deprecation return withSentry(wrappingTarget, parameterizedRoute).apply(thisArg, args); }, @@ -49,7 +49,7 @@ export const withSentryAPI = wrapApiHandlerWithSentry; */ export function withSentry(apiHandler: NextApiHandler, parameterizedRoute?: string): NextApiHandler { return new Proxy(apiHandler, { - apply: async (wrappingTarget, thisArg, args: [AugmentedNextApiRequest, AugmentedNextApiResponse]) => { + apply: (wrappingTarget, thisArg, args: [AugmentedNextApiRequest, AugmentedNextApiResponse]) => { const [req, res] = args; // We're now auto-wrapping API route handlers using `wrapApiHandlerWithSentry` (which uses `withSentry` under the hood), but diff --git a/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts b/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts index e7d0d6eac621..1d821d86dcda 100644 --- a/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts +++ b/packages/nextjs/src/server/wrapDocumentGetInitialPropsWithSentry.ts @@ -19,7 +19,7 @@ export function wrapDocumentGetInitialPropsWithSentry( origDocumentGetInitialProps: DocumentGetInitialProps, ): DocumentGetInitialProps { return new Proxy(origDocumentGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { + apply: (wrappingTarget, thisArg, args: Parameters) => { if (isBuild()) { return wrappingTarget.apply(thisArg, args); } @@ -41,7 +41,7 @@ export function wrapDocumentGetInitialPropsWithSentry( dataFetchingMethodName: 'getInitialProps', }); - return await tracedGetInitialProps.apply(thisArg, args); + return tracedGetInitialProps.apply(thisArg, args); } else { return errorWrappedGetInitialProps.apply(thisArg, args); } diff --git a/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts b/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts index d8c7cc8f68ab..78f910dfb0e4 100644 --- a/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts +++ b/packages/nextjs/src/server/wrapGetStaticPropsWithSentry.ts @@ -19,7 +19,7 @@ export function wrapGetStaticPropsWithSentry( parameterizedRoute: string, ): GetStaticProps { return new Proxy(origGetStaticPropsa, { - apply: async (wrappingTarget, thisArg, args: Parameters>) => { + apply: (wrappingTarget, thisArg, args: Parameters>) => { if (isBuild()) { return wrappingTarget.apply(thisArg, args); } diff --git a/packages/replay/src/util/sendReplay.ts b/packages/replay/src/util/sendReplay.ts index f10bb223d3d9..5f10011182c6 100644 --- a/packages/replay/src/util/sendReplay.ts +++ b/packages/replay/src/util/sendReplay.ts @@ -57,7 +57,7 @@ export async function sendReplay( // will retry in intervals of 5, 10, 30 retryConfig.interval *= ++retryConfig.count; - return await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { setTimeout(async () => { try { await sendReplay(replayData, retryConfig); diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index 18c7a86ca188..cf142ae8c45c 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -135,8 +135,8 @@ describe('Integration | flush', () => { it('long first flush enqueues following events', async () => { // Mock this to resolve after 20 seconds so that we can queue up following flushes - mockAddPerformanceEntries.mockImplementationOnce(async () => { - return await new Promise(resolve => setTimeout(resolve, 20000)); + mockAddPerformanceEntries.mockImplementationOnce(() => { + return new Promise(resolve => setTimeout(resolve, 20000)); }); expect(mockAddPerformanceEntries).not.toHaveBeenCalled(); diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index 07c701e912f1..4e69ad8ef3b0 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -52,7 +52,7 @@ export function getHooksFileName(svelteConfig: Config, hookType: 'client' | 'ser */ export async function getAdapterOutputDir(svelteConfig: Config, adapter: SupportedSvelteKitAdapters): Promise { if (adapter === 'node') { - return await getNodeAdapterOutputDir(svelteConfig); + return getNodeAdapterOutputDir(svelteConfig); } // Auto and Vercel adapters simply use config.kit.outDir diff --git a/packages/utils/test/buildPolyfills/originals.js b/packages/utils/test/buildPolyfills/originals.js index d3dcb22e8082..969591755367 100644 --- a/packages/utils/test/buildPolyfills/originals.js +++ b/packages/utils/test/buildPolyfills/originals.js @@ -2,11 +2,11 @@ // the modified versions do the same thing the originals do. // From Sucrase -export async function _asyncNullishCoalesce(lhs, rhsFn) { +export function _asyncNullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { - return await rhsFn(); + return rhsFn(); } } From c77555e84c0df7abbe9b5806f6e8ea160f2fcaa9 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 23 Jun 2023 09:24:48 -0400 Subject: [PATCH 17/28] ref(replay): Remove circular dep in replay eventBuffer (#8389) When running `yarn build:watch` I found some circular deps: ``` src/eventBuffer/index.ts -> src/eventBuffer/EventBufferArray.ts -> src/eventBuffer/index.ts src/eventBuffer/index.ts -> src/eventBuffer/EventBufferProxy.ts -> src/eventBuffer/EventBufferCompressionWorker.ts -> src/eventBuffer/index.ts ``` This refactors the `EventBufferSizeExceededError` export to remove that. --- packages/replay/src/eventBuffer/EventBufferArray.ts | 2 +- .../src/eventBuffer/EventBufferCompressionWorker.ts | 2 +- packages/replay/src/eventBuffer/error.ts | 8 ++++++++ packages/replay/src/eventBuffer/index.ts | 8 -------- packages/replay/src/util/addEvent.ts | 2 +- .../replay/test/unit/eventBuffer/EventBufferArray.test.ts | 5 +++-- .../unit/eventBuffer/EventBufferCompressionWorker.test.ts | 3 ++- 7 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 packages/replay/src/eventBuffer/error.ts diff --git a/packages/replay/src/eventBuffer/EventBufferArray.ts b/packages/replay/src/eventBuffer/EventBufferArray.ts index a7b363891026..a4a823269ece 100644 --- a/packages/replay/src/eventBuffer/EventBufferArray.ts +++ b/packages/replay/src/eventBuffer/EventBufferArray.ts @@ -1,7 +1,7 @@ import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; -import { EventBufferSizeExceededError } from '.'; +import { EventBufferSizeExceededError } from './error'; /** * A basic event buffer that does not do any compression. diff --git a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts index 5b4c0eb4487a..695114ebec77 100644 --- a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts +++ b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts @@ -3,7 +3,7 @@ import type { ReplayRecordingData } from '@sentry/types'; import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; -import { EventBufferSizeExceededError } from '.'; +import { EventBufferSizeExceededError } from './error'; import { WorkerHandler } from './WorkerHandler'; /** diff --git a/packages/replay/src/eventBuffer/error.ts b/packages/replay/src/eventBuffer/error.ts new file mode 100644 index 000000000000..1d60388d42d7 --- /dev/null +++ b/packages/replay/src/eventBuffer/error.ts @@ -0,0 +1,8 @@ +import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; + +/** This error indicates that the event buffer size exceeded the limit.. */ +export class EventBufferSizeExceededError extends Error { + public constructor() { + super(`Event buffer exceeded maximum size of ${REPLAY_MAX_EVENT_BUFFER_SIZE}.`); + } +} diff --git a/packages/replay/src/eventBuffer/index.ts b/packages/replay/src/eventBuffer/index.ts index fe58b76f3c7b..f0eb83c68243 100644 --- a/packages/replay/src/eventBuffer/index.ts +++ b/packages/replay/src/eventBuffer/index.ts @@ -1,7 +1,6 @@ import { getWorkerURL } from '@sentry-internal/replay-worker'; import { logger } from '@sentry/utils'; -import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import type { EventBuffer } from '../types'; import { EventBufferArray } from './EventBufferArray'; import { EventBufferProxy } from './EventBufferProxy'; @@ -31,10 +30,3 @@ export function createEventBuffer({ useCompression }: CreateEventBufferParams): __DEBUG_BUILD__ && logger.log('[Replay] Using simple buffer'); return new EventBufferArray(); } - -/** This error indicates that the event buffer size exceeded the limit.. */ -export class EventBufferSizeExceededError extends Error { - public constructor() { - super(`Event buffer exceeded maximum size of ${REPLAY_MAX_EVENT_BUFFER_SIZE}.`); - } -} diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index 79bf4ff2f362..9533c1690dd8 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -2,7 +2,7 @@ import { EventType } from '@sentry-internal/rrweb'; import { getCurrentHub } from '@sentry/core'; import { logger } from '@sentry/utils'; -import { EventBufferSizeExceededError } from '../eventBuffer'; +import { EventBufferSizeExceededError } from '../eventBuffer/error'; import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent } from '../types'; import { timestampToMs } from './timestampToMs'; diff --git a/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts b/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts index c7b0a4bd7e90..494d03e9572f 100644 --- a/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts +++ b/packages/replay/test/unit/eventBuffer/EventBufferArray.test.ts @@ -1,6 +1,7 @@ import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants'; -import { createEventBuffer, EventBufferSizeExceededError } from './../../../src/eventBuffer'; -import { BASE_TIMESTAMP } from './../../index'; +import { createEventBuffer } from '../../../src/eventBuffer'; +import { EventBufferSizeExceededError } from '../../../src/eventBuffer/error'; +import { BASE_TIMESTAMP } from '../../index'; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; diff --git a/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts b/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts index 6c3e5948fac1..cab6855e411d 100644 --- a/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts +++ b/packages/replay/test/unit/eventBuffer/EventBufferCompressionWorker.test.ts @@ -4,8 +4,9 @@ import pako from 'pako'; import { BASE_TIMESTAMP } from '../..'; import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants'; +import { createEventBuffer } from '../../../src/eventBuffer'; +import { EventBufferSizeExceededError } from '../../../src/eventBuffer/error'; import { EventBufferProxy } from '../../../src/eventBuffer/EventBufferProxy'; -import { createEventBuffer, EventBufferSizeExceededError } from './../../../src/eventBuffer'; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; From 98d3916351a3d5849c94328568a48dc2dbe13c34 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 23 Jun 2023 12:22:26 -0400 Subject: [PATCH 18/28] feat(types): Add tracePropagationTargets to top level options (#8395) --- packages/node/src/integrations/http.ts | 8 ++++++++ packages/node/src/types.ts | 20 +------------------- packages/types/src/options.ts | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 5e2ce9e253a2..970c60f75839 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -20,6 +20,12 @@ interface TracingOptions { * requests. If this option is provided, the SDK will match the * request URL of outgoing requests against the items in this * array, and only attach tracing headers if a match was found. + * + * @deprecated Use top level `tracePropagationTargets` option instead. + * ``` + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }) */ tracePropagationTargets?: TracePropagationTargets; @@ -156,6 +162,7 @@ function _createWrappedRequestMethodFactory( }; const shouldAttachTraceData = (url: string): boolean => { + // eslint-disable-next-line deprecation/deprecation if (tracingOptions?.tracePropagationTargets === undefined) { return true; } @@ -165,6 +172,7 @@ function _createWrappedRequestMethodFactory( return cachedDecision; } + // eslint-disable-next-line deprecation/deprecation const decision = stringMatchesSomePattern(url, tracingOptions.tracePropagationTargets); headersUrlMap.set(url, decision); return decision; diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3e464d1c6457..b0ecb354dd82 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, SamplingContext, TracePropagationTargets } from '@sentry/types'; +import type { ClientOptions, Options, SamplingContext } from '@sentry/types'; import type { NodeTransportOptions } from './transports'; @@ -32,24 +32,6 @@ export interface BaseNodeOptions { */ includeLocalVariables?: boolean; - /** - * List of strings/regex controlling to which outgoing requests - * the SDK will attach tracing headers. - * - * By default the SDK will attach those headers to all outgoing - * requests. If this option is provided, the SDK will match the - * request URL of outgoing requests against the items in this - * array, and only attach tracing headers if a match was found. - * - * @example - * ```js - * Sentry.init({ - * tracePropagationTargets: ['api.site.com'], - * }); - * ``` - */ - tracePropagationTargets?: TracePropagationTargets; - // TODO (v8): Remove this in v8 /** * @deprecated Moved to constructor options of the `Http` and `Undici` integration. diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index 0f8a163b2615..60d747136d90 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -5,6 +5,7 @@ import type { Integration } from './integration'; import type { CaptureContext } from './scope'; import type { SdkMetadata } from './sdkmetadata'; import type { StackLineParser, StackParser } from './stacktrace'; +import type { TracePropagationTargets } from './tracing'; import type { SamplingContext } from './transaction'; import type { BaseTransportOptions, Transport } from './transport'; @@ -221,6 +222,24 @@ export interface ClientOptions; + /** + * List of strings/regex controlling to which outgoing requests + * the SDK will attach tracing headers. + * + * By default the SDK will attach those headers to all outgoing + * requests. If this option is provided, the SDK will match the + * request URL of outgoing requests against the items in this + * array, and only attach tracing headers if a match was found. + * + * @example + * ```js + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }); + * ``` + */ + tracePropagationTargets?: TracePropagationTargets; + /** * Function to compute tracing sample rate dynamically and filter unwanted traces. * From e583fe9db2ee32a52b0e873b5ea89ee7c6a40a76 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 26 Jun 2023 10:46:13 +0200 Subject: [PATCH 19/28] fix(sveltekit): Check for cached requests in client-side fetch instrumentation (#8391) As outlined in https://github.com/getsentry/sentry-javascript/issues/8174#issuecomment-1557042801, our current SvelteKit fetch instrumentation breaks SvelteKit's request caching mechanism. This is problematic as `fetch` requests from universal `load` functions were made again on the client side during hydration although the response was already cached from the initial server-side request. The reason for the cache miss is that in the instrumentation we add our tracing headers to the requests, which lead to a different cache key than the one produced on the server side. This fix vendors in code from [the SvelteKit repo](https://github.com/sveltejs/kit) so that we can perform the same cache lookup in our instrumentation. If the lookup was successful (--> cache hit), we won't attach any headers or create breadcrumbs to 1. let Kit's fetch return the cached response and 2. not add spans for a fetch request that didn't even happen. --- packages/sveltekit/src/client/load.ts | 7 ++ .../src/client/vendor/buildSelector.ts | 57 +++++++++++++ packages/sveltekit/src/client/vendor/hash.ts | 51 ++++++++++++ .../src/client/vendor/lookUpCache.ts | 79 +++++++++++++++++++ packages/sveltekit/test/client/load.test.ts | 7 +- .../test/client/vendor/lookUpCache.test.ts | 45 +++++++++++ packages/sveltekit/test/vitest.setup.ts | 5 ++ 7 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 packages/sveltekit/src/client/vendor/buildSelector.ts create mode 100644 packages/sveltekit/src/client/vendor/hash.ts create mode 100644 packages/sveltekit/src/client/vendor/lookUpCache.ts create mode 100644 packages/sveltekit/test/client/vendor/lookUpCache.test.ts diff --git a/packages/sveltekit/src/client/load.ts b/packages/sveltekit/src/client/load.ts index bbc184b6d3a0..e56d33b2e23c 100644 --- a/packages/sveltekit/src/client/load.ts +++ b/packages/sveltekit/src/client/load.ts @@ -17,6 +17,7 @@ import type { LoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; import { isRedirect } from '../common/utils'; +import { isRequestCached } from './vendor/lookUpCache'; type PatchedLoadEvent = LoadEvent & Partial; @@ -153,6 +154,11 @@ function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch return new Proxy(originalFetch, { apply: (wrappingTarget, thisArg, args: Parameters) => { const [input, init] = args; + + if (isRequestCached(input, init)) { + return wrappingTarget.apply(thisArg, args); + } + const { url: rawUrl, method } = parseFetchArgs(args); // TODO: extract this to a util function (and use it in breadcrumbs integration as well) @@ -196,6 +202,7 @@ function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch patchedInit.headers = headers; } + let fetchPromise: Promise; const patchedFetchArgs = [input, patchedInit]; diff --git a/packages/sveltekit/src/client/vendor/buildSelector.ts b/packages/sveltekit/src/client/vendor/buildSelector.ts new file mode 100644 index 000000000000..9ff0ddebe7c7 --- /dev/null +++ b/packages/sveltekit/src/client/vendor/buildSelector.ts @@ -0,0 +1,57 @@ +/* eslint-disable @sentry-internal/sdk/no-optional-chaining */ + +// Vendored from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js +// with types only changes. + +// The MIT License (MIT) + +// Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) + +// 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 { hash } from './hash'; + +/** + * Build the cache key for a given request + * @param {URL | RequestInfo} resource + * @param {RequestInit} [opts] + */ +export function build_selector(resource: URL | RequestInfo, opts: RequestInit | undefined): string { + const url = JSON.stringify(resource instanceof Request ? resource.url : resource); + + let selector = `script[data-sveltekit-fetched][data-url=${url}]`; + + if (opts?.headers || opts?.body) { + /** @type {import('types').StrictBody[]} */ + const values = []; + + if (opts.headers) { + // @ts-ignore - TS complains but this is a 1:1 copy of the original code and apparently it works + values.push([...new Headers(opts.headers)].join(',')); + } + + if (opts.body && (typeof opts.body === 'string' || ArrayBuffer.isView(opts.body))) { + values.push(opts.body); + } + + selector += `[data-hash="${hash(...values)}"]`; + } + + return selector; +} diff --git a/packages/sveltekit/src/client/vendor/hash.ts b/packages/sveltekit/src/client/vendor/hash.ts new file mode 100644 index 000000000000..1723dac703a6 --- /dev/null +++ b/packages/sveltekit/src/client/vendor/hash.ts @@ -0,0 +1,51 @@ +/* eslint-disable no-bitwise */ + +// Vendored from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/hash.js +// with types only changes. + +// The MIT License (MIT) + +// Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import type { StrictBody } from '@sveltejs/kit/types/internal'; + +/** + * Hash using djb2 + * @param {import('types').StrictBody[]} values + */ +export function hash(...values: StrictBody[]): string { + let hash = 5381; + + for (const value of values) { + if (typeof value === 'string') { + let i = value.length; + while (i) hash = (hash * 33) ^ value.charCodeAt(--i); + } else if (ArrayBuffer.isView(value)) { + const buffer = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + let i = buffer.length; + while (i) hash = (hash * 33) ^ buffer[--i]; + } else { + throw new TypeError('value must be a string or TypedArray'); + } + } + + return (hash >>> 0).toString(36); +} diff --git a/packages/sveltekit/src/client/vendor/lookUpCache.ts b/packages/sveltekit/src/client/vendor/lookUpCache.ts new file mode 100644 index 000000000000..afcaf676b40d --- /dev/null +++ b/packages/sveltekit/src/client/vendor/lookUpCache.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-bitwise */ + +// Parts of this code are taken from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js +// Attribution given directly in the function code below + +// The MIT License (MIT) + +// Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors) + +// 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 { WINDOW } from '@sentry/svelte'; +import { getDomElement } from '@sentry/utils'; + +import { build_selector } from './buildSelector'; + +/** + * Checks if a request is cached by looking for a script tag with the same selector as the constructed selector of the request. + * + * This function is a combination of the cache lookups in sveltekit's internal client-side fetch functions + * - initial_fetch (used during hydration) https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L76 + * - subsequent_fetch (used afterwards) https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L98 + * + * Parts of this function's logic is taken from SvelteKit source code. + * These lines are annotated with attribution in comments above them. + * + * @param input first fetch param + * @param init second fetch param + * @returns true if a cache hit was encountered, false otherwise + */ +export function isRequestCached(input: URL | RequestInfo, init: RequestInit | undefined): boolean { + // build_selector call copied from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L77 + const selector = build_selector(input, init); + + const script = getDomElement(selector); + + if (!script) { + return false; + } + + // If the script has a data-ttl attribute, we check if we're still in the TTL window: + try { + // ttl retrieval taken from https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L83-L84 + const ttl = Number(script.getAttribute('data-ttl')) * 1000; + + if (isNaN(ttl)) { + return false; + } + + if (ttl) { + // cache hit determination taken from: https://github.com/sveltejs/kit/blob/1c1ddd5e34fce28e6f89299d6c59162bed087589/packages/kit/src/runtime/client/fetcher.js#L105-L106 + return ( + WINDOW.performance.now() < ttl && + ['default', 'force-cache', 'only-if-cached', undefined].includes(init && init.cache) + ); + } + } catch { + return false; + } + + // Otherwise, we check if the script has a content and return true in that case + return !!script.textContent; +} diff --git a/packages/sveltekit/test/client/load.test.ts b/packages/sveltekit/test/client/load.test.ts index 65b4cc1da3b1..07608bb9845a 100644 --- a/packages/sveltekit/test/client/load.test.ts +++ b/packages/sveltekit/test/client/load.test.ts @@ -27,6 +27,12 @@ vi.mock('@sentry/svelte', async () => { }; }); +vi.mock('../../src/client/vendor/lookUpCache', () => { + return { + isRequestCached: () => false, + }; +}); + const mockTrace = vi.fn(); const mockedBrowserTracing = { @@ -433,7 +439,6 @@ describe('wrapLoadWithSentry', () => { ['is undefined', undefined], ["doesn't have a `getClientById` method", {}], ])("doesn't instrument fetch if the client %s", async (_, client) => { - // @ts-expect-error: we're mocking the client mockedGetClient.mockImplementationOnce(() => client); async function load(_event: Parameters[0]): Promise> { diff --git a/packages/sveltekit/test/client/vendor/lookUpCache.test.ts b/packages/sveltekit/test/client/vendor/lookUpCache.test.ts new file mode 100644 index 000000000000..29b13494be12 --- /dev/null +++ b/packages/sveltekit/test/client/vendor/lookUpCache.test.ts @@ -0,0 +1,45 @@ +import { JSDOM } from 'jsdom'; +import { vi } from 'vitest'; + +import { isRequestCached } from '../../../src/client/vendor/lookUpCache'; + +globalThis.document = new JSDOM().window.document; + +vi.useFakeTimers().setSystemTime(new Date('2023-06-22')); +vi.spyOn(performance, 'now').mockReturnValue(1000); + +describe('isRequestCached', () => { + it('should return true if a script tag with the same selector as the constructed request selector is found', () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(true); + }); + + it('should return false if a script with the same selector as the constructed request selector is not found', () => { + globalThis.document.body.innerHTML = ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(false); + }); + + it('should return true if a script with the same selector as the constructed request selector is found and its TTL is valid', () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(true); + }); + + it('should return false if a script with the same selector as the constructed request selector is found and its TTL is expired', () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(false); + }); + + it("should return false if the TTL is set but can't be parsed as a number", () => { + globalThis.document.body.innerHTML = + ''; + + expect(isRequestCached('/api/todos/1', undefined)).toBe(false); + }); +}); diff --git a/packages/sveltekit/test/vitest.setup.ts b/packages/sveltekit/test/vitest.setup.ts index 48c9b0e33528..af2810a98a96 100644 --- a/packages/sveltekit/test/vitest.setup.ts +++ b/packages/sveltekit/test/vitest.setup.ts @@ -11,3 +11,8 @@ export function setup() { }; }); } + +if (!globalThis.fetch) { + // @ts-ignore - Needed for vitest to work with SvelteKit fetch instrumentation + globalThis.Request = class Request {}; +} From 22b5887411f924058c33d875123fb526ac609f4e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 26 Jun 2023 08:43:43 -0400 Subject: [PATCH 20/28] ref: Remove undefined checks for `hub.getScope()` (#8401) --- .../nextjs-app-dir/components/transaction-context.tsx | 2 +- packages/ember/addon/index.ts | 2 +- packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts | 2 +- packages/nextjs/test/edge/withSentryAPI.test.ts | 4 ++-- .../express/sentry-trace/baggage-header-out/server.ts | 2 +- .../sentry-trace/baggage-transaction-name/server.ts | 2 +- packages/node/src/handlers.ts | 4 ++-- packages/node/src/integrations/http.ts | 8 ++------ packages/node/src/sdk.ts | 2 +- packages/node/test/handlers.test.ts | 4 ++-- packages/node/test/integrations/http.test.ts | 4 ++-- packages/node/test/integrations/requestdata.test.ts | 2 +- packages/opentelemetry-node/test/spanprocessor.test.ts | 2 +- packages/remix/src/utils/instrumentServer.ts | 2 +- packages/replay/src/util/sendReplayRequest.ts | 2 +- .../test/integration/coreHandlers/handleScope.test.ts | 4 ++-- packages/svelte/src/performance.ts | 4 +--- packages/tracing-internal/src/browser/request.ts | 6 ++---- packages/tracing/test/hub.test.ts | 6 +++--- packages/vue/src/tracing.ts | 3 +-- 20 files changed, 29 insertions(+), 38 deletions(-) diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx index a357439bee1f..384b06ab5528 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx @@ -29,7 +29,7 @@ export function TransactionContextProvider({ children }: PropsWithChildren) { transactionActive: false, start: (transactionName: string) => { const t = startTransaction({ name: transactionName }); - getCurrentHub().getScope()?.setSpan(t); + getCurrentHub().getScope().setSpan(t); setTransaction(t); }, } diff --git a/packages/ember/addon/index.ts b/packages/ember/addon/index.ts index cf1f7e2f23b9..2db7ac4192f6 100644 --- a/packages/ember/addon/index.ts +++ b/packages/ember/addon/index.ts @@ -63,7 +63,7 @@ export function InitSentryForEmber(_runtimeConfig?: BrowserOptions) { } export const getActiveTransaction = () => { - return Sentry.getCurrentHub()?.getScope()?.getTransaction(); + return Sentry.getCurrentHub().getScope().getTransaction(); }; export const instrumentRoutePerformance = (BaseRoute: any) => { diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index ebfdbf5fdf74..f903d77f46c4 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -14,7 +14,7 @@ export function wrapApiHandlerWithSentry( apply: (wrappingTarget, thisArg, args: Parameters) => { const req = args[0]; - const activeSpan = !!getCurrentHub().getScope()?.getSpan(); + const activeSpan = getCurrentHub().getScope().getSpan(); const wrappedHandler = withEdgeWrapping(wrappingTarget, { spanDescription: diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts index 08a91e0c5e11..a991ecf88e6b 100644 --- a/packages/nextjs/test/edge/withSentryAPI.test.ts +++ b/packages/nextjs/test/edge/withSentryAPI.test.ts @@ -73,7 +73,7 @@ describe('wrapApiHandlerWithSentry', () => { it('should return a function that starts a span on the current transaction with the correct description when there is an active transaction and no request is being passed', async () => { const testTransaction = coreSdk.startTransaction({ name: 'testTransaction' }); - coreSdk.getCurrentHub().getScope()?.setSpan(testTransaction); + coreSdk.getCurrentHub().getScope().setSpan(testTransaction); const startChildSpy = jest.spyOn(testTransaction, 'startChild'); @@ -92,6 +92,6 @@ describe('wrapApiHandlerWithSentry', () => { ); testTransaction.finish(); - coreSdk.getCurrentHub().getScope()?.setSpan(undefined); + coreSdk.getCurrentHub().getScope().setSpan(undefined); }); }); diff --git a/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts b/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts index 16f5371bc1ee..f582288f8314 100644 --- a/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts +++ b/packages/node-integration-tests/suites/express/sentry-trace/baggage-header-out/server.ts @@ -25,7 +25,7 @@ app.use(Sentry.Handlers.tracingHandler()); app.use(cors()); app.get('/test/express', (_req, res) => { - const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const transaction = Sentry.getCurrentHub().getScope().getTransaction(); if (transaction) { transaction.traceId = '86f39e84263a4de99c326acab3bfe3bd'; } diff --git a/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts b/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts index 3d8afeaed2f8..78e24ffa9f10 100644 --- a/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts +++ b/packages/node-integration-tests/suites/express/sentry-trace/baggage-transaction-name/server.ts @@ -27,7 +27,7 @@ app.use(Sentry.Handlers.tracingHandler()); app.use(cors()); app.get('/test/express', (_req, res) => { - const transaction = Sentry.getCurrentHub().getScope()?.getTransaction(); + const transaction = Sentry.getCurrentHub().getScope().getTransaction(); if (transaction) { transaction.traceId = '86f39e84263a4de99c326acab3bfe3bd'; transaction.setMetadata({ source: 'route' }); diff --git a/packages/node/src/handlers.ts b/packages/node/src/handlers.ts index 3e10c09eb903..95a4cfe65e38 100644 --- a/packages/node/src/handlers.ts +++ b/packages/node/src/handlers.ts @@ -163,7 +163,7 @@ export function requestHandler( // If Scope contains a Single mode Session, it is removed in favor of using Session Aggregates mode const scope = currentHub.getScope(); - if (scope && scope.getSession()) { + if (scope.getSession()) { scope.setSession(); } } @@ -339,7 +339,7 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) { return function ({ path, type, next, rawInput }: TrpcMiddlewareArguments): T { const hub = getCurrentHub(); const clientOptions = hub.getClient()?.getOptions(); - const sentryTransaction = hub.getScope()?.getTransaction(); + const sentryTransaction = hub.getScope().getTransaction(); if (sentryTransaction) { sentryTransaction.setName(`trpc/${path}`, 'route'); diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 970c60f75839..54d761861348 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -192,9 +192,7 @@ function _createWrappedRequestMethodFactory( } let requestSpan: Span | undefined; - let parentSpan: Span | undefined; - - const scope = getCurrentHub().getScope(); + const parentSpan = getCurrentHub().getScope().getSpan(); const method = requestOptions.method || 'GET'; const requestSpanData: SanitizedRequestData = { @@ -210,9 +208,7 @@ function _createWrappedRequestMethodFactory( requestSpanData['http.query'] = requestOptions.search.substring(1); } - if (scope && tracingOptions && shouldCreateSpan(rawRequestUrl)) { - parentSpan = scope.getSpan(); - + if (tracingOptions && shouldCreateSpan(rawRequestUrl)) { if (parentSpan) { requestSpan = parentSpan.startChild({ description: `${method} ${requestSpanData.url}`, diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index d0a02c746247..9e55fd4b5a84 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -276,7 +276,7 @@ function startSessionTracking(): void { // such as calling process.exit() or uncaught exceptions. // Ref: https://nodejs.org/api/process.html#process_event_beforeexit process.on('beforeExit', () => { - const session = hub.getScope()?.getSession(); + const session = hub.getScope().getSession(); const terminalStates: SessionStatus[] = ['exited', 'crashed']; // Only call endSession, if the Session exists on Scope and SessionStatus is not a // Terminal Status i.e. Exited or Crashed because diff --git a/packages/node/test/handlers.test.ts b/packages/node/test/handlers.test.ts index 3cf5127ce848..046bdf0bb31f 100644 --- a/packages/node/test/handlers.test.ts +++ b/packages/node/test/handlers.test.ts @@ -319,7 +319,7 @@ describe('tracingHandler', () => { sentryTracingMiddleware(req, res, next); - const transaction = sentryCore.getCurrentHub().getScope()?.getTransaction(); + const transaction = sentryCore.getCurrentHub().getScope().getTransaction(); expect(transaction).toBeDefined(); expect(transaction).toEqual( @@ -439,7 +439,7 @@ describe('tracingHandler', () => { sentryTracingMiddleware(req, res, next); - const transaction = sentryCore.getCurrentHub().getScope()?.getTransaction(); + const transaction = sentryCore.getCurrentHub().getScope().getTransaction(); expect(transaction?.metadata.request).toEqual(req); }); diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts index 6481e9481bf2..3f5a87d15363 100644 --- a/packages/node/test/integrations/http.test.ts +++ b/packages/node/test/integrations/http.test.ts @@ -49,7 +49,7 @@ describe('tracing', () => { ...customContext, }); - hub.getScope()?.setSpan(transaction); + hub.getScope().setSpan(transaction); return transaction; } @@ -266,7 +266,7 @@ describe('tracing', () => { function createTransactionAndPutOnScope(hub: Hub) { addTracingExtensions(); const transaction = hub.startTransaction({ name: 'dogpark' }); - hub.getScope()?.setSpan(transaction); + hub.getScope().setSpan(transaction); return transaction; } diff --git a/packages/node/test/integrations/requestdata.test.ts b/packages/node/test/integrations/requestdata.test.ts index 91d9870f8292..52e20c9d6e4b 100644 --- a/packages/node/test/integrations/requestdata.test.ts +++ b/packages/node/test/integrations/requestdata.test.ts @@ -126,7 +126,7 @@ describe('`RequestData` integration', () => { type GCPHandler = (req: PolymorphicRequest, res: http.ServerResponse) => void; const mockGCPWrapper = (origHandler: GCPHandler, options: Record): GCPHandler => { const wrappedHandler: GCPHandler = (req, res) => { - getCurrentHub().getScope()?.setSDKProcessingMetadata({ + getCurrentHub().getScope().setSDKProcessingMetadata({ request: req, requestDataOptionsFromGCPWrapper: options, }); diff --git a/packages/opentelemetry-node/test/spanprocessor.test.ts b/packages/opentelemetry-node/test/spanprocessor.test.ts index 97256bd867a4..cde0c7338d00 100644 --- a/packages/opentelemetry-node/test/spanprocessor.test.ts +++ b/packages/opentelemetry-node/test/spanprocessor.test.ts @@ -114,7 +114,7 @@ describe('SentrySpanProcessor', () => { expect(sentrySpan?.spanId).toEqual(childOtelSpan.spanContext().spanId); expect(sentrySpan?.parentSpanId).toEqual(sentrySpanTransaction?.spanId); - expect(hub.getScope()?.getSpan()).toBeUndefined(); + expect(hub.getScope().getSpan()).toBeUndefined(); child.end(endTime); diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index ef5449067df9..ada328badb78 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -306,7 +306,7 @@ export function startRequestHandlerTransaction( }, }); - hub.getScope()?.setSpan(transaction); + hub.getScope().setSpan(transaction); return transaction; } diff --git a/packages/replay/src/util/sendReplayRequest.ts b/packages/replay/src/util/sendReplayRequest.ts index 65f217f857cd..b6f49c0b9c9a 100644 --- a/packages/replay/src/util/sendReplayRequest.ts +++ b/packages/replay/src/util/sendReplayRequest.ts @@ -34,7 +34,7 @@ export async function sendReplayRequest({ const transport = client && client.getTransport(); const dsn = client && client.getDsn(); - if (!client || !scope || !transport || !dsn || !session.sampled) { + if (!client || !transport || !dsn || !session.sampled) { return; } diff --git a/packages/replay/test/integration/coreHandlers/handleScope.test.ts b/packages/replay/test/integration/coreHandlers/handleScope.test.ts index d9d30d710a6a..2ccaafdefff7 100644 --- a/packages/replay/test/integration/coreHandlers/handleScope.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleScope.test.ts @@ -23,7 +23,7 @@ describe('Integration | coreHandlers | handleScope', () => { expect(mockHandleScopeListener).toHaveBeenCalledTimes(1); - getCurrentHub().getScope()?.addBreadcrumb({ category: 'console', message: 'testing' }); + getCurrentHub().getScope().addBreadcrumb({ category: 'console', message: 'testing' }); expect(mockHandleScope).toHaveBeenCalledTimes(1); expect(mockHandleScope).toHaveReturnedWith(expect.objectContaining({ category: 'console', message: 'testing' })); @@ -32,7 +32,7 @@ describe('Integration | coreHandlers | handleScope', () => { // This will trigger breadcrumb/scope listener, but handleScope should return // null because breadcrumbs has not changed - getCurrentHub().getScope()?.setUser({ email: 'foo@foo.com' }); + getCurrentHub().getScope().setUser({ email: 'foo@foo.com' }); expect(mockHandleScope).toHaveBeenCalledTimes(1); expect(mockHandleScope).toHaveReturnedWith(null); }); diff --git a/packages/svelte/src/performance.ts b/packages/svelte/src/performance.ts index 5cb9e0254557..8bcafb8d9ed8 100644 --- a/packages/svelte/src/performance.ts +++ b/packages/svelte/src/performance.ts @@ -90,7 +90,5 @@ function recordUpdateSpans(componentName: string, initSpan?: Span): void { } function getActiveTransaction(): Transaction | undefined { - const currentHub = getCurrentHub(); - const scope = currentHub && currentHub.getScope(); - return scope && scope.getTransaction(); + return getCurrentHub().getScope().getTransaction(); } diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts index 284d8f339435..8045e5feed8a 100644 --- a/packages/tracing-internal/src/browser/request.ts +++ b/packages/tracing-internal/src/browser/request.ts @@ -195,8 +195,7 @@ export function fetchCallback( return; } - const currentScope = getCurrentHub().getScope(); - const currentSpan = currentScope && currentScope.getSpan(); + const currentSpan = getCurrentHub().getScope().getSpan(); const activeTransaction = currentSpan && currentSpan.transaction; if (currentSpan && activeTransaction) { @@ -336,8 +335,7 @@ export function xhrCallback( return; } - const currentScope = getCurrentHub().getScope(); - const currentSpan = currentScope && currentScope.getSpan(); + const currentSpan = getCurrentHub().getScope().getSpan(); const activeTransaction = currentSpan && currentSpan.transaction; if (currentSpan && activeTransaction) { diff --git a/packages/tracing/test/hub.test.ts b/packages/tracing/test/hub.test.ts index d292a231f1b7..044582fcf6e2 100644 --- a/packages/tracing/test/hub.test.ts +++ b/packages/tracing/test/hub.test.ts @@ -44,7 +44,7 @@ describe('Hub', () => { scope.setSpan(transaction); }); - expect(hub.getScope()?.getTransaction()).toBe(transaction); + expect(hub.getScope().getTransaction()).toBe(transaction); }); it('should find a transaction which has been set on the scope if sampled = false', () => { @@ -57,7 +57,7 @@ describe('Hub', () => { scope.setSpan(transaction); }); - expect(hub.getScope()?.getTransaction()).toBe(transaction); + expect(hub.getScope().getTransaction()).toBe(transaction); }); it("should not find an open transaction if it's not on the scope", () => { @@ -66,7 +66,7 @@ describe('Hub', () => { makeMain(hub); hub.startTransaction({ name: 'dogpark' }); - expect(hub.getScope()?.getTransaction()).toBeUndefined(); + expect(hub.getScope().getTransaction()).toBeUndefined(); }); }); diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index 55b7b7304baa..1be68b26b61e 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -30,8 +30,7 @@ const HOOKS: { [key in Operation]: Hook[] } = { /** Grabs active transaction off scope, if any */ export function getActiveTransaction(): Transaction | undefined { - const scope = getCurrentHub().getScope(); - return scope && scope.getTransaction(); + return getCurrentHub().getScope().getTransaction(); } /** Finish top-level span and activity with a debounce configured using `timeout` option */ From 947db1b2fa63e2dac15457b35fc5c7ec6c979e7a Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 26 Jun 2023 09:26:41 -0400 Subject: [PATCH 21/28] feat(browser): send profiles in same envelope as transactions (#8375) This change ensures that browser profiles are sent in the same envelope as transactions and enables sourcemap support. --- .../browser/src/profiling/hubextensions.ts | 171 +++------ packages/browser/src/profiling/integration.ts | 92 +++-- .../browser/src/profiling/jsSelfProfiling.ts | 8 - packages/browser/src/profiling/sendProfile.ts | 89 ----- packages/browser/src/profiling/utils.ts | 345 +++++++++++------- .../test/unit/profiling/integration.test.ts | 151 -------- packages/types/src/profiling.ts | 10 +- 7 files changed, 350 insertions(+), 516 deletions(-) delete mode 100644 packages/browser/src/profiling/sendProfile.ts delete mode 100644 packages/browser/test/unit/profiling/integration.test.ts diff --git a/packages/browser/src/profiling/hubextensions.ts b/packages/browser/src/profiling/hubextensions.ts index e2d94a11d33f..1c04eeb68362 100644 --- a/packages/browser/src/profiling/hubextensions.ts +++ b/packages/browser/src/profiling/hubextensions.ts @@ -1,29 +1,17 @@ -import { getCurrentHub, getMainCarrier } from '@sentry/core'; -import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types'; +/* eslint-disable complexity */ +import { getCurrentHub } from '@sentry/core'; +import type { Transaction } from '@sentry/types'; import { logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers'; -import type { - JSSelfProfile, - JSSelfProfiler, - JSSelfProfilerConstructor, - ProcessedJSSelfProfile, -} from './jsSelfProfiling'; -import { sendProfile } from './sendProfile'; +import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor } from './jsSelfProfiling'; +import { addProfileToMap, isValidSampleRate } from './utils'; -// Max profile duration. -const MAX_PROFILE_DURATION_MS = 30_000; +export const MAX_PROFILE_DURATION_MS = 30_000; // Keep a flag value to avoid re-initializing the profiler constructor. If it fails // once, it will always fail and this allows us to early return. let PROFILING_CONSTRUCTOR_FAILED = false; -// While we experiment, per transaction sampling interval will be more flexible to work with. -type StartTransaction = ( - this: Hub, - transactionContext: TransactionContext, - customSamplingContext?: CustomSamplingContext, -) => Transaction | undefined; - /** * Check if profiler constructor is available. * @param maybeProfiler @@ -55,7 +43,7 @@ export function onProfilingStartRouteTransaction(transaction: Transaction | unde * startProfiling is called after the call to startTransaction in order to avoid our own code from * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. */ -function wrapTransactionWithProfiling(transaction: Transaction): Transaction { +export function wrapTransactionWithProfiling(transaction: Transaction): Transaction { // Feature support check first const JSProfilerConstructor = WINDOW.Profiler; @@ -68,14 +56,6 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { return transaction; } - // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. - if (!transaction.sampled) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Transaction is not sampled, skipping profiling'); - } - return transaction; - } - // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (__DEBUG_BUILD__) { @@ -86,21 +66,41 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { const client = getCurrentHub().getClient(); const options = client && client.getOptions(); + if (!options) { + __DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.'); + return transaction; + } - // @ts-ignore not part of the browser options yet - const profilesSampleRate = (options && options.profilesSampleRate) || 0; - if (profilesSampleRate === undefined) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Profiling disabled, enable it by setting `profilesSampleRate` option to SDK init call.'); - } + // @ts-ignore profilesSampleRate is not part of the browser options yet + const profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + + // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The + // only valid values are booleans or numbers between 0 and 1.) + if (!isValidSampleRate(profilesSampleRate)) { + __DEBUG_BUILD__ && logger.warn('[Profiling] Discarding profile because of invalid sample rate.'); return transaction; } + // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped + if (!profilesSampleRate) { + __DEBUG_BUILD__ && + logger.log( + '[Profiling] Discarding profile because a negative sampling decision was inherited or profileSampleRate is set to 0', + ); + return transaction; + } + + // Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is + // a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false. + const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate; // Check if we should sample this profile - if (Math.random() > profilesSampleRate) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Skip profiling transaction due to sampling.'); - } + if (!sampled) { + __DEBUG_BUILD__ && + logger.log( + `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( + profilesSampleRate, + )})`, + ); return transaction; } @@ -147,19 +147,19 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { // event of an error or user mistake (calling transaction.finish multiple times), it is important that the behavior of onProfileHandler // is idempotent as we do not want any timings or profiles to be overriden by the last call to onProfileHandler. // After the original finish method is called, the event will be reported through the integration and delegated to transport. - let processedProfile: ProcessedJSSelfProfile | null = null; + const processedProfile: JSSelfProfile | null = null; /** * Idempotent handler for profile stop */ - function onProfileHandler(): void { + async function onProfileHandler(): Promise { // Check if the profile exists and return it the behavior has to be idempotent as users may call transaction.finish multiple times. if (!transaction) { - return; + return null; } // Satisfy the type checker, but profiler will always be defined here. if (!profiler) { - return; + return null; } if (processedProfile) { if (__DEBUG_BUILD__) { @@ -169,12 +169,12 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { 'already exists, returning early', ); } - return; + return null; } - profiler + return profiler .stop() - .then((p: JSSelfProfile): void => { + .then((p: JSSelfProfile): null => { if (maxDurationTimeoutID) { WINDOW.clearTimeout(maxDurationTimeoutID); maxDurationTimeoutID = undefined; @@ -192,16 +192,11 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { 'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started', ); } - return; - } - - // If a profile has less than 2 samples, it is not useful and should be discarded. - if (p.samples.length < 2) { - return; + return null; } - processedProfile = { ...p, profile_id: profileId }; - sendProfile(profileId, processedProfile); + addProfileToMap(profileId, p); + return null; }) .catch(error => { if (__DEBUG_BUILD__) { @@ -219,6 +214,7 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { transaction.name || transaction.description, ); } + // If the timeout exceeds, we want to stop profiling, but not finish the transaction void onProfileHandler(); }, MAX_PROFILE_DURATION_MS); @@ -230,73 +226,26 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { * startProfiling is called after the call to startTransaction in order to avoid our own code from * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. */ - function profilingWrappedTransactionFinish(): Promise { + function profilingWrappedTransactionFinish(): Transaction { if (!transaction) { return originalFinish(); } // onProfileHandler should always return the same profile even if this is called multiple times. // Always call onProfileHandler to ensure stopProfiling is called and the timeout is cleared. - onProfileHandler(); - - // Set profile context - transaction.setContext('profile', { profile_id: profileId }); + void onProfileHandler().then( + () => { + transaction.setContext('profile', { profile_id: profileId }); + originalFinish(); + }, + () => { + // If onProfileHandler fails, we still want to call the original finish method. + originalFinish(); + }, + ); - return originalFinish(); + return transaction; } transaction.finish = profilingWrappedTransactionFinish; return transaction; } - -/** - * Wraps startTransaction with profiling logic. This is done automatically by the profiling integration. - */ -function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction { - return function wrappedStartTransaction( - this: Hub, - transactionContext: TransactionContext, - customSamplingContext?: CustomSamplingContext, - ): Transaction | undefined { - const transaction: Transaction | undefined = startTransaction.call(this, transactionContext, customSamplingContext); - if (transaction === undefined) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Transaction is undefined, skipping profiling'); - } - return transaction; - } - - return wrapTransactionWithProfiling(transaction); - }; -} - -/** - * Patches startTransaction and stopTransaction with profiling logic. - */ -export function addProfilingExtensionMethods(): void { - const carrier = getMainCarrier(); - if (!carrier.__SENTRY__) { - if (__DEBUG_BUILD__) { - logger.log("[Profiling] Can't find main carrier, profiling won't work."); - } - return; - } - carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; - - if (!carrier.__SENTRY__.extensions['startTransaction']) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] startTransaction does not exists, profiling will not work. Make sure you import @sentry/tracing package before @sentry/profiling-node as import order matters.', - ); - } - return; - } - - if (__DEBUG_BUILD__) { - logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...'); - } - - carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling( - // This is already patched by sentry/tracing, we are going to re-patch it... - carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction, - ); -} diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 9a9751c50d61..36fb6432e6df 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,8 +1,16 @@ -import type { Event, EventProcessor, Integration } from '@sentry/types'; +import type { EventProcessor, Hub, Integration, Transaction } from '@sentry/types'; +import type { Profile } from '@sentry/types/src/profiling'; import { logger } from '@sentry/utils'; -import { PROFILING_EVENT_CACHE } from './cache'; -import { addProfilingExtensionMethods } from './hubextensions'; +import type { BrowserClient } from './../client'; +import { wrapTransactionWithProfiling } from './hubextensions'; +import type { ProfiledEvent } from './utils'; +import { + addProfilesToEnvelope, + createProfilingEvent, + findProfiledTransactionsFromEnvelope, + PROFILE_MAP, +} from './utils'; /** * Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"] @@ -15,34 +23,66 @@ import { addProfilingExtensionMethods } from './hubextensions'; */ export class BrowserProfilingIntegration implements Integration { public readonly name: string = 'BrowserProfilingIntegration'; + public getCurrentHub?: () => Hub = undefined; /** * @inheritDoc */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { - // Patching the hub to add the extension methods. - // Warning: we have an implicit dependency on import order and we will fail patching if the constructor of - // BrowserProfilingIntegration is called before @sentry/tracing is imported. This is because we need to patch - // the methods of @sentry/tracing which are patched as a side effect of importing @sentry/tracing. - addProfilingExtensionMethods(); - - // Add our event processor - addGlobalEventProcessor(this.handleGlobalEvent.bind(this)); - } + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this.getCurrentHub = getCurrentHub; + const client = this.getCurrentHub().getClient() as BrowserClient; - /** - * @inheritDoc - */ - public handleGlobalEvent(event: Event): Event { - const profileId = event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id']; - - if (profileId && typeof profileId === 'string') { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Profiling event found, caching it.'); - } - PROFILING_EVENT_CACHE.add(profileId, event); - } + if (client && typeof client.on === 'function') { + client.on('startTransaction', (transaction: Transaction) => { + wrapTransactionWithProfiling(transaction); + }); + + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!PROFILE_MAP['size']) { + return; + } + + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; + } + + const profilesToAddToEnvelope: Profile[] = []; - return event; + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction && profiledTransaction.contexts; + const profile_id = context && context['profile'] && (context['profile']['profile_id'] as string); + + if (!profile_id) { + __DEBUG_BUILD__ && + logger.log('[Profiling] cannot find profile for a transaction without a profile context'); + continue; + } + + // Remove the profile from the transaction context before sending, relay will take care of the rest. + if (context && context['profile']) { + delete context.profile; + } + + const profile = PROFILE_MAP.get(profile_id); + if (!profile) { + __DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + PROFILE_MAP.delete(profile_id); + const profileEvent = createProfilingEvent(profile_id, profile, profiledTransaction as ProfiledEvent); + + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } + } + + addProfilesToEnvelope(envelope, profilesToAddToEnvelope); + }); + } else { + logger.warn('[Profiling] Client does not support hooks, profiling will be disabled'); + } } } diff --git a/packages/browser/src/profiling/jsSelfProfiling.ts b/packages/browser/src/profiling/jsSelfProfiling.ts index efa4a0a0a0bc..8dc981d3d5d3 100644 --- a/packages/browser/src/profiling/jsSelfProfiling.ts +++ b/packages/browser/src/profiling/jsSelfProfiling.ts @@ -26,10 +26,6 @@ export type JSSelfProfile = { samples: JSSelfProfileSample[]; }; -export interface ProcessedJSSelfProfile extends JSSelfProfile { - profile_id: string; -} - type BufferFullCallback = (trace: JSSelfProfile) => void; export interface JSSelfProfiler { @@ -49,7 +45,3 @@ declare global { Profiler: typeof JSSelfProfilerConstructor | undefined; } } - -export interface RawThreadCpuProfile extends JSSelfProfile { - profile_id: string; -} diff --git a/packages/browser/src/profiling/sendProfile.ts b/packages/browser/src/profiling/sendProfile.ts deleted file mode 100644 index 83ca990c516e..000000000000 --- a/packages/browser/src/profiling/sendProfile.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { getCurrentHub } from '@sentry/core'; -import { logger } from '@sentry/utils'; - -import { PROFILING_EVENT_CACHE } from './cache'; -import type { ProcessedJSSelfProfile } from './jsSelfProfiling'; -import type { ProfiledEvent } from './utils'; -import { createProfilingEventEnvelope } from './utils'; -/** - * Performs lookup in the event cache and sends the profile to Sentry. - * If the profiled transaction event is found, we use the profiled transaction event and profile - * to construct a profile type envelope and send it to Sentry. - */ -export function sendProfile(profileId: string, profile: ProcessedJSSelfProfile): void { - const event = PROFILING_EVENT_CACHE.get(profileId); - - if (!event) { - // We could not find a corresponding transaction event for this profile. - // Opt to do nothing for now, but in the future we should implement a simple retry mechanism. - if (__DEBUG_BUILD__) { - logger.log("[Profiling] Couldn't find a transaction event for this profile, dropping it."); - } - return; - } - - event.sdkProcessingMetadata = event.sdkProcessingMetadata || {}; - if (event.sdkProcessingMetadata && !event.sdkProcessingMetadata['profile']) { - event.sdkProcessingMetadata['profile'] = profile; - } - - // Client, Dsn and Transport are all required to be able to send the profiling event to Sentry. - // If either of them is not available, we remove the profile from the transaction event. - // and forward it to the next event processor. - const hub = getCurrentHub(); - const client = hub.getClient(); - - if (!client) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] getClient did not return a Client, removing profile from event and forwarding to next event processors.', - ); - } - return; - } - - const dsn = client.getDsn(); - if (!dsn) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] getDsn did not return a Dsn, removing profile from event and forwarding to next event processors.', - ); - } - return; - } - - const transport = client.getTransport(); - if (!transport) { - if (__DEBUG_BUILD__) { - logger.log( - '[Profiling] getTransport did not return a Transport, removing profile from event and forwarding to next event processors.', - ); - } - return; - } - - // If all required components are available, we construct a profiling event envelope and send it to Sentry. - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Preparing envelope and sending a profiling event'); - } - const envelope = createProfilingEventEnvelope(event as ProfiledEvent, dsn); - - // Evict event from the cache - we want to prevent the LRU cache from prioritizing already sent events over new ones. - PROFILING_EVENT_CACHE.delete(profileId); - - if (!envelope) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Failed to construct envelope'); - } - return; - } - - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Envelope constructed, sending it'); - } - - // Wrap in try/catch because send will throw in case of a network error. - transport.send(envelope).then(null, reason => { - __DEBUG_BUILD__ && logger.log('[Profiling] Error while sending event:', reason); - }); -} diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 7b2e1b60e848..6c9b4d8ed6b9 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,20 +1,12 @@ -import { DEFAULT_ENVIRONMENT } from '@sentry/core'; -import type { - DsnComponents, - DynamicSamplingContext, - Event, - EventEnvelope, - EventEnvelopeHeaders, - EventItem, - Profile as SentryProfile, - SdkInfo, - SdkMetadata, - ThreadCpuProfile, -} from '@sentry/types'; -import { createEnvelope, dropUndefinedKeys, dsnToString, logger, uuid4 } from '@sentry/utils'; +/* eslint-disable max-lines */ + +import { DEFAULT_ENVIRONMENT, getCurrentHub } from '@sentry/core'; +import type { DebugImage, Envelope, Event, StackFrame, StackParser } from '@sentry/types'; +import type { Profile, ThreadCpuProfile } from '@sentry/types/src/profiling'; +import { forEachEnvelopeItem, GLOBAL_OBJ, logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers'; -import type { JSSelfProfile, JSSelfProfileStack, RawThreadCpuProfile } from './jsSelfProfiling'; +import type { JSSelfProfile, JSSelfProfileStack } from './jsSelfProfiling'; const MS_TO_NS = 1e6; // Use 0 as main thread id which is identical to threadId in node:worker_threads @@ -23,9 +15,9 @@ const THREAD_ID_STRING = String(0); const THREAD_NAME = 'main'; // Machine properties (eval only once) -let OS_PLATFORM = ''; // macos -let OS_PLATFORM_VERSION = ''; // 13.2 -let OS_ARCH = ''; // arm64 +let OS_PLATFORM = ''; +let OS_PLATFORM_VERSION = ''; +let OS_ARCH = ''; let OS_BROWSER = (WINDOW.navigator && WINDOW.navigator.userAgent) || ''; let OS_MODEL = ''; const OS_LOCALE = @@ -72,7 +64,7 @@ if (isUserAgentData(userAgentData)) { .catch(e => void e); } -function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): profile is RawThreadCpuProfile { +function isProcessedJSSelfProfile(profile: ThreadCpuProfile | JSSelfProfile): profile is JSSelfProfile { return !('thread_metadata' in profile); } @@ -81,8 +73,8 @@ function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): /** * */ -export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThreadCpuProfile): ThreadCpuProfile { - if (!isRawThreadCpuProfile(profile)) { +export function enrichWithThreadInformation(profile: ThreadCpuProfile | JSSelfProfile): ThreadCpuProfile { + if (!isProcessedJSSelfProfile(profile)) { return profile; } @@ -93,52 +85,7 @@ export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThrea // by the integration before the event is processed by other integrations. export interface ProfiledEvent extends Event { sdkProcessingMetadata: { - profile?: RawThreadCpuProfile; - }; -} - -/** Extract sdk info from from the API metadata */ -function getSdkMetadataForEnvelopeHeader(metadata?: SdkMetadata): SdkInfo | undefined { - if (!metadata || !metadata.sdk) { - return undefined; - } - - return { name: metadata.sdk.name, version: metadata.sdk.version } as SdkInfo; -} - -/** - * Apply SdkInfo (name, version, packages, integrations) to the corresponding event key. - * Merge with existing data if any. - **/ -function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { - if (!sdkInfo) { - return event; - } - event.sdk = event.sdk || {}; - event.sdk.name = event.sdk.name || sdkInfo.name || 'unknown sdk'; - event.sdk.version = event.sdk.version || sdkInfo.version || 'unknown sdk version'; - event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])]; - event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])]; - return event; -} - -function createEventEnvelopeHeaders( - event: Event, - sdkInfo: SdkInfo | undefined, - tunnel: string | undefined, - 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) }), - ...(event.type === 'transaction' && - dynamicSamplingContext && { - trace: dropUndefinedKeys({ ...dynamicSamplingContext }) as DynamicSamplingContext, - }), + profile?: JSSelfProfile; }; } @@ -171,50 +118,30 @@ function getTraceId(event: Event): string { /** * Creates a profiling event envelope from a Sentry event. */ -export function createProfilingEventEnvelope( +export function createProfilePayload( event: ProfiledEvent, - dsn: DsnComponents, - metadata?: SdkMetadata, - tunnel?: string, -): EventEnvelope | null { + processedProfile: JSSelfProfile, + profile_id: string, +): Profile { if (event.type !== 'transaction') { // createProfilingEventEnvelope should only be called for transactions, // we type guard this behavior with isProfiledTransactionEvent. throw new TypeError('Profiling events may only be attached to transactions, this should never occur.'); } - const rawProfile = event.sdkProcessingMetadata['profile']; - - if (rawProfile === undefined || rawProfile === null) { + if (processedProfile === undefined || processedProfile === null) { throw new TypeError( - `Cannot construct profiling event envelope without a valid profile. Got ${rawProfile} instead.`, + `Cannot construct profiling event envelope without a valid profile. Got ${processedProfile} instead.`, ); } - if (!rawProfile.profile_id) { - throw new TypeError('Profile is missing profile_id'); - } - - if (rawProfile.samples.length <= 1) { - if (__DEBUG_BUILD__) { - // Log a warning if the profile has less than 2 samples so users can know why - // they are not seeing any profiling data and we cant avoid the back and forth - // of asking them to provide us with a dump of the profile data. - logger.log('[Profiling] Discarding profile because it contains less than 2 samples'); - } - return null; - } - const traceId = getTraceId(event); - const sdkInfo = getSdkMetadataForEnvelopeHeader(metadata); - enhanceEventWithSdkInfo(event, metadata && metadata.sdk); - const envelopeHeaders = createEventEnvelopeHeaders(event, sdkInfo, tunnel, dsn); - const enrichedThreadProfile = enrichWithThreadInformation(rawProfile); + const enrichedThreadProfile = enrichWithThreadInformation(processedProfile); const transactionStartMs = typeof event.start_timestamp === 'number' ? event.start_timestamp * 1000 : Date.now(); const transactionEndMs = typeof event.timestamp === 'number' ? event.timestamp * 1000 : Date.now(); - const profile: SentryProfile = { - event_id: rawProfile.profile_id, + const profile: Profile = { + event_id: profile_id, timestamp: new Date(transactionStartMs).toISOString(), platform: 'javascript', version: '1', @@ -236,6 +163,9 @@ export function createProfilingEventEnvelope( architecture: OS_ARCH, is_emulator: false, }, + debug_meta: { + images: applyDebugMetadata(processedProfile.resources), + }, profile: enrichedThreadProfile, transactions: [ { @@ -249,15 +179,7 @@ export function createProfilingEventEnvelope( ], }; - const envelopeItem: EventItem = [ - { - type: 'profile', - }, - // @ts-ignore this is missing in typedef - profile, - ]; - - return createEnvelope(envelopeHeaders, [envelopeItem]); + return profile; } /** @@ -267,31 +189,16 @@ export function isProfiledTransactionEvent(event: Event): event is ProfiledEvent return !!(event.sdkProcessingMetadata && event.sdkProcessingMetadata['profile']); } -// Due to how profiles are attached to event metadata, we may sometimes want to remove them to ensure -// they are not processed by other Sentry integrations. This can be the case when we cannot construct a valid -// profile from the data we have or some of the mechanisms to send the event (Hub, Transport etc) are not available to us. -/** - * - */ -export function maybeRemoveProfileFromSdkMetadata(event: Event | ProfiledEvent): Event { - if (!isProfiledTransactionEvent(event)) { - return event; - } - - delete event.sdkProcessingMetadata.profile; - return event; -} - /** * Converts a JSSelfProfile to a our sampled format. * Does not currently perform stack indexing. */ -export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): ThreadCpuProfile { +export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profile['profile'] { let EMPTY_STACK_ID: undefined | number = undefined; let STACK_ID = 0; // Initialize the profile that we will fill with data - const profile: ThreadCpuProfile = { + const profile: Profile['profile'] = { samples: [], stacks: [], frames: [], @@ -351,7 +258,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Threa stackTop = stackTop.parentId === undefined ? undefined : input.stacks[stackTop.parentId]; } - const sample: ThreadCpuProfile['samples'][0] = { + const sample: Profile['profile']['samples'][0] = { // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, @@ -365,3 +272,195 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Threa return profile; } + +/** + * Adds items to envelope if they are not already present - mutates the envelope. + * @param envelope + */ +export function addProfilesToEnvelope(envelope: Envelope, profiles: Profile[]): Envelope { + if (!profiles.length) { + return envelope; + } + + for (const profile of profiles) { + // @ts-ignore untyped envelope + envelope[1].push([{ type: 'profile' }, profile]); + } + return envelope; +} + +/** + * Finds transactions with profile_id context in the envelope + * @param envelope + * @returns + */ +export function findProfiledTransactionsFromEnvelope(envelope: Envelope): Event[] { + const events: Event[] = []; + + forEachEnvelopeItem(envelope, (item, type) => { + if (type !== 'transaction') { + return; + } + + for (let j = 1; j < item.length; j++) { + const event = item[j] as Event; + + if (event && event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id']) { + events.push(item[j] as Event); + } + } + }); + + return events; +} + +const debugIdStackParserCache = new WeakMap>(); +/** + * Applies debug meta data to an event from a list of paths to resources (sourcemaps) + */ +export function applyDebugMetadata(resource_paths: ReadonlyArray): DebugImage[] { + const debugIdMap = GLOBAL_OBJ._sentryDebugIds; + + if (!debugIdMap) { + return []; + } + + const hub = getCurrentHub(); + if (!hub) { + return []; + } + const client = hub.getClient(); + if (!client) { + return []; + } + const options = client.getOptions(); + if (!options) { + return []; + } + const stackParser = options.stackParser; + if (!stackParser) { + return []; + } + + let debugIdStackFramesCache: Map; + const cachedDebugIdStackFrameCache = debugIdStackParserCache.get(stackParser); + if (cachedDebugIdStackFrameCache) { + debugIdStackFramesCache = cachedDebugIdStackFrameCache; + } else { + debugIdStackFramesCache = new Map(); + debugIdStackParserCache.set(stackParser, debugIdStackFramesCache); + } + + // Build a map of filename -> debug_id + const filenameDebugIdMap = Object.keys(debugIdMap).reduce>((acc, debugIdStackTrace) => { + let parsedStack: StackFrame[]; + + const cachedParsedStack = debugIdStackFramesCache.get(debugIdStackTrace); + if (cachedParsedStack) { + parsedStack = cachedParsedStack; + } else { + parsedStack = stackParser(debugIdStackTrace); + debugIdStackFramesCache.set(debugIdStackTrace, parsedStack); + } + + for (let i = parsedStack.length - 1; i >= 0; i--) { + const stackFrame = parsedStack[i]; + const file = stackFrame && stackFrame.filename; + + if (stackFrame && file) { + acc[file] = debugIdMap[debugIdStackTrace] as string; + break; + } + } + return acc; + }, {}); + + const images: DebugImage[] = []; + for (const path of resource_paths) { + if (path && filenameDebugIdMap[path]) { + images.push({ + type: 'sourcemap', + code_file: path, + debug_id: filenameDebugIdMap[path] as string, + }); + } + } + + return images; +} + +/** + * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). + */ +export function isValidSampleRate(rate: unknown): boolean { + // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck + if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) { + __DEBUG_BUILD__ && + logger.warn( + `[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( + rate, + )} of type ${JSON.stringify(typeof rate)}.`, + ); + return false; + } + + // Boolean sample rates are always valid + if (rate === true || rate === false) { + return true; + } + + // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false + if (rate < 0 || rate > 1) { + __DEBUG_BUILD__ && + logger.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`); + return false; + } + return true; +} + +function isValidProfile(profile: JSSelfProfile): profile is JSSelfProfile & { profile_id: string } { + if (profile.samples.length < 2) { + if (__DEBUG_BUILD__) { + // Log a warning if the profile has less than 2 samples so users can know why + // they are not seeing any profiling data and we cant avoid the back and forth + // of asking them to provide us with a dump of the profile data. + logger.log('[Profiling] Discarding profile because it contains less than 2 samples'); + } + return false; + } + + if (!profile.frames.length) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Discarding profile because it contains no frames'); + } + return false; + } + + return true; +} + +/** + * Creates a profiling envelope item, if the profile does not pass validation, returns null. + * @param event + * @returns {Profile | null} + */ +export function createProfilingEvent(profile_id: string, profile: JSSelfProfile, event: ProfiledEvent): Profile | null { + if (!isValidProfile(profile)) { + return null; + } + + return createProfilePayload(event, profile, profile_id); +} + +export const PROFILE_MAP: Map = new Map(); +/** + * + */ +export function addProfileToMap(profile_id: string, profile: JSSelfProfile): void { + PROFILE_MAP.set(profile_id, profile); + + if (PROFILE_MAP.size > 30) { + const last: string = PROFILE_MAP.keys().next().value; + PROFILE_MAP.delete(last); + } +} diff --git a/packages/browser/test/unit/profiling/integration.test.ts b/packages/browser/test/unit/profiling/integration.test.ts deleted file mode 100644 index 1ea59ee7068e..000000000000 --- a/packages/browser/test/unit/profiling/integration.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { getCurrentHub } from '@sentry/browser'; -import type { Event } from '@sentry/types'; -import { TextDecoder, TextEncoder } from 'util'; - -// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) -const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true; -// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) -const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true; - -import { JSDOM } from 'jsdom'; - -import { PROFILING_EVENT_CACHE } from '../../../src/profiling/cache'; -import { BrowserProfilingIntegration } from '../../../src/profiling/integration'; -import { sendProfile } from '../../../src/profiling/sendProfile'; - -// @ts-ignore store a reference so we can reset it later -const globalDocument = global.document; -// @ts-ignore store a reference so we can reset it later -const globalWindow = global.window; -// @ts-ignore store a reference so we can reset it later -const globalLocation = global.location; - -describe('BrowserProfilingIntegration', () => { - beforeEach(() => { - // Clear profiling event cache - PROFILING_EVENT_CACHE.clear(); - - const dom = new JSDOM(); - // @ts-ignore need to override global document - global.document = dom.window.document; - // @ts-ignore need to override global document - global.window = dom.window; - // @ts-ignore need to override global document - global.location = dom.window.location; - }); - - // Reset back to previous values - afterEach(() => { - // @ts-ignore need to override global document - global.document = globalDocument; - // @ts-ignore need to override global document - global.window = globalWindow; - // @ts-ignore need to override global document - global.location = globalLocation; - }); - - afterAll(() => { - // @ts-ignore patch the encoder on the window, else importing JSDOM fails - patchedEncoder && delete global.window.TextEncoder; - // @ts-ignore patch the encoder on the window, else importing JSDOM fails - patchedDecoder && delete global.window.TextDecoder; - }); - - it('does not store event in profiling event cache if context["profile"]["profile_id"] is not present', () => { - const integration = new BrowserProfilingIntegration(); - const event: Event = { - contexts: {}, - }; - integration.handleGlobalEvent(event); - expect(PROFILING_EVENT_CACHE.size()).toBe(0); - }); - - it('stores event in profiling event cache if context["profile"]["profile_id"] is present', () => { - const integration = new BrowserProfilingIntegration(); - const event: Event = { - contexts: { - profile: { - profile_id: 'profile_id', - }, - }, - }; - integration.handleGlobalEvent(event); - expect(PROFILING_EVENT_CACHE.get(event.contexts!.profile!.profile_id as string)).toBe(event); - }); - - it('sending profile evicts it from the LRU cache', () => { - const hub = getCurrentHub(); - const client: any = { - getDsn() { - return {}; - }, - getTransport() { - return { - send() {}, - }; - }, - }; - - hub.bindClient(client); - - const integration = new BrowserProfilingIntegration(); - const event: Event = { - type: 'transaction', - contexts: { - profile: { - profile_id: 'profile_id', - }, - }, - }; - - integration.handleGlobalEvent(event); - - sendProfile('profile_id', { - resources: [], - samples: [], - stacks: [], - frames: [], - profile_id: 'profile_id', - }); - - expect(PROFILING_EVENT_CACHE.get('profile_id')).toBe(undefined); - }); -}); - -describe('ProfilingEventCache', () => { - beforeEach(() => { - PROFILING_EVENT_CACHE.clear(); - }); - - it('caps the size of the profiling event cache', () => { - for (let i = 0; i <= 21; i++) { - const integration = new BrowserProfilingIntegration(); - const event: Event = { - contexts: { - profile: { - profile_id: `profile_id_${i}`, - }, - }, - }; - integration.handleGlobalEvent(event); - } - expect(PROFILING_EVENT_CACHE.size()).toBe(20); - // Evicts the first item in the cache - expect(PROFILING_EVENT_CACHE.get('profile_id_0')).toBe(undefined); - }); - - it('handles collision by replacing the value', () => { - PROFILING_EVENT_CACHE.add('profile_id_0', {}); - const second = {}; - PROFILING_EVENT_CACHE.add('profile_id_0', second); - - expect(PROFILING_EVENT_CACHE.get('profile_id_0')).toBe(second); - expect(PROFILING_EVENT_CACHE.size()).toBe(1); - }); - - it('clears cache', () => { - PROFILING_EVENT_CACHE.add('profile_id_0', {}); - PROFILING_EVENT_CACHE.clear(); - expect(PROFILING_EVENT_CACHE.size()).toBe(0); - }); -}); diff --git a/packages/types/src/profiling.ts b/packages/types/src/profiling.ts index 1b6dac566901..d99736df735e 100644 --- a/packages/types/src/profiling.ts +++ b/packages/types/src/profiling.ts @@ -1,3 +1,4 @@ +import type { DebugImage } from './debugMeta'; export type ThreadId = string; export type FrameId = number; export type StackId = number; @@ -50,14 +51,7 @@ export interface Profile { platform: string; profile: ThreadCpuProfile; debug_meta?: { - images: { - debug_id: string; - image_addr: string; - code_file: string; - type: string; - image_size: number; - image_vmaddr: string; - }[]; + images: DebugImage[]; }; transaction?: { name: string; From 0c194462a034b9dbf96ec59fb3da44a2f2aeeea3 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Mon, 26 Jun 2023 18:23:51 +0100 Subject: [PATCH 22/28] test(remix): Add Remix v2 future flags integration tests. (#8397) Added an integration test application using [Remix 1.17.0](https://github.com/remix-run/remix/releases/tag/remix%401.17.0) and [v2 future flags](https://remix.run/docs/en/main/pages/api-development-strategy) to see the state of current SDK support for v2. --- .github/workflows/build.yml | 4 +- packages/remix/package.json | 4 +- .../{app => app_v1}/entry.client.tsx | 0 .../{app => app_v1}/entry.server.tsx | 0 .../test/integration/{app => app_v1}/root.tsx | 0 .../routes/action-json-response/$id.tsx | 2 + .../app_v1/routes/capture-exception.tsx | 2 + .../app_v1/routes/capture-message.tsx | 2 + .../routes/error-boundary-capture/$id.tsx | 2 + .../test/integration/app_v1/routes/index.tsx | 2 + .../routes/loader-defer-response/index.tsx | 2 + .../routes/loader-json-response/$id.tsx | 2 + .../app_v1/routes/manual-tracing/$id.tsx | 2 + .../app_v1/routes/scope-bleed/$id.tsx | 2 + .../test/integration/app_v2/entry.client.tsx | 16 +++++ .../test/integration/app_v2/entry.server.tsx | 27 +++++++++ .../remix/test/integration/app_v2/root.tsx | 60 +++++++++++++++++++ .../routes/action-json-response.$id.tsx | 2 + .../app_v2/routes/capture-exception.tsx | 2 + .../app_v2/routes/capture-message.tsx | 2 + .../routes/error-boundary-capture.$id.tsx | 2 + .../test/integration/app_v2/routes/index.tsx | 2 + .../app_v2/routes/loader-defer-response.tsx | 2 + .../routes/loader-json-response.$id.tsx | 2 + .../app_v2/routes/manual-tracing.$id.tsx | 2 + .../app_v2/routes/scope-bleed.$id.tsx | 2 + .../routes/action-json-response.$id.tsx} | 0 .../routes/capture-exception.tsx | 0 .../routes/capture-message.tsx | 2 +- .../routes/error-boundary-capture.$id.tsx} | 0 .../{app => common}/routes/index.tsx | 0 .../routes/loader-defer-response.tsx} | 0 .../routes/loader-json-response.$id.tsx} | 0 .../routes/manual-tracing.$id.tsx} | 2 +- .../routes/scope-bleed.$id.tsx} | 0 .../remix/test/integration/remix.config.js | 11 +++- .../test/client/errorboundary.test.ts | 6 +- .../test/client/manualtracing.test.ts | 4 +- .../integration/test/client/pageload.test.ts | 5 +- .../integration/test/server/action.test.ts | 24 ++++---- .../integration/test/server/loader.test.ts | 57 +++++++++++------- packages/remix/test/integration/tsconfig.json | 2 +- 42 files changed, 220 insertions(+), 40 deletions(-) rename packages/remix/test/integration/{app => app_v1}/entry.client.tsx (100%) rename packages/remix/test/integration/{app => app_v1}/entry.server.tsx (100%) rename packages/remix/test/integration/{app => app_v1}/root.tsx (100%) create mode 100644 packages/remix/test/integration/app_v1/routes/action-json-response/$id.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/capture-exception.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/capture-message.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/error-boundary-capture/$id.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/index.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/loader-defer-response/index.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/loader-json-response/$id.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/manual-tracing/$id.tsx create mode 100644 packages/remix/test/integration/app_v1/routes/scope-bleed/$id.tsx create mode 100644 packages/remix/test/integration/app_v2/entry.client.tsx create mode 100644 packages/remix/test/integration/app_v2/entry.server.tsx create mode 100644 packages/remix/test/integration/app_v2/root.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/action-json-response.$id.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/capture-exception.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/capture-message.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/error-boundary-capture.$id.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/index.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/loader-defer-response.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/loader-json-response.$id.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/manual-tracing.$id.tsx create mode 100644 packages/remix/test/integration/app_v2/routes/scope-bleed.$id.tsx rename packages/remix/test/integration/{app/routes/action-json-response/$id.tsx => common/routes/action-json-response.$id.tsx} (100%) rename packages/remix/test/integration/{app => common}/routes/capture-exception.tsx (100%) rename packages/remix/test/integration/{app => common}/routes/capture-message.tsx (89%) rename packages/remix/test/integration/{app/routes/error-boundary-capture/$id.tsx => common/routes/error-boundary-capture.$id.tsx} (100%) rename packages/remix/test/integration/{app => common}/routes/index.tsx (100%) rename packages/remix/test/integration/{app/routes/loader-defer-response/index.tsx => common/routes/loader-defer-response.tsx} (100%) rename packages/remix/test/integration/{app/routes/loader-json-response/$id.tsx => common/routes/loader-json-response.$id.tsx} (100%) rename packages/remix/test/integration/{app/routes/manual-tracing/$id.tsx => common/routes/manual-tracing.$id.tsx} (91%) rename packages/remix/test/integration/{app/routes/scope-bleed/$id.tsx => common/routes/scope-bleed.$id.tsx} (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b8e3fcafcb34..e2ec7fdcf411 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -685,7 +685,7 @@ jobs: yarn test job_remix_integration_tests: - name: Remix (Node ${{ matrix.node }}) Tests + name: Remix v${{ matrix.remix }} (Node ${{ matrix.node }}) Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_remix == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-20.04 @@ -694,6 +694,7 @@ jobs: fail-fast: false matrix: node: [14, 16, 18] + remix: [1, 2] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v3 @@ -710,6 +711,7 @@ jobs: - name: Run integration tests env: NODE_VERSION: ${{ matrix.node }} + REMIX_VERSION: ${{ matrix.remix }} run: | cd packages/remix yarn test:integration:ci diff --git a/packages/remix/package.json b/packages/remix/package.json index e964b237149c..a9bf20778ea8 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -59,7 +59,9 @@ "lint:eslint": "eslint . --format stylish", "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", "test": "yarn test:unit", - "test:integration": "run-s test:integration:clean test:integration:prepare test:integration:client test:integration:server", + "test:integration": "run-s test:integration:v1 test:integration:v2", + "test:integration:v1": "run-s test:integration:clean test:integration:prepare test:integration:client test:integration:server", + "test:integration:v2": "export REMIX_VERSION=2 && run-s test:integration:v1", "test:integration:ci": "run-s test:integration:clean test:integration:prepare test:integration:client:ci test:integration:server", "test:integration:prepare": "(cd test/integration && yarn)", "test:integration:clean": "(cd test/integration && rimraf .cache node_modules build)", diff --git a/packages/remix/test/integration/app/entry.client.tsx b/packages/remix/test/integration/app_v1/entry.client.tsx similarity index 100% rename from packages/remix/test/integration/app/entry.client.tsx rename to packages/remix/test/integration/app_v1/entry.client.tsx diff --git a/packages/remix/test/integration/app/entry.server.tsx b/packages/remix/test/integration/app_v1/entry.server.tsx similarity index 100% rename from packages/remix/test/integration/app/entry.server.tsx rename to packages/remix/test/integration/app_v1/entry.server.tsx diff --git a/packages/remix/test/integration/app/root.tsx b/packages/remix/test/integration/app_v1/root.tsx similarity index 100% rename from packages/remix/test/integration/app/root.tsx rename to packages/remix/test/integration/app_v1/root.tsx diff --git a/packages/remix/test/integration/app_v1/routes/action-json-response/$id.tsx b/packages/remix/test/integration/app_v1/routes/action-json-response/$id.tsx new file mode 100644 index 000000000000..ed034a14c52a --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/action-json-response/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/action-json-response.$id'; +export { default } from '../../../common/routes/action-json-response.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/capture-exception.tsx b/packages/remix/test/integration/app_v1/routes/capture-exception.tsx new file mode 100644 index 000000000000..1ba745d2e63d --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/capture-exception.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-exception'; +export { default } from '../../common/routes/capture-exception'; diff --git a/packages/remix/test/integration/app_v1/routes/capture-message.tsx b/packages/remix/test/integration/app_v1/routes/capture-message.tsx new file mode 100644 index 000000000000..9dae2318cc14 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/capture-message.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-message'; +export { default } from '../../common/routes/capture-message'; diff --git a/packages/remix/test/integration/app_v1/routes/error-boundary-capture/$id.tsx b/packages/remix/test/integration/app_v1/routes/error-boundary-capture/$id.tsx new file mode 100644 index 000000000000..2c287dfe9696 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/error-boundary-capture/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/error-boundary-capture.$id'; +export { default } from '../../../common/routes/error-boundary-capture.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/index.tsx b/packages/remix/test/integration/app_v1/routes/index.tsx new file mode 100644 index 000000000000..22c086a4c2cf --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/index.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/index'; +export { default } from '../../common/routes/index'; diff --git a/packages/remix/test/integration/app_v1/routes/loader-defer-response/index.tsx b/packages/remix/test/integration/app_v1/routes/loader-defer-response/index.tsx new file mode 100644 index 000000000000..fd3a7b3f898d --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/loader-defer-response/index.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/loader-defer-response'; +export { default } from '../../../common/routes/loader-defer-response'; diff --git a/packages/remix/test/integration/app_v1/routes/loader-json-response/$id.tsx b/packages/remix/test/integration/app_v1/routes/loader-json-response/$id.tsx new file mode 100644 index 000000000000..ddf33953d77d --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/loader-json-response/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/loader-json-response.$id'; +export { default } from '../../../common/routes/loader-json-response.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/manual-tracing/$id.tsx b/packages/remix/test/integration/app_v1/routes/manual-tracing/$id.tsx new file mode 100644 index 000000000000..9979714818ff --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/manual-tracing/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/manual-tracing.$id'; +export { default } from '../../../common/routes/manual-tracing.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/scope-bleed/$id.tsx b/packages/remix/test/integration/app_v1/routes/scope-bleed/$id.tsx new file mode 100644 index 000000000000..d86864dccb9b --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/scope-bleed/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/scope-bleed.$id'; +export { default } from '../../../common/routes/scope-bleed.$id'; diff --git a/packages/remix/test/integration/app_v2/entry.client.tsx b/packages/remix/test/integration/app_v2/entry.client.tsx new file mode 100644 index 000000000000..f9cfc14f2507 --- /dev/null +++ b/packages/remix/test/integration/app_v2/entry.client.tsx @@ -0,0 +1,16 @@ +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import { hydrate } from 'react-dom'; +import * as Sentry from '@sentry/remix'; +import { useEffect } from 'react'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches), + }), + ], +}); + +hydrate(, document); diff --git a/packages/remix/test/integration/app_v2/entry.server.tsx b/packages/remix/test/integration/app_v2/entry.server.tsx new file mode 100644 index 000000000000..ae879492e236 --- /dev/null +++ b/packages/remix/test/integration/app_v2/entry.server.tsx @@ -0,0 +1,27 @@ +import type { EntryContext } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import { renderToString } from 'react-dom/server'; +import * as Sentry from '@sentry/remix'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + // Disabling to test series of envelopes deterministically. + autoSessionTracking: false, +}); + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + let markup = renderToString(); + + responseHeaders.set('Content-Type', 'text/html'); + + return new Response('' + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/remix/test/integration/app_v2/root.tsx b/packages/remix/test/integration/app_v2/root.tsx new file mode 100644 index 000000000000..faf075951d69 --- /dev/null +++ b/packages/remix/test/integration/app_v2/root.tsx @@ -0,0 +1,60 @@ +import { V2_MetaFunction, LoaderFunction, json, defer, redirect } from '@remix-run/node'; +import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'; +import { withSentry } from '@sentry/remix'; + +export const meta: V2_MetaFunction = ({ data }) => [ + { charset: 'utf-8' }, + { title: 'New Remix App' }, + { name: 'viewport', content: 'width=device-width,initial-scale=1' }, + { name: 'sentry-trace', content: data.sentryTrace }, + { name: 'baggage', content: data.sentryBaggage }, +]; + +export const loader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const type = url.searchParams.get('type'); + + switch (type) { + case 'empty': + return {}; + case 'plain': + return { + data_one: [], + data_two: 'a string', + }; + case 'json': + return json({ data_one: [], data_two: 'a string' }, { headers: { 'Cache-Control': 'max-age=300' } }); + case 'defer': + return defer({ data_one: [], data_two: 'a string' }); + case 'null': + return null; + case 'undefined': + return undefined; + case 'throwRedirect': + throw redirect('/?type=plain'); + case 'returnRedirect': + return redirect('/?type=plain'); + default: { + return {}; + } + } +}; + +function App() { + return ( + + + + + + + + + + + + + ); +} + +export default withSentry(App); diff --git a/packages/remix/test/integration/app_v2/routes/action-json-response.$id.tsx b/packages/remix/test/integration/app_v2/routes/action-json-response.$id.tsx new file mode 100644 index 000000000000..7a00bfb2bfe7 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/action-json-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/action-json-response.$id'; +export { default } from '../../common/routes/action-json-response.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/capture-exception.tsx b/packages/remix/test/integration/app_v2/routes/capture-exception.tsx new file mode 100644 index 000000000000..1ba745d2e63d --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/capture-exception.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-exception'; +export { default } from '../../common/routes/capture-exception'; diff --git a/packages/remix/test/integration/app_v2/routes/capture-message.tsx b/packages/remix/test/integration/app_v2/routes/capture-message.tsx new file mode 100644 index 000000000000..9dae2318cc14 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/capture-message.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/capture-message'; +export { default } from '../../common/routes/capture-message'; diff --git a/packages/remix/test/integration/app_v2/routes/error-boundary-capture.$id.tsx b/packages/remix/test/integration/app_v2/routes/error-boundary-capture.$id.tsx new file mode 100644 index 000000000000..011f92462069 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/error-boundary-capture.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/error-boundary-capture.$id'; +export { default } from '../../common/routes/error-boundary-capture.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/index.tsx b/packages/remix/test/integration/app_v2/routes/index.tsx new file mode 100644 index 000000000000..22c086a4c2cf --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/index.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/index'; +export { default } from '../../common/routes/index'; diff --git a/packages/remix/test/integration/app_v2/routes/loader-defer-response.tsx b/packages/remix/test/integration/app_v2/routes/loader-defer-response.tsx new file mode 100644 index 000000000000..38415a9a3781 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/loader-defer-response.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/loader-defer-response'; +export { default } from '../../common/routes/loader-defer-response'; diff --git a/packages/remix/test/integration/app_v2/routes/loader-json-response.$id.tsx b/packages/remix/test/integration/app_v2/routes/loader-json-response.$id.tsx new file mode 100644 index 000000000000..7761875bdb76 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/loader-json-response.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/loader-json-response.$id'; +export { default } from '../../common/routes/loader-json-response.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/manual-tracing.$id.tsx b/packages/remix/test/integration/app_v2/routes/manual-tracing.$id.tsx new file mode 100644 index 000000000000..a7cfebe4ed46 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/manual-tracing.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/manual-tracing.$id'; +export { default } from '../../common/routes/manual-tracing.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/scope-bleed.$id.tsx b/packages/remix/test/integration/app_v2/routes/scope-bleed.$id.tsx new file mode 100644 index 000000000000..5ba2376f0339 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/scope-bleed.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/scope-bleed.$id'; +export { default } from '../../common/routes/scope-bleed.$id'; diff --git a/packages/remix/test/integration/app/routes/action-json-response/$id.tsx b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/action-json-response/$id.tsx rename to packages/remix/test/integration/common/routes/action-json-response.$id.tsx diff --git a/packages/remix/test/integration/app/routes/capture-exception.tsx b/packages/remix/test/integration/common/routes/capture-exception.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/capture-exception.tsx rename to packages/remix/test/integration/common/routes/capture-exception.tsx diff --git a/packages/remix/test/integration/app/routes/capture-message.tsx b/packages/remix/test/integration/common/routes/capture-message.tsx similarity index 89% rename from packages/remix/test/integration/app/routes/capture-message.tsx rename to packages/remix/test/integration/common/routes/capture-message.tsx index 459e25e1b4ee..06e92f79e931 100644 --- a/packages/remix/test/integration/app/routes/capture-message.tsx +++ b/packages/remix/test/integration/common/routes/capture-message.tsx @@ -3,5 +3,5 @@ import * as Sentry from '@sentry/remix'; export default function ErrorBoundaryCapture() { Sentry.captureMessage('Sentry Manually Captured Message'); - return
; + return
; } diff --git a/packages/remix/test/integration/app/routes/error-boundary-capture/$id.tsx b/packages/remix/test/integration/common/routes/error-boundary-capture.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/error-boundary-capture/$id.tsx rename to packages/remix/test/integration/common/routes/error-boundary-capture.$id.tsx diff --git a/packages/remix/test/integration/app/routes/index.tsx b/packages/remix/test/integration/common/routes/index.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/index.tsx rename to packages/remix/test/integration/common/routes/index.tsx diff --git a/packages/remix/test/integration/app/routes/loader-defer-response/index.tsx b/packages/remix/test/integration/common/routes/loader-defer-response.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/loader-defer-response/index.tsx rename to packages/remix/test/integration/common/routes/loader-defer-response.tsx diff --git a/packages/remix/test/integration/app/routes/loader-json-response/$id.tsx b/packages/remix/test/integration/common/routes/loader-json-response.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/loader-json-response/$id.tsx rename to packages/remix/test/integration/common/routes/loader-json-response.$id.tsx diff --git a/packages/remix/test/integration/app/routes/manual-tracing/$id.tsx b/packages/remix/test/integration/common/routes/manual-tracing.$id.tsx similarity index 91% rename from packages/remix/test/integration/app/routes/manual-tracing/$id.tsx rename to packages/remix/test/integration/common/routes/manual-tracing.$id.tsx index 75cf8574819a..2f925881b9cf 100644 --- a/packages/remix/test/integration/app/routes/manual-tracing/$id.tsx +++ b/packages/remix/test/integration/common/routes/manual-tracing.$id.tsx @@ -3,5 +3,5 @@ import * as Sentry from '@sentry/remix'; export default function ManualTracing() { const transaction = Sentry.startTransaction({ name: 'test_transaction_1' }); transaction.finish(); - return
; + return
; } diff --git a/packages/remix/test/integration/app/routes/scope-bleed/$id.tsx b/packages/remix/test/integration/common/routes/scope-bleed.$id.tsx similarity index 100% rename from packages/remix/test/integration/app/routes/scope-bleed/$id.tsx rename to packages/remix/test/integration/common/routes/scope-bleed.$id.tsx diff --git a/packages/remix/test/integration/remix.config.js b/packages/remix/test/integration/remix.config.js index 02f847cbf1ca..b4c7ac0837b8 100644 --- a/packages/remix/test/integration/remix.config.js +++ b/packages/remix/test/integration/remix.config.js @@ -1,7 +1,16 @@ /** @type {import('@remix-run/dev').AppConfig} */ +const useV2 = process.env.REMIX_VERSION === '2'; + module.exports = { - appDirectory: 'app', + appDirectory: useV2 ? 'app_v2' : 'app_v1', assetsBuildDirectory: 'public/build', serverBuildPath: 'build/index.js', publicPath: '/build/', + future: { + v2_errorBoundary: useV2, + v2_headers: useV2, + v2_meta: useV2, + v2_normalizeFormMethod: useV2, + v2_routeConvention: useV2, + }, }; diff --git a/packages/remix/test/integration/test/client/errorboundary.test.ts b/packages/remix/test/integration/test/client/errorboundary.test.ts index 6bf6314095fd..b90b3e8d3eaa 100644 --- a/packages/remix/test/integration/test/client/errorboundary.test.ts +++ b/packages/remix/test/integration/test/client/errorboundary.test.ts @@ -2,6 +2,8 @@ import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; import { test, expect } from '@playwright/test'; import { Event } from '@sentry/types'; +const useV2 = process.env.REMIX_VERSION === '2'; + test('should capture React component errors.', async ({ page }) => { const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url: '/error-boundary-capture/0', @@ -12,7 +14,9 @@ test('should capture React component errors.', async ({ page }) => { expect(pageloadEnvelope.contexts?.trace.op).toBe('pageload'); expect(pageloadEnvelope.tags?.['routing.instrumentation']).toBe('remix-router'); expect(pageloadEnvelope.type).toBe('transaction'); - expect(pageloadEnvelope.transaction).toBe('routes/error-boundary-capture/$id'); + expect(pageloadEnvelope.transaction).toBe( + useV2 ? 'routes/error-boundary-capture.$id' : 'routes/error-boundary-capture/$id', + ); expect(errorEnvelope.level).toBe('error'); expect(errorEnvelope.sdk?.name).toBe('sentry.javascript.remix'); diff --git a/packages/remix/test/integration/test/client/manualtracing.test.ts b/packages/remix/test/integration/test/client/manualtracing.test.ts index edc919d2d4a9..424408e7be9d 100644 --- a/packages/remix/test/integration/test/client/manualtracing.test.ts +++ b/packages/remix/test/integration/test/client/manualtracing.test.ts @@ -2,6 +2,8 @@ import { getMultipleSentryEnvelopeRequests } from './utils/helpers'; import { test, expect } from '@playwright/test'; import { Event } from '@sentry/types'; +const useV2 = process.env.REMIX_VERSION === '2'; + test('should report a manually created / finished transaction.', async ({ page }) => { const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url: '/manual-tracing/0', @@ -17,5 +19,5 @@ test('should report a manually created / finished transaction.', async ({ page } expect(pageloadEnvelope.contexts?.trace?.op).toBe('pageload'); expect(pageloadEnvelope.tags?.['routing.instrumentation']).toBe('remix-router'); expect(pageloadEnvelope.type).toBe('transaction'); - expect(pageloadEnvelope.transaction).toBe('routes/manual-tracing/$id'); + expect(pageloadEnvelope.transaction).toBe(useV2 ? 'routes/manual-tracing.$id' : 'routes/manual-tracing/$id'); }); diff --git a/packages/remix/test/integration/test/client/pageload.test.ts b/packages/remix/test/integration/test/client/pageload.test.ts index 1543bd2a342c..7c49e4ac9c8c 100644 --- a/packages/remix/test/integration/test/client/pageload.test.ts +++ b/packages/remix/test/integration/test/client/pageload.test.ts @@ -1,3 +1,5 @@ +const useV2 = process.env.REMIX_VERSION === '2'; + import { getFirstSentryEnvelopeRequest } from './utils/helpers'; import { test, expect } from '@playwright/test'; import { Event } from '@sentry/types'; @@ -8,5 +10,6 @@ test('should add `pageload` transaction on load.', async ({ page }) => { expect(envelope.contexts?.trace.op).toBe('pageload'); expect(envelope.tags?.['routing.instrumentation']).toBe('remix-router'); expect(envelope.type).toBe('transaction'); - expect(envelope.transaction).toBe('routes/index'); + + expect(envelope.transaction).toBe(useV2 ? 'root' : 'routes/index'); }); diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts index 6bb4b74d3540..664162f2076d 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -1,5 +1,7 @@ import { assertSentryTransaction, assertSentryEvent, RemixTestEnv } from './utils/helpers'; +const useV2 = process.env.REMIX_VERSION === '2'; + jest.spyOn(console, 'error').mockImplementation(); // Repeat tests for each adapter @@ -11,10 +13,10 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada const transaction = envelope[2]; assertSentryTransaction(transaction, { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, spans: [ { - description: 'routes/action-json-response/$id', + description: `routes/action-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.action', }, { @@ -22,11 +24,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada op: 'function.remix.loader', }, { - description: 'routes/action-json-response/$id', + description: `routes/action-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.loader', }, { - description: 'routes/action-json-response/$id', + description: `routes/action-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.document_request', }, ], @@ -102,7 +104,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); assertSentryTransaction(transaction[2], { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, request: { method: 'POST', url, @@ -161,7 +163,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -177,7 +179,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -227,7 +229,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -277,7 +279,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -327,7 +329,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -377,7 +379,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }, }, tags: { - transaction: 'routes/action-json-response/$id', + transaction: `routes/action-json-response${useV2 ? '.' : '/'}$id`, }, }); diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index 2545f63d6e92..9613d19fce42 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -1,6 +1,8 @@ import { assertSentryTransaction, RemixTestEnv, assertSentryEvent } from './utils/helpers'; import { Event } from '@sentry/types'; +const useV2 = process.env.REMIX_VERSION === '2'; + jest.spyOn(console, 'error').mockImplementation(); // Repeat tests for each adapter @@ -52,7 +54,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada const transaction = envelope[2]; assertSentryTransaction(transaction, { - transaction: 'routes/loader-json-response/$id', + transaction: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, transaction_info: { source: 'route', }, @@ -62,11 +64,11 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada op: 'function.remix.loader', }, { - description: 'routes/loader-json-response/$id', + description: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.loader', }, { - description: 'routes/loader-json-response/$id', + description: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, op: 'function.remix.document_request', }, ], @@ -98,7 +100,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada }, }, tags: { - transaction: 'routes/loader-json-response/$id', + transaction: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -114,7 +116,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada }, }, tags: { - transaction: 'routes/loader-json-response/$id', + transaction: `routes/loader-json-response${useV2 ? '.' : '/'}$id`, }, }); @@ -195,24 +197,39 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada const transaction = envelope[2]; assertSentryTransaction(transaction, { - transaction: 'root', + transaction: useV2 ? 'routes/loader-defer-response' : 'root', transaction_info: { source: 'route', }, - spans: [ - { - description: 'root', - op: 'function.remix.loader', - }, - { - description: 'routes/loader-defer-response/index', - op: 'function.remix.loader', - }, - { - description: 'root', - op: 'function.remix.document_request', - }, - ], + spans: useV2 + ? [ + { + description: 'root', + op: 'function.remix.loader', + }, + { + description: 'routes/loader-defer-response', + op: 'function.remix.loader', + }, + { + description: 'routes/loader-defer-response', + op: 'function.remix.document_request', + }, + ] + : [ + { + description: 'root', + op: 'function.remix.loader', + }, + { + description: 'routes/loader-defer-response/index', + op: 'function.remix.loader', + }, + { + description: 'root', + op: 'function.remix.document_request', + }, + ], }); }); }); diff --git a/packages/remix/test/integration/tsconfig.json b/packages/remix/test/integration/tsconfig.json index 2129c1a599f6..f190b5da307f 100644 --- a/packages/remix/test/integration/tsconfig.json +++ b/packages/remix/test/integration/tsconfig.json @@ -13,7 +13,7 @@ "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { - "~/*": ["./app/*"] + "~/*": ["app_v1/*", "app_v2/*"] }, "noEmit": true } From ac435ddda1f8d7d7ed9e6a3686a05a146d009b1d Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 27 Jun 2023 09:23:49 -0400 Subject: [PATCH 23/28] feat(tracing): Favour client options tracePropagationTargets (#8399) ref https://github.com/getsentry/sentry-javascript/issues/8352 As we work toward adding tracing without performance support, this PR updates the `BrowserTracing` integration to use and favour the top level `tracePropagationTargets` option if it exists. This option was made top level in https://github.com/getsentry/sentry-javascript/pull/8395 `tracePropagationTargets` is now part of the unified API for distributed tracing. It's also expected that electron/react native will behave the same way as well. This also leaves us the flexibility to extract tracing out of BrowserTracing, or create a new integration that just does tracing but no performance monitoring. We can make sure this migration is smooth and easy to understand with a good set of docs, which is what I will be working on next. In these docs changes, we'll be updating the automatic instrumentation sections, and formally documented `tracePropagationTargets` as a high level option. --- .../src/browser/browsertracing.ts | 32 +++++++++- .../test/browser/browsertracing.test.ts | 59 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/tracing-internal/src/browser/browsertracing.ts b/packages/tracing-internal/src/browser/browsertracing.ts index deb240ec4233..06c531b7941f 100644 --- a/packages/tracing-internal/src/browser/browsertracing.ts +++ b/packages/tracing-internal/src/browser/browsertracing.ts @@ -177,9 +177,19 @@ export class BrowserTracing implements Integration { private _collectWebVitals: () => void; + private _hasSetTracePropagationTargets: boolean = false; + public constructor(_options?: Partial) { addTracingExtensions(); + if (__DEBUG_BUILD__) { + this._hasSetTracePropagationTargets = !!( + _options && + // eslint-disable-next-line deprecation/deprecation + (_options.tracePropagationTargets || _options.tracingOrigins) + ); + } + this.options = { ...DEFAULT_BROWSER_TRACING_OPTIONS, ..._options, @@ -214,6 +224,9 @@ export class BrowserTracing implements Integration { */ public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { this._getCurrentHub = getCurrentHub; + const hub = getCurrentHub(); + const client = hub.getClient(); + const clientOptions = client && client.getOptions(); const { routingInstrumentation: instrumentRouting, @@ -222,11 +235,28 @@ export class BrowserTracing implements Integration { markBackgroundTransactions, traceFetch, traceXHR, - tracePropagationTargets, shouldCreateSpanForRequest, _experiments, } = this.options; + const clientOptionsTracePropagationTargets = clientOptions && clientOptions.tracePropagationTargets; + // There are three ways to configure tracePropagationTargets: + // 1. via top level client option `tracePropagationTargets` + // 2. via BrowserTracing option `tracePropagationTargets` + // 3. via BrowserTracing option `tracingOrigins` (deprecated) + // + // To avoid confusion, favour top level client option `tracePropagationTargets`, and fallback to + // BrowserTracing option `tracePropagationTargets` and then `tracingOrigins` (deprecated). + // This is done as it minimizes bundle size (we don't have to have undefined checks). + // + // If both 1 and either one of 2 or 3 are set (from above), we log out a warning. + const tracePropagationTargets = clientOptionsTracePropagationTargets || this.options.tracePropagationTargets; + if (__DEBUG_BUILD__ && this._hasSetTracePropagationTargets && clientOptionsTracePropagationTargets) { + logger.warn( + '[Tracing] The `tracePropagationTargets` option was set in the BrowserTracing integration and top level `Sentry.init`. The top level `Sentry.init` value is being used.', + ); + } + instrumentRouting( (context: TransactionContext) => { const transaction = this._createRouteTransaction(context); diff --git a/packages/tracing-internal/test/browser/browsertracing.test.ts b/packages/tracing-internal/test/browser/browsertracing.test.ts index 669e79e3c097..c7cec8a54735 100644 --- a/packages/tracing-internal/test/browser/browsertracing.test.ts +++ b/packages/tracing-internal/test/browser/browsertracing.test.ts @@ -250,6 +250,65 @@ describe('BrowserTracing', () => { tracePropagationTargets: ['something'], }); }); + + it('uses `tracePropagationTargets` set by client over integration set targets', () => { + jest.clearAllMocks(); + hub.getClient()!.getOptions().tracePropagationTargets = ['something-else']; + const sampleTracePropagationTargets = ['something']; + createBrowserTracing(true, { + routingInstrumentation: customInstrumentRouting, + tracePropagationTargets: sampleTracePropagationTargets, + }); + + expect(instrumentOutgoingRequestsMock).toHaveBeenCalledWith({ + traceFetch: true, + traceXHR: true, + tracePropagationTargets: ['something-else'], + }); + }); + + it.each([ + [true, 'tracePropagationTargets', 'defined', { tracePropagationTargets: ['something'] }], + [false, 'tracePropagationTargets', 'undefined', { tracePropagationTargets: undefined }], + [true, 'tracingOrigins', 'defined', { tracingOrigins: ['something'] }], + [false, 'tracingOrigins', 'undefined', { tracingOrigins: undefined }], + [ + true, + 'tracePropagationTargets and tracingOrigins', + 'defined', + { tracePropagationTargets: ['something'], tracingOrigins: ['something-else'] }, + ], + [ + false, + 'tracePropagationTargets and tracingOrigins', + 'undefined', + { tracePropagationTargets: undefined, tracingOrigins: undefined }, + ], + [ + true, + 'tracePropagationTargets and tracingOrigins', + 'defined and undefined', + { tracePropagationTargets: ['something'], tracingOrigins: undefined }, + ], + [ + true, + 'tracePropagationTargets and tracingOrigins', + 'undefined and defined', + { tracePropagationTargets: undefined, tracingOrigins: ['something'] }, + ], + ])( + 'sets `_hasSetTracePropagationTargets` to %s if %s is %s', + (hasSet: boolean, _: string, __: string, options: Partial) => { + jest.clearAllMocks(); + const inst = createBrowserTracing(true, { + routingInstrumentation: customInstrumentRouting, + ...options, + }); + + // @ts-ignore accessing private property + expect(inst._hasSetTracePropagationTargets).toBe(hasSet); + }, + ); }); describe('beforeNavigate', () => { From 88eb034113cc689f0b7a95bbda6a534dc6b2053f Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 27 Jun 2023 10:42:34 -0400 Subject: [PATCH 24/28] feat: collect timings on profiler stop calls (#8409) Collect span information on how long the async calls to profiler.stop take which will help us evaluate if calling txn.finish after profiler.stop is a viable approach --- packages/browser/src/profiling/cache.ts | 4 ---- packages/browser/src/profiling/hubextensions.ts | 7 +++++++ 2 files changed, 7 insertions(+), 4 deletions(-) delete mode 100644 packages/browser/src/profiling/cache.ts diff --git a/packages/browser/src/profiling/cache.ts b/packages/browser/src/profiling/cache.ts deleted file mode 100644 index 34b5da5d12fa..000000000000 --- a/packages/browser/src/profiling/cache.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { Event } from '@sentry/types'; -import { makeFifoCache } from '@sentry/utils'; - -export const PROFILING_EVENT_CACHE = makeFifoCache(20); diff --git a/packages/browser/src/profiling/hubextensions.ts b/packages/browser/src/profiling/hubextensions.ts index 1c04eeb68362..49763ac35659 100644 --- a/packages/browser/src/profiling/hubextensions.ts +++ b/packages/browser/src/profiling/hubextensions.ts @@ -172,9 +172,15 @@ export function wrapTransactionWithProfiling(transaction: Transaction): Transact return null; } + // This is temporary - we will use the collected span data to evaluate + // if deferring txn.finish until profiler resolves is a viable approach. + const stopProfilerSpan = transaction.startChild({ description: 'profiler.stop', op: 'profiler' }); + return profiler .stop() .then((p: JSSelfProfile): null => { + stopProfilerSpan.finish(); + if (maxDurationTimeoutID) { WINDOW.clearTimeout(maxDurationTimeoutID); maxDurationTimeoutID = undefined; @@ -199,6 +205,7 @@ export function wrapTransactionWithProfiling(transaction: Transaction): Transact return null; }) .catch(error => { + stopProfilerSpan.finish(); if (__DEBUG_BUILD__) { logger.log('[Profiling] error while stopping profiler:', error); } From b3ee89e5751a8f41585cbb67d5a6241c412fb27d Mon Sep 17 00:00:00 2001 From: Kev <6111995+k-fish@users.noreply.github.com> Date: Tue, 27 Jun 2023 21:32:50 -0400 Subject: [PATCH 25/28] feat(tracing): Add experiment to capture http timings (#8371) This adds an experiment that will allow http timings to be captured. We currently capture timings on Sentry SaaS with some custom code and append them to the spans, which has been helpful to identify some performance problems that were previously hidden (http/1.1 stall time). Following this work we can add these to the waterfall to represent measurements as subtimings and will power an upcoming http/1.1 stall performance issue. --- .../browsertracing/http-timings/init.js | 17 ++++ .../browsertracing/http-timings/subject.js | 1 + .../browsertracing/http-timings/test.ts | 50 ++++++++++++ .../src/browser/browsertracing.ts | 5 +- .../tracing-internal/src/browser/request.ts | 78 +++++++++++++++++-- 5 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/init.js create mode 100644 packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/subject.js create mode 100644 packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/test.ts diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/init.js b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/init.js new file mode 100644 index 000000000000..efe1e2ef9778 --- /dev/null +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; +import { Integrations } from '@sentry/tracing'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + new Integrations.BrowserTracing({ + idleTimeout: 1000, + _experiments: { + enableHTTPTimings: true, + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/subject.js b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/subject.js new file mode 100644 index 000000000000..f62499b1e9c5 --- /dev/null +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/subject.js @@ -0,0 +1 @@ +fetch('http://example.com/0').then(fetch('http://example.com/1').then(fetch('http://example.com/2'))); diff --git a/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/test.ts b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/test.ts new file mode 100644 index 000000000000..c4b5d3e92e62 --- /dev/null +++ b/packages/browser-integration-tests/suites/tracing/browsertracing/http-timings/test.ts @@ -0,0 +1,50 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should create fetch spans with http timing', async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + await page.route('http://example.com/*', async route => { + const request = route.request(); + const postData = await request.postDataJSON(); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(Object.assign({ id: 1 }, postData)), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + const requestSpans = tracingEvent.spans?.filter(({ op }) => op === 'http.client'); + + expect(requestSpans).toHaveLength(3); + + await page.pause(); + requestSpans?.forEach((span, index) => + expect(span).toMatchObject({ + description: `GET http://example.com/${index}`, + parent_span_id: tracingEvent.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: tracingEvent.contexts?.trace?.trace_id, + data: expect.objectContaining({ + 'http.request.connect_start': expect.any(Number), + 'http.request.request_start': expect.any(Number), + 'http.request.response_start': expect.any(Number), + 'network.protocol.version': expect.any(String), + }), + }), + ); +}); diff --git a/packages/tracing-internal/src/browser/browsertracing.ts b/packages/tracing-internal/src/browser/browsertracing.ts index 06c531b7941f..4d1b1bca7c97 100644 --- a/packages/tracing-internal/src/browser/browsertracing.ts +++ b/packages/tracing-internal/src/browser/browsertracing.ts @@ -111,6 +111,7 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions { _experiments: Partial<{ enableLongTask: boolean; enableInteractions: boolean; + enableHTTPTimings: boolean; onStartRouteTransaction: (t: Transaction | undefined, ctx: TransactionContext, getCurrentHub: () => Hub) => void; }>; @@ -145,7 +146,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { startTransactionOnLocationChange: true, startTransactionOnPageLoad: true, enableLongTask: true, - _experiments: {}, ...defaultRequestInstrumentationOptions, }; @@ -283,6 +283,9 @@ export class BrowserTracing implements Integration { traceXHR, tracePropagationTargets, shouldCreateSpanForRequest, + _experiments: { + enableHTTPTimings: _experiments.enableHTTPTimings, + }, }); } diff --git a/packages/tracing-internal/src/browser/request.ts b/packages/tracing-internal/src/browser/request.ts index 8045e5feed8a..d7e397ae01ac 100644 --- a/packages/tracing-internal/src/browser/request.ts +++ b/packages/tracing-internal/src/browser/request.ts @@ -4,6 +4,7 @@ import type { DynamicSamplingContext, Span } from '@sentry/types'; import { addInstrumentationHandler, BAGGAGE_HEADER_NAME, + browserPerformanceTimeOrigin, dynamicSamplingContextToSentryBaggageHeader, isInstanceOf, SENTRY_XHR_DATA_KEY, @@ -14,6 +15,13 @@ export const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/]; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { + /** + * Allow experiments for the request instrumentation. + */ + _experiments: Partial<{ + enableHTTPTimings: boolean; + }>; + /** * @deprecated Will be removed in v8. * Use `shouldCreateSpanForRequest` to control span creation and `tracePropagationTargets` to control @@ -108,12 +116,13 @@ export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions // TODO (v8): Remove this property tracingOrigins: DEFAULT_TRACE_PROPAGATION_TARGETS, tracePropagationTargets: DEFAULT_TRACE_PROPAGATION_TARGETS, + _experiments: {}, }; /** Registers span creators for xhr and fetch requests */ export function instrumentOutgoingRequests(_options?: Partial): void { // eslint-disable-next-line deprecation/deprecation - const { traceFetch, traceXHR, tracePropagationTargets, tracingOrigins, shouldCreateSpanForRequest } = { + const { traceFetch, traceXHR, tracePropagationTargets, tracingOrigins, shouldCreateSpanForRequest, _experiments } = { traceFetch: defaultRequestInstrumentationOptions.traceFetch, traceXHR: defaultRequestInstrumentationOptions.traceXHR, ..._options, @@ -132,15 +141,63 @@ export function instrumentOutgoingRequests(_options?: Partial { - fetchCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + const createdSpan = fetchCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + if (_experiments?.enableHTTPTimings && createdSpan) { + addHTTPTimings(createdSpan); + } }); } if (traceXHR) { addInstrumentationHandler('xhr', (handlerData: XHRData) => { - xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); + if (_experiments?.enableHTTPTimings && createdSpan) { + addHTTPTimings(createdSpan); + } + }); + } +} + +/** + * Creates a temporary observer to listen to the next fetch/xhr resourcing timings, + * so that when timings hit their per-browser limit they don't need to be removed. + * + * @param span A span that has yet to be finished, must contain `url` on data. + */ +function addHTTPTimings(span: Span): void { + const url = span.data.url; + const observer = new PerformanceObserver(list => { + const entries = list.getEntries() as PerformanceResourceTiming[]; + entries.forEach(entry => { + if ((entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') && entry.name.endsWith(url)) { + const spanData = resourceTimingEntryToSpanData(entry); + spanData.forEach(data => span.setData(...data)); + observer.disconnect(); + } }); + }); + observer.observe({ + entryTypes: ['resource'], + }); +} + +function resourceTimingEntryToSpanData(resourceTiming: PerformanceResourceTiming): [string, string | number][] { + const version = resourceTiming.nextHopProtocol.split('/')[1] || 'none'; + + const timingSpanData: [string, string | number][] = []; + if (version) { + timingSpanData.push(['network.protocol.version', version]); + } + + if (!browserPerformanceTimeOrigin) { + return timingSpanData; } + return [ + ...timingSpanData, + ['http.request.connect_start', (browserPerformanceTimeOrigin + resourceTiming.connectStart) / 1000], + ['http.request.request_start', (browserPerformanceTimeOrigin + resourceTiming.requestStart) / 1000], + ['http.request.response_start', (browserPerformanceTimeOrigin + resourceTiming.responseStart) / 1000], + ]; } /** @@ -154,13 +211,15 @@ export function shouldAttachHeaders(url: string, tracePropagationTargets: (strin /** * Create and track fetch request spans + * + * @returns Span if a span was created, otherwise void. */ -export function fetchCallback( +function fetchCallback( handlerData: FetchData, shouldCreateSpan: (url: string) => boolean, shouldAttachHeaders: (url: string) => boolean, spans: Record, -): void { +): Span | void { if (!hasTracingEnabled() || !(handlerData.fetchData && shouldCreateSpan(handlerData.fetchData.url))) { return; } @@ -229,6 +288,7 @@ export function fetchCallback( options, ); } + return span; } } @@ -301,13 +361,15 @@ export function addTracingHeadersToFetchRequest( /** * Create and track xhr request spans + * + * @returns Span if a span was created, otherwise void. */ -export function xhrCallback( +function xhrCallback( handlerData: XHRData, shouldCreateSpan: (url: string) => boolean, shouldAttachHeaders: (url: string) => boolean, spans: Record, -): void { +): Span | void { const xhr = handlerData.xhr; const sentryXhrData = xhr && xhr[SENTRY_XHR_DATA_KEY]; @@ -370,5 +432,7 @@ export function xhrCallback( // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } + + return span; } } From da295ffe12e694787e6905cac56839aea172da77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kry=C5=A1tof=20Wold=C5=99ich?= <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 28 Jun 2023 13:14:47 +0200 Subject: [PATCH 26/28] Add `http.response.status_code` to `span.data` (#8366) --- packages/core/src/tracing/span.ts | 1 + .../test/client/tracingFetch.test.ts | 1 + .../test/server/cjsApiEndpoints.test.ts | 9 ++++++++ .../test/server/errorApiEndpoint.test.ts | 3 +++ .../test/server/tracing200.test.ts | 3 +++ .../test/server/tracing500.test.ts | 3 +++ .../test/server/tracingHttp.test.ts | 6 ++++++ .../suites/express/tracing/test.ts | 4 ++++ packages/node/test/handlers.test.ts | 1 + .../node/test/integrations/undici.test.ts | 16 +++++++------- .../integration/test/server/action.test.ts | 21 +++++++++++++++++++ .../integration/test/server/loader.test.ts | 9 ++++++++ packages/replay/test/fixtures/transaction.ts | 1 + packages/tracing/test/span.test.ts | 1 + 14 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/core/src/tracing/span.ts b/packages/core/src/tracing/span.ts index fe9e7aba017c..59a8ed11d68a 100644 --- a/packages/core/src/tracing/span.ts +++ b/packages/core/src/tracing/span.ts @@ -227,6 +227,7 @@ export class Span implements SpanInterface { */ public setHttpStatus(httpStatus: number): this { this.setTag('http.status_code', String(httpStatus)); + this.setData('http.response.status_code', httpStatus); const spanStatus = spanStatusfromHttpCode(httpStatus); if (spanStatus !== 'unknown_error') { this.setStatus(spanStatus); diff --git a/packages/nextjs/test/integration/test/client/tracingFetch.test.ts b/packages/nextjs/test/integration/test/client/tracingFetch.test.ts index 88634c92012e..b1eb8a5f1bb8 100644 --- a/packages/nextjs/test/integration/test/client/tracingFetch.test.ts +++ b/packages/nextjs/test/integration/test/client/tracingFetch.test.ts @@ -36,6 +36,7 @@ test('should correctly instrument `fetch` for performance tracing', async ({ pag url: 'http://example.com', type: 'fetch', 'http.response_content_length': expect.any(Number), + 'http.response.status_code': 200, }, description: 'GET http://example.com', op: 'http.client', diff --git a/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts b/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts index fc0ae186f64b..dfbf3b620aa8 100644 --- a/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts +++ b/packages/nextjs/test/integration/test/server/cjsApiEndpoints.test.ts @@ -18,6 +18,9 @@ describe('CommonJS API Endpoints', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: `GET ${unwrappedRoute}`, @@ -51,6 +54,9 @@ describe('CommonJS API Endpoints', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: `GET ${wrappedRoute}`, @@ -84,6 +90,9 @@ describe('CommonJS API Endpoints', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: `GET ${route}`, diff --git a/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts b/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts index 45168eeeee33..d259db3f2801 100644 --- a/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts +++ b/packages/nextjs/test/integration/test/server/errorApiEndpoint.test.ts @@ -45,6 +45,9 @@ describe('Error API Endpoints', () => { op: 'http.server', status: 'internal_error', tags: { 'http.status_code': '500' }, + data: { + 'http.response.status_code': 500, + }, }, }, transaction: 'GET /api/error', diff --git a/packages/nextjs/test/integration/test/server/tracing200.test.ts b/packages/nextjs/test/integration/test/server/tracing200.test.ts index f68279138558..ac6b2db163aa 100644 --- a/packages/nextjs/test/integration/test/server/tracing200.test.ts +++ b/packages/nextjs/test/integration/test/server/tracing200.test.ts @@ -16,6 +16,9 @@ describe('Tracing 200', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, transaction: 'GET /api/users', diff --git a/packages/nextjs/test/integration/test/server/tracing500.test.ts b/packages/nextjs/test/integration/test/server/tracing500.test.ts index 79b23dcfb786..b94fe781e2d2 100644 --- a/packages/nextjs/test/integration/test/server/tracing500.test.ts +++ b/packages/nextjs/test/integration/test/server/tracing500.test.ts @@ -16,6 +16,9 @@ describe('Tracing 500', () => { op: 'http.server', status: 'internal_error', tags: { 'http.status_code': '500' }, + data: { + 'http.response.status_code': 500, + }, }, }, transaction: 'GET /api/broken', diff --git a/packages/nextjs/test/integration/test/server/tracingHttp.test.ts b/packages/nextjs/test/integration/test/server/tracingHttp.test.ts index 912c54e0996b..0f8615c4b7f0 100644 --- a/packages/nextjs/test/integration/test/server/tracingHttp.test.ts +++ b/packages/nextjs/test/integration/test/server/tracingHttp.test.ts @@ -20,6 +20,9 @@ describe('Tracing HTTP', () => { op: 'http.server', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, }, spans: [ @@ -28,6 +31,9 @@ describe('Tracing HTTP', () => { op: 'http.client', status: 'ok', tags: { 'http.status_code': '200' }, + data: { + 'http.response.status_code': 200, + }, }, ], transaction: 'GET /api/http', diff --git a/packages/node-integration-tests/suites/express/tracing/test.ts b/packages/node-integration-tests/suites/express/tracing/test.ts index ae9d619c48cc..835c2938ae5b 100644 --- a/packages/node-integration-tests/suites/express/tracing/test.ts +++ b/packages/node-integration-tests/suites/express/tracing/test.ts @@ -11,6 +11,7 @@ test('should create and send transactions for Express routes and spans for middl trace: { data: { url: '/test/express', + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', @@ -43,6 +44,7 @@ test('should set a correct transaction name for routes specified in RegEx', asyn trace: { data: { url: '/test/regex', + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', @@ -71,6 +73,7 @@ test.each([['array1'], ['array5']])( trace: { data: { url: `/test/${segment}`, + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', @@ -107,6 +110,7 @@ test.each([ trace: { data: { url: `/test/${segment}`, + 'http.response.status_code': 200, }, op: 'http.server', status: 'ok', diff --git a/packages/node/test/handlers.test.ts b/packages/node/test/handlers.test.ts index 046bdf0bb31f..298d61cf1aac 100644 --- a/packages/node/test/handlers.test.ts +++ b/packages/node/test/handlers.test.ts @@ -351,6 +351,7 @@ describe('tracingHandler', () => { expect(finishTransaction).toHaveBeenCalled(); expect(transaction.status).toBe('ok'); expect(transaction.tags).toEqual(expect.objectContaining({ 'http.status_code': '200' })); + expect(transaction.data).toEqual(expect.objectContaining({ 'http.response.status_code': 200 })); done(); }); }); diff --git a/packages/node/test/integrations/undici.test.ts b/packages/node/test/integrations/undici.test.ts index f4925f7046a2..46756cbe88cd 100644 --- a/packages/node/test/integrations/undici.test.ts +++ b/packages/node/test/integrations/undici.test.ts @@ -56,9 +56,9 @@ conditionalTest({ min: 16 })('Undici integration', () => { { description: 'GET http://localhost:18099/', op: 'http.client', - data: { + data: expect.objectContaining({ 'http.method': 'GET', - }, + }), }, ], [ @@ -68,10 +68,10 @@ conditionalTest({ min: 16 })('Undici integration', () => { { description: 'GET http://localhost:18099/', op: 'http.client', - data: { + data: expect.objectContaining({ 'http.method': 'GET', 'http.query': '?foo=bar', - }, + }), }, ], [ @@ -80,9 +80,9 @@ conditionalTest({ min: 16 })('Undici integration', () => { { method: 'POST' }, { description: 'POST http://localhost:18099/', - data: { + data: expect.objectContaining({ 'http.method': 'POST', - }, + }), }, ], [ @@ -91,9 +91,9 @@ conditionalTest({ min: 16 })('Undici integration', () => { { method: 'POST' }, { description: 'POST http://localhost:18099/', - data: { + data: expect.objectContaining({ 'http.method': 'POST', - }, + }), }, ], [ diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts index 664162f2076d..cc25a87611d4 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -65,6 +65,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada tags: { 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, }); @@ -160,6 +163,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '302', }, + data: { + 'http.response.status_code': 302, + }, }, }, tags: { @@ -176,6 +182,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'GET', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { @@ -226,6 +235,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { @@ -276,6 +288,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { @@ -326,6 +341,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { @@ -376,6 +394,9 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada method: 'POST', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index 9613d19fce42..8a99c699cc37 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -23,6 +23,9 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada tags: { 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, }); @@ -97,6 +100,9 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada method: 'GET', 'http.status_code': '302', }, + data: { + 'http.response.status_code': 302, + }, }, }, tags: { @@ -113,6 +119,9 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada method: 'GET', 'http.status_code': '500', }, + data: { + 'http.response.status_code': 500, + }, }, }, tags: { diff --git a/packages/replay/test/fixtures/transaction.ts b/packages/replay/test/fixtures/transaction.ts index dfe0be4cfd4b..b9d6fa309fa1 100644 --- a/packages/replay/test/fixtures/transaction.ts +++ b/packages/replay/test/fixtures/transaction.ts @@ -59,6 +59,7 @@ export function Transaction(traceId?: string, obj?: Partial): any { method: 'GET', url: '/api/0/projects/sentry-emerging-tech/billy-test/replays/c11bd625b0e14081a0827a22a0a9be4e/', type: 'fetch', + 'http.response.status_code': 200, }, description: 'GET /api/0/projects/sentry-emerging-tech/billy-test/replays/c11bd625b0e14081a0827a22a0a9be4e/', op: 'http.client', diff --git a/packages/tracing/test/span.test.ts b/packages/tracing/test/span.test.ts index e642f7490589..1720dd4e6ec4 100644 --- a/packages/tracing/test/span.test.ts +++ b/packages/tracing/test/span.test.ts @@ -96,6 +96,7 @@ describe('Span', () => { span.setHttpStatus(404); expect((span.getTraceContext() as any).status).toBe('not_found'); expect(span.tags['http.status_code']).toBe('404'); + expect(span.data['http.response.status_code']).toBe(404); }); test('isSuccess', () => { From c89c54a361460528daa89c2b7394fa7daea1db1b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 28 Jun 2023 15:33:38 +0200 Subject: [PATCH 27/28] build: Update typescript from 3.8.3 to 4.9.5 (#8255) This updates the TS version we use from 3.8.3 to 4.9.5. This allows us to use newer TS features, as well as as improves compatibility with newer @types/xxx packages that use newer features, which leads to issues when transitive dependencies are used etc. We use [downlevel-dts](https://www.npmjs.com/package/downlevel-dts) to generate a 3.8 compatible types output _in addition_, so this should be fully backwards compatible. TS provides a built-in way to tell it to use a certain types output for a certain version, so this should work with no breaking change. The main changes necessary here are small tweaks to types which are required in 4.9.x. --- .github/workflows/build.yml | 19 +++++- nx.json | 4 +- package.json | 5 +- packages/angular-ivy/package.json | 3 +- packages/angular/package.json | 4 +- packages/angular/src/errorhandler.ts | 6 +- packages/angular/test/errorhandler.test.ts | 2 +- .../browser-integration-tests/package.json | 1 - packages/browser/package.json | 9 ++- packages/browser/src/eventbuilder.ts | 3 +- .../browser/src/integrations/breadcrumbs.ts | 2 +- .../test/unit/integrations/helpers.test.ts | 2 +- .../test/unit/transports/offline.test.ts | 2 +- packages/core/package.json | 9 ++- packages/core/src/transports/offline.ts | 4 +- packages/core/test/lib/hint.test.ts | 1 + .../core/test/lib/transports/offline.test.ts | 2 +- packages/e2e-tests/package.json | 1 - .../create-next-app/package.json | 2 +- .../create-react-app/package.json | 2 +- .../create-react-app/test-recipe.json | 7 ++ .../nextjs-app-dir/package.json | 2 +- .../package.json | 2 +- .../standard-frontend-react/package.json | 2 +- .../standard-frontend-react/test-recipe.json | 7 ++ .../test-applications/sveltekit/package.json | 2 +- packages/ember/package.json | 1 - packages/gatsby/package.json | 7 +- packages/hub/package.json | 9 ++- packages/integration-shims/package.json | 7 +- packages/integrations/package.json | 9 ++- .../integrations/test/captureconsole.test.ts | 2 +- packages/nextjs/package.json | 9 ++- .../wrapApiHandlerWithSentryVercelCrons.ts | 4 +- packages/nextjs/src/config/webpack.ts | 3 +- packages/nextjs/test/integration/package.json | 2 +- packages/nextjs/test/serverSdk.test.ts | 1 + .../node-integration-tests/utils/run-tests.ts | 2 +- packages/node/package.json | 9 ++- packages/node/test/index.test.ts | 2 +- packages/node/test/utils.test.ts | 2 +- packages/opentelemetry-node/package.json | 7 +- packages/overhead-metrics/package.json | 2 +- packages/react/package.json | 9 ++- packages/react/test/reactrouterv6.4.test.tsx | 6 ++ packages/remix/package.json | 9 ++- packages/remix/src/utils/instrumentServer.ts | 5 +- .../remix/src/utils/serverAdapters/express.ts | 2 +- packages/remix/test/integration/package.json | 2 +- packages/replay-worker/package.json | 9 ++- packages/replay-worker/src/handleMessage.ts | 2 +- packages/replay/.eslintignore | 2 + packages/replay/package.json | 9 ++- .../replay/src/coreHandlers/handleFetch.ts | 2 +- .../src/util/createPerformanceEntries.ts | 2 - .../src/util/dedupePerformanceEntries.ts | 2 - .../test/fixtures/performanceEntry/lcp.ts | 2 - .../fixtures/performanceEntry/navigation.ts | 6 +- packages/serverless/package.json | 9 ++- packages/svelte/package.json | 9 ++- packages/sveltekit/package.json | 1 - packages/tracing-internal/package.json | 9 ++- .../src/browser/metrics/index.ts | 2 + .../web-vitals/lib/getNavigationEntry.ts | 1 + .../src/node/integrations/prisma.ts | 2 +- packages/tracing/package.json | 7 +- packages/types/package.json | 7 +- packages/typescript/package.json | 2 +- packages/utils/package.json | 9 ++- packages/utils/src/envelope.ts | 2 +- packages/utils/test/envelope.test.ts | 6 +- packages/utils/test/worldwide.test.ts | 1 + packages/vue/package.json | 9 ++- packages/wasm/package.json | 9 ++- scripts/ensure-bundle-deps.ts | 2 +- scripts/verify-packages-versions.js | 2 +- yarn.lock | 68 +++++++++---------- 77 files changed, 271 insertions(+), 138 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2ec7fdcf411..0b254d24022b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -655,7 +655,9 @@ jobs: yarn test:package job_node_integration_tests: - name: Node (${{ matrix.node }}) Integration Tests + name: + Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Integration + Tests needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_node == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-20.04 @@ -664,6 +666,12 @@ jobs: fail-fast: false matrix: node: [10, 12, 14, 16, 18, 20] + typescript: + - false + include: + # Only check typescript for latest version (to streamline CI) + - node: 20 + typescript: '3.8' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v3 @@ -677,6 +685,11 @@ jobs: uses: ./.github/actions/restore-cache env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Overwrite typescript version + if: matrix.typescript + run: yarn add --dev --ignore-workspace-root-check typescript@${{ matrix.typescript }} + - name: Run integration tests env: NODE_VERSION: ${{ matrix.node }} @@ -717,7 +730,7 @@ jobs: yarn test:integration:ci job_e2e_tests: - name: E2E Tests (Shard ${{ matrix.shard }}) + name: E2E (Shard ${{ matrix.shard }}) Tests # We only run E2E tests for non-fork PRs because the E2E tests require secrets to work and they can't be accessed from forks # Dependabot PRs sadly also don't have access to secrets, so we skip them as well if: @@ -730,6 +743,7 @@ jobs: fail-fast: false matrix: shard: [1, 2, 3] + steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v3 @@ -746,6 +760,7 @@ jobs: uses: ./.github/actions/restore-cache env: DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Get node version id: versions run: | diff --git a/nx.json b/nx.json index 096a0b0c9620..2341174dd956 100644 --- a/nx.json +++ b/nx.json @@ -64,7 +64,9 @@ ], "outputs": [ "{projectRoot}/build/types", - "{projectRoot}/build/npm/types" + "{projectRoot}/build/types-ts3.8", + "{projectRoot}/build/npm/types", + "{projectRoot}/build/npm/types-ts3.8" ] }, "lint:eslint": { diff --git a/package.json b/package.json index 8ebd0541f657..feb5be7839d0 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "chai": "^4.1.2", "codecov": "^3.6.5", "deepmerge": "^4.2.2", + "downlevel-dts": "~0.11.0", "es-check": "7.1.0", "eslint": "7.32.0", "jest": "^27.5.1", @@ -114,9 +115,9 @@ "size-limit": "^4.5.5", "ts-jest": "^27.1.4", "ts-node": "10.9.1", - "tslib": "^2.3.1", + "tslib": "2.4.1", "typedoc": "^0.18.0", - "typescript": "3.8.3", + "typescript": "4.9.5", "vitest": "^0.29.2", "yalc": "^1.0.0-pre.53" }, diff --git a/packages/angular-ivy/package.json b/packages/angular-ivy/package.json index b18a7e014fe3..f05c8de96ebf 100644 --- a/packages/angular-ivy/package.json +++ b/packages/angular-ivy/package.json @@ -24,7 +24,7 @@ "@sentry/browser": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^2.3.0" + "tslib": "^2.4.1" }, "devDependencies": { "@angular-devkit/build-angular": "~12.2.18", @@ -37,7 +37,6 @@ "@angular/platform-browser-dynamic": "~12.2.0", "@angular/router": "~12.2.0", "ng-packagr": "^12.1.1", - "typescript": "~4.3.5", "zone.js": "~0.11.4" }, "scripts": { diff --git a/packages/angular/package.json b/packages/angular/package.json index 39a12c3303a7..ad1c7d7aa272 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -24,7 +24,7 @@ "@sentry/browser": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^2.0.0" + "tslib": "^2.4.1" }, "devDependencies": { "@angular-devkit/build-angular": "~0.1002.4", @@ -38,7 +38,7 @@ "@angular/router": "~10.2.5", "ng-packagr": "^10.1.0", "rxjs": "6.5.5", - "typescript": "~4.0.2", + "typescript": "4.0.2", "zone.js": "^0.11.8" }, "scripts": { diff --git a/packages/angular/src/errorhandler.ts b/packages/angular/src/errorhandler.ts index 89f3b8b556ac..6940e0cc8cc1 100644 --- a/packages/angular/src/errorhandler.ts +++ b/packages/angular/src/errorhandler.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import type { ErrorHandler as AngularErrorHandler } from '@angular/core'; import { Inject, Injectable } from '@angular/core'; import * as Sentry from '@sentry/browser'; -import { captureException } from '@sentry/browser'; +import type { Event, Scope } from '@sentry/types'; import { addExceptionMechanism, isString } from '@sentry/utils'; import { runOutsideAngular } from './zone'; @@ -101,7 +101,7 @@ class SentryErrorHandler implements AngularErrorHandler { // Capture handled exception and send it to Sentry. const eventId = runOutsideAngular(() => - captureException(extractedError, scope => { + Sentry.captureException(extractedError, (scope: Scope) => { scope.addEventProcessor(event => { addExceptionMechanism(event, { type: 'angular', @@ -126,7 +126,7 @@ class SentryErrorHandler implements AngularErrorHandler { const client = Sentry.getCurrentHub().getClient(); if (client && client.on && !this._registeredAfterSendEventHandler) { - client.on('afterSendEvent', event => { + client.on('afterSendEvent', (event: Event) => { if (!event.type) { Sentry.showReportDialog({ ...this._options.dialogOptions, eventId: event.event_id }); } diff --git a/packages/angular/test/errorhandler.test.ts b/packages/angular/test/errorhandler.test.ts index c43ad41629c1..633d4d81f7e9 100644 --- a/packages/angular/test/errorhandler.test.ts +++ b/packages/angular/test/errorhandler.test.ts @@ -57,7 +57,7 @@ describe('SentryErrorHandler', () => { describe('handleError method', () => { it('handleError method assigns the correct mechanism', () => { const addEventProcessorSpy = jest.spyOn(FakeScope, 'addEventProcessor').mockImplementationOnce(callback => { - void callback({}, { event_id: 'fake-event-id' }); + void (callback as (event: any, hint: any) => void)({}, { event_id: 'fake-event-id' }); return FakeScope; }); diff --git a/packages/browser-integration-tests/package.json b/packages/browser-integration-tests/package.json index 63e61ebf57b7..9d95ca877c9c 100644 --- a/packages/browser-integration-tests/package.json +++ b/packages/browser-integration-tests/package.json @@ -53,7 +53,6 @@ "html-webpack-plugin": "^5.5.0", "pako": "^2.1.0", "playwright": "^1.31.1", - "typescript": "^4.5.2", "webpack": "^5.52.0" }, "devDependencies": { diff --git a/packages/browser/package.json b/packages/browser/package.json index d59b14ce1b48..6ec505295c27 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -12,6 +12,9 @@ "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"] } + }, "publishConfig": { "access": "public" }, @@ -21,7 +24,7 @@ "@sentry/replay": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@sentry-internal/integration-shims": "7.56.0", @@ -53,7 +56,9 @@ "build:bundle:es5": "JS_VERSION=es5 rollup -c rollup.bundle.config.js", "build:bundle:es6": "JS_VERSION=es6 rollup -c rollup.bundle.config.js", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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": "yarn build:watch", "build:bundle:watch": "rollup -c rollup.bundle.config.js --watch", diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index 6acc1080c56f..e361f1366cf3 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -208,7 +208,7 @@ export function eventFromUnknownInput( // https://developer.mozilla.org/en-US/docs/Web/API/DOMError // https://developer.mozilla.org/en-US/docs/Web/API/DOMException // https://webidl.spec.whatwg.org/#es-DOMException-specialness - if (isDOMError(exception as DOMError) || isDOMException(exception as DOMException)) { + if (isDOMError(exception) || isDOMException(exception as DOMException)) { const domException = exception as DOMException; if ('stack' in (exception as Error)) { @@ -220,6 +220,7 @@ export function eventFromUnknownInput( addExceptionTypeValue(event, message); } if ('code' in domException) { + // eslint-disable-next-line deprecation/deprecation event.tags = { ...event.tags, 'DOMException.code': `${domException.code}` }; } diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 3d019945b53b..b5f734ce939c 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -348,5 +348,5 @@ function _historyBreadcrumb(handlerData: HandlerData & { from: string; to: strin } function _isEvent(event: unknown): event is Event { - return event && !!(event as Record).target; + return !!event && !!(event as Record).target; } diff --git a/packages/browser/test/unit/integrations/helpers.test.ts b/packages/browser/test/unit/integrations/helpers.test.ts index a3fe734d79d4..5b06835f834d 100644 --- a/packages/browser/test/unit/integrations/helpers.test.ts +++ b/packages/browser/test/unit/integrations/helpers.test.ts @@ -157,7 +157,7 @@ describe('internal wrap()', () => { try { wrapped(); } catch (error) { - expect(error.message).toBe('boom'); + expect((error as Error).message).toBe('boom'); } }); diff --git a/packages/browser/test/unit/transports/offline.test.ts b/packages/browser/test/unit/transports/offline.test.ts index b224df725bc6..aa5819fe155a 100644 --- a/packages/browser/test/unit/transports/offline.test.ts +++ b/packages/browser/test/unit/transports/offline.test.ts @@ -47,7 +47,7 @@ export const createTestTransport = (...sendResults: MockResult( retryDelay = START_DELAY; return result; } catch (e) { - if (store && (await shouldQueue(envelope, e, retryDelay))) { + if (store && (await shouldQueue(envelope, e as Error, retryDelay))) { await store.insert(envelope); flushWithBackOff(); - log('Error sending. Event queued', e); + log('Error sending. Event queued', e as Error); return {}; } else { throw e; diff --git a/packages/core/test/lib/hint.test.ts b/packages/core/test/lib/hint.test.ts index a975174dcd78..bd795ed79c8e 100644 --- a/packages/core/test/lib/hint.test.ts +++ b/packages/core/test/lib/hint.test.ts @@ -16,6 +16,7 @@ describe('Hint', () => { afterEach(() => { jest.clearAllMocks(); + // @ts-ignore for testing delete GLOBAL_OBJ.__SENTRY__; }); diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index 056d11aac90a..6a6474d51ef9 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -98,7 +98,7 @@ const createTestTransport = ( reject(next); } else { sendCount += 1; - resolve(next as TransportMakeRequestResponse | undefined); + resolve(next as TransportMakeRequestResponse); } }); }), diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index 13560617e1b2..6bacdf4d2159 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -26,7 +26,6 @@ "fs-extra": "11.1.0", "glob": "8.0.3", "ts-node": "10.9.1", - "typescript": "3.8.3", "yaml": "2.2.2" }, "volta": { diff --git a/packages/e2e-tests/test-applications/create-next-app/package.json b/packages/e2e-tests/test-applications/create-next-app/package.json index af2a7830f3d8..3232c1eca7fe 100644 --- a/packages/e2e-tests/test-applications/create-next-app/package.json +++ b/packages/e2e-tests/test-applications/create-next-app/package.json @@ -16,7 +16,7 @@ "next": "13.0.7", "react": "18.2.0", "react-dom": "18.2.0", - "typescript": "4.9.4" + "typescript": "4.9.5" }, "devDependencies": { "@playwright/test": "^1.27.1" diff --git a/packages/e2e-tests/test-applications/create-react-app/package.json b/packages/e2e-tests/test-applications/create-react-app/package.json index e0fc502238d0..062eef12aa45 100644 --- a/packages/e2e-tests/test-applications/create-react-app/package.json +++ b/packages/e2e-tests/test-applications/create-react-app/package.json @@ -15,7 +15,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-scripts": "5.0.1", - "typescript": "4.4.2", + "typescript": "4.9.5", "web-vitals": "2.1.0" }, "scripts": { diff --git a/packages/e2e-tests/test-applications/create-react-app/test-recipe.json b/packages/e2e-tests/test-applications/create-react-app/test-recipe.json index a45dde0d9c07..f0f5e19553cc 100644 --- a/packages/e2e-tests/test-applications/create-react-app/test-recipe.json +++ b/packages/e2e-tests/test-applications/create-react-app/test-recipe.json @@ -3,6 +3,13 @@ "testApplicationName": "create-react-app", "buildCommand": "pnpm install && pnpm build", "tests": [], + "versions": [ + { + "dependencyOverrides": { + "typescript": "3.8.3" + } + } + ], "canaryVersions": [ { "dependencyOverrides": { diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/packages/e2e-tests/test-applications/nextjs-app-dir/package.json index b4e58ced63a2..45f79250d05f 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -16,7 +16,7 @@ "next": "13.2.4", "react": "18.2.0", "react-dom": "18.2.0", - "typescript": "4.9.4", + "typescript": "4.9.5", "wait-port": "1.0.4", "ts-node": "10.9.1", "@playwright/test": "^1.27.1" diff --git a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json index f1e97a01925a..abfb3bfea914 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json +++ b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/package.json @@ -16,7 +16,7 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "4.4.2", + "typescript": "4.9.5", "web-vitals": "2.1.0" }, "scripts": { diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/package.json b/packages/e2e-tests/test-applications/standard-frontend-react/package.json index d8f6db397dcd..8ea59b80e55a 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/package.json +++ b/packages/e2e-tests/test-applications/standard-frontend-react/package.json @@ -15,7 +15,7 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "4.4.2", + "typescript": "4.9.5", "web-vitals": "2.1.0" }, "scripts": { diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json b/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json index c3f5f2b89166..2f2e72c8f501 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json +++ b/packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json @@ -8,6 +8,13 @@ "testCommand": "pnpm test" } ], + "versions": [ + { + "dependencyOverrides": { + "typescript": "3.8.3" + } + } + ], "canaryVersions": [ { "dependencyOverrides": { diff --git a/packages/e2e-tests/test-applications/sveltekit/package.json b/packages/e2e-tests/test-applications/sveltekit/package.json index 003868af5ab8..5c4139365599 100644 --- a/packages/e2e-tests/test-applications/sveltekit/package.json +++ b/packages/e2e-tests/test-applications/sveltekit/package.json @@ -22,7 +22,7 @@ "svelte": "^3.54.0", "svelte-check": "^3.0.1", "ts-node": "10.9.1", - "tslib": "^2.4.1", + "tslib": "2.4.1", "typescript": "^5.0.0", "vite": "^4.2.0", "wait-port": "1.0.4" diff --git a/packages/ember/package.json b/packages/ember/package.json index 2e184fc17a7e..d4e13264be24 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -75,7 +75,6 @@ "loader.js": "~4.7.0", "qunit": "~2.19.2", "qunit-dom": "~2.0.0", - "typescript": "~4.5.2", "webpack": "~5.74.0" }, "engines": { diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 337af4a11f32..4bc327bd98f9 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -16,6 +16,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -40,7 +43,9 @@ "build:plugin": "tsc -p tsconfig.plugin.json", "build:transpile": "run-p build:rollup build:plugin", "build:rollup": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/hub/package.json b/packages/hub/package.json index bc795aa6bee5..947b4aad6da1 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -19,13 +22,15 @@ "@sentry/core": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "scripts": { "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index 472198466754..eb7e242de4da 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -5,12 +5,17 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "sideEffects": false, "private": true, "scripts": { "build": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:dev": "yarn build", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:watch", diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 13e1f5a04343..4025485a3e68 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -15,11 +15,14 @@ "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"] } + }, "dependencies": { "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", "localforage": "^1.8.1", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@sentry/browser": "7.56.0", @@ -30,7 +33,9 @@ "build:bundle": "ts-node scripts/buildBundles.ts --parallel", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/integrations/test/captureconsole.test.ts b/packages/integrations/test/captureconsole.test.ts index 6ff08df2e46f..be766d5f6c8d 100644 --- a/packages/integrations/test/captureconsole.test.ts +++ b/packages/integrations/test/captureconsole.test.ts @@ -113,7 +113,7 @@ describe('CaptureConsole setup', () => { it('setup should fail gracefully when console is not available', () => { const consoleRef = global.console; - // remove console + // @ts-ignore remove console delete global.console; expect(() => { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 69b24dcfbb44..09588b12491d 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -13,6 +13,9 @@ "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", "types": "build/types/index.types.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -28,7 +31,7 @@ "chalk": "3.0.0", "rollup": "2.78.0", "stacktrace-parser": "^0.1.10", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@types/webpack": "^4.41.31", @@ -49,7 +52,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "ts-node scripts/buildRollup.ts", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "nodemon --ext ts --watch src scripts/buildRollup.ts", diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts index 3b9bc8ca7045..d324cc2de7c3 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts @@ -16,13 +16,13 @@ export function wrapApiHandlerWithSentryVercelCrons { + apply: (originalFunction, thisArg, args: any[]) => { return runWithAsyncContext(() => { if (!args || !args[0]) { return originalFunction.apply(thisArg, args); } - const [req] = args; + const [req] = args as [NextApiRequest | EdgeRequest]; let maybePromiseResult; const cronsKey = 'nextUrl' in req ? req.nextUrl.pathname : req.url; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 7237d8ce24be..bb5ef56ce4ad 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -180,8 +180,7 @@ export function constructWebpackConfigFunction( } } } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (e.code === 'ENOENT') { + if ((e as { code: string }).code === 'ENOENT') { // noop if file does not exist } else { // log but noop diff --git a/packages/nextjs/test/integration/package.json b/packages/nextjs/test/integration/package.json index 5c55363fe714..a382ac88f62c 100644 --- a/packages/nextjs/test/integration/package.json +++ b/packages/nextjs/test/integration/package.json @@ -21,7 +21,7 @@ "@types/react": "17.0.47", "@types/react-dom": "17.0.17", "nock": "^13.1.0", - "typescript": "^4.2.4", + "typescript": "4.9.5", "yargs": "^16.2.0" }, "resolutions": { diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 1d2dd60d053c..8e73f71b3771 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -21,6 +21,7 @@ function findIntegrationByName(integrations: Integration[] = [], name: string): describe('Server init()', () => { afterEach(() => { jest.clearAllMocks(); + // @ts-ignore for testing delete GLOBAL_OBJ.__SENTRY__; delete process.env.VERCEL; }); diff --git a/packages/node-integration-tests/utils/run-tests.ts b/packages/node-integration-tests/utils/run-tests.ts index f29aa1a2399b..2c6715f451ab 100644 --- a/packages/node-integration-tests/utils/run-tests.ts +++ b/packages/node-integration-tests/utils/run-tests.ts @@ -13,7 +13,7 @@ const workers = os.cpus().map(async (_, i) => { while (testPaths.length > 0) { const testPath = testPaths.pop(); console.log(`(Worker ${i}) Running test "${testPath}"`); - await new Promise(resolve => { + await new Promise(resolve => { const jestProcess = childProcess.spawn('jest', ['--runTestsByPath', testPath as string, '--forceExit']); // We're collecting the output and logging it all at once instead of inheriting stdout and stderr, so that diff --git a/packages/node/package.json b/packages/node/package.json index c24c033ab70f..7601f80d172a 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/npm/types/index.d.ts": ["build/npm/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -23,7 +26,7 @@ "cookie": "^0.4.1", "https-proxy-agent": "^5.0.0", "lru_map": "^0.3.3", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@types/cookie": "0.3.2", @@ -38,7 +41,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/node/test/index.test.ts b/packages/node/test/index.test.ts index 8b035dff7b03..ab9bd41adaf9 100644 --- a/packages/node/test/index.test.ts +++ b/packages/node/test/index.test.ts @@ -239,7 +239,7 @@ describe('SentryNode', () => { try { throw new Error('cause'); } catch (c) { - e.cause = c; + (e as any).cause = c; captureException(e); } } diff --git a/packages/node/test/utils.test.ts b/packages/node/test/utils.test.ts index 6c8bb3627852..0a62cf011c92 100644 --- a/packages/node/test/utils.test.ts +++ b/packages/node/test/utils.test.ts @@ -38,7 +38,7 @@ describe('deepReadDirSync', () => { expect(deepReadDirSync(dirPath)).toEqual([]); done(); } catch (error) { - done(error); + done(error as Error); } }); }); diff --git a/packages/opentelemetry-node/package.json b/packages/opentelemetry-node/package.json index 4cd6cde98e76..d2264e50c1d1 100644 --- a/packages/opentelemetry-node/package.json +++ b/packages/opentelemetry-node/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -38,7 +41,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/overhead-metrics/package.json b/packages/overhead-metrics/package.json index 910f6b0e001c..2d158435af7b 100644 --- a/packages/overhead-metrics/package.json +++ b/packages/overhead-metrics/package.json @@ -32,7 +32,7 @@ "playwright-core": "^1.29.1", "simple-git": "^3.16.0", "simple-statistics": "^7.8.0", - "typescript": "^4.9.4" + "typescript": "4.9.5" }, "devDependencies": { "ts-node": "^10.9.1" diff --git a/packages/react/package.json b/packages/react/package.json index b0f360019949..c947b4ad4fb1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -20,7 +23,7 @@ "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", "hoist-non-react-statics": "^3.3.2", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "peerDependencies": { "react": "15.x || 16.x || 17.x || 18.x" @@ -54,7 +57,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/react/test/reactrouterv6.4.test.tsx b/packages/react/test/reactrouterv6.4.test.tsx index fe91dab07979..a01da694ce2f 100644 --- a/packages/react/test/reactrouterv6.4.test.tsx +++ b/packages/react/test/reactrouterv6.4.test.tsx @@ -63,6 +63,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(1); @@ -98,6 +99,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -135,6 +137,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -172,6 +175,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -221,6 +225,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(2); @@ -254,6 +259,7 @@ describe('React Router v6.4', () => { }, ); + // @ts-ignore router is fine render(); expect(mockStartTransaction).toHaveBeenCalledTimes(1); diff --git a/packages/remix/package.json b/packages/remix/package.json index a9bf20778ea8..97722d1b9bbc 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -16,6 +16,9 @@ "module": "build/esm/index.server.js", "browser": "build/esm/index.client.js", "types": "build/types/index.types.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -26,7 +29,7 @@ "@sentry/react": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3", + "tslib": "^2.4.1 || ^1.9.3", "yargs": "^17.6.0" }, "devDependencies": { @@ -44,7 +47,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index ada328badb78..5e0f300bf82e 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -19,6 +19,7 @@ import type { CreateRequestHandlerFunction, DataFunction, DataFunctionArgs, + EntryContext, HandleDocumentRequestFunction, ReactRouterDomPkg, RemixRequest, @@ -56,7 +57,7 @@ async function extractResponseError(response: Response): Promise { return responseData; } -async function captureRemixServerException(err: Error, name: string, request: Request): Promise { +async function captureRemixServerException(err: unknown, name: string, request: Request): Promise { // Skip capturing if the thrown error is not a 5xx response // https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders if (isResponse(err) && err.status < 500) { @@ -112,7 +113,7 @@ function makeWrappedDocumentRequestFunction( request: Request, responseStatusCode: number, responseHeaders: Headers, - context: Record, + context: EntryContext, loadContext?: Record, ): Promise { let res: Response; diff --git a/packages/remix/src/utils/serverAdapters/express.ts b/packages/remix/src/utils/serverAdapters/express.ts index 59cb299e489b..000ad3a00b15 100644 --- a/packages/remix/src/utils/serverAdapters/express.ts +++ b/packages/remix/src/utils/serverAdapters/express.ts @@ -142,7 +142,7 @@ async function finishSentryProcessing(res: AugmentedExpressResponse): Promise { + await new Promise(resolve => { setImmediate(() => { transaction.finish(); resolve(); diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 821a4242a04d..fa6db4823643 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -20,7 +20,7 @@ "@types/react": "^17.0.47", "@types/react-dom": "^17.0.17", "nock": "^13.1.0", - "typescript": "^4.2.4" + "typescript": "4.9.5" }, "resolutions": { "@sentry/browser": "file:../../../browser", diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 3641e152f07e..5a20cafa56f4 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -5,12 +5,17 @@ "main": "build/npm/esm/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, "private": true, "scripts": { "build": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.worker.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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:dev": "yarn build", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:watch", @@ -38,7 +43,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@types/pako": "^2.0.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "dependencies": { "pako": "^2.1.0" diff --git a/packages/replay-worker/src/handleMessage.ts b/packages/replay-worker/src/handleMessage.ts index 958797e82cbb..2a00f54a581f 100644 --- a/packages/replay-worker/src/handleMessage.ts +++ b/packages/replay-worker/src/handleMessage.ts @@ -54,7 +54,7 @@ export function handleMessage(e: MessageEvent): void { id, method, success: false, - response: err.message, + response: (err as Error).message, }); // eslint-disable-next-line no-console diff --git a/packages/replay/.eslintignore b/packages/replay/.eslintignore index c76c6c2d64d1..82f028e93347 100644 --- a/packages/replay/.eslintignore +++ b/packages/replay/.eslintignore @@ -4,3 +4,5 @@ demo/build/ # TODO: Check if we can re-introduce linting in demo demo metrics +# For whatever reason, the eslint-ignore comment in this file is not working, so skipping this file +src/types/rrweb.ts diff --git a/packages/replay/package.json b/packages/replay/package.json index f3b1cc69d2b9..2fc49e4e73d6 100644 --- a/packages/replay/package.json +++ b/packages/replay/package.json @@ -5,13 +5,18 @@ "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": "tsc -p tsconfig.types.json", + "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", @@ -48,7 +53,7 @@ "@sentry-internal/rrweb": "1.108.0", "@sentry-internal/rrweb-snapshot": "1.108.0", "jsdom-worker": "^0.2.1", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "dependencies": { "@sentry/core": "7.56.0", diff --git a/packages/replay/src/coreHandlers/handleFetch.ts b/packages/replay/src/coreHandlers/handleFetch.ts index 1358701ab8f3..36ec538f1b28 100644 --- a/packages/replay/src/coreHandlers/handleFetch.ts +++ b/packages/replay/src/coreHandlers/handleFetch.ts @@ -21,7 +21,7 @@ export function handleFetch(handlerData: HandlerDataFetch): null | ReplayPerform name: url, data: { method, - statusCode: response && (response as Response).status, + statusCode: response ? (response as Response).status : undefined, }, }; } diff --git a/packages/replay/src/util/createPerformanceEntries.ts b/packages/replay/src/util/createPerformanceEntries.ts index 1dbb07829fb3..f7b02aa324be 100644 --- a/packages/replay/src/util/createPerformanceEntries.ts +++ b/packages/replay/src/util/createPerformanceEntries.ts @@ -9,8 +9,6 @@ import type { LargestContentfulPaintData, NavigationData, PaintData, - PerformanceNavigationTiming, - PerformancePaintTiming, ReplayPerformanceEntry, ResourceData, } from '../types'; diff --git a/packages/replay/src/util/dedupePerformanceEntries.ts b/packages/replay/src/util/dedupePerformanceEntries.ts index 17933710c91f..22ac369e90a2 100644 --- a/packages/replay/src/util/dedupePerformanceEntries.ts +++ b/packages/replay/src/util/dedupePerformanceEntries.ts @@ -1,5 +1,3 @@ -import type { PerformanceNavigationTiming, PerformancePaintTiming } from '../types'; - const NAVIGATION_ENTRY_KEYS: Array = [ 'name', 'type', diff --git a/packages/replay/test/fixtures/performanceEntry/lcp.ts b/packages/replay/test/fixtures/performanceEntry/lcp.ts index 4db2eb53f565..891e133a981f 100644 --- a/packages/replay/test/fixtures/performanceEntry/lcp.ts +++ b/packages/replay/test/fixtures/performanceEntry/lcp.ts @@ -1,5 +1,3 @@ -import type { PerformancePaintTiming } from '../../../src/types'; - export function PerformanceEntryLcp(obj?: Partial): PerformancePaintTiming { const entry = { name: '', diff --git a/packages/replay/test/fixtures/performanceEntry/navigation.ts b/packages/replay/test/fixtures/performanceEntry/navigation.ts index d76ebce86538..476ae1d29098 100644 --- a/packages/replay/test/fixtures/performanceEntry/navigation.ts +++ b/packages/replay/test/fixtures/performanceEntry/navigation.ts @@ -1,5 +1,3 @@ -import type { PerformanceNavigationTiming } from '../../../src/types'; - export function PerformanceEntryNavigation(obj?: Partial): PerformanceNavigationTiming { const entry = { activationStart: 0, @@ -33,10 +31,10 @@ export function PerformanceEntryNavigation(obj?: Partial void { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin) { + // @ts-ignore we want to make sure all of these are available, even if TS is sure they are if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts index 9aaa8939b6dc..75ec564eb5de 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts @@ -31,6 +31,7 @@ const getNavigationEntryFromPerformanceTiming = (): NavigationTimingPolyfillEntr for (const key in timing) { if (key !== 'navigationStart' && key !== 'toJSON') { + // eslint-disable-next-line deprecation/deprecation navigationEntry[key] = Math.max((timing[key as keyof PerformanceTiming] as number) - timing.navigationStart, 0); } } diff --git a/packages/tracing-internal/src/node/integrations/prisma.ts b/packages/tracing-internal/src/node/integrations/prisma.ts index ab6fd1f1ae5b..458e0f304ebe 100644 --- a/packages/tracing-internal/src/node/integrations/prisma.ts +++ b/packages/tracing-internal/src/node/integrations/prisma.ts @@ -40,7 +40,7 @@ interface PrismaClient { } function isValidPrismaClient(possibleClient: unknown): possibleClient is PrismaClient { - return possibleClient && !!(possibleClient as PrismaClient)['$use']; + return !!possibleClient && !!(possibleClient as PrismaClient)['$use']; } /** Tracing integration for @prisma/client package */ diff --git a/packages/tracing/package.json b/packages/tracing/package.json index c177599cdcfe..2113c78809f8 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -12,6 +12,9 @@ "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"] } + }, "publishConfig": { "access": "public" }, @@ -30,7 +33,9 @@ "build": "run-p build:transpile build:types", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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:types:watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/types/package.json b/packages/types/package.json index 736924037fc0..77f343f59de0 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -19,7 +22,9 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 44c0379492fc..43578348e184 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -11,7 +11,7 @@ "access": "public" }, "peerDependencies": { - "typescript": "3.8.3" + "typescript": "4.9.5" }, "scripts": { "clean": "yarn rimraf sentry-internal-typescript-*.tgz", diff --git a/packages/utils/package.json b/packages/utils/package.json index 625cf7652e13..524174880ac1 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -12,12 +12,15 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, "dependencies": { "@sentry/types": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "devDependencies": { "@types/array.prototype.flat": "^1.2.1", @@ -29,7 +32,9 @@ "build:dev": "yarn build", "build:transpile": "yarn ts-node scripts/buildRollup.ts", "build:transpile:uncached": "yarn ts-node scripts/buildRollup.ts", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 66adb7e78ebd..e91aefdbab5b 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -34,7 +34,7 @@ export function createEnvelope(headers: E[0], items: E[1] = */ export function addItemToEnvelope(envelope: E, newItem: E[1][number]): E { const [headers, items] = envelope; - return [headers, [...items, newItem]] as E; + return [headers, [...items, newItem]] as unknown as E; } /** diff --git a/packages/utils/test/envelope.test.ts b/packages/utils/test/envelope.test.ts index 9649da0e2108..10cdcc2cab73 100644 --- a/packages/utils/test/envelope.test.ts +++ b/packages/utils/test/envelope.test.ts @@ -1,4 +1,4 @@ -import type { EventEnvelope } from '@sentry/types'; +import type { Event, EventEnvelope } from '@sentry/types'; import { TextDecoder, TextEncoder } from 'util'; const encoder = new TextEncoder(); @@ -68,8 +68,8 @@ describe('envelope', () => { }); it("doesn't throw when being passed a an envelope that contains a circular item payload", () => { - const chicken: { egg?: unknown } = {}; - const egg = { chicken }; + const chicken: { egg?: any } = {}; + const egg = { chicken } as unknown as Event; chicken.egg = chicken; const env = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ diff --git a/packages/utils/test/worldwide.test.ts b/packages/utils/test/worldwide.test.ts index 6137c95c093b..52203a248d69 100644 --- a/packages/utils/test/worldwide.test.ts +++ b/packages/utils/test/worldwide.test.ts @@ -3,6 +3,7 @@ import { GLOBAL_OBJ } from '../src/worldwide'; describe('GLOBAL_OBJ', () => { test('should return the same object', () => { const backup = global.process; + // @ts-ignore for testing delete global.process; const first = GLOBAL_OBJ; const second = GLOBAL_OBJ; diff --git a/packages/vue/package.json b/packages/vue/package.json index b82ca6ae4e61..875f5ce643a9 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -12,6 +12,9 @@ "main": "build/cjs/index.js", "module": "build/esm/index.js", "types": "build/types/index.d.ts", + "typesVersions": { + "<4.9": { "build/types/index.d.ts": ["build/types-ts3.8/index.d.ts"] } + }, "publishConfig": { "access": "public" }, @@ -20,7 +23,7 @@ "@sentry/core": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "peerDependencies": { "vue": "2.x || 3.x" @@ -32,7 +35,9 @@ "build": "run-p build:transpile build:types", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", diff --git a/packages/wasm/package.json b/packages/wasm/package.json index fcd2bef8a637..674fa58e4dd1 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -12,6 +12,9 @@ "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"] } + }, "publishConfig": { "access": "public" }, @@ -19,14 +22,16 @@ "@sentry/browser": "7.56.0", "@sentry/types": "7.56.0", "@sentry/utils": "7.56.0", - "tslib": "^1.9.3" + "tslib": "^2.4.1 || ^1.9.3" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", "build:bundle": "rollup --config rollup.bundle.config.js", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.js", - "build:types": "tsc -p tsconfig.types.json", + "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:bundle:watch": "rollup --config rollup.bundle.config.js --watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", diff --git a/scripts/ensure-bundle-deps.ts b/scripts/ensure-bundle-deps.ts index 8be6377ee4e1..4e61a6ecf993 100644 --- a/scripts/ensure-bundle-deps.ts +++ b/scripts/ensure-bundle-deps.ts @@ -129,7 +129,7 @@ function checkForBundleDeps(packagesDir: string, dependencyDirs: string[]): bool * Wait the given number of milliseconds before continuing. */ async function sleep(ms: number): Promise { - await new Promise(resolve => + await new Promise(resolve => setTimeout(() => { resolve(); }, ms), diff --git a/scripts/verify-packages-versions.js b/scripts/verify-packages-versions.js index 9e24e8090096..9c54cf2020c4 100644 --- a/scripts/verify-packages-versions.js +++ b/scripts/verify-packages-versions.js @@ -1,6 +1,6 @@ const pkg = require('../package.json'); -const TYPESCRIPT_VERSION = '3.8.3'; +const TYPESCRIPT_VERSION = '4.9.5'; if (pkg.devDependencies.typescript !== TYPESCRIPT_VERSION) { console.error(` diff --git a/yarn.lock b/yarn.lock index b4ab26edf619..224622dc7189 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11128,6 +11128,15 @@ dotenv@~10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +downlevel-dts@~0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/downlevel-dts/-/downlevel-dts-0.11.0.tgz#514a2d723009c5845730c1db6c994484c596ed9c" + integrity sha512-vo835pntK7kzYStk7xUHDifiYJvXxVhUapt85uk2AI94gUUAQX9HNRtrcMHNSc3YHJUEHGbYIGsM99uIbgAtxw== + dependencies: + semver "^7.3.2" + shelljs "^0.8.3" + typescript next + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -24140,7 +24149,7 @@ shell-quote@1.7.2, shell-quote@^1.6.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== -shelljs@^0.8.4: +shelljs@^0.8.3, shelljs@^0.8.4: version "0.8.5" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== @@ -26244,20 +26253,20 @@ tslib@2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== -tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" + integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== + +tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" - integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== - -tslib@^2.2.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, "tslib@^2.4.1 || ^1.9.3": + version "2.5.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" + integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA== tsutils@^3.0.0, tsutils@^3.17.1, tsutils@^3.21.0: version "3.21.0" @@ -26405,45 +26414,30 @@ typescript-memoize@^1.0.0-alpha.3, typescript-memoize@^1.0.1: resolved "https://registry.yarnpkg.com/typescript-memoize/-/typescript-memoize-1.0.1.tgz#0a8199aa28f6fe18517f6e9308ef7bfbe9a98d59" integrity sha512-oJNge1qUrOK37d5Y6Ly2txKeuelYVsFtNF6U9kXIN7juudcQaHJQg2MxLOy0CqtkW65rVDYuTCOjnSIVPd8z3w== -typescript@3.8.3: - version "3.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" - integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== - typescript@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== -typescript@4.3.5, typescript@~4.3.5: +typescript@4.3.5: version "4.3.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== -"typescript@^3 || ^4", typescript@^4.5.2: - version "4.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.3.tgz#3aea307c1746b8c384435d8ac36b8a2e580d85db" - integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA== - -typescript@^3.9.5, typescript@^3.9.7: - version "3.9.9" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.9.tgz#e69905c54bc0681d0518bd4d587cc6f2d0b1a674" - integrity sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w== - -typescript@^4.9.3, typescript@^4.9.4: +typescript@4.9.5, "typescript@^3 || ^4": version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@~4.0.2: - version "4.0.8" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.8.tgz#5739105541db80a971fdbd0d56511d1a6f17d37f" - integrity sha512-oz1765PN+imfz1MlZzSZPtC/tqcwsCyIYA8L47EkRnRW97ztRk83SzMiWLrnChC0vqoYxSU1fcFUDA5gV/ZiPg== - -typescript@~4.5.2: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^3.9.5, typescript@^3.9.7: + version "3.9.10" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" + integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== + +typescript@next: + version "5.2.0-dev.20230530" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" + integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" From 4d87498173790f1020430a5bd550bd3b202eb6fd Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 28 Jun 2023 16:10:15 +0200 Subject: [PATCH 28/28] meta(changelog): Update changelog for 7.57.0 --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da270fb1ccec..f2b1e7168f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,61 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.57.0 + +### Important Changes + +- **build: Update typescript from 3.8.3 to 4.9.5 (#8255)** + +This release version [bumps the internally used typescript version from 3.8.x to 4.9.x](https://github.com/getsentry/sentry-javascript/pull/8255). +We use ds-downlevel to generate two versions of our types, one for >=3.8, one for >=4.9. +This means that this change should be fully backwards compatible and not have any noticable user impact, +but if you still encounter issues please let us know. + +- **feat(types): Add tracePropagationTargets to top level options (#8395)** + +Instead of passing `tracePropagationTargets` to the `BrowserTracing` integration, you can now define them on the top level: + +```js +Sentry.init({ + tracePropagationTargets: ['api.site.com'], +}); +``` + +- **fix(angular): Filter out `TryCatch` integration by default (#8367)** + +The Angular and Angular-ivy SDKs will not install the TryCatch integration anymore by default. +This integration conflicted with the `SentryErrorHander`, sometimes leading to duplicated errors and/or missing data on events. + +- **feat(browser): Better event name handling for non-Error objects (#8374)** + +When capturing non-errors via `Sentry.captureException()`, e.g. `Sentry.captureException({ prop: "custom object" })`, +we now generate a more helpful value for the synthetic exception. Instead of e.g. `Non-Error exception captured with keys: currentTarget, isTrusted, target, type`, you'll now get messages like: + +``` +Object captured as exception with keys: prop1, prop2 +Event `MouseEvent` (type=click) captured as exception +Event `ErrorEvent` captured as exception with message `Script error.` +``` + +### Other Changes + +- feat(browser): Send profiles in same envelope as transactions (#8375) +- feat(profiling): Collect timings on profiler stop calls (#8409) +- feat(replay): Do not capture replays < 5 seconds (GA) (#8277) +- feat(tracing): Add experiment to capture http timings (#8371) +- feat(tracing): Add `http.response.status_code` to `span.data` (#8366) +- fix(angular): Stop routing spans on navigation cancel and error events (#8369) +- fix(core): Only start spans in `trace` if tracing is enabled (#8357) +- fix(nextjs): Inject init calls via loader instead of via entrypoints (#8368) +- fix(replay): Mark ui.slowClickDetected `clickCount` as optional (#8376) +- fix(serverless): Export `autoDiscoverNodePerformanceMonitoringIntegrations` from SDK (#8382) +- fix(sveltekit): Check for cached requests in client-side fetch instrumentation (#8391) +- fix(sveltekit): Only instrument SvelteKit `fetch` if the SDK client is valid (#8381) +- fix(tracing): Instrument Prisma client in constructor of integration (#8383) +- ref(replay): More graceful `sessionStorage` check (#8394) +- ref(replay): Remove circular dep in replay eventBuffer (#8389) + ## 7.56.0 - feat(replay): Rework slow click & multi click detection (#8322)