From c74eff88e30856d29a5acac6e2e54a6d3ebd66cd Mon Sep 17 00:00:00 2001 From: Jack Works Date: Thu, 20 Apr 2023 23:24:18 +0800 Subject: [PATCH 01/28] fix(core): Avoid crash when Function.prototype is frozen (#7899) Co-authored-by: Francesco Novy --- .../core/src/integrations/functiontostring.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/integrations/functiontostring.ts b/packages/core/src/integrations/functiontostring.ts index d26a49b99780..ca287390a818 100644 --- a/packages/core/src/integrations/functiontostring.ts +++ b/packages/core/src/integrations/functiontostring.ts @@ -22,10 +22,16 @@ export class FunctionToString implements Integration { // eslint-disable-next-line @typescript-eslint/unbound-method originalFunctionToString = Function.prototype.toString; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Function.prototype.toString = function (this: WrappedFunction, ...args: any[]): string { - const context = getOriginalFunction(this) || this; - return originalFunctionToString.apply(context, args); - }; + // intrinsics (like Function.prototype) might be immutable in some environments + // e.g. Node with --frozen-intrinsics, XS (an embedded JavaScript engine) or SES (a JavaScript proposal) + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Function.prototype.toString = function (this: WrappedFunction, ...args: any[]): string { + const context = getOriginalFunction(this) || this; + return originalFunctionToString.apply(context, args); + }; + } catch { + // ignore errors here, just don't patch this + } } } From 856953b7e26df6069d62d9b6a6f3212d57a311e9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Apr 2023 17:30:52 +0200 Subject: [PATCH 02/28] ci: Improve last commenter action (#7911) --- .github/workflows/label-last-commenter.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/label-last-commenter.yml b/.github/workflows/label-last-commenter.yml index 2d938459de78..7e7629e95bc5 100644 --- a/.github/workflows/label-last-commenter.yml +++ b/.github/workflows/label-last-commenter.yml @@ -5,18 +5,18 @@ on: types: [created] jobs: - deploy: + toggle_labels: + name: Toggle Labels runs-on: ubuntu-latest if: ${{ !github.event.issue.pull_request }} steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Add label if commenter is not member + # Note: We only add the label if the issue is still open if: | github.event.comment.author_association != 'COLLABORATOR' && github.event.comment.author_association != 'MEMBER' && github.event.comment.author_association != 'OWNER' + && !github.event.issue.closed uses: actions-ecosystem/action-add-labels@v1 with: labels: 'Waiting for: Team' From 1d5430e3ee508ed5fb95b5f70572623cf0de1aa2 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 20 Apr 2023 20:57:11 +0200 Subject: [PATCH 03/28] ci: Finally fix auto-merge for gitflow (#7924) --- .github/workflows/gitflow-sync-develop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index 43cf9374fd2b..bf9ba8feaf9a 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -39,13 +39,13 @@ jobs: github_token: ${{ secrets.REPO_SCOPED_TOKEN }} - name: Enable automerge for PR - run: gh pr merge --merge --auto "1" + if: steps.open-pr.outputs.pr_number != '' + run: gh pr merge --merge --auto "${{ steps.open-pr.outputs.pr_number }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # https://github.com/marketplace/actions/auto-approve - name: Auto approve PR - # Always skip this for now, until we got a proper bot setup if: steps.open-pr.outputs.pr_number != '' uses: hmarr/auto-approve-action@v3 with: From 390faf3c315ff822a973cc7f75d5e20f915f68b5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 21 Apr 2023 10:16:32 +0200 Subject: [PATCH 04/28] fix(replay): Ensure we still truncate large bodies if they are failed JSON (#7923) --- .../src/coreHandlers/util/networkUtils.ts | 4 +-- .../coreHandlers/util/networkUtils.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts index 6ff4a6ce27d9..79515bf36ff4 100644 --- a/packages/replay/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay/src/coreHandlers/util/networkUtils.ts @@ -201,8 +201,8 @@ function normalizeNetworkBody(body: string | undefined): { }; } catch { return { - body, - warnings: ['INVALID_JSON'], + body: exceedsSizeLimit ? `${body.slice(0, NETWORK_BODY_MAX_SIZE)}…` : body, + warnings: exceedsSizeLimit ? ['INVALID_JSON', 'TEXT_TRUNCATED'] : ['INVALID_JSON'], }; } } diff --git a/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts b/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts index f187fbe59de0..2fcafbe98669 100644 --- a/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts +++ b/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts @@ -1,5 +1,6 @@ import { TextEncoder } from 'util'; +import { NETWORK_BODY_MAX_SIZE } from '../../../../src/constants'; import { buildNetworkRequestOrResponse, getBodySize, @@ -186,5 +187,36 @@ describe('Unit | coreHandlers | util | networkUtils', () => { expect(actual).toEqual({ size: 1, headers: {}, body: expectedBody, _meta: expectedMeta }); }); + + it.each([ + [ + 'large JSON string', + JSON.stringify({ + aa: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 10), + }), + { + aa: `${'a'.repeat(NETWORK_BODY_MAX_SIZE - 7)}~~`, + }, + { warnings: ['JSON_TRUNCATED'] }, + ], + [ + 'large plain string', + 'a'.repeat(NETWORK_BODY_MAX_SIZE + 10), + `${'a'.repeat(NETWORK_BODY_MAX_SIZE)}…`, + { warnings: ['TEXT_TRUNCATED'] }, + ], + [ + 'large invalid JSON string', + `{--${JSON.stringify({ + aa: 'a'.repeat(NETWORK_BODY_MAX_SIZE + 10), + })}`, + `{--{"aa":"${'a'.repeat(NETWORK_BODY_MAX_SIZE - 10)}…`, + { warnings: ['INVALID_JSON', 'TEXT_TRUNCATED'] }, + ], + ])('works with %s', (label, input, expectedBody, expectedMeta) => { + const actual = buildNetworkRequestOrResponse({}, 1, input); + + expect(actual).toEqual({ size: 1, headers: {}, body: expectedBody, _meta: expectedMeta }); + }); }); }); From 2bcbf3e8a83662cde2a4243242e8196532f6ec7e Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 21 Apr 2023 11:31:48 +0200 Subject: [PATCH 05/28] fix(nextjs): Fix inject logic for Next.js 13.3.1 canary (#7921) --- .../src/config/loaders/wrappingLoader.ts | 7 ++++++ packages/nextjs/src/config/webpack.ts | 23 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 2f0686ae490a..2cd83f672fd5 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -43,6 +43,7 @@ type LoaderOptions = { pageExtensionRegex: string; excludeServerRoutes: Array; wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component'; + sentryConfigFilePath?: string; }; function moduleExists(id: string): boolean { @@ -59,6 +60,7 @@ function moduleExists(id: string): boolean { * any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) or API routes it contains * are wrapped, and then everything is re-exported. */ +// eslint-disable-next-line complexity export default function wrappingLoader( this: LoaderThis, userCode: string, @@ -71,6 +73,7 @@ export default function wrappingLoader( pageExtensionRegex, excludeServerRoutes = [], wrappingTargetKind, + sentryConfigFilePath, } = 'getOptions' in this ? this.getOptions() : this.query; this.async(); @@ -193,6 +196,10 @@ export default function wrappingLoader( } else { templateCode = templateCode.replace(/__COMPONENT_TYPE__/g, 'Unknown'); } + + if (sentryConfigFilePath) { + templateCode = `import "${sentryConfigFilePath}";`.concat(templateCode); + } } else if (wrappingTargetKind === 'middleware') { templateCode = middlewareWrapperTemplateCode; } else { diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index e00ac0d6a659..8287afdd7e52 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -23,8 +23,8 @@ import type { } from './types'; const RUNTIME_TO_SDK_ENTRYPOINT_MAP = { - browser: './client', - node: './server', + client: './client', + server: './server', edge: './edge', } as const; @@ -64,7 +64,7 @@ export function constructWebpackConfigFunction( buildContext: BuildContext, ): WebpackConfigObject { const { isServer, dev: isDev, dir: projectDir } = buildContext; - const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'node') : 'browser'; + const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'server') : 'client'; let rawNewConfig = { ...incomingConfig }; @@ -124,6 +124,7 @@ export function constructWebpackConfigFunction( pagesDir: pagesDirPath, pageExtensionRegex, excludeServerRoutes: userSentryOptions.excludeServerRoutes, + sentryConfigFilePath: getUserConfigFilePath(projectDir, runtime), }; const normalizeLoaderResourcePath = (resourcePath: string): string => { @@ -452,6 +453,22 @@ export function getUserConfigFile(projectDir: string, platform: 'server' | 'clie } } +/** + * Gets the absolute path to a sentry config file for a particular platform. Returns `undefined` if it doesn't exist. + */ +export function getUserConfigFilePath(projectDir: string, platform: 'server' | 'client' | 'edge'): string | undefined { + const possibilities = [`sentry.${platform}.config.ts`, `sentry.${platform}.config.js`]; + + for (const filename of possibilities) { + const configPath = path.resolve(projectDir, filename); + if (fs.existsSync(configPath)) { + return configPath; + } + } + + return undefined; +} + /** * Add files to a specific element of the given `entry` webpack config property. * From 13cbb1db0ca13cf3a5d229eedc71b95b4a189e01 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Apr 2023 12:14:48 +0200 Subject: [PATCH 06/28] doc(sveltekit): Promote the SDK to beta state (#7874) --- packages/sveltekit/README.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index 21393e19c4e7..48a13d757f48 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -19,9 +19,9 @@ TODO: No docs yet, comment back in once we have docs ## SDK Status -This SDK is currently in **Alpha state** and we're still experimenting with APIs and functionality. -We therefore make no guarantees in terms of semver or breaking changes. -If you want to try this SDK and come across a problem, please open a [GitHub Issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). +This SDK is currently in **Beta state**. Bugs and issues might still appear and we're still actively working +on the SDK. Also, we're still adding features. +If you experience problems or have feedback, please open a [GitHub Issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). ## Compatibility @@ -31,11 +31,7 @@ Currently, the minimum supported version of SvelteKit is `1.0.0`. This package is a wrapper around `@sentry/node` for the server and `@sentry/svelte` for the client side, with added functionality related to SvelteKit. -## Usage - -Although the SDK is not yet stable, you're more than welcome to give it a try and provide us with early feedback. - -**Here's how to get started:** +## Setup ### 1. Prerequesits & Installation @@ -258,7 +254,7 @@ export default { ## Known Limitations -This SDK is still under active development and several features are missing. +This SDK is still under active development. Take a look at our [SvelteKit SDK Development Roadmap](https://github.com/getsentry/sentry-javascript/issues/6692) to follow the progress: - **Adapters** other than `@sveltejs/adapter-node` are currently not supported. From 15d9102bb18987e17ee602111fbf0435e8d9c6f5 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 21 Apr 2023 15:08:09 +0200 Subject: [PATCH 07/28] feat(nextjs): Add `disableLogger` option that automatically tree shakes logger statements (#7908) --- packages/nextjs/src/config/types.ts | 12 ++++++++++-- packages/nextjs/src/config/webpack.ts | 9 +++++++++ packages/nextjs/test/config/fixtures.ts | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 523a2f995837..f2156382e6f3 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -1,6 +1,6 @@ import type { GLOBAL_OBJ } from '@sentry/utils'; import type { SentryCliPluginOptions } from '@sentry/webpack-plugin'; -import type { WebpackPluginInstance } from 'webpack'; +import type { DefinePlugin, WebpackPluginInstance } from 'webpack'; export type SentryWebpackPluginOptions = SentryCliPluginOptions; export type SentryWebpackPlugin = WebpackPluginInstance & { options: SentryWebpackPluginOptions }; @@ -128,6 +128,11 @@ export type UserSentryOptions = { * NOTE: This feature only works with Next.js 11+ */ tunnelRoute?: string; + + /** + * Tree shakes Sentry SDK logger statements from the bundle. + */ + disableLogger?: boolean; }; export type NextConfigFunction = (phase: string, defaults: { defaultConfig: NextConfigObject }) => NextConfigObject; @@ -167,7 +172,10 @@ export type BuildContext = { dir: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any config: any; - webpack: { version: string }; + webpack: { + version: string; + DefinePlugin: typeof DefinePlugin; + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any defaultLoaders: any; totalPages: number; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 8287afdd7e52..73fb60660451 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -296,6 +296,15 @@ export function constructWebpackConfigFunction( } } + if (userSentryOptions.disableLogger) { + newConfig.plugins = newConfig.plugins || []; + newConfig.plugins.push( + new buildContext.webpack.DefinePlugin({ + __SENTRY_DEBUG__: false, + }), + ); + } + return newConfig; }; } diff --git a/packages/nextjs/test/config/fixtures.ts b/packages/nextjs/test/config/fixtures.ts index f747edbc2be9..69f15a18f088 100644 --- a/packages/nextjs/test/config/fixtures.ts +++ b/packages/nextjs/test/config/fixtures.ts @@ -99,7 +99,7 @@ export function getBuildContext( distDir: '.next', ...materializedNextConfig, } as NextConfigObject, - webpack: { version: webpackVersion }, + webpack: { version: webpackVersion, DefinePlugin: class {} as any }, defaultLoaders: true, totalPages: 2, isServer: buildTarget === 'server' || buildTarget === 'edge', From 24a235d7e392ac1b5cbc080000e6d8e49a98dd39 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 24 Apr 2023 09:07:44 +0200 Subject: [PATCH 08/28] fix(replay): Ensure console breadcrumb args are truncated (#7917) --- .../replay/captureConsoleLog/template.html | 39 +++++ .../suites/replay/captureConsoleLog/test.ts | 136 ++++++++++++++++++ .../utils/replayHelpers.ts | 48 ++++--- packages/replay/src/constants.ts | 3 + .../replay/src/coreHandlers/handleScope.ts | 60 ++++++++ .../unit/coreHandlers/handleScope.test.ts | 82 +++++++++++ 6 files changed, 346 insertions(+), 22 deletions(-) create mode 100644 packages/browser-integration-tests/suites/replay/captureConsoleLog/template.html create mode 100644 packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts diff --git a/packages/browser-integration-tests/suites/replay/captureConsoleLog/template.html b/packages/browser-integration-tests/suites/replay/captureConsoleLog/template.html new file mode 100644 index 000000000000..17699e3c0ffe --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/captureConsoleLog/template.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + diff --git a/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts b/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts new file mode 100644 index 000000000000..c0ceed092995 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/captureConsoleLog/test.ts @@ -0,0 +1,136 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; + +sentryTest('should capture console messages in replay', async ({ getLocalTestPath, page, forceFlushReplay }) => { + // console integration is not used in bundles/loader + const bundle = process.env.PW_BUNDLE || ''; + if (shouldSkipReplayTest() || bundle.startsWith('bundle_') || bundle.startsWith('loader_')) { + 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 getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + + const reqPromise1 = waitForReplayRequest( + page, + (_event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'console'); + }, + 5_000, + ); + + await page.click('[data-log]'); + + // Sometimes this doesn't seem to trigger, so we trigger it twice to be sure... + await page.click('[data-log]'); + + await forceFlushReplay(); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'console')).toEqual( + expect.arrayContaining([ + { + timestamp: expect.any(Number), + type: 'default', + category: 'console', + data: { arguments: ['Test log', '[HTMLElement: HTMLBodyElement]'], logger: 'console' }, + level: 'log', + message: 'Test log [object HTMLBodyElement]', + }, + ]), + ); +}); + +sentryTest('should capture very large console logs', async ({ getLocalTestPath, page, forceFlushReplay }) => { + // console integration is not used in bundles/loader + const bundle = process.env.PW_BUNDLE || ''; + if (shouldSkipReplayTest() || bundle.startsWith('bundle_') || bundle.startsWith('loader_')) { + 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 getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + + const reqPromise1 = waitForReplayRequest( + page, + (_event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'console'); + }, + 5_000, + ); + + await page.click('[data-log-large]'); + + // Sometimes this doesn't seem to trigger, so we trigger it twice to be sure... + await page.click('[data-log-large]'); + + await forceFlushReplay(); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'console')).toEqual( + expect.arrayContaining([ + { + timestamp: expect.any(Number), + type: 'default', + category: 'console', + data: { + arguments: [ + expect.objectContaining({ + 'item-0': { + aa: expect.objectContaining({ + 'item-0': { + aa: expect.any(Object), + bb: expect.any(String), + cc: expect.any(String), + dd: expect.any(String), + }, + }), + bb: expect.any(String), + cc: expect.any(String), + dd: expect.any(String), + }, + }), + ], + logger: 'console', + _meta: { + warnings: ['CONSOLE_ARG_TRUNCATED'], + }, + }, + level: 'log', + message: '[object Object]', + }, + ]), + ); +}); diff --git a/packages/browser-integration-tests/utils/replayHelpers.ts b/packages/browser-integration-tests/utils/replayHelpers.ts index bd7696ebd927..b1448dc97d85 100644 --- a/packages/browser-integration-tests/utils/replayHelpers.ts +++ b/packages/browser-integration-tests/utils/replayHelpers.ts @@ -49,38 +49,42 @@ export type RecordingSnapshot = FullRecordingSnapshot | IncrementalRecordingSnap export function waitForReplayRequest( page: Page, segmentIdOrCallback?: number | ((event: ReplayEvent, res: Response) => boolean), + timeout?: number, ): Promise { const segmentId = typeof segmentIdOrCallback === 'number' ? segmentIdOrCallback : undefined; const callback = typeof segmentIdOrCallback === 'function' ? segmentIdOrCallback : undefined; - return page.waitForResponse(res => { - const req = res.request(); + return page.waitForResponse( + res => { + const req = res.request(); - const postData = req.postData(); - if (!postData) { - return false; - } - - try { - const event = envelopeRequestParser(req); - - if (!isReplayEvent(event)) { + const postData = req.postData(); + if (!postData) { return false; } - if (callback) { - return callback(event, res); - } + try { + const event = envelopeRequestParser(req); - if (segmentId !== undefined) { - return event.segment_id === segmentId; - } + if (!isReplayEvent(event)) { + return false; + } - return true; - } catch { - return false; - } - }); + if (callback) { + return callback(event, res); + } + + if (segmentId !== undefined) { + return event.segment_id === segmentId; + } + + return true; + } catch { + return false; + } + }, + timeout ? { timeout } : undefined, + ); } export function isReplayEvent(event: Event): event is ReplayEvent { diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index 68ff5fc481ff..e7b5fe2f1b52 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -31,3 +31,6 @@ export const RETRY_MAX_COUNT = 3; /* The max (uncompressed) size in bytes of a network body. Any body larger than this will be truncated. */ export const NETWORK_BODY_MAX_SIZE = 150_000; + +/* The max size of a single console arg that is captured. Any arg larger than this will be truncated. */ +export const CONSOLE_ARG_MAX_SIZE = 5_000; diff --git a/packages/replay/src/coreHandlers/handleScope.ts b/packages/replay/src/coreHandlers/handleScope.ts index 88624e94efb8..8f937b140882 100644 --- a/packages/replay/src/coreHandlers/handleScope.ts +++ b/packages/replay/src/coreHandlers/handleScope.ts @@ -1,7 +1,10 @@ import type { Breadcrumb, Scope } from '@sentry/types'; +import { normalize } from '@sentry/utils'; +import { CONSOLE_ARG_MAX_SIZE } from '../constants'; import type { ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; +import { fixJson } from '../util/truncateJson/fixJson'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; let _LAST_BREADCRUMB: null | Breadcrumb = null; @@ -48,5 +51,62 @@ export function handleScope(scope: Scope): Breadcrumb | null { return null; } + if (newBreadcrumb.category === 'console') { + return normalizeConsoleBreadcrumb(newBreadcrumb); + } + return createBreadcrumb(newBreadcrumb); } + +/** exported for tests only */ +export function normalizeConsoleBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb { + const args = breadcrumb.data && breadcrumb.data.arguments; + + if (!Array.isArray(args) || args.length === 0) { + return createBreadcrumb(breadcrumb); + } + + let isTruncated = false; + + // Avoid giant args captures + const normalizedArgs = args.map(arg => { + if (!arg) { + return arg; + } + if (typeof arg === 'string') { + if (arg.length > CONSOLE_ARG_MAX_SIZE) { + isTruncated = true; + return `${arg.slice(0, CONSOLE_ARG_MAX_SIZE)}…`; + } + + return arg; + } + if (typeof arg === 'object') { + try { + const normalizedArg = normalize(arg, 7); + const stringified = JSON.stringify(normalizedArg); + if (stringified.length > CONSOLE_ARG_MAX_SIZE) { + const fixedJson = fixJson(stringified.slice(0, CONSOLE_ARG_MAX_SIZE)); + const json = JSON.parse(fixedJson); + // We only set this after JSON.parse() was successfull, so we know we didn't run into `catch` + isTruncated = true; + return json; + } + return normalizedArg; + } catch { + // fall back to default + } + } + + return arg; + }); + + return createBreadcrumb({ + ...breadcrumb, + data: { + ...breadcrumb.data, + arguments: normalizedArgs, + ...(isTruncated ? { _meta: { warnings: ['CONSOLE_ARG_TRUNCATED'] } } : {}), + }, + }); +} diff --git a/packages/replay/test/unit/coreHandlers/handleScope.test.ts b/packages/replay/test/unit/coreHandlers/handleScope.test.ts index 2f46fafba635..1bce28f860c8 100644 --- a/packages/replay/test/unit/coreHandlers/handleScope.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleScope.test.ts @@ -1,5 +1,6 @@ import type { Breadcrumb, Scope } from '@sentry/types'; +import { CONSOLE_ARG_MAX_SIZE } from '../../../src/constants'; import * as HandleScope from '../../../src/coreHandlers/handleScope'; describe('Unit | coreHandlers | handleScope', () => { @@ -59,4 +60,85 @@ describe('Unit | coreHandlers | handleScope', () => { expect(mockHandleScope).toHaveBeenCalledTimes(1); expect(mockHandleScope).toHaveReturnedWith(null); }); + + describe('normalizeConsoleBreadcrumb', () => { + it('handles console messages with no arguments', () => { + const breadcrumb: Breadcrumb = { category: 'console', message: 'test' }; + const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb); + + expect(actual).toMatchObject({ category: 'console', message: 'test' }); + }); + + it('handles console messages with empty arguments', () => { + const breadcrumb: Breadcrumb = { category: 'console', message: 'test', data: { arguments: [] } }; + const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb); + + expect(actual).toMatchObject({ category: 'console', message: 'test', data: { arguments: [] } }); + }); + + it('handles console messages with simple arguments', () => { + const breadcrumb: Breadcrumb = { + category: 'console', + message: 'test', + data: { arguments: [1, 'a', true, null, undefined] }, + }; + const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb); + + expect(actual).toMatchObject({ + category: 'console', + message: 'test', + data: { + arguments: [1, 'a', true, null, undefined], + }, + }); + }); + + it('truncates large strings', () => { + const breadcrumb: Breadcrumb = { + category: 'console', + message: 'test', + data: { + arguments: ['a'.repeat(CONSOLE_ARG_MAX_SIZE + 10), 'b'.repeat(CONSOLE_ARG_MAX_SIZE + 10)], + }, + }; + const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb); + + expect(actual).toMatchObject({ + category: 'console', + message: 'test', + data: { + arguments: [`${'a'.repeat(CONSOLE_ARG_MAX_SIZE)}…`, `${'b'.repeat(CONSOLE_ARG_MAX_SIZE)}…`], + _meta: { warnings: ['CONSOLE_ARG_TRUNCATED'] }, + }, + }); + }); + + it('truncates large JSON objects', () => { + const breadcrumb: Breadcrumb = { + category: 'console', + message: 'test', + data: { + arguments: [ + { aa: 'yes' }, + { bb: 'b'.repeat(CONSOLE_ARG_MAX_SIZE + 10) }, + { c: 'c'.repeat(CONSOLE_ARG_MAX_SIZE + 10) }, + ], + }, + }; + const actual = HandleScope.normalizeConsoleBreadcrumb(breadcrumb); + + expect(actual).toMatchObject({ + category: 'console', + message: 'test', + data: { + arguments: [ + { aa: 'yes' }, + { bb: `${'b'.repeat(CONSOLE_ARG_MAX_SIZE - 7)}~~` }, + { c: `${'c'.repeat(CONSOLE_ARG_MAX_SIZE - 6)}~~` }, + ], + _meta: { warnings: ['CONSOLE_ARG_TRUNCATED'] }, + }, + }); + }); + }); }); From 5a3c29dbfbc7ce85be294486b6f6a48f6e6c58b0 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 24 Apr 2023 11:43:03 +0200 Subject: [PATCH 09/28] fix(replay): Ensure we do not set replayId on dsc if replay is disabled (#7939) --- .../suites/replay/dsc/init.js | 5 -- .../suites/replay/dsc/test.ts | 48 +++++++++++++++++++ .../replay/src/util/addGlobalListeners.ts | 2 +- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/browser-integration-tests/suites/replay/dsc/init.js b/packages/browser-integration-tests/suites/replay/dsc/init.js index c43f001779eb..da0177961ea2 100644 --- a/packages/browser-integration-tests/suites/replay/dsc/init.js +++ b/packages/browser-integration-tests/suites/replay/dsc/init.js @@ -16,8 +16,3 @@ Sentry.init({ replaysSessionSampleRate: 0.0, replaysOnErrorSampleRate: 1.0, }); - -Sentry.configureScope(scope => { - scope.setUser({ id: 'user123', segment: 'segmentB' }); - scope.setTransactionName('testTransactionDSC'); -}); diff --git a/packages/browser-integration-tests/suites/replay/dsc/test.ts b/packages/browser-integration-tests/suites/replay/dsc/test.ts index f4cca11e2339..83e95d84b9d5 100644 --- a/packages/browser-integration-tests/suites/replay/dsc/test.ts +++ b/packages/browser-integration-tests/suites/replay/dsc/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import type * as Sentry from '@sentry/browser'; import type { EventEnvelopeHeaders } from '@sentry/types'; import { sentryTest } from '../../../utils/fixtures'; @@ -9,6 +10,8 @@ import { } from '../../../utils/helpers'; import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../utils/replayHelpers'; +type TestWindow = Window & { Sentry: typeof Sentry; Replay: Sentry.Replay }; + sentryTest('should add replay_id to dsc of transactions', async ({ getLocalTestPath, page, browserName }) => { // This is flaky on webkit, so skipping there... if (shouldSkipReplayTest() || shouldSkipTracingTest() || browserName === 'webkit') { @@ -18,6 +21,13 @@ sentryTest('should add replay_id to dsc of transactions', async ({ getLocalTestP const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); + await page.evaluate(() => { + (window as unknown as TestWindow).Sentry.configureScope(scope => { + scope.setUser({ id: 'user123', segment: 'segmentB' }); + scope.setTransactionName('testTransactionDSC'); + }); + }); + const envHeader = await getFirstSentryEnvelopeRequest(page, url, envelopeHeaderRequestParser); await waitForReplayRunning(page); @@ -35,3 +45,41 @@ sentryTest('should add replay_id to dsc of transactions', async ({ getLocalTestP replay_id: replay.session?.id, }); }); + +sentryTest( + 'should not add replay_id to dsc of transactions if replay is not enabled', + async ({ getLocalTestPath, page, browserName }) => { + // This is flaky on webkit, so skipping there... + if (shouldSkipReplayTest() || shouldSkipTracingTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + (window as unknown as TestWindow).Replay.stop(); + + (window as unknown as TestWindow).Sentry.configureScope(scope => { + scope.setUser({ id: 'user123', segment: 'segmentB' }); + scope.setTransactionName('testTransactionDSC'); + }); + }); + + const envHeader = await getFirstSentryEnvelopeRequest(page, url, envelopeHeaderRequestParser); + + await waitForReplayRunning(page); + const replay = await getReplaySnapshot(page); + + expect(replay.session?.id).toBeDefined(); + + expect(envHeader.trace).toBeDefined(); + expect(envHeader.trace).toEqual({ + environment: 'production', + user_segment: 'segmentB', + sample_rate: '1', + trace_id: expect.any(String), + public_key: 'public', + }); + }, +); diff --git a/packages/replay/src/util/addGlobalListeners.ts b/packages/replay/src/util/addGlobalListeners.ts index 46ba18bb9ed3..189867c721bb 100644 --- a/packages/replay/src/util/addGlobalListeners.ts +++ b/packages/replay/src/util/addGlobalListeners.ts @@ -35,7 +35,7 @@ export function addGlobalListeners(replay: ReplayContainer): void { client.on('afterSendEvent', handleAfterSendEvent(replay)); client.on('createDsc', (dsc: DynamicSamplingContext) => { const replayId = replay.getSessionId(); - if (replayId) { + if (replayId && replay.isEnabled()) { dsc.replay_id = replayId; } }); From bbbea867439cc0812554b1eee56596c4ceeecd1c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 24 Apr 2023 15:26:42 +0200 Subject: [PATCH 10/28] test(e2e): Use pnpm for e2e tests (#7930) --- .github/workflows/build.yml | 3 ++ .github/workflows/canary.yml | 3 ++ packages/e2e-tests/README.md | 4 +- packages/e2e-tests/lib/buildApp.ts | 5 --- packages/e2e-tests/lib/constants.ts | 1 - packages/e2e-tests/lib/runAllTestApps.ts | 7 +--- packages/e2e-tests/lib/runTestApp.ts | 40 +++++++++---------- .../create-next-app/test-recipe.json | 2 +- .../create-react-app/src/App.test.tsx | 9 ----- .../create-react-app/test-recipe.json | 2 +- .../create-react-app/tsconfig.json | 3 +- .../nextjs-app-dir/test-recipe.json | 2 +- .../node-express-app/test-recipe.json | 2 +- .../test-recipe.json | 2 +- .../standard-frontend-react/test-recipe.json | 2 +- 15 files changed, 37 insertions(+), 50 deletions(-) delete mode 100644 packages/e2e-tests/test-applications/create-react-app/src/App.test.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index acf25d7b5cc0..bac522026228 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -768,6 +768,9 @@ jobs: uses: actions/checkout@v3 with: ref: ${{ env.HEAD_COMMIT }} + - uses: pnpm/action-setup@v2 + with: + version: 7 - name: Set up Node uses: actions/setup-node@v3 with: diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index d3968a0dfb75..cdb19c3dc857 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -26,6 +26,9 @@ jobs: uses: actions/checkout@v3 with: ref: ${{ env.HEAD_COMMIT }} + - uses: pnpm/action-setup@v2 + with: + version: 7 - name: Set up Node uses: actions/setup-node@v3 with: diff --git a/packages/e2e-tests/README.md b/packages/e2e-tests/README.md index 2d8db0f5b41f..316d03997483 100644 --- a/packages/e2e-tests/README.md +++ b/packages/e2e-tests/README.md @@ -54,11 +54,11 @@ To get you started with the recipe, you can copy the following into `test-recipe { "$schema": "../../test-recipe-schema.json", "testApplicationName": "My New Test Application", - "buildCommand": "yarn install", + "buildCommand": "pnpm install", "tests": [ { "testName": "My new test", - "testCommand": "yarn test", + "testCommand": "pnpm test", "timeoutSeconds": 60 } ] diff --git a/packages/e2e-tests/lib/buildApp.ts b/packages/e2e-tests/lib/buildApp.ts index 15b9e0497cdc..ab8f95668d6a 100644 --- a/packages/e2e-tests/lib/buildApp.ts +++ b/packages/e2e-tests/lib/buildApp.ts @@ -1,7 +1,6 @@ /* eslint-disable no-console */ import * as fs from 'fs-extra'; -import * as os from 'os'; import * as path from 'path'; import { DEFAULT_BUILD_TIMEOUT_SECONDS } from './constants'; @@ -29,13 +28,9 @@ export async function buildApp(appDir: string, recipeInstance: RecipeInstance, e if (recipe.buildCommand) { console.log(`Running build command for test application "${label}"`); - fs.mkdirSync(path.join(os.tmpdir(), 'e2e-test-yarn-caches'), { recursive: true }); - const tempYarnCache = fs.mkdtempSync(path.join(os.tmpdir(), 'e2e-test-yarn-caches', 'cache-')); - const env = { ...process.env, ...envVars, - YARN_CACHE_FOLDER: tempYarnCache, // Use a separate yarn cache for each build commmand because multiple yarn commands running at the same time may corrupt the cache }; const buildResult = await spawnAsync(recipe.buildCommand, { diff --git a/packages/e2e-tests/lib/constants.ts b/packages/e2e-tests/lib/constants.ts index fe24b98841fd..bdc550009148 100644 --- a/packages/e2e-tests/lib/constants.ts +++ b/packages/e2e-tests/lib/constants.ts @@ -3,4 +3,3 @@ export const DEFAULT_BUILD_TIMEOUT_SECONDS = 60 * 5; export const DEFAULT_TEST_TIMEOUT_SECONDS = 60 * 2; export const VERDACCIO_VERSION = '5.22.1'; export const PUBLISH_PACKAGES_DOCKER_IMAGE_NAME = 'publish-packages'; -export const TMP_DIR = 'tmp'; diff --git a/packages/e2e-tests/lib/runAllTestApps.ts b/packages/e2e-tests/lib/runAllTestApps.ts index 4f2e405d58aa..ad30c34e4738 100644 --- a/packages/e2e-tests/lib/runAllTestApps.ts +++ b/packages/e2e-tests/lib/runAllTestApps.ts @@ -1,7 +1,4 @@ /* eslint-disable no-console */ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; import { constructRecipeInstances } from './constructRecipeInstances'; import { buildAndTestApp } from './runTestApp'; @@ -11,7 +8,7 @@ export async function runAllTestApps( recipePaths: string[], envVarsToInject: Record, ): Promise { - const maxParallel = process.env.CI ? 1 : 1; // For now we are disabling parallel execution because it was causing problems (runners were too slow and timeouts happened) + const maxParallel = process.env.CI ? 3 : 6; const recipeInstances = constructRecipeInstances(recipePaths); @@ -37,8 +34,6 @@ export async function runAllTestApps( const failed = results.filter(result => result.buildFailed || result.testFailed); - fs.rmSync(path.join(os.tmpdir(), 'e2e-test-yarn-caches'), { force: true, recursive: true }); - if (failed.length) { console.log(`${failed.length} test(s) failed.`); process.exit(1); diff --git a/packages/e2e-tests/lib/runTestApp.ts b/packages/e2e-tests/lib/runTestApp.ts index 22dbdb9c5693..6a5366dc5b05 100644 --- a/packages/e2e-tests/lib/runTestApp.ts +++ b/packages/e2e-tests/lib/runTestApp.ts @@ -1,15 +1,11 @@ -/* eslint-disable no-console */ - -import * as fs from 'fs-extra'; +import * as fs from 'fs'; +import * as fsExtra from 'fs-extra'; import * as path from 'path'; import { buildApp } from './buildApp'; -import { TMP_DIR } from './constants'; import { testApp } from './testApp'; import type { Env, RecipeInstance, RecipeTestResult } from './types'; -let tmpDirCount = 0; - // This should never throw, we always return a result here export async function buildAndTestApp( recipeInstance: RecipeInstance, @@ -18,9 +14,11 @@ export async function buildAndTestApp( const { recipe, portModulo, portGap } = recipeInstance; const recipeDirname = path.dirname(recipe.path); - const targetDir = path.join(TMP_DIR, `${recipe.testApplicationName}-${tmpDirCount++}`); + const tmpFolder = path.join(__dirname, '..', 'tmp'); + await fs.promises.mkdir(tmpFolder, { recursive: true }); + const targetDir = await fs.promises.mkdtemp(path.join(tmpFolder, 'tmp-app-')); - await fs.copy(recipeDirname, targetDir); + await fsExtra.copy(recipeDirname, targetDir); const env: Env = { ...envVarsToInject, @@ -31,7 +29,7 @@ export async function buildAndTestApp( try { await buildApp(targetDir, recipeInstance, env); } catch (error) { - await fs.remove(targetDir); + await fsExtra.remove(targetDir); return { ...recipeInstance, @@ -42,15 +40,17 @@ export async function buildAndTestApp( } // This cannot throw, we always return a result here - const results = await testApp(targetDir, recipeInstance, env); - - // Cleanup - await fs.remove(targetDir); - - return { - ...recipeInstance, - buildFailed: false, - testFailed: results.some(result => result.result !== 'PASS'), - tests: results, - }; + return testApp(targetDir, recipeInstance, env) + .finally(() => { + // Cleanup + void fsExtra.remove(targetDir); + }) + .then(results => { + return { + ...recipeInstance, + buildFailed: false, + testFailed: results.some(result => result.result !== 'PASS'), + tests: results, + }; + }); } diff --git a/packages/e2e-tests/test-applications/create-next-app/test-recipe.json b/packages/e2e-tests/test-applications/create-next-app/test-recipe.json index 38b4bbc06af0..546f98446933 100644 --- a/packages/e2e-tests/test-applications/create-next-app/test-recipe.json +++ b/packages/e2e-tests/test-applications/create-next-app/test-recipe.json @@ -1,7 +1,7 @@ { "$schema": "../../test-recipe-schema.json", "testApplicationName": "create-next-app", - "buildCommand": "yarn install && npx playwright install && yarn build", + "buildCommand": "pnpm install && npx playwright install && pnpm build", "tests": [ { "testName": "Playwright tests - Prod Mode", diff --git a/packages/e2e-tests/test-applications/create-react-app/src/App.test.tsx b/packages/e2e-tests/test-applications/create-react-app/src/App.test.tsx deleted file mode 100644 index 2a68616d9846..000000000000 --- a/packages/e2e-tests/test-applications/create-react-app/src/App.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); 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 ee9c8e1dc40c..a2b1bd98ea61 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 @@ -1,6 +1,6 @@ { "$schema": "../../test-recipe-schema.json", "testApplicationName": "create-react-app", - "buildCommand": "yarn install && yarn build", + "buildCommand": "pnpm install && pnpm build", "tests": [] } diff --git a/packages/e2e-tests/test-applications/create-react-app/tsconfig.json b/packages/e2e-tests/test-applications/create-react-app/tsconfig.json index 1d693b2b06ac..4bd4dd6a0417 100644 --- a/packages/e2e-tests/test-applications/create-react-app/tsconfig.json +++ b/packages/e2e-tests/test-applications/create-react-app/tsconfig.json @@ -16,5 +16,6 @@ "noEmit": true, "jsx": "react" }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.tsx"] } diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/test-recipe.json b/packages/e2e-tests/test-applications/nextjs-app-dir/test-recipe.json index b711dd6e922c..3fe2f95b324c 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/test-recipe.json +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/test-recipe.json @@ -1,7 +1,7 @@ { "$schema": "../../test-recipe-schema.json", "testApplicationName": "nextjs-13-app-dir", - "buildCommand": "yarn install && npx playwright install && yarn build", + "buildCommand": "pnpm install && npx playwright install && pnpm build", "buildAssertionCommand": "yarn ts-node --script-mode assert-build.ts", "tests": [ { diff --git a/packages/e2e-tests/test-applications/node-express-app/test-recipe.json b/packages/e2e-tests/test-applications/node-express-app/test-recipe.json index 039049258171..c899ef8cfe52 100644 --- a/packages/e2e-tests/test-applications/node-express-app/test-recipe.json +++ b/packages/e2e-tests/test-applications/node-express-app/test-recipe.json @@ -1,7 +1,7 @@ { "$schema": "../../test-recipe-schema.json", "testApplicationName": "node-express-app", - "buildCommand": "yarn install && yarn build", + "buildCommand": "pnpm install && pnpm build", "tests": [ { "testName": "Test express server", diff --git a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/test-recipe.json b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/test-recipe.json index 864736daaad8..98d86d555161 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/test-recipe.json +++ b/packages/e2e-tests/test-applications/standard-frontend-react-tracing-import/test-recipe.json @@ -1,7 +1,7 @@ { "$schema": "../../test-recipe-schema.json", "testApplicationName": "standard-frontend-react-tracing-import", - "buildCommand": "yarn install && npx playwright install && yarn build", + "buildCommand": "pnpm install && npx playwright install && pnpm build", "tests": [ { "testName": "Playwright tests", 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 76916d74d280..f3a16c838d13 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 @@ -1,7 +1,7 @@ { "$schema": "../../test-recipe-schema.json", "testApplicationName": "standard-frontend-react", - "buildCommand": "yarn install && npx playwright install && yarn build", + "buildCommand": "pnpm install && npx playwright install && pnpm build", "tests": [ { "testName": "Playwright tests", From 0161cddcaec4ace898fc1d4835d64ab2f0a4ab37 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 25 Apr 2023 05:26:43 -0400 Subject: [PATCH 11/28] feat(replay): Change `flush()` API to record current event buffer (#7743) This adds a public API: `capture` that will record the current event buffer and by default, convert the replay type to "session" and continue recording. We have extracted the logic that was used for "onError" capturing and made it a public API. --- .../src/coreHandlers/handleAfterSendEvent.ts | 16 +--- packages/replay/src/integration.ts | 12 ++- packages/replay/src/replay.ts | 40 +++++++- packages/replay/src/types.ts | 7 +- .../test/integration/errorSampleRate.test.ts | 94 +++++++++++++++++++ 5 files changed, 148 insertions(+), 21 deletions(-) diff --git a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts index f3a531f3ffdc..dc94022e5b4a 100644 --- a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts @@ -53,19 +53,9 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal event.exception && event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing ) { - setTimeout(async () => { - // Allow flush to complete before resuming as a session recording, otherwise - // the checkout from `startRecording` may be included in the payload. - // Prefer to keep the error replay as a separate (and smaller) segment - // than the session replay. - await replay.flushImmediate(); - - if (replay.stopRecording()) { - // Reset all "capture on error" configuration before - // starting a new recording - replay.recordingMode = 'session'; - replay.startRecording(); - } + setTimeout(() => { + // Capture current event buffer as new replay + void replay.sendBufferedReplayOrFlush(); }); } }; diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index f426a9051f97..cd3f5b28e57c 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -4,7 +4,7 @@ import { dropUndefinedKeys } from '@sentry/utils'; import { DEFAULT_FLUSH_MAX_DELAY, DEFAULT_FLUSH_MIN_DELAY } from './constants'; import { ReplayContainer } from './replay'; -import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions } from './types'; +import type { RecordingOptions, ReplayConfiguration, ReplayPluginOptions, SendBufferedReplayOptions } from './types'; import { getPrivacyOptions } from './util/getPrivacyOptions'; import { isBrowser } from './util/isBrowser'; @@ -216,14 +216,18 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, } /** - * Immediately send all pending events. + * Immediately send all pending events. In buffer-mode, this should be used + * to capture the initial replay. + * + * Unless `continueRecording` is false, the replay will continue to record and + * behave as a "session"-based replay. */ - public flush(): Promise | void { + public flush(options?: SendBufferedReplayOptions): Promise | void { if (!this._replay || !this._replay.isEnabled()) { return; } - return this._replay.flushImmediate(); + return this._replay.sendBufferedReplayOrFlush(options); } /** diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 62bb32f3c995..990ca825266e 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -20,6 +20,7 @@ import type { ReplayContainer as ReplayContainerInterface, ReplayExperimentalPluginOptions, ReplayPluginOptions, + SendBufferedReplayOptions, Session, Timeouts, } from './types'; @@ -230,17 +231,18 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Stops the recording, if it was running. - * Returns true if it was stopped, else false. + * + * Returns true if it was previously stopped, or is now stopped, + * otherwise false. */ public stopRecording(): boolean { try { if (this._stopRecording) { this._stopRecording(); this._stopRecording = undefined; - return true; } - return false; + return true; } catch (err) { this._handleException(err); return false; @@ -303,6 +305,38 @@ export class ReplayContainer implements ReplayContainerInterface { this.startRecording(); } + /** + * If not in "session" recording mode, flush event buffer which will create a new replay. + * Unless `continueRecording` is false, the replay will continue to record and + * behave as a "session"-based replay. + * + * Otherwise, queue up a flush. + */ + public async sendBufferedReplayOrFlush({ continueRecording = true }: SendBufferedReplayOptions = {}): Promise { + if (this.recordingMode === 'session') { + return this.flushImmediate(); + } + + // Allow flush to complete before resuming as a session recording, otherwise + // the checkout from `startRecording` may be included in the payload. + // Prefer to keep the error replay as a separate (and smaller) segment + // than the session replay. + await this.flushImmediate(); + + const hasStoppedRecording = this.stopRecording(); + + if (!continueRecording || !hasStoppedRecording) { + return; + } + + // Re-start recording, but in "session" recording mode + + // Reset all "capture on error" configuration before + // starting a new recording + this.recordingMode = 'session'; + this.startRecording(); + } + /** * We want to batch uploads of replay events. Save events only if * `` milliseconds have elapsed since the last event diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index e9faa29b9d6d..684e5442ed9c 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -446,6 +446,10 @@ export interface EventBuffer { export type AddUpdateCallback = () => boolean | void; +export interface SendBufferedReplayOptions { + continueRecording?: boolean; +} + export interface ReplayContainer { eventBuffer: EventBuffer | null; performanceEvents: AllPerformanceEntry[]; @@ -464,7 +468,8 @@ export interface ReplayContainer { resume(): void; startRecording(): void; stopRecording(): boolean; - flushImmediate(): void; + sendBufferedReplayOrFlush(options?: SendBufferedReplayOptions): Promise; + flushImmediate(): Promise; triggerUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 85f154e523f4..0b5baa8e2512 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -158,6 +158,100 @@ describe('Integration | errorSampleRate', () => { }); }); + it('manually flushes replay and does not continue to record', async () => { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + mockRecord._emitter(TEST_EVENT); + + 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 new Promise(process.nextTick); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + expect(replay).toHaveSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'error', + contexts: { + replay: { + error_sample_rate: 1, + session_sample_rate: 0, + }, + }, + }), + recordingData: JSON.stringify([ + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + 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', + }); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + // This is still the last replay sent since we passed `continueRecording: + // false`. + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'error', + contexts: { + replay: { + error_sample_rate: 1, + session_sample_rate: 0, + }, + }, + }), + recordingData: JSON.stringify([ + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + TEST_EVENT, + { + type: 5, + timestamp: BASE_TIMESTAMP, + data: { + tag: 'breadcrumb', + payload: { + timestamp: BASE_TIMESTAMP / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + ]), + }); + }); + it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_DURATION]ms', async () => { Object.defineProperty(document, 'visibilityState', { configurable: true, From c0e55048d680eaf71e1369e09104fa546e72e0d0 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 25 Apr 2023 15:12:45 +0200 Subject: [PATCH 12/28] feat(tracing): Add `db.system` span data to DB spans (#7952) Co-authored-by: Francesco Novy --- .../suites/tracing-new/auto-instrument/mongodb/test.ts | 5 +++++ .../suites/tracing-new/auto-instrument/mysql/test.ts | 6 ++++++ .../suites/tracing-new/auto-instrument/pg/test.ts | 9 +++++++++ .../suites/tracing-new/prisma-orm/test.ts | 6 +++--- .../suites/tracing/auto-instrument/mongodb/test.ts | 5 +++++ .../suites/tracing/auto-instrument/mysql/test.ts | 6 ++++++ .../suites/tracing/auto-instrument/pg/test.ts | 9 +++++++++ .../suites/tracing/prisma-orm/test.ts | 6 +++--- packages/tracing-internal/src/extensions.ts | 2 +- packages/tracing-internal/src/node/integrations/mongo.ts | 1 + packages/tracing-internal/src/node/integrations/mysql.ts | 3 +++ .../tracing-internal/src/node/integrations/postgres.ts | 3 +++ .../tracing-internal/src/node/integrations/prisma.ts | 5 ++++- packages/tracing/test/integrations/node/mongo.test.ts | 3 +++ packages/tracing/test/integrations/node/postgres.test.ts | 9 +++++++++ packages/tracing/test/integrations/node/prisma.test.ts | 5 ++++- 16 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts index 5664aac9422b..8511841c6da8 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts @@ -34,6 +34,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', doc: '{"title":"Rick and Morty"}', + 'db.system': 'mongodb', }, description: 'insertOne', op: 'db', @@ -44,6 +45,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', query: '{"title":"Back to the Future"}', + 'db.system': 'mongodb', }, description: 'findOne', op: 'db', @@ -55,6 +57,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { namespace: 'admin.movies', filter: '{"title":"Back to the Future"}', update: '{"$set":{"title":"South Park"}}', + 'db.system': 'mongodb', }, description: 'updateOne', op: 'db', @@ -65,6 +68,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', query: '{"title":"South Park"}', + 'db.system': 'mongodb', }, description: 'findOne', op: 'db', @@ -75,6 +79,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', query: '{"title":"South Park"}', + 'db.system': 'mongodb', }, description: 'find', op: 'db', diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/test.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/test.ts index 3b96f2cafec0..dbdd658f6ef4 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/test.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mysql/test.ts @@ -12,11 +12,17 @@ test('should auto-instrument `mysql` package.', async () => { { description: 'SELECT 1 + 1 AS solution', op: 'db', + data: { + 'db.system': 'mysql', + }, }, { description: 'SELECT NOW()', op: 'db', + data: { + 'db.system': 'mysql', + }, }, ], }); diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/test.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/test.ts index edfa67cee9d7..359e04d7d5f0 100644 --- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/test.ts +++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/pg/test.ts @@ -40,14 +40,23 @@ test('should auto-instrument `pg` package.', async () => { { description: 'SELECT * FROM foo where bar ilike "baz%"', op: 'db', + data: { + 'db.system': 'postgresql', + }, }, { description: 'SELECT * FROM bazz', op: 'db', + data: { + 'db.system': 'postgresql', + }, }, { description: 'SELECT NOW()', op: 'db', + data: { + 'db.system': 'postgresql', + }, }, ], }); diff --git a/packages/node-integration-tests/suites/tracing-new/prisma-orm/test.ts b/packages/node-integration-tests/suites/tracing-new/prisma-orm/test.ts index e3393f5fe2f8..654e61c6a5e1 100644 --- a/packages/node-integration-tests/suites/tracing-new/prisma-orm/test.ts +++ b/packages/node-integration-tests/suites/tracing-new/prisma-orm/test.ts @@ -8,9 +8,9 @@ conditionalTest({ min: 12 })('Prisma ORM Integration', () => { assertSentryTransaction(envelope[2], { transaction: 'Test Transaction', spans: [ - { description: 'User create', op: 'db.sql.prisma' }, - { description: 'User findMany', op: 'db.sql.prisma' }, - { description: 'User deleteMany', op: 'db.sql.prisma' }, + { description: 'User create', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + { description: 'User findMany', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + { description: 'User deleteMany', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, ], }); }); diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts index 5664aac9422b..8511841c6da8 100644 --- a/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts +++ b/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts @@ -34,6 +34,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', doc: '{"title":"Rick and Morty"}', + 'db.system': 'mongodb', }, description: 'insertOne', op: 'db', @@ -44,6 +45,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', query: '{"title":"Back to the Future"}', + 'db.system': 'mongodb', }, description: 'findOne', op: 'db', @@ -55,6 +57,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { namespace: 'admin.movies', filter: '{"title":"Back to the Future"}', update: '{"$set":{"title":"South Park"}}', + 'db.system': 'mongodb', }, description: 'updateOne', op: 'db', @@ -65,6 +68,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', query: '{"title":"South Park"}', + 'db.system': 'mongodb', }, description: 'findOne', op: 'db', @@ -75,6 +79,7 @@ conditionalTest({ min: 12 })('MongoDB Test', () => { dbName: 'admin', namespace: 'admin.movies', query: '{"title":"South Park"}', + 'db.system': 'mongodb', }, description: 'find', op: 'db', diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts index 3b96f2cafec0..dbdd658f6ef4 100644 --- a/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts +++ b/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts @@ -12,11 +12,17 @@ test('should auto-instrument `mysql` package.', async () => { { description: 'SELECT 1 + 1 AS solution', op: 'db', + data: { + 'db.system': 'mysql', + }, }, { description: 'SELECT NOW()', op: 'db', + data: { + 'db.system': 'mysql', + }, }, ], }); diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts index edfa67cee9d7..359e04d7d5f0 100644 --- a/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts +++ b/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts @@ -40,14 +40,23 @@ test('should auto-instrument `pg` package.', async () => { { description: 'SELECT * FROM foo where bar ilike "baz%"', op: 'db', + data: { + 'db.system': 'postgresql', + }, }, { description: 'SELECT * FROM bazz', op: 'db', + data: { + 'db.system': 'postgresql', + }, }, { description: 'SELECT NOW()', op: 'db', + data: { + 'db.system': 'postgresql', + }, }, ], }); diff --git a/packages/node-integration-tests/suites/tracing/prisma-orm/test.ts b/packages/node-integration-tests/suites/tracing/prisma-orm/test.ts index e3393f5fe2f8..654e61c6a5e1 100644 --- a/packages/node-integration-tests/suites/tracing/prisma-orm/test.ts +++ b/packages/node-integration-tests/suites/tracing/prisma-orm/test.ts @@ -8,9 +8,9 @@ conditionalTest({ min: 12 })('Prisma ORM Integration', () => { assertSentryTransaction(envelope[2], { transaction: 'Test Transaction', spans: [ - { description: 'User create', op: 'db.sql.prisma' }, - { description: 'User findMany', op: 'db.sql.prisma' }, - { description: 'User deleteMany', op: 'db.sql.prisma' }, + { description: 'User create', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + { description: 'User findMany', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + { description: 'User deleteMany', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, ], }); }); diff --git a/packages/tracing-internal/src/extensions.ts b/packages/tracing-internal/src/extensions.ts index 555a1451b55e..0d4c2b112e47 100644 --- a/packages/tracing-internal/src/extensions.ts +++ b/packages/tracing-internal/src/extensions.ts @@ -22,7 +22,7 @@ function _autoloadDatabaseIntegrations(): void { const integration = dynamicRequire(module, './node/integrations/mongo') as { Mongo: IntegrationClass; }; - return new integration.Mongo({ mongoose: true }); + return new integration.Mongo(); }, mysql() { const integration = dynamicRequire(module, './node/integrations/mysql') as { diff --git a/packages/tracing-internal/src/node/integrations/mongo.ts b/packages/tracing-internal/src/node/integrations/mongo.ts index dd5474dd4d51..e7118378cb4c 100644 --- a/packages/tracing-internal/src/node/integrations/mongo.ts +++ b/packages/tracing-internal/src/node/integrations/mongo.ts @@ -231,6 +231,7 @@ export class Mongo implements LazyLoadedIntegration { collectionName: collection.collectionName, dbName: collection.dbName, namespace: collection.namespace, + 'db.system': 'mongodb', }; const spanContext: SpanContext = { op: 'db', diff --git a/packages/tracing-internal/src/node/integrations/mysql.ts b/packages/tracing-internal/src/node/integrations/mysql.ts index 529e3b859bc2..e907b30e379a 100644 --- a/packages/tracing-internal/src/node/integrations/mysql.ts +++ b/packages/tracing-internal/src/node/integrations/mysql.ts @@ -55,6 +55,9 @@ export class Mysql implements LazyLoadedIntegration { const span = parentSpan?.startChild({ description: typeof options === 'string' ? options : (options as { sql: string }).sql, op: 'db', + data: { + 'db.system': 'mysql', + }, }); if (typeof callback === 'function') { diff --git a/packages/tracing-internal/src/node/integrations/postgres.ts b/packages/tracing-internal/src/node/integrations/postgres.ts index be7e299cf6aa..3c33099eabf0 100644 --- a/packages/tracing-internal/src/node/integrations/postgres.ts +++ b/packages/tracing-internal/src/node/integrations/postgres.ts @@ -79,6 +79,9 @@ export class Postgres implements LazyLoadedIntegration { const span = parentSpan?.startChild({ description: typeof config === 'string' ? config : (config as { text: string }).text, op: 'db', + data: { + 'db.system': 'postgresql', + }, }); if (typeof callback === 'function') { diff --git a/packages/tracing-internal/src/node/integrations/prisma.ts b/packages/tracing-internal/src/node/integrations/prisma.ts index 7d23cf6adbb5..20a84aa36aaf 100644 --- a/packages/tracing-internal/src/node/integrations/prisma.ts +++ b/packages/tracing-internal/src/node/integrations/prisma.ts @@ -91,7 +91,10 @@ export class Prisma implements Integration { 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' }, () => next(params)); + return trace( + { name: model ? `${model} ${action}` : action, op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + () => next(params), + ); }); } } diff --git a/packages/tracing/test/integrations/node/mongo.test.ts b/packages/tracing/test/integrations/node/mongo.test.ts index 8c406a6ecd14..547b7708b55e 100644 --- a/packages/tracing/test/integrations/node/mongo.test.ts +++ b/packages/tracing/test/integrations/node/mongo.test.ts @@ -78,6 +78,7 @@ describe('patchOperation()', () => { dbName: 'mockedDbName', doc: JSON.stringify(doc), namespace: 'mockedNamespace', + 'db.system': 'mongodb', }, op: 'db', description: 'insertOne', @@ -96,6 +97,7 @@ describe('patchOperation()', () => { dbName: 'mockedDbName', doc: JSON.stringify(doc), namespace: 'mockedNamespace', + 'db.system': 'mongodb', }, op: 'db', description: 'insertOne', @@ -111,6 +113,7 @@ describe('patchOperation()', () => { collectionName: 'mockedCollectionName', dbName: 'mockedDbName', namespace: 'mockedNamespace', + 'db.system': 'mongodb', }, op: 'db', description: 'initializeOrderedBulkOp', diff --git a/packages/tracing/test/integrations/node/postgres.test.ts b/packages/tracing/test/integrations/node/postgres.test.ts index 6a965b1ed9f8..d1b1f03b7914 100644 --- a/packages/tracing/test/integrations/node/postgres.test.ts +++ b/packages/tracing/test/integrations/node/postgres.test.ts @@ -72,6 +72,9 @@ describe('setupOnce', () => { expect(parentSpan.startChild).toBeCalledWith({ description: 'SELECT NOW()', op: 'db', + data: { + 'db.system': 'postgresql', + }, }); expect(childSpan.finish).toBeCalled(); done(); @@ -84,6 +87,9 @@ describe('setupOnce', () => { expect(parentSpan.startChild).toBeCalledWith({ description: 'SELECT NOW()', op: 'db', + data: { + 'db.system': 'postgresql', + }, }); expect(childSpan.finish).toBeCalled(); done(); @@ -96,6 +102,9 @@ describe('setupOnce', () => { expect(parentSpan.startChild).toBeCalledWith({ description: 'SELECT NOW()', op: 'db', + data: { + 'db.system': 'postgresql', + }, }); expect(childSpan.finish).toBeCalled(); }); diff --git a/packages/tracing/test/integrations/node/prisma.test.ts b/packages/tracing/test/integrations/node/prisma.test.ts index 1eb85a251704..3096401ec43a 100644 --- a/packages/tracing/test/integrations/node/prisma.test.ts +++ b/packages/tracing/test/integrations/node/prisma.test.ts @@ -54,7 +54,10 @@ describe('setupOnce', function () { it('should add middleware with $use method correctly', done => { void Client.user.create()?.then(() => { expect(mockTrace).toHaveBeenCalledTimes(1); - expect(mockTrace).toHaveBeenLastCalledWith({ name: 'user create', op: 'db.sql.prisma' }, expect.any(Function)); + expect(mockTrace).toHaveBeenLastCalledWith( + { name: 'user create', op: 'db.sql.prisma', data: { 'db.system': 'prisma' } }, + expect.any(Function), + ); done(); }); }); From 8b97f809d87d9a36d26bdf03b367270d88a86e8f Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 25 Apr 2023 15:15:01 +0200 Subject: [PATCH 13/28] feat(core): Add multiplexed transport (#7926) --- packages/core/src/index.ts | 1 + packages/core/src/transports/multiplexed.ts | 92 ++++++++++ .../test/lib/transports/multiplexed.test.ts | 162 ++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 packages/core/src/transports/multiplexed.ts create mode 100644 packages/core/test/lib/transports/multiplexed.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04fc78e12f12..1acf0264f69b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export { BaseClient } from './baseclient'; export { initAndBind } from './sdk'; export { createTransport } from './transports/base'; export { makeOfflineTransport } from './transports/offline'; +export { makeMultiplexedTransport } from './transports/multiplexed'; export { SDK_VERSION } from './version'; export { getIntegrationsToSetup } from './integration'; export { FunctionToString, InboundFilters } from './integrations'; diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts new file mode 100644 index 000000000000..859101bf56bc --- /dev/null +++ b/packages/core/src/transports/multiplexed.ts @@ -0,0 +1,92 @@ +import type { + BaseTransportOptions, + Envelope, + EnvelopeItemType, + Event, + EventItem, + Transport, + TransportMakeRequestResponse, +} from '@sentry/types'; +import { dsnFromString, forEachEnvelopeItem } from '@sentry/utils'; + +import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; + +interface MatchParam { + /** The envelope to be sent */ + envelope: Envelope; + /** + * A function that returns an event from the envelope if one exists. You can optionally pass an array of envelope item + * types to filter by - only envelopes matching the given types will be multiplexed. + * + * @param types Defaults to ['event', 'transaction', 'profile', 'replay_event'] + */ + getEvent(types?: EnvelopeItemType[]): Event | undefined; +} + +type Matcher = (param: MatchParam) => string[]; + +function eventFromEnvelope(env: Envelope, types: EnvelopeItemType[]): Event | undefined { + let event: Event | undefined; + + forEachEnvelopeItem(env, (item, type) => { + if (types.includes(type)) { + event = Array.isArray(item) ? (item as EventItem)[1] : undefined; + } + // bail out if we found an event + return !!event; + }); + + return event; +} + +/** + * Creates a transport that can send events to different DSNs depending on the envelope contents. + */ +export function makeMultiplexedTransport( + createTransport: (options: TO) => Transport, + matcher: Matcher, +): (options: TO) => Transport { + return options => { + const fallbackTransport = createTransport(options); + const otherTransports: Record = {}; + + function getTransport(dsn: string): Transport { + if (!otherTransports[dsn]) { + const url = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(dsn)); + otherTransports[dsn] = createTransport({ ...options, url }); + } + + return otherTransports[dsn]; + } + + async function send(envelope: Envelope): Promise { + function getEvent(types?: EnvelopeItemType[]): Event | undefined { + const eventTypes: EnvelopeItemType[] = + types && types.length ? types : ['event', 'transaction', 'profile', 'replay_event']; + return eventFromEnvelope(envelope, eventTypes); + } + + const transports = matcher({ envelope, getEvent }).map(dsn => getTransport(dsn)); + + // If we have no transports to send to, use the fallback transport + if (transports.length === 0) { + transports.push(fallbackTransport); + } + + const results = await Promise.all(transports.map(transport => transport.send(envelope))); + + return results[0]; + } + + async function flush(timeout: number | undefined): Promise { + const allTransports = [...Object.keys(otherTransports).map(dsn => otherTransports[dsn]), fallbackTransport]; + const results = await Promise.all(allTransports.map(transport => transport.flush(timeout))); + return results.every(r => r); + } + + return { + send, + flush, + }; + }; +} diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts new file mode 100644 index 000000000000..0849af8a81f7 --- /dev/null +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -0,0 +1,162 @@ +import type { BaseTransportOptions, ClientReport, EventEnvelope, EventItem, Transport } from '@sentry/types'; +import { createClientReportEnvelope, createEnvelope, dsnFromString } from '@sentry/utils'; +import { TextEncoder } from 'util'; + +import { createTransport, getEnvelopeEndpointWithUrlEncodedAuth, makeMultiplexedTransport } from '../../../src'; + +const DSN1 = 'https://1234@5678.ingest.sentry.io/4321'; +const DSN1_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN1)); + +const DSN2 = 'https://5678@1234.ingest.sentry.io/8765'; +const DSN2_URL = getEnvelopeEndpointWithUrlEncodedAuth(dsnFromString(DSN2)); + +const ERROR_EVENT = { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }; +const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, ERROR_EVENT] as EventItem, +]); + +const TRANSACTION_ENVELOPE = createEnvelope( + { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, + [[{ type: 'transaction' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem], +); + +const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [ + { + reason: 'before_send', + category: 'error', + quantity: 30, + }, + { + reason: 'network_error', + category: 'transaction', + quantity: 23, + }, +]; + +const CLIENT_REPORT_ENVELOPE = createClientReportEnvelope( + DEFAULT_DISCARDED_EVENTS, + 'https://public@dsn.ingest.sentry.io/1337', + 123456, +); + +type Assertion = (url: string, body: string | Uint8Array) => void; + +const createTestTransport = (...assertions: Assertion[]): ((options: BaseTransportOptions) => Transport) => { + return (options: BaseTransportOptions) => + createTransport(options, request => { + return new Promise(resolve => { + const assertion = assertions.shift(); + if (!assertion) { + throw new Error('No assertion left'); + } + assertion(options.url, request.body); + resolve({ statusCode: 200 }); + }); + }); +}; + +const transportOptions = { + recordDroppedEvent: () => undefined, // noop + textEncoder: new TextEncoder(), +}; + +describe('makeMultiplexedTransport', () => { + it('Falls back to options DSN when no match', async () => { + expect.assertions(1); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN1_URL); + }), + () => [], + ); + + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(ERROR_ENVELOPE); + }); + + it('DSN can be overridden via match callback', async () => { + expect.assertions(1); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN2_URL); + }), + () => [DSN2], + ); + + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(ERROR_ENVELOPE); + }); + + it('match callback can return multiple DSNs', async () => { + expect.assertions(2); + + const makeTransport = makeMultiplexedTransport( + createTestTransport( + url => { + expect(url).toBe(DSN1_URL); + }, + url => { + expect(url).toBe(DSN2_URL); + }, + ), + () => [DSN1, DSN2], + ); + + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(ERROR_ENVELOPE); + }); + + it('callback getEvent returns event', async () => { + expect.assertions(3); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN2_URL); + }), + ({ envelope, getEvent }) => { + expect(envelope).toBe(ERROR_ENVELOPE); + expect(getEvent()).toBe(ERROR_EVENT); + return [DSN2]; + }, + ); + + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(ERROR_ENVELOPE); + }); + + it('callback getEvent returns undefined if not event', async () => { + expect.assertions(2); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN2_URL); + }), + ({ getEvent }) => { + expect(getEvent()).toBeUndefined(); + return [DSN2]; + }, + ); + + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(CLIENT_REPORT_ENVELOPE); + }); + + it('callback getEvent can ignore transactions', async () => { + expect.assertions(2); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN2_URL); + }), + ({ getEvent }) => { + expect(getEvent(['event'])).toBeUndefined(); + return [DSN2]; + }, + ); + + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(TRANSACTION_ENVELOPE); + }); +}); From 7d10c44bf77b5fd6a2cfd3f9a3e1745e6af9381d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 10:54:04 +0200 Subject: [PATCH 14/28] feat(replay): Allow to configure URLs to capture network bodies/headers (#7953) --- .../fetch/captureRequestBody/init.js | 6 +- .../fetch/captureRequestBody/test.ts | 517 ++++++++++-------- .../fetch/captureRequestHeaders/init.js | 6 +- .../fetch/captureRequestHeaders/test.ts | 88 +++ .../fetch/captureRequestSize/test.ts | 28 +- .../fetch/captureResponseBody/init.js | 6 +- .../fetch/captureResponseBody/test.ts | 497 ++++++++++------- .../fetch/captureResponseHeaders/init.js | 6 +- .../fetch/captureResponseHeaders/test.ts | 82 +++ .../fetch/captureResponseSize/test.ts | 42 +- .../xhr/captureRequestBody/init.js | 7 +- .../xhr/captureRequestBody/test.ts | 502 ++++++++++------- .../xhr/captureRequestHeaders/init.js | 6 +- .../xhr/captureRequestHeaders/test.ts | 94 ++++ .../xhr/captureRequestSize/test.ts | 18 + .../xhr/captureResponseBody/init.js | 6 +- .../xhr/captureResponseBody/test.ts | 397 ++++++++------ .../xhr/captureResponseHeaders/init.js | 6 +- .../xhr/captureResponseHeaders/test.ts | 97 ++++ .../xhr/captureResponseSize/test.ts | 29 +- .../utils/replayEventTemplates.ts | 30 +- .../coreHandlers/handleNetworkBreadcrumbs.ts | 8 +- .../src/coreHandlers/util/fetchUtils.ts | 39 +- .../src/coreHandlers/util/networkUtils.ts | 18 +- .../replay/src/coreHandlers/util/xhrUtils.ts | 38 +- packages/replay/src/integration.ts | 16 + packages/replay/src/replay.ts | 31 -- packages/replay/src/types.ts | 62 ++- .../handleNetworkBreadcrumbs.test.ts | 320 ++++++++++- .../replay/test/utils/setupReplayContainer.ts | 4 + rollup/plugins/bundlePlugins.js | 2 + 31 files changed, 2065 insertions(+), 943 deletions(-) diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js index ff7729968b4e..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts index 0f9bd5dfae59..b5c81b4b1b6c 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts @@ -8,252 +8,327 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest( - 'captures text requestBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - }); - }); - - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); - }); +sentryTest('captures text request body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - - await page.evaluate(() => { - /* eslint-disable */ - fetch('http://localhost:7654/foo', { - method: 'POST', - body: 'input body', - }).then(() => { - // @ts-ignore Sentry is a global - Sentry.captureException('test error'); - }); - /* eslint-enable */ + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, }); + }); - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + body: 'input body', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + request_body_size: 10, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { data: { method: 'POST', - request_body_size: 10, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - request: { - size: 10, - headers: {}, - body: 'input body', - }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, + statusCode: 200, + request: { + size: 10, + headers: {}, + body: 'input body', }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), + response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, - ]); - }, -); - -sentryTest( - 'captures JSON requestBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures JSON request body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); - - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); - - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - - await page.evaluate(() => { - /* eslint-disable */ - fetch('http://localhost:7654/foo', { - method: 'POST', - body: '{"foo":"bar"}', - }).then(() => { - // @ts-ignore Sentry is a global - Sentry.captureException('test error'); - }); - /* eslint-enable */ + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + body: '{"foo":"bar"}', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + request_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { data: { method: 'POST', - request_body_size: 13, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - request: { - size: 13, - headers: {}, - body: { foo: 'bar' }, - }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, + statusCode: 200, + request: { + size: 13, + headers: {}, + body: { foo: 'bar' }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), + response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, - ]); - }, -); - -sentryTest( - 'captures non-text requestBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures non-text request body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); - - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); - - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); - - await page.evaluate(() => { - const body = new URLSearchParams(); - body.append('name', 'Anne'); - body.append('age', '32'); - - /* eslint-disable */ - fetch('http://localhost:7654/foo', { - method: 'POST', - body: body, - }).then(() => { - // @ts-ignore Sentry is a global - Sentry.captureException('test error'); - }); - /* eslint-enable */ + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); + + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + body: body, + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + request_body_size: 16, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { data: { method: 'POST', - request_body_size: 16, - status_code: 200, - url: 'http://localhost:7654/foo', + statusCode: 200, + request: { + size: 16, + headers: {}, + body: 'name=Anne&age=32', + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('does not capture request body when URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, }); + }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - request: { - size: 16, - headers: {}, - body: 'name=Anne&age=32', + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/bar', { + method: 'POST', + body: 'input body', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + request_body_size: 10, + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'POST', + statusCode: 200, + request: { + size: 10, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), }, - ]); - }, -); + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js index ce0f253a910d..a60fcdcfc530 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureRequestHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkRequestHeaders: ['X-Test-Header'], }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts index 64ae5133b559..4b1b1d882eb2 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts @@ -329,3 +329,91 @@ sentryTest('captures request headers as Headers instance', async ({ getLocalTest }, ]); }); + +sentryTest('does not captures request headers if URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/bar', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cache: 'no-cache', + 'X-Custom-Header': 'foo', + 'X-Test-Header': 'test-value', + }, + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'POST', + statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts index 9aeb5615f628..712210558176 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts @@ -8,13 +8,11 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - await page.route('**/foo', route => { return route.fulfill({ status: 200, @@ -75,8 +73,16 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest request: { size: 13, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), @@ -86,13 +92,11 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest ]); }); -sentryTest('captures request size from non-text request body', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures request size from non-text request body', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - await page.route('**/foo', async route => { return route.fulfill({ status: 200, @@ -155,8 +159,16 @@ sentryTest('captures request size from non-text request body', async ({ getLocal request: { size: 26, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js index ff7729968b4e..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts index 6f0fc2c4d4f2..1741b6a19803 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts @@ -8,254 +8,329 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest( - 'captures text responseBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: 'response body', - }); - }); - - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); - }); +sentryTest('captures text response body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: 'response body', + }); + }); - await page.evaluate(() => { - /* eslint-disable */ - fetch('http://localhost:7654/foo', { - method: 'POST', - }).then(() => { - // @ts-ignore Sentry is a global - Sentry.captureException('test error'); - }); - /* eslint-enable */ + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const request = await requestPromise; - const eventData = envelopeRequestParser(request); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - expect(eventData.exception?.values).toHaveLength(1); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + response_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { data: { method: 'POST', - response_body_size: 13, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - response: { - size: 13, - headers: { - 'content-length': '13', - ...additionalHeaders, - }, - body: 'response body', + statusCode: 200, + response: { + size: 13, + headers: { + 'content-length': '13', + ...additionalHeaders, }, + body: 'response body', }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), }, - ]); - }, -); - -sentryTest( - 'captures JSON responseBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: JSON.stringify({ res: 'this' }), - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures JSON response body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ res: 'this' }), }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - await page.evaluate(() => { - /* eslint-disable */ - fetch('http://localhost:7654/foo', { - method: 'POST', - }).then(() => { - // @ts-ignore Sentry is a global - Sentry.captureException('test error'); - }); - /* eslint-enable */ + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + response_body_size: 14, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { data: { method: 'POST', - response_body_size: 14, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - response: { - size: 14, - headers: { - 'content-length': '14', - ...additionalHeaders, - }, - body: { res: 'this' }, + statusCode: 200, + response: { + size: 14, + headers: { + 'content-length': '14', + ...additionalHeaders, }, + body: { res: 'this' }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), }, - ]); - }, -); - -sentryTest( - 'captures non-text responseBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'application/octet-stream' } : {}; - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: Buffer.from('Hello world'), - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures non-text response body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'application/octet-stream' } : {}; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: Buffer.from('Hello world'), }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - await page.evaluate(() => { - /* eslint-disable */ - fetch('http://localhost:7654/foo', { + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + response_body_size: 24, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { method: 'POST', - }).then(() => { - // @ts-ignore Sentry is a global - Sentry.captureException('test error'); - }); - /* eslint-enable */ + statusCode: 200, + response: { + size: 24, + headers: { + 'content-length': '24', + ...additionalHeaders, + }, + body: 'Hello world', + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('does not capture response body when URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, + body: 'response body', + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const request = await requestPromise; - const eventData = envelopeRequestParser(request); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - expect(eventData.exception?.values).toHaveLength(1); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/bar', { + method: 'POST', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + response_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { data: { method: 'POST', - response_body_size: 24, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - response: { - size: 24, - headers: { - 'content-length': '24', - ...additionalHeaders, - }, - body: 'Hello world', + statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 13, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), }, - ]); - }, -); + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/init.js index e675af70ba91..241dcc7adc29 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureResponseHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkResponseHeaders: ['X-Test-Header'], }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts index 177dfa8efd57..b377e1667ee6 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts @@ -158,3 +158,85 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page }) => { }, ]); }); + +sentryTest('does not capture response headers if URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Test-Header': 'test-value', + 'X-Other-Header': 'test-value-2', + 'access-control-expose-headers': '*', + }, + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/bar').then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'GET', + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'GET', + statusCode: 200, + request: { + headers: {}, + _meta: { warnings: ['URL_SKIPPED'] }, + }, + response: { + headers: {}, + _meta: { warnings: ['URL_SKIPPED'] }, + }, + }, + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts index eeb51d16ee9a..3604f270ec23 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts @@ -74,12 +74,18 @@ sentryTest('captures response size from Content-Length header if available', asy data: { method: 'GET', statusCode: 200, - response: { - headers: { - 'content-length': '789', - 'content-type': 'application/json', + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, + }, + response: { size: 789, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -156,11 +162,18 @@ sentryTest('captures response size without Content-Length header', async ({ getL data: { method: 'GET', statusCode: 200, - response: { - headers: { - 'content-type': 'application/json', + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, + }, + response: { size: 29, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -171,13 +184,11 @@ sentryTest('captures response size without Content-Length header', async ({ getL ]); }); -sentryTest('captures response size from non-text response body', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures response size from non-text response body', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'application/octet-stream' } : {}; - await page.route('**/foo', async route => { return route.fulfill({ status: 200, @@ -237,10 +248,17 @@ sentryTest('captures response size from non-text response body', async ({ getLoc data: { method: 'POST', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 24, - headers: { - ...additionalHeaders, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, }, diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js index de48fc6febee..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js @@ -4,10 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - captureResponseHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts index 567990fe55fb..0d8b8579939e 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts @@ -8,258 +8,338 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest( - 'captures text requestBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - // These are a bit flaky on non-chromium browsers - if (shouldSkipReplayTest() || browserName !== 'chromium') { - sentryTest.skip(); - } - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - }); +sentryTest('captures text request body', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send('input body'); + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send('input body'); - xhr.addEventListener('readystatechange', function () { - if (xhr.readyState === 4) { - // @ts-ignore Sentry is a global - setTimeout(() => Sentry.captureException('test error', 0)); - } - }); - /* eslint-enable */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'xhr', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 10, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { data: { method: 'POST', - request_body_size: 10, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - request: { - size: 10, - headers: {}, - body: 'input body', - }, + statusCode: 200, + request: { + size: 10, + headers: {}, + body: 'input body', }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.xhr', - startTimestamp: expect.any(Number), }, - ]); - }, -); - -sentryTest( - 'captures JSON requestBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - // These are a bit flaky on non-chromium browsers - if (shouldSkipReplayTest() || browserName !== 'chromium') { - sentryTest.skip(); - } - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures JSON request body', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send('{"foo":"bar"}'); + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send('{"foo":"bar"}'); - xhr.addEventListener('readystatechange', function () { - if (xhr.readyState === 4) { - // @ts-ignore Sentry is a global - setTimeout(() => Sentry.captureException('test error', 0)); - } - }); - /* eslint-enable */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'xhr', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { data: { method: 'POST', - request_body_size: 13, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - request: { - size: 13, - headers: {}, - body: { foo: 'bar' }, - }, + statusCode: 200, + request: { + size: 13, + headers: {}, + body: { foo: 'bar' }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.xhr', - startTimestamp: expect.any(Number), }, - ]); - }, -); - -sentryTest( - 'captures non-text requestBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - // These are a bit flaky on non-chromium browsers - if (shouldSkipReplayTest() || browserName !== 'chromium') { - sentryTest.skip(); - } - - await page.route('**/foo', async route => { - return route.fulfill({ - status: 200, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures non-text request body', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', async route => { + return route.fulfill({ + status: 200, }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - await page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - const body = new URLSearchParams(); - body.append('name', 'Anne'); - body.append('age', '32'); + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send(body); + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send(body); - xhr.addEventListener('readystatechange', function () { - if (xhr.readyState === 4) { - // @ts-ignore Sentry is a global - setTimeout(() => Sentry.captureException('test error', 0)); - } - }); - /* eslint-enable */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'xhr', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 16, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { data: { method: 'POST', - request_body_size: 16, - status_code: 200, - url: 'http://localhost:7654/foo', + statusCode: 200, + request: { + size: 16, + headers: {}, + body: 'name=Anne&age=32', + }, }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('does not capture request body when URL does not match', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - request: { - size: 16, - headers: {}, - body: 'name=Anne&age=32', + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/bar'); + xhr.send('input body'); + + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 10, + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + statusCode: 200, + request: { + size: 10, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.xhr', - startTimestamp: expect.any(Number), }, - ]); - }, -); + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js index ce0f253a910d..a60fcdcfc530 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureRequestHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkRequestHeaders: ['X-Test-Header'], }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts index 9be377b9ea84..3a341f9df8a6 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts @@ -93,3 +93,97 @@ sentryTest('captures request headers', async ({ getLocalTestPath, page, browserN }, ]); }); + +sentryTest( + 'does not capture request headers if URL does not match', + async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/bar'); + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Cache', 'no-cache'); + xhr.setRequestHeader('X-Test-Header', 'test-value'); + xhr.send(); + + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); + }, +); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts index 9d30f3aab3a1..83461fd61486 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts @@ -78,6 +78,15 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest request: { size: 13, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -160,6 +169,15 @@ sentryTest('captures request size from non-text request body', async ({ getLocal request: { size: 26, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js index ff7729968b4e..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts index 0bfc4374faf1..4e0eb915f98a 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts @@ -8,194 +8,273 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest( - 'captures text responseBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - // These are a bit flaky on non-chromium browsers - if (shouldSkipReplayTest() || browserName !== 'chromium') { - sentryTest.skip(); - } - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: 'response body', - headers: { - 'Content-Length': '', - }, - }); +sentryTest('captures text response body', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: 'response body', + headers: { + 'Content-Length': '', + }, }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send(); + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send(); - xhr.addEventListener('readystatechange', function () { - if (xhr.readyState === 4) { - // @ts-ignore Sentry is a global - setTimeout(() => Sentry.captureException('test error', 0)); - } - }); - /* eslint-enable */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'xhr', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + response_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { data: { method: 'POST', - response_body_size: 13, - status_code: 200, - url: 'http://localhost:7654/foo', + statusCode: 200, + response: { + size: 13, + headers: {}, + body: 'response body', + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures JSON response body', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ res: 'this' }), + headers: { + 'Content-Length': '', }, }); + }); - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - response: { - size: 13, - headers: {}, - body: 'response body', - }, - }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.xhr', - startTimestamp: expect.any(Number), - }, - ]); - }, -); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); -sentryTest( - 'captures JSON responseBody when experiment is configured', - async ({ getLocalTestPath, page, browserName }) => { - // These are a bit flaky on non-chromium browsers - if (shouldSkipReplayTest() || browserName !== 'chromium') { - sentryTest.skip(); - } + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: JSON.stringify({ res: 'this' }), - headers: { - 'Content-Length': '', + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send(); + + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + response_body_size: 14, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + statusCode: 200, + response: { + size: 14, + headers: {}, + body: { res: 'this' }, }, - }); + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures non-text response body', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', async route => { + return route.fulfill({ + status: 200, + body: Buffer.from('Hello world'), + headers: { + 'Content-Length': '', + }, }); + }); - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send(); + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send(); - xhr.addEventListener('readystatechange', function () { - if (xhr.readyState === 4) { - // @ts-ignore Sentry is a global - setTimeout(() => Sentry.captureException('test error', 0)); - } - }); - /* eslint-enable */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - const request = await requestPromise; - const eventData = envelopeRequestParser(request); - - expect(eventData.exception?.values).toHaveLength(1); - - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'xhr', - type: 'http', + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + response_body_size: 24, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { data: { method: 'POST', - response_body_size: 14, - status_code: 200, - url: 'http://localhost:7654/foo', - }, - }); - - const replayReq1 = await replayRequestPromise1; - const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); - expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ - { - data: { - method: 'POST', - statusCode: 200, - response: { - size: 14, - headers: {}, - body: { res: 'this' }, - }, + statusCode: 200, + response: { + size: 24, + headers: {}, + body: 'Hello world', }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.xhr', - startTimestamp: expect.any(Number), }, - ]); - }, -); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); sentryTest( - 'captures non-text responseBody when experiment is configured', + 'does not capture response body when URL does not match', async ({ getLocalTestPath, page, browserName }) => { // These are a bit flaky on non-chromium browsers if (shouldSkipReplayTest() || browserName !== 'chromium') { sentryTest.skip(); } - await page.route('**/foo', async route => { + await page.route('**/bar', route => { return route.fulfill({ status: 200, - body: Buffer.from('Hello world'), + body: 'response body', headers: { 'Content-Length': '', }, @@ -216,11 +295,11 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await page.evaluate(() => { + void page.evaluate(() => { /* eslint-disable */ const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); + xhr.open('POST', 'http://localhost:7654/bar'); xhr.send(); xhr.addEventListener('readystatechange', function () { @@ -244,9 +323,9 @@ sentryTest( type: 'http', data: { method: 'POST', - response_body_size: 24, + response_body_size: 13, status_code: 200, - url: 'http://localhost:7654/foo', + url: 'http://localhost:7654/bar', }, }); @@ -257,13 +336,21 @@ sentryTest( data: { method: 'POST', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { - size: 24, + size: 13, headers: {}, - body: 'Hello world', + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, - description: 'http://localhost:7654/foo', + description: 'http://localhost:7654/bar', endTimestamp: expect.any(Number), op: 'resource.xhr', startTimestamp: expect.any(Number), diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/init.js index e675af70ba91..241dcc7adc29 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureResponseHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkResponseHeaders: ['X-Test-Header'], }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts index 5b34368c488f..ac80334663d8 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts @@ -96,3 +96,100 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page, browser }, ]); }); + +sentryTest( + 'does not capture response headers if URL does not match', + async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Test-Header': 'test-value', + 'X-Other-Header': 'test-value-2', + 'access-control-expose-headers': '*', + }, + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('GET', 'http://localhost:7654/bar'); + xhr.send(); + + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'GET', + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'GET', + statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); + }, +); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts index 3467d1b5f04c..cf3de69d8fd4 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts @@ -80,10 +80,17 @@ sentryTest( data: { method: 'GET', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 789, - headers: { - 'content-length': '789', + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, }, @@ -169,9 +176,18 @@ sentryTest('captures response size without Content-Length header', async ({ getL data: { method: 'GET', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 29, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -253,9 +269,18 @@ sentryTest('captures response size for non-string bodies', async ({ getLocalTest data: { method: 'POST', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 24, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', diff --git a/packages/browser-integration-tests/utils/replayEventTemplates.ts b/packages/browser-integration-tests/utils/replayEventTemplates.ts index be194e9a2bb6..e6ee4bda18d2 100644 --- a/packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -156,8 +156,20 @@ export const expectedFetchPerformanceSpan = { data: { method: 'POST', statusCode: 200, - request: { size: 3, headers: {} }, - response: { size: 11, headers: { 'content-length': '11', 'content-type': 'application/json' } }, + request: { + size: 3, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 11, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, }, }; @@ -169,7 +181,19 @@ export const expectedXHRPerformanceSpan = { data: { method: 'GET', statusCode: 200, - response: { size: 11, headers: { 'content-length': '11', 'content-type': 'application/json' } }, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 11, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, }, }; diff --git a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index 0e3940271115..59324031e336 100644 --- a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -31,10 +31,16 @@ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { try { const textEncoder = new TextEncoder(); + const { networkDetailAllowUrls, networkCaptureBodies, networkRequestHeaders, networkResponseHeaders } = + replay.getOptions(); + const options: ExtendedNetworkBreadcrumbsOptions = { replay, textEncoder, - ...replay.getExperimentalOptions().network, + networkDetailAllowUrls, + networkCaptureBodies, + networkRequestHeaders, + networkResponseHeaders, }; if (client && client.on) { diff --git a/packages/replay/src/coreHandlers/util/fetchUtils.ts b/packages/replay/src/coreHandlers/util/fetchUtils.ts index b2f7a1ac72d7..9411332d0197 100644 --- a/packages/replay/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay/src/coreHandlers/util/fetchUtils.ts @@ -11,11 +11,13 @@ import type { import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, + buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, getBodyString, makeNetworkReplayBreadcrumb, parseContentLengthHeader, + urlMatches, } from './networkUtils'; /** @@ -78,33 +80,37 @@ async function _prepareFetchData( const { url, method, - status_code: statusCode, + status_code: statusCode = 0, request_body_size: requestBodySize, response_body_size: responseBodySize, } = breadcrumb.data; - const request = _getRequestInfo(options, hint.input, requestBodySize); - const response = await _getResponseInfo(options, hint.response, responseBodySize); + const captureDetails = urlMatches(url, options.networkDetailAllowUrls); + + const request = captureDetails + ? _getRequestInfo(options, hint.input, requestBodySize) + : buildSkippedNetworkRequestOrResponse(requestBodySize); + const response = await _getResponseInfo(captureDetails, options, hint.response, responseBodySize); return { startTimestamp, endTimestamp, url, method, - statusCode: statusCode || 0, + statusCode, request, response, }; } function _getRequestInfo( - { captureBodies, requestHeaders }: ReplayNetworkOptions, + { networkCaptureBodies, networkRequestHeaders }: ReplayNetworkOptions, input: FetchHint['input'], requestBodySize?: number, ): ReplayNetworkRequestOrResponse | undefined { - const headers = getRequestHeaders(input, requestHeaders); + const headers = getRequestHeaders(input, networkRequestHeaders); - if (!captureBodies) { + if (!networkCaptureBodies) { return buildNetworkRequestOrResponse(headers, requestBodySize, undefined); } @@ -115,19 +121,24 @@ function _getRequestInfo( } async function _getResponseInfo( + captureDetails: boolean, { - captureBodies, + networkCaptureBodies, textEncoder, - responseHeaders, + networkResponseHeaders, }: ReplayNetworkOptions & { textEncoder: TextEncoderInternal; }, response: Response, responseBodySize?: number, ): Promise { - const headers = getAllHeaders(response.headers, responseHeaders); + if (!captureDetails && responseBodySize !== undefined) { + return buildSkippedNetworkRequestOrResponse(responseBodySize); + } + + const headers = getAllHeaders(response.headers, networkResponseHeaders); - if (!captureBodies && responseBodySize !== undefined) { + if (!networkCaptureBodies && responseBodySize !== undefined) { return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } @@ -142,7 +153,11 @@ async function _getResponseInfo( ? getBodySize(bodyText, textEncoder) : responseBodySize; - if (captureBodies) { + if (!captureDetails) { + return buildSkippedNetworkRequestOrResponse(size); + } + + if (networkCaptureBodies) { return buildNetworkRequestOrResponse(headers, size, bodyText); } diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts index 79515bf36ff4..8ee1a87b7b6d 100644 --- a/packages/replay/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay/src/coreHandlers/util/networkUtils.ts @@ -1,5 +1,5 @@ import type { TextEncoderInternal } from '@sentry/types'; -import { dropUndefinedKeys } from '@sentry/utils'; +import { dropUndefinedKeys, stringMatchesSomePattern } from '@sentry/utils'; import { NETWORK_BODY_MAX_SIZE } from '../../constants'; import type { @@ -120,6 +120,17 @@ export function getNetworkBody(bodyText: string | undefined): NetworkBody | unde return bodyText; } +/** Build the request or response part of a replay network breadcrumb that was skipped. */ +export function buildSkippedNetworkRequestOrResponse(bodySize: number | undefined): ReplayNetworkRequestOrResponse { + return { + headers: {}, + size: bodySize, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }; +} + /** Build the request or response part of a replay network breadcrumb. */ export function buildNetworkRequestOrResponse( headers: Record, @@ -220,3 +231,8 @@ function _strIsProbablyJson(str: string): boolean { // Simple check: If this does not start & end with {} or [], it's not JSON return (first === '[' && last === ']') || (first === '{' && last === '}'); } + +/** Match an URL against a list of strings/Regex. */ +export function urlMatches(url: string, urls: (string | RegExp)[]): boolean { + return stringMatchesSomePattern(url, urls); +} diff --git a/packages/replay/src/coreHandlers/util/xhrUtils.ts b/packages/replay/src/coreHandlers/util/xhrUtils.ts index b241bd945771..dfb1ccaf3ffc 100644 --- a/packages/replay/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay/src/coreHandlers/util/xhrUtils.ts @@ -5,11 +5,13 @@ import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, X import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, + buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, getBodyString, makeNetworkReplayBreadcrumb, parseContentLengthHeader, + urlMatches, } from './networkUtils'; /** @@ -67,28 +69,44 @@ function _prepareXhrData( const { url, method, - status_code: statusCode, + status_code: statusCode = 0, request_body_size: requestBodySize, response_body_size: responseBodySize, } = breadcrumb.data; - const xhrInfo = xhr[SENTRY_XHR_DATA_KEY]; - const requestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.requestHeaders) : {}; - const responseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.responseHeaders); - if (!url) { return null; } + if (!urlMatches(url, options.networkDetailAllowUrls)) { + const request = buildSkippedNetworkRequestOrResponse(requestBodySize); + const response = buildSkippedNetworkRequestOrResponse(responseBodySize); + return { + startTimestamp, + endTimestamp, + url, + method, + statusCode, + request, + response, + }; + } + + const xhrInfo = xhr[SENTRY_XHR_DATA_KEY]; + const networkRequestHeaders = xhrInfo + ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) + : {}; + const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); + const request = buildNetworkRequestOrResponse( - requestHeaders, + networkRequestHeaders, requestBodySize, - options.captureBodies ? getBodyString(input) : undefined, + options.networkCaptureBodies ? getBodyString(input) : undefined, ); const response = buildNetworkRequestOrResponse( - responseHeaders, + networkResponseHeaders, responseBodySize, - options.captureBodies ? hint.xhr.responseText : undefined, + options.networkCaptureBodies ? hint.xhr.responseText : undefined, ); return { @@ -96,7 +114,7 @@ function _prepareXhrData( endTimestamp, url, method, - statusCode: statusCode || 0, + statusCode, request, response, }; diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index cd3f5b28e57c..dfc1eeef7a06 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -11,6 +11,8 @@ import { isBrowser } from './util/isBrowser'; const MEDIA_SELECTORS = 'img,image,svg,video,object,picture,embed,map,audio,link[rel="icon"],link[rel="apple-touch-icon"]'; +const DEFAULT_NETWORK_HEADERS = ['content-length', 'content-type', 'accept']; + let _initialized = false; type InitialReplayPluginOptions = Omit & @@ -58,6 +60,11 @@ export class Replay implements Integration { maskAllInputs = true, blockAllMedia = true, + networkDetailAllowUrls = [], + networkCaptureBodies = true, + networkRequestHeaders = [], + networkResponseHeaders = [], + mask = [], unmask = [], block = [], @@ -116,6 +123,11 @@ export class Replay implements Integration { errorSampleRate, useCompression, blockAllMedia, + networkDetailAllowUrls, + networkCaptureBodies, + networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders), + networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders), + _experiments, }; @@ -288,3 +300,7 @@ function loadReplayOptionsFromClient(initialOptions: InitialReplayPluginOptions) return finalOptions; } + +function _getMergedNetworkHeaders(headers: string[]): string[] { + return [...DEFAULT_NETWORK_HEADERS, ...headers.map(header => header.toLowerCase())]; +} diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 990ca825266e..f8b890462d84 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -18,7 +18,6 @@ import type { PopEventContext, RecordingOptions, ReplayContainer as ReplayContainerInterface, - ReplayExperimentalPluginOptions, ReplayPluginOptions, SendBufferedReplayOptions, Session, @@ -65,8 +64,6 @@ export class ReplayContainer implements ReplayContainerInterface { maxSessionLife: MAX_SESSION_LIFE, } as const; - private readonly _experimentalOptions: ReplayExperimentalPluginOptions; - /** * Options to pass to `rrweb.record()` */ @@ -129,8 +126,6 @@ export class ReplayContainer implements ReplayContainerInterface { this._debouncedFlush = debounce(() => this._flush(), this._options.flushMinDelay, { maxWait: this._options.flushMaxDelay, }); - - this._experimentalOptions = _getExperimentalOptions(options); } /** Get the event context. */ @@ -153,15 +148,6 @@ export class ReplayContainer implements ReplayContainerInterface { return this._options; } - /** - * Get the experimental options. - * THIS IS INTERNAL AND SUBJECT TO CHANGE! - * @hidden - */ - public getExperimentalOptions(): ReplayExperimentalPluginOptions { - return this._experimentalOptions; - } - /** * Initializes the plugin. * @@ -902,20 +888,3 @@ export class ReplayContainer implements ReplayContainerInterface { return true; }; } - -function _getExperimentalOptions(options: ReplayPluginOptions): ReplayExperimentalPluginOptions { - const requestHeaders = options._experiments.captureRequestHeaders || []; - const responseHeaders = options._experiments.captureResponseHeaders || []; - const captureBodies = options._experiments.captureNetworkBodies || false; - - // Add defaults - const defaultHeaders = ['content-length', 'content-type', 'accept']; - - return { - network: { - captureBodies, - requestHeaders: [...defaultHeaders, ...requestHeaders.map(header => header.toLowerCase())], - responseHeaders: [...defaultHeaders, ...responseHeaders.map(header => header.toLowerCase())], - }, - }; -} diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 684e5442ed9c..e2f4b6d1b71f 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -196,6 +196,39 @@ export interface SampleRates { errorSampleRate: number; } +export interface ReplayNetworkOptions { + /** + * Capture request/response details for XHR/Fetch requests that match the given URLs. + * The URLs can be strings or regular expressions. + * When provided a string, we will match any URL that contains the given string. + * You can use a Regex to handle exact matches or more complex matching. + * + * Only URLs matching these patterns will have bodies & additional headers captured. + */ + networkDetailAllowUrls: (string | RegExp)[]; + + /** + * If request & response bodies should be captured. + * Only applies to URLs matched by `networkDetailAllowUrls`. + * Defaults to true. + */ + networkCaptureBodies: boolean; + + /** + * Capture the following request headers, in addition to the default ones. + * Only applies to URLs matched by `networkDetailAllowUrls`. + * Any headers defined here will be captured in addition to the default headers. + */ + networkRequestHeaders: string[]; + + /** + * Capture the following response headers, in addition to the default ones. + * Only applies to URLs matched by `networkDetailAllowUrls`. + * Any headers defined here will be captured in addition to the default headers. + */ + networkResponseHeaders: string[]; +} + /** * Session options that are configurable by the integration configuration */ @@ -207,7 +240,7 @@ export interface SessionOptions extends SampleRates { stickySession: boolean; } -export interface ReplayPluginOptions extends SessionOptions { +export interface ReplayPluginOptions extends SessionOptions, ReplayNetworkOptions { /** * The amount of time to wait before sending a replay */ @@ -242,33 +275,9 @@ export interface ReplayPluginOptions extends SessionOptions { traceInternals: boolean; mutationLimit: number; mutationBreadcrumbLimit: number; - captureNetworkBodies: boolean; - captureRequestHeaders: string[]; - captureResponseHeaders: string[]; }>; } -export interface ReplayNetworkOptions { - /** - * If request & response bodies should be captured. - */ - captureBodies: boolean; - - /** - * Capture the following request headers, in addition to the default ones. - */ - requestHeaders: string[]; - - /** - * Capture the following response headers, in addition to the default ones. - */ - responseHeaders: string[]; -} - -export interface ReplayExperimentalPluginOptions { - network: ReplayNetworkOptions; -} - export interface ReplayIntegrationPrivacyOptions { /** * Mask text content for elements that match the CSS selectors in the list. @@ -473,7 +482,6 @@ export interface ReplayContainer { triggerUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; - getExperimentalOptions(): ReplayExperimentalPluginOptions; getSessionId(): string | undefined; checkAndHandleExpiredSession(): boolean | void; setInitialState(): void; @@ -522,7 +530,7 @@ type JsonArray = unknown[]; export type NetworkBody = JsonObject | JsonArray | string; -export type NetworkMetaWarning = 'JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'INVALID_JSON'; +export type NetworkMetaWarning = 'JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'INVALID_JSON' | 'URL_SKIPPED'; interface NetworkMeta { warnings?: NetworkMetaWarning[]; diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index b47a849f868e..825c6e4eb452 100644 --- a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -62,9 +62,10 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { options = { textEncoder: new TextEncoder(), replay: setupReplayContainer(), - captureBodies: false, - requestHeaders: ['content-type', 'accept', 'x-custom-header'], - responseHeaders: ['content-type', 'accept', 'x-custom-header'], + networkDetailAllowUrls: ['https://example.com'], + networkCaptureBodies: false, + networkRequestHeaders: ['content-type', 'accept', 'x-custom-header'], + networkResponseHeaders: ['content-type', 'accept', 'x-custom-header'], }; jest.runAllTimers(); @@ -402,8 +403,78 @@ other-header: test`; ]); }); + it('does not add fetch request/response body if URL does not match', async () => { + options.networkCaptureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url: 'https://example2.com', + status_code: 200, + }, + }; + + const mockResponse = getMockResponse('13', 'test response'); + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: 'test input' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url: 'https://example2.com', + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 13, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'https://example2.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + it('adds fetch request/response body if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -469,7 +540,7 @@ other-header: test`; }); it('adds fetch request/response body as JSON if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -534,7 +605,7 @@ other-header: test`; }); it('skips fetch request/response body if configured & no body found', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -588,7 +659,7 @@ other-header: test`; }); it('truncates fetch text request/response body if configured & too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -659,7 +730,7 @@ other-header: test`; }); it('truncates fetch JSON request/response body if configured & too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const largeBody = JSON.stringify({ a: LARGE_BODY }); @@ -737,8 +808,82 @@ other-header: test`; ]); }); + it('does not add xhr request/response body if URL does not match', async () => { + options.networkCaptureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url: 'https://example2.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: 'test response', + }); + Object.defineProperty(xhr, 'responseText', { + value: 'test response', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: 'test input', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url: 'https://example2.com', + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 13, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'https://example2.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + it('adds xhr request/response body if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -808,7 +953,7 @@ other-header: test`; }); it('adds xhr JSON request/response body if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -878,7 +1023,7 @@ other-header: test`; }); it('skips xhr request/response body if configured & no body found', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -936,7 +1081,7 @@ other-header: test`; }); it('truncates text xhr request/response body if configured & body too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -1012,7 +1157,7 @@ other-header: test`; }); it('truncates JSON xhr request/response body if configured & body too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const largeBody = JSON.stringify({ a: LARGE_BODY }); @@ -1088,5 +1233,154 @@ other-header: test`; }, ]); }); + + describe.each([ + ['exact string match', 'https://example.com/foo'], + ['partial string match', 'https://example.com/bar/what'], + ['exact regex match', 'http://example.com/exact'], + ['partial regex match', 'http://example.com/partial/string'], + ])('matching URL %s', (_label, url) => { + it('correctly matches URL for fetch request', async () => { + options.networkDetailAllowUrls = [ + 'https://example.com/foo', + 'com/bar', + /^http:\/\/example.com\/exact$/, + /^http:\/\/example.com\/partial/, + ]; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url, + status_code: 200, + }, + }; + + const mockResponse = getMockResponse('13', 'test response'); + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: 'test input' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url, + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + }, + response: { + size: 13, + headers: {}, + }, + }, + description: url, + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('correctly matches URL for xhe request', async () => { + options.networkDetailAllowUrls = [ + 'https://example.com/foo', + 'com/bar', + /^http:\/\/example.com\/exact$/, + /^http:\/\/example.com\/partial/, + ]; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url, + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: 'test response', + }); + Object.defineProperty(xhr, 'responseText', { + value: 'test response', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: 'test input', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url, + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + }, + response: { + size: 13, + headers: {}, + }, + }, + description: url, + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + }); }); }); diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts index e6a427e19638..b8c9d71292bd 100644 --- a/packages/replay/test/utils/setupReplayContainer.ts +++ b/packages/replay/test/utils/setupReplayContainer.ts @@ -16,6 +16,10 @@ export function setupReplayContainer({ errorSampleRate: 1, useCompression: false, blockAllMedia: true, + networkDetailAllowUrls: [], + networkCaptureBodies: true, + networkRequestHeaders: [], + networkResponseHeaders: [], _experiments: {}, ...options, }, diff --git a/rollup/plugins/bundlePlugins.js b/rollup/plugins/bundlePlugins.js index f1e3b184cd11..c2b939e3db41 100644 --- a/rollup/plugins/bundlePlugins.js +++ b/rollup/plugins/bundlePlugins.js @@ -127,6 +127,8 @@ export function makeTerserPlugin() { '_cssText', // We want to keep the _integrations variable unmangled to send all installed integrations from replay '_integrations', + // _meta is used to store metadata of replay network events + '_meta', ], }, }, From 300b2208d7a7100dd9a59cbf582f46087a061754 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 26 Apr 2023 04:54:55 -0400 Subject: [PATCH 15/28] feat(replay): Change `stop()` to flush and remove current session (#7741) `stop()` will now flush the eventBuffer before clearing it, as well as removing the session from Session Storage. Due to the flushing, `stop()` is now async. Closes: https://github.com/getsentry/sentry-javascript/issues/7738 --- .../suites/replay/dsc/test.ts | 12 +++++- packages/replay/src/integration.ts | 10 ++--- packages/replay/src/replay.ts | 34 ++++++++++++++--- .../utils => src/session}/clearSession.ts | 5 ++- packages/replay/src/types.ts | 2 +- packages/replay/src/util/addEvent.ts | 2 +- .../test/integration/errorSampleRate.test.ts | 2 +- .../replay/test/integration/events.test.ts | 2 +- .../replay/test/integration/flush.test.ts | 2 +- .../test/integration/rateLimiting.test.ts | 5 +-- .../test/integration/sendReplayEvent.test.ts | 11 ++---- .../replay/test/integration/session.test.ts | 2 +- packages/replay/test/integration/stop.test.ts | 37 ++++++++++++++----- .../replay/test/utils/setupReplayContainer.ts | 2 +- 14 files changed, 86 insertions(+), 42 deletions(-) rename packages/replay/{test/utils => src/session}/clearSession.ts (76%) diff --git a/packages/browser-integration-tests/suites/replay/dsc/test.ts b/packages/browser-integration-tests/suites/replay/dsc/test.ts index 83e95d84b9d5..810305711a15 100644 --- a/packages/browser-integration-tests/suites/replay/dsc/test.ts +++ b/packages/browser-integration-tests/suites/replay/dsc/test.ts @@ -54,11 +54,19 @@ sentryTest( sentryTest.skip(); } + 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 getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await page.evaluate(() => { - (window as unknown as TestWindow).Replay.stop(); + await page.evaluate(async () => { + await (window as unknown as TestWindow).Replay.stop(); (window as unknown as TestWindow).Sentry.configureScope(scope => { scope.setUser({ id: 'user123', segment: 'segmentB' }); diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index dfc1eeef7a06..5103d4e4af8e 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -219,12 +219,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown */ - public stop(): void { + public stop(): Promise { if (!this._replay) { - return; + return Promise.resolve(); } - this._replay.stop(); + return this._replay.stop(); } /** @@ -234,9 +234,9 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, * Unless `continueRecording` is false, the replay will continue to record and * behave as a "session"-based replay. */ - public flush(options?: SendBufferedReplayOptions): Promise | void { + public flush(options?: SendBufferedReplayOptions): Promise { if (!this._replay || !this._replay.isEnabled()) { - return; + return Promise.resolve(); } return this._replay.sendBufferedReplayOrFlush(options); diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index f8b890462d84..32109ed2c333 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -7,6 +7,7 @@ import { logger } from '@sentry/utils'; import { ERROR_CHECKOUT_TIME, MAX_SESSION_LIFE, SESSION_IDLE_DURATION, WINDOW } from './constants'; import { setupPerformanceObserver } from './coreHandlers/performanceObserver'; import { createEventBuffer } from './eventBuffer'; +import { clearSession } from './session/clearSession'; import { getSession } from './session/getSession'; import { saveSession } from './session/saveSession'; import type { @@ -239,7 +240,7 @@ export class ReplayContainer implements ReplayContainerInterface { * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown */ - public stop(reason?: string): void { + public async stop(reason?: string): Promise { if (!this._isEnabled) { return; } @@ -255,12 +256,24 @@ export class ReplayContainer implements ReplayContainerInterface { log(msg); } + // We can't move `_isEnabled` after awaiting a flush, otherwise we can + // enter into an infinite loop when `stop()` is called while flushing. this._isEnabled = false; this._removeListeners(); this.stopRecording(); + + this._debouncedFlush.cancel(); + // See comment above re: `_isEnabled`, we "force" a flush, ignoring the + // `_isEnabled` state of the plugin since it was disabled above. + await this._flush({ force: true }); + + // After flush, destroy event buffer this.eventBuffer && this.eventBuffer.destroy(); this.eventBuffer = null; - this._debouncedFlush.cancel(); + + // Clear session from session storage, note this means if a new session + // is started after, it will not have `previousSessionId` + clearSession(this); } catch (err) { this._handleException(err); } @@ -500,7 +513,7 @@ export class ReplayContainer implements ReplayContainerInterface { this.session = session; if (!this.session.sampled) { - this.stop('session unsampled'); + void this.stop('session unsampled'); return false; } @@ -793,7 +806,7 @@ export class ReplayContainer implements ReplayContainerInterface { // This means we retried 3 times and all of them failed, // or we ran into a problem we don't want to retry, like rate limiting. // In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments - this.stop('sendReplay'); + void this.stop('sendReplay'); const client = getCurrentHub().getClient(); @@ -807,8 +820,17 @@ export class ReplayContainer implements ReplayContainerInterface { * Flush recording data to Sentry. Creates a lock so that only a single flush * can be active at a time. Do not call this directly. */ - private _flush: () => Promise = async () => { - if (!this._isEnabled) { + private _flush = async ({ + force = false, + }: { + /** + * If true, flush while ignoring the `_isEnabled` state of + * Replay integration. (By default, flush is noop if integration + * is stopped). + */ + force?: boolean; + } = {}): Promise => { + if (!this._isEnabled && !force) { // This can happen if e.g. the replay was stopped because of exceeding the retry limit return; } diff --git a/packages/replay/test/utils/clearSession.ts b/packages/replay/src/session/clearSession.ts similarity index 76% rename from packages/replay/test/utils/clearSession.ts rename to packages/replay/src/session/clearSession.ts index b5b64ac04531..d084764c2fb9 100644 --- a/packages/replay/test/utils/clearSession.ts +++ b/packages/replay/src/session/clearSession.ts @@ -1,7 +1,10 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/types'; -export function clearSession(replay: ReplayContainer) { +/** + * Removes the session from Session Storage and unsets session in replay instance + */ +export function clearSession(replay: ReplayContainer): void { deleteSession(); replay.session = undefined; } diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index e2f4b6d1b71f..3577ccb37338 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -472,7 +472,7 @@ export interface ReplayContainer { isPaused(): boolean; getContext(): InternalEventContext; start(): void; - stop(reason?: string): void; + stop(reason?: string): Promise; pause(): void; resume(): void; startRecording(): void; diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index b32050665519..17e933557d83 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -46,7 +46,7 @@ export async function addEvent( return await replay.eventBuffer.addEvent(event, isCheckout); } catch (error) { __DEBUG_BUILD__ && logger.error(error); - replay.stop('addEvent'); + await replay.stop('addEvent'); const client = getCurrentHub().getClient(); diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 0b5baa8e2512..1dd127b6e0a1 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -9,13 +9,13 @@ import { WINDOW, } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import { addEvent } from '../../src/util/addEvent'; 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 { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); diff --git a/packages/replay/test/integration/events.test.ts b/packages/replay/test/integration/events.test.ts index 57c49ba1e245..4fffc5fadbfa 100644 --- a/packages/replay/test/integration/events.test.ts +++ b/packages/replay/test/integration/events.test.ts @@ -2,12 +2,12 @@ import { getCurrentHub } from '@sentry/core'; import { WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import { addEvent } from '../../src/util/addEvent'; import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'; import type { RecordMock } from '../index'; import { BASE_TIMESTAMP } from '../index'; import { resetSdkMock } from '../mocks/resetSdkMock'; -import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index a97d2b3c878c..18c7a86ca188 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -2,13 +2,13 @@ import * as SentryUtils from '@sentry/utils'; import { DEFAULT_FLUSH_MIN_DELAY, WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import type { EventBuffer } from '../../src/types'; import * as AddMemoryEntry from '../../src/util/addMemoryEntry'; import { createPerformanceEntries } from '../../src/util/createPerformanceEntries'; import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; import * as SendReplay from '../../src/util/sendReplay'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; -import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); diff --git a/packages/replay/test/integration/rateLimiting.test.ts b/packages/replay/test/integration/rateLimiting.test.ts index fcd170b31784..723dc682d100 100644 --- a/packages/replay/test/integration/rateLimiting.test.ts +++ b/packages/replay/test/integration/rateLimiting.test.ts @@ -3,10 +3,10 @@ import type { Transport } from '@sentry/types'; import { DEFAULT_FLUSH_MIN_DELAY } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import * as SendReplayRequest from '../../src/util/sendReplayRequest'; import { BASE_TIMESTAMP, mockSdk } from '../index'; import { mockRrweb } from '../mocks/mockRrweb'; -import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); @@ -86,8 +86,7 @@ describe('Integration | rate-limiting behaviour', () => { expect(replay.stop).toHaveBeenCalledTimes(1); // No user activity to trigger an update - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - expect(replay.session?.segmentId).toBe(1); + expect(replay.session).toBe(undefined); // let's simulate the default rate-limit time of inactivity (60secs) and check that we // don't do anything in the meantime or after the time has passed diff --git a/packages/replay/test/integration/sendReplayEvent.test.ts b/packages/replay/test/integration/sendReplayEvent.test.ts index 867499890bb7..d7a9974bcaa9 100644 --- a/packages/replay/test/integration/sendReplayEvent.test.ts +++ b/packages/replay/test/integration/sendReplayEvent.test.ts @@ -4,10 +4,10 @@ import * as SentryUtils from '@sentry/utils'; import { DEFAULT_FLUSH_MIN_DELAY, WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import { addEvent } from '../../src/util/addEvent'; import * as SendReplayRequest from '../../src/util/sendReplayRequest'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; -import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); @@ -396,13 +396,8 @@ describe('Integration | sendReplayEvent', () => { 'Something bad happened', ); - // No activity has occurred, session's last activity should remain the same - expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP); - - // segmentId increases despite error - expect(replay.session?.segmentId).toBe(1); - - // Replay should be completely stopped now + // Replay has stopped, no session should exist + expect(replay.session).toBe(undefined); expect(replay.isEnabled()).toBe(false); // Events are ignored now, because we stopped diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index 4720e7b65bc6..0dede22edfca 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -9,13 +9,13 @@ import { WINDOW, } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import type { Session } from '../../src/types'; import { addEvent } from '../../src/util/addEvent'; import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; import { BASE_TIMESTAMP } from '../index'; import type { RecordMock } from '../mocks/mockRrweb'; import { resetSdkMock } from '../mocks/resetSdkMock'; -import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); diff --git a/packages/replay/test/integration/stop.test.ts b/packages/replay/test/integration/stop.test.ts index 05772ecab6c7..a477ee8e044f 100644 --- a/packages/replay/test/integration/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -3,14 +3,16 @@ import * as SentryUtils from '@sentry/utils'; import type { Replay } from '../../src'; import { WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import { addEvent } from '../../src/util/addEvent'; // mock functions need to be imported first import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; -import { clearSession } from '../utils/clearSession'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); +type MockRunFlush = jest.MockedFunction; + describe('Integration | stop', () => { let replay: ReplayContainer; let integration: Replay; @@ -20,6 +22,7 @@ describe('Integration | stop', () => { const { record: mockRecord } = mockRrweb(); let mockAddInstrumentationHandler: MockAddInstrumentationHandler; + let mockRunFlush: MockRunFlush; beforeAll(async () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); @@ -29,6 +32,10 @@ describe('Integration | stop', () => { ) as MockAddInstrumentationHandler; ({ replay, integration } = await mockSdk()); + + // @ts-ignore private API + mockRunFlush = jest.spyOn(replay, '_runFlush'); + jest.runAllTimers(); }); @@ -68,9 +75,10 @@ describe('Integration | stop', () => { // Not sure where the 20ms comes from tbh const EXTRA_TICKS = 20; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + const previousSessionId = replay.session?.id; // stop replays - integration.stop(); + await integration.stop(); // Pretend 5 seconds have passed jest.advanceTimersByTime(ELAPSED); @@ -80,14 +88,17 @@ describe('Integration | stop', () => { await new Promise(process.nextTick); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(replay).not.toHaveLastSentReplay(); - // Session's last activity should not be updated - expect(replay.session?.lastActivity).toEqual(BASE_TIMESTAMP); + // Session's does not exist + expect(replay.session).toEqual(undefined); // eventBuffer is destroyed expect(replay.eventBuffer).toBe(null); // re-enable replay integration.start(); + // will be different session + expect(replay.session?.id).not.toEqual(previousSessionId); + jest.advanceTimersByTime(ELAPSED); const timestamp = +new Date(BASE_TIMESTAMP + ELAPSED + ELAPSED + EXTRA_TICKS) / 1000; @@ -126,12 +137,16 @@ describe('Integration | stop', () => { expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED + 20); }); - it('does not buffer events when stopped', async function () { - WINDOW.dispatchEvent(new Event('blur')); + it('does not buffer new events after being stopped', async function () { + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; + addEvent(replay, TEST_EVENT); expect(replay.eventBuffer?.hasEvents).toBe(true); + expect(mockRunFlush).toHaveBeenCalledTimes(0); // stop replays - integration.stop(); + await integration.stop(); + + expect(mockRunFlush).toHaveBeenCalledTimes(1); expect(replay.eventBuffer).toBe(null); @@ -139,14 +154,16 @@ describe('Integration | stop', () => { await new Promise(process.nextTick); expect(replay.eventBuffer).toBe(null); - expect(replay).not.toHaveLastSentReplay(); + expect(replay).toHaveLastSentReplay({ + recordingData: JSON.stringify([TEST_EVENT]), + }); }); it('does not call core SDK `addInstrumentationHandler` after initial setup', async function () { // NOTE: We clear addInstrumentationHandler mock after every test - integration.stop(); + await integration.stop(); integration.start(); - integration.stop(); + await integration.stop(); integration.start(); expect(mockAddInstrumentationHandler).not.toHaveBeenCalled(); diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts index b8c9d71292bd..cf4812da5f2f 100644 --- a/packages/replay/test/utils/setupReplayContainer.ts +++ b/packages/replay/test/utils/setupReplayContainer.ts @@ -1,7 +1,7 @@ import { createEventBuffer } from '../../src/eventBuffer'; import { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; import type { RecordingOptions, ReplayPluginOptions } from '../../src/types'; -import { clearSession } from './clearSession'; export function setupReplayContainer({ options, From 7d75fac1192c3903cbbc42184608197484b593c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Apr 2023 20:26:45 +0000 Subject: [PATCH 16/28] build(deps-dev): Bump yaml from 2.1.1 to 2.2.2 Bumps [yaml](https://github.com/eemeli/yaml) from 2.1.1 to 2.2.2. - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.1.1...v2.2.2) --- updated-dependencies: - dependency-name: yaml dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- packages/e2e-tests/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index 00f84f345ede..c84ce001d1ec 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -27,7 +27,7 @@ "glob": "8.0.3", "ts-node": "10.9.1", "typescript": "3.8.3", - "yaml": "2.1.1" + "yaml": "2.2.2" }, "volta": { "extends": "../../package.json" diff --git a/yarn.lock b/yarn.lock index a20a58bc37d9..e3fadfbb2260 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28015,10 +28015,10 @@ yam@^1.0.0: fs-extra "^4.0.2" lodash.merge "^4.6.0" -yaml@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.1.1.tgz#1e06fb4ca46e60d9da07e4f786ea370ed3c3cfec" - integrity sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw== +yaml@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.2.tgz#ec551ef37326e6d42872dad1970300f8eb83a073" + integrity sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA== yaml@^1.10.0, yaml@^1.10.2: version "1.10.2" From 6de02c07ad5fb60e7bee270703877f635e1b43b2 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 11:46:44 +0200 Subject: [PATCH 17/28] ci: Fix issue state check (#7962) Not sure where I got the `closed` stuff from, but this seems to be actually correct based on https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#issues. --- .github/workflows/label-last-commenter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/label-last-commenter.yml b/.github/workflows/label-last-commenter.yml index 7e7629e95bc5..6b8161542c97 100644 --- a/.github/workflows/label-last-commenter.yml +++ b/.github/workflows/label-last-commenter.yml @@ -16,7 +16,7 @@ jobs: github.event.comment.author_association != 'COLLABORATOR' && github.event.comment.author_association != 'MEMBER' && github.event.comment.author_association != 'OWNER' - && !github.event.issue.closed + && github.event.issue.state == 'open' uses: actions-ecosystem/action-add-labels@v1 with: labels: 'Waiting for: Team' From 82cebd3a2f39ac3fc99bd2d8f9d67fb49d8474c1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 11:55:56 +0200 Subject: [PATCH 18/28] ref: Fix comment for normalize (#7954) --- packages/utils/src/normalize.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/utils/src/normalize.ts b/packages/utils/src/normalize.ts index 4b2dd611f8e2..15095c29775e 100644 --- a/packages/utils/src/normalize.ts +++ b/packages/utils/src/normalize.ts @@ -100,17 +100,16 @@ function visit( return value as ObjOrArray; } - // Do not normalize objects that we know have already been normalized. As a general rule, the - // "__sentry_skip_normalization__" property should only be used sparingly and only should only be set on objects that - // have already been normalized. - let overriddenDepth = depth; - - if (typeof (value as ObjOrArray)['__sentry_override_normalization_depth__'] === 'number') { - overriddenDepth = (value as ObjOrArray)['__sentry_override_normalization_depth__'] as number; - } + // We can set `__sentry_override_normalization_depth__` on an object to ensure that from there + // We keep a certain amount of depth. + // This should be used sparingly, e.g. we use it for the redux integration to ensure we get a certain amount of state. + const remainingDepth = + typeof (value as ObjOrArray)['__sentry_override_normalization_depth__'] === 'number' + ? ((value as ObjOrArray)['__sentry_override_normalization_depth__'] as number) + : depth; // We're also done if we've reached the max depth - if (overriddenDepth === 0) { + if (remainingDepth === 0) { // At this point we know `serialized` is a string of the form `"[object XXXX]"`. Clean it up so it's just `"[XXXX]"`. return stringified.replace('object ', ''); } @@ -126,7 +125,7 @@ function visit( try { const jsonValue = valueWithToJSON.toJSON(); // We need to normalize the return value of `.toJSON()` in case it has circular references - return visit('', jsonValue, overriddenDepth - 1, maxProperties, memo); + return visit('', jsonValue, remainingDepth - 1, maxProperties, memo); } catch (err) { // pass (The built-in `toJSON` failed, but we can still try to do it ourselves) } @@ -155,7 +154,7 @@ function visit( // Recursively visit all the child nodes const visitValue = visitable[visitKey]; - normalized[visitKey] = visit(visitKey, visitValue, overriddenDepth - 1, maxProperties, memo); + normalized[visitKey] = visit(visitKey, visitValue, remainingDepth - 1, maxProperties, memo); numAdded++; } From f649b247ad2cbd85787b73dcf772a2d714f6a6bd Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 12:05:35 +0200 Subject: [PATCH 19/28] fix(utils): `normalize()` to a max. of 100 levels deep by default (#7957) This is to avoid infinite recursion that we cannot detect, e.g. when using dynamic proxies. --- packages/utils/src/normalize.ts | 2 +- packages/utils/test/normalize.test.ts | 59 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/utils/src/normalize.ts b/packages/utils/src/normalize.ts index 15095c29775e..6bc4afadbf28 100644 --- a/packages/utils/src/normalize.ts +++ b/packages/utils/src/normalize.ts @@ -33,7 +33,7 @@ type ObjOrArray = { [key: string]: T }; * @returns A normalized version of the object, or `"**non-serializable**"` if any errors are thrown during normalization. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function normalize(input: unknown, depth: number = +Infinity, maxProperties: number = +Infinity): any { +export function normalize(input: unknown, depth: number = 100, maxProperties: number = +Infinity): any { try { // since we're at the outermost level, we don't provide a key return visit('', input, depth, maxProperties); diff --git a/packages/utils/test/normalize.test.ts b/packages/utils/test/normalize.test.ts index 008bde5dfebe..76579d29e048 100644 --- a/packages/utils/test/normalize.test.ts +++ b/packages/utils/test/normalize.test.ts @@ -86,6 +86,65 @@ describe('normalize()', () => { expect(normalize(obj)).toEqual({ name: 'Alice', identity: { self: '[Circular ~]' } }); }); + test('circular objects with proxy', () => { + const obj1 = { name: 'Alice', child: null } as any; + const obj2 = { name: 'John', child: null } as any; + + function getObj1(target: any, prop: string | number | symbol): any { + return prop === 'child' + ? new Proxy(obj2, { + get(t, p) { + return getObj2(t, p); + }, + }) + : target[prop]; + } + + function getObj2(target: any, prop: string | number | symbol): any { + return prop === 'child' + ? new Proxy(obj1, { + get(t, p) { + return getObj1(t, p); + }, + }) + : target[prop]; + } + + const proxy1 = new Proxy(obj1, { + get(target, prop) { + return getObj1(target, prop); + }, + }); + + const actual = normalize(proxy1); + + // This generates 100 nested objects, as we cannot identify the circular reference since they are dynamic proxies + // However, this test verifies that we can normalize at all, and do not fail out + expect(actual).toEqual({ + name: 'Alice', + child: { name: 'John', child: expect.objectContaining({ name: 'Alice', child: expect.any(Object) }) }, + }); + + let last = actual; + for (let i = 0; i < 99; i++) { + expect(last).toEqual( + expect.objectContaining({ + name: expect.any(String), + child: expect.any(Object), + }), + ); + last = last.child; + } + + // Last one is transformed to [Object] + expect(last).toEqual( + expect.objectContaining({ + name: expect.any(String), + child: '[Object]', + }), + ); + }); + test('deep circular objects', () => { const obj = { name: 'Alice', child: { name: 'Bob' } } as any; obj.child.self = obj.child; From bce8c363e591ed8cc141ca233766bcf09a57f2c9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 12:07:15 +0200 Subject: [PATCH 20/28] feat(replay): Extend session idle time until expire to 15min (#7955) Now, a session will only expire & trigger a new session if no user activity happened for 15min. After 5min of inactivity, we will pause recording events. If the user resumes in the next 10 minutes, we'll resume the session, else re-create it if they resume later. --- .../suites/replay/sessionExpiry/init.js | 3 +- .../suites/replay/sessionInactive/init.js | 5 +- .../suites/replay/sessionInactive/test.ts | 17 ++- .../suites/replay/sessionMaxAge/init.js | 3 +- packages/replay/src/constants.ts | 9 +- packages/replay/src/replay.ts | 17 ++- packages/replay/src/types.ts | 8 +- packages/replay/src/util/addEvent.ts | 2 +- packages/replay/src/util/isSessionExpired.ts | 4 +- .../test/integration/errorSampleRate.test.ts | 18 ++-- .../replay/test/integration/session.test.ts | 101 ++++++++++++++++-- .../test/unit/session/getSession.test.ts | 28 +++-- .../test/unit/util/isSessionExpired.test.ts | 40 ++++++- 13 files changed, 193 insertions(+), 62 deletions(-) diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/init.js b/packages/browser-integration-tests/suites/replay/sessionExpiry/init.js index 3e685021e1fe..46af904118a6 100644 --- a/packages/browser-integration-tests/suites/replay/sessionExpiry/init.js +++ b/packages/browser-integration-tests/suites/replay/sessionExpiry/init.js @@ -17,6 +17,7 @@ Sentry.init({ }); window.Replay._replay.timeouts = { - sessionIdle: 2000, // this is usually 5min, but we want to test this with shorter times + sessionIdlePause: 1000, // this is usually 5min, but we want to test this with shorter times + sessionIdleExpire: 2000, // this is usually 15min, but we want to test this with shorter times maxSessionLife: 3600000, // default: 60min }; diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/init.js b/packages/browser-integration-tests/suites/replay/sessionInactive/init.js index a3da64ec3bae..4c641d160d79 100644 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/init.js +++ b/packages/browser-integration-tests/suites/replay/sessionInactive/init.js @@ -17,6 +17,7 @@ Sentry.init({ }); window.Replay._replay.timeouts = { - sessionIdle: 1000, // default: 5min - maxSessionLife: 2000, // this is usually 60min, but we want to test this with shorter times + sessionIdlePause: 1000, // this is usually 5min, but we want to test this with shorter times + sessionIdleExpire: 900000, // defayult: 15min + maxSessionLife: 3600000, // default: 60min }; diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts b/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts index ed53f155feea..ef2b841cbea3 100644 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts +++ b/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts @@ -11,8 +11,8 @@ import { waitForReplayRequest, } from '../../../utils/replayHelpers'; -// Session should expire after 2s - keep in sync with init.js -const SESSION_TIMEOUT = 2000; +// Session should be paused after 2s - keep in sync with init.js +const SESSION_PAUSED = 2000; sentryTest('handles an inactive session', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -44,11 +44,8 @@ sentryTest('handles an inactive session', async ({ getLocalTestPath, page }) => await page.click('#button1'); - // We wait for another segment 0 - const reqPromise1 = waitForReplayRequest(page, 0); - // Now we wait for the session timeout, nothing should be sent in the meanwhile - await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT)); + await new Promise(resolve => setTimeout(resolve, SESSION_PAUSED)); // nothing happened because no activity/inactivity was detected const replay = await getReplaySnapshot(page); @@ -64,7 +61,10 @@ sentryTest('handles an inactive session', async ({ getLocalTestPath, page }) => expect(replay2._isEnabled).toEqual(true); expect(replay2._isPaused).toEqual(true); - // Trigger an action, should re-start the recording + // We wait for next segment to be sent once we resume the session + const reqPromise1 = waitForReplayRequest(page); + + // Trigger an action, should resume the recording await page.click('#button2'); const req1 = await reqPromise1; @@ -72,9 +72,6 @@ sentryTest('handles an inactive session', async ({ getLocalTestPath, page }) => expect(replay3._isEnabled).toEqual(true); expect(replay3._isPaused).toEqual(false); - const replayEvent1 = getReplayEvent(req1); - expect(replayEvent1).toEqual(getExpectedReplayEvent({})); - const fullSnapshots1 = getFullRecordingSnapshots(req1); expect(fullSnapshots1.length).toEqual(1); const stringifiedSnapshot1 = normalize(fullSnapshots1[0]); diff --git a/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js b/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js index cf98205a5576..0c16dc6ca3a1 100644 --- a/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js +++ b/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js @@ -17,6 +17,7 @@ Sentry.init({ }); window.Replay._replay.timeouts = { - sessionIdle: 300000, // default: 5min + sessionIdlePause: 300000, // default: 5min + sessionIdleExpire: 900000, // default: 15min maxSessionLife: 4000, // this is usually 60min, but we want to test this with shorter times }; diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index e7b5fe2f1b52..f1f8627c120c 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -11,11 +11,14 @@ export const REPLAY_EVENT_NAME = 'replay_event'; export const RECORDING_EVENT_NAME = 'replay_recording'; export const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay'; -// The idle limit for a session -export const SESSION_IDLE_DURATION = 300_000; // 5 minutes in ms +// The idle limit for a session after which recording is paused. +export const SESSION_IDLE_PAUSE_DURATION = 300_000; // 5 minutes in ms + +// The idle limit for a session after which the session expires. +export const SESSION_IDLE_EXPIRE_DURATION = 900_000; // 15 minutes in ms // The maximum length of a session -export const MAX_SESSION_LIFE = 3_600_000; // 60 minutes +export const MAX_SESSION_LIFE = 3_600_000; // 60 minutes in ms /** Default flush delays */ export const DEFAULT_FLUSH_MIN_DELAY = 5_000; diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 32109ed2c333..241a711f20cd 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -4,7 +4,13 @@ import { captureException, getCurrentHub } from '@sentry/core'; import type { Breadcrumb, ReplayRecordingMode } from '@sentry/types'; import { logger } from '@sentry/utils'; -import { ERROR_CHECKOUT_TIME, MAX_SESSION_LIFE, SESSION_IDLE_DURATION, WINDOW } from './constants'; +import { + ERROR_CHECKOUT_TIME, + MAX_SESSION_LIFE, + SESSION_IDLE_EXPIRE_DURATION, + SESSION_IDLE_PAUSE_DURATION, + WINDOW, +} from './constants'; import { setupPerformanceObserver } from './coreHandlers/performanceObserver'; import { createEventBuffer } from './eventBuffer'; import { clearSession } from './session/clearSession'; @@ -61,7 +67,8 @@ export class ReplayContainer implements ReplayContainerInterface { * @hidden */ public readonly timeouts: Timeouts = { - sessionIdle: SESSION_IDLE_DURATION, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, maxSessionLife: MAX_SESSION_LIFE, } as const; @@ -422,12 +429,12 @@ export class ReplayContainer implements ReplayContainerInterface { const oldSessionId = this.getSessionId(); // Prevent starting a new session if the last user activity is older than - // SESSION_IDLE_DURATION. Otherwise non-user activity can trigger a new + // SESSION_IDLE_PAUSE_DURATION. Otherwise non-user activity can trigger a new // session+recording. This creates noisy replays that do not have much // content in them. if ( this._lastActivity && - isExpired(this._lastActivity, this.timeouts.sessionIdle) && + isExpired(this._lastActivity, this.timeouts.sessionIdlePause) && this.session && this.session.sampled === 'session' ) { @@ -637,7 +644,7 @@ export class ReplayContainer implements ReplayContainerInterface { const isSessionActive = this.checkAndHandleExpiredSession(); if (!isSessionActive) { - // If the user has come back to the page within SESSION_IDLE_DURATION + // If the user has come back to the page within SESSION_IDLE_PAUSE_DURATION // ms, we will re-use the existing session, otherwise create a new // session __DEBUG_BUILD__ && logger.log('[Replay] Document has become active, but session has expired'); diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 3577ccb37338..a443cede2e58 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -25,7 +25,8 @@ export interface SendReplayData { } export interface Timeouts { - sessionIdle: number; + sessionIdlePause: number; + sessionIdleExpire: number; maxSessionLife: number; } @@ -464,10 +465,7 @@ export interface ReplayContainer { performanceEvents: AllPerformanceEntry[]; session: Session | undefined; recordingMode: ReplayRecordingMode; - timeouts: { - sessionIdle: number; - maxSessionLife: number; - }; + timeouts: Timeouts; isEnabled(): boolean; isPaused(): boolean; getContext(): InternalEventContext; diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index 17e933557d83..ef2e466a071f 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -31,7 +31,7 @@ export async function addEvent( // page has been left open and idle for a long period of time and user // comes back to trigger a new session. The performance entries rely on // `performance.timeOrigin`, which is when the page first opened. - if (timestampInMs + replay.timeouts.sessionIdle < Date.now()) { + if (timestampInMs + replay.timeouts.sessionIdlePause < Date.now()) { return null; } diff --git a/packages/replay/src/util/isSessionExpired.ts b/packages/replay/src/util/isSessionExpired.ts index b7025a19cbf6..a51104fd5a47 100644 --- a/packages/replay/src/util/isSessionExpired.ts +++ b/packages/replay/src/util/isSessionExpired.ts @@ -9,7 +9,7 @@ export function isSessionExpired(session: Session, timeouts: Timeouts, targetTim // First, check that maximum session length has not been exceeded isExpired(session.started, timeouts.maxSessionLife, targetTime) || // check that the idle timeout has not been exceeded (i.e. user has - // performed an action within the last `idleTimeout` ms) - isExpired(session.lastActivity, timeouts.sessionIdle, targetTime) + // performed an action within the last `sessionIdleExpire` ms) + isExpired(session.lastActivity, timeouts.sessionIdleExpire, targetTime) ); } diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 1dd127b6e0a1..b9da6b081bbc 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -5,7 +5,7 @@ import { ERROR_CHECKOUT_TIME, MAX_SESSION_LIFE, REPLAY_SESSION_KEY, - SESSION_IDLE_DURATION, + SESSION_IDLE_EXPIRE_DURATION, WINDOW, } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; @@ -252,7 +252,7 @@ describe('Integration | errorSampleRate', () => { }); }); - it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_DURATION]ms', async () => { + 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 () { @@ -260,7 +260,7 @@ describe('Integration | errorSampleRate', () => { }, }); - jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1); + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); document.dispatchEvent(new Event('visibilitychange')); @@ -284,8 +284,8 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); - // User comes back before `SESSION_IDLE_DURATION` elapses - jest.advanceTimersByTime(SESSION_IDLE_DURATION - 100); + // User comes back before `SESSION_IDLE_EXPIRE_DURATION` elapses + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 100); Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { @@ -403,9 +403,9 @@ describe('Integration | errorSampleRate', () => { }); // Should behave the same as above test - it('stops replay if user has been idle for more than SESSION_IDLE_DURATION and does not start a new session thereafter', async () => { + 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_DURATION + 1); + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); const TEST_EVENT = { data: { name: 'lost event' }, @@ -418,7 +418,7 @@ describe('Integration | errorSampleRate', () => { jest.runAllTimers(); await new Promise(process.nextTick); - // We stop recording after SESSION_IDLE_DURATION of inactivity in error mode + // We stop recording after SESSION_IDLE_EXPIRE_DURATION of inactivity in error mode expect(replay).not.toHaveLastSentReplay(); expect(replay.isEnabled()).toBe(false); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); @@ -544,7 +544,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); // Go idle - jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1); + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); await new Promise(process.nextTick); mockRecord._emitter(TEST_EVENT); diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index 0dede22edfca..1338566be9aa 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -5,7 +5,8 @@ import { DEFAULT_FLUSH_MIN_DELAY, MAX_SESSION_LIFE, REPLAY_SESSION_KEY, - SESSION_IDLE_DURATION, + SESSION_IDLE_EXPIRE_DURATION, + SESSION_IDLE_PAUSE_DURATION, WINDOW, } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; @@ -58,7 +59,7 @@ describe('Integration | session', () => { // Require a "user interaction" to start a new session, visibility is not enough. This can be noisy // (e.g. rapidly switching tabs/window focus) and leads to empty sessions. - it('does not create a new session when document becomes visible after [SESSION_IDLE_DURATION]ms', () => { + it('does not create a new session when document becomes visible after [SESSION_IDLE_EXPIRE_DURATION]ms', () => { Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { @@ -68,7 +69,7 @@ describe('Integration | session', () => { const initialSession = { ...replay.session } as Session; - jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1); + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); document.dispatchEvent(new Event('visibilitychange')); @@ -76,10 +77,10 @@ describe('Integration | session', () => { expect(replay).toHaveSameSession(initialSession); }); - it('does not create a new session when document becomes focused after [SESSION_IDLE_DURATION]ms', () => { + it('does not create a new session when document becomes focused after [SESSION_IDLE_EXPIRE_DURATION]ms', () => { const initialSession = { ...replay.session } as Session; - jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1); + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); WINDOW.dispatchEvent(new Event('focus')); @@ -87,7 +88,7 @@ describe('Integration | session', () => { expect(replay).toHaveSameSession(initialSession); }); - it('does not create a new session if user hides the tab and comes back within [SESSION_IDLE_DURATION] seconds', () => { + it('does not create a new session if user hides the tab and comes back within [SESSION_IDLE_EXPIRE_DURATION] seconds', () => { const initialSession = { ...replay.session } as Session; Object.defineProperty(document, 'visibilityState', { @@ -100,8 +101,8 @@ describe('Integration | session', () => { expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(replay).toHaveSameSession(initialSession); - // User comes back before `SESSION_IDLE_DURATION` elapses - jest.advanceTimersByTime(SESSION_IDLE_DURATION - 1); + // User comes back before `SESSION_IDLE_EXPIRE_DURATION` elapses + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 1); Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { @@ -115,7 +116,7 @@ describe('Integration | session', () => { expect(replay).toHaveSameSession(initialSession); }); - it('creates a new session if user has been idle for more than SESSION_IDLE_DURATION and comes back to click their mouse', async () => { + it('creates a new session if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and comes back to click their mouse', async () => { const initialSession = { ...replay.session } as Session; expect(initialSession?.id).toBeDefined(); @@ -131,7 +132,7 @@ describe('Integration | session', () => { value: new URL(url), }); - const ELAPSED = SESSION_IDLE_DURATION + 1; + const ELAPSED = SESSION_IDLE_EXPIRE_DURATION + 1; jest.advanceTimersByTime(ELAPSED); // Session has become in an idle state @@ -230,6 +231,86 @@ describe('Integration | session', () => { }); }); + it('pauses and resumes a session if user has been idle for more than SESSION_IDLE_PASUE_DURATION and comes back to click their mouse', async () => { + const initialSession = { ...replay.session } as Session; + + expect(initialSession?.id).toBeDefined(); + expect(replay.getContext()).toEqual( + expect.objectContaining({ + initialUrl: 'http://localhost/', + initialTimestamp: BASE_TIMESTAMP, + }), + ); + + const url = 'http://dummy/'; + Object.defineProperty(WINDOW, 'location', { + value: new URL(url), + }); + + const ELAPSED = SESSION_IDLE_PAUSE_DURATION + 1; + jest.advanceTimersByTime(ELAPSED); + + // Session has become in an idle state + // + // This event will put the Replay SDK into a paused state + const TEST_EVENT = { + data: { name: 'lost event' }, + timestamp: BASE_TIMESTAMP, + type: 3, + }; + mockRecord._emitter(TEST_EVENT); + + // performance events can still be collected while recording is stopped + // TODO: we may want to prevent `addEvent` from adding to buffer when user is inactive + replay.addUpdate(() => { + createPerformanceSpans(replay, [ + { + type: 'navigation.navigate' as const, + name: 'foo', + start: BASE_TIMESTAMP + ELAPSED, + end: BASE_TIMESTAMP + ELAPSED + 100, + data: { + decodedBodySize: 1, + encodedBodySize: 2, + duration: 0, + domInteractive: 0, + domContentLoadedEventEnd: 0, + domContentLoadedEventStart: 0, + loadEventStart: 0, + loadEventEnd: 0, + domComplete: 0, + redirectCount: 0, + size: 0, + }, + }, + ]); + return true; + }); + + await new Promise(process.nextTick); + + expect(replay).not.toHaveLastSentReplay(); + expect(replay.isPaused()).toBe(true); + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).toHaveSameSession(initialSession); + expect(mockRecord).toHaveBeenCalledTimes(1); + + // Now do a click which will create a new session and start recording again + domHandler({ + name: 'click', + }); + + // Should be same session + expect(replay).toHaveSameSession(initialSession); + + // Replay does not send immediately + expect(replay).not.toHaveLastSentReplay(); + + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + expect(replay).toHaveLastSentReplay(); + }); + it('should have a session after setup', () => { expect(replay.session).toMatchObject({ lastActivity: BASE_TIMESTAMP, diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts index 2e7d3ec969b7..be01f0602e51 100644 --- a/packages/replay/test/unit/session/getSession.test.ts +++ b/packages/replay/test/unit/session/getSession.test.ts @@ -1,4 +1,9 @@ -import { MAX_SESSION_LIFE, SESSION_IDLE_DURATION, WINDOW } from '../../../src/constants'; +import { + MAX_SESSION_LIFE, + SESSION_IDLE_EXPIRE_DURATION, + SESSION_IDLE_PAUSE_DURATION, + WINDOW, +} from '../../../src/constants'; import * as CreateSession from '../../../src/session/createSession'; import * as FetchSession from '../../../src/session/fetchSession'; import { getSession } from '../../../src/session/getSession'; @@ -43,7 +48,8 @@ describe('Unit | session | getSession', () => { it('creates a non-sticky session when one does not exist', function () { const { session } = getSession({ timeouts: { - sessionIdle: SESSION_IDLE_DURATION, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, @@ -70,7 +76,8 @@ describe('Unit | session | getSession', () => { const { session } = getSession({ timeouts: { - sessionIdle: 1000, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1000, maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, @@ -86,7 +93,8 @@ describe('Unit | session | getSession', () => { it('creates a non-sticky session, when one is expired', function () { const { session } = getSession({ timeouts: { - sessionIdle: 1000, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1000, maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, @@ -112,7 +120,8 @@ describe('Unit | session | getSession', () => { const { session } = getSession({ timeouts: { - sessionIdle: SESSION_IDLE_DURATION, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, maxSessionLife: MAX_SESSION_LIFE, }, stickySession: true, @@ -147,7 +156,8 @@ describe('Unit | session | getSession', () => { const { session } = getSession({ timeouts: { - sessionIdle: 1000, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1000, maxSessionLife: MAX_SESSION_LIFE, }, stickySession: true, @@ -173,7 +183,8 @@ describe('Unit | session | getSession', () => { const { session } = getSession({ timeouts: { - sessionIdle: 1000, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1000, maxSessionLife: MAX_SESSION_LIFE, }, stickySession: true, @@ -192,7 +203,8 @@ describe('Unit | session | getSession', () => { it('fetches a non-expired non-sticky session', function () { const { session } = getSession({ timeouts: { - sessionIdle: 1000, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1000, maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, diff --git a/packages/replay/test/unit/util/isSessionExpired.test.ts b/packages/replay/test/unit/util/isSessionExpired.test.ts index 627105d322f0..38b24056d36f 100644 --- a/packages/replay/test/unit/util/isSessionExpired.test.ts +++ b/packages/replay/test/unit/util/isSessionExpired.test.ts @@ -1,4 +1,4 @@ -import { MAX_SESSION_LIFE } from '../../../src/constants'; +import { MAX_SESSION_LIFE, SESSION_IDLE_PAUSE_DURATION } from '../../../src/constants'; import { makeSession } from '../../../src/session/Session'; import { isSessionExpired } from '../../../src/util/isSessionExpired'; @@ -15,14 +15,28 @@ function createSession(extra?: Record) { describe('Unit | util | isSessionExpired', () => { it('session last activity is older than expiry time', function () { - expect(isSessionExpired(createSession(), { maxSessionLife: MAX_SESSION_LIFE, sessionIdle: 100 }, 200)).toBe(true); // Session expired at ts = 100 + expect( + isSessionExpired( + createSession(), + { + maxSessionLife: MAX_SESSION_LIFE, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 100, + }, + 200, + ), + ).toBe(true); // Session expired at ts = 100 }); it('session last activity is not older than expiry time', function () { expect( isSessionExpired( createSession({ lastActivity: 100 }), - { maxSessionLife: MAX_SESSION_LIFE, sessionIdle: 150 }, + { + maxSessionLife: MAX_SESSION_LIFE, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 150, + }, 200, ), ).toBe(false); // Session expires at ts >= 250 @@ -30,13 +44,29 @@ describe('Unit | util | isSessionExpired', () => { it('session age is not older than max session life', function () { expect( - isSessionExpired(createSession(), { maxSessionLife: MAX_SESSION_LIFE, sessionIdle: 1_800_000 }, 50_000), + isSessionExpired( + createSession(), + { + maxSessionLife: MAX_SESSION_LIFE, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1_800_000, + }, + 50_000, + ), ).toBe(false); }); it('session age is older than max session life', function () { expect( - isSessionExpired(createSession(), { maxSessionLife: MAX_SESSION_LIFE, sessionIdle: 1_800_000 }, 1_800_001), + isSessionExpired( + createSession(), + { + maxSessionLife: MAX_SESSION_LIFE, + sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, + sessionIdleExpire: 1_800_000, + }, + 1_800_001, + ), ).toBe(true); // Session expires at ts >= 1_800_000 }); }); From d1b09c6520c3efb0d591f8242207c90ac1becc46 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 12:50:17 +0200 Subject: [PATCH 21/28] ref(core): Update multiplexed transport to default to errors only (#7964) By default, we only multiplex errors, but allow to opt-in to more envelope types. --- packages/core/src/transports/multiplexed.ts | 6 ++-- .../test/lib/transports/multiplexed.test.ts | 33 ++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/core/src/transports/multiplexed.ts b/packages/core/src/transports/multiplexed.ts index 859101bf56bc..9f47b5b76542 100644 --- a/packages/core/src/transports/multiplexed.ts +++ b/packages/core/src/transports/multiplexed.ts @@ -17,8 +17,9 @@ interface MatchParam { /** * A function that returns an event from the envelope if one exists. You can optionally pass an array of envelope item * types to filter by - only envelopes matching the given types will be multiplexed. + * Allowed values are: 'event', 'transaction', 'profile', 'replay_event' * - * @param types Defaults to ['event', 'transaction', 'profile', 'replay_event'] + * @param types Defaults to ['event'] */ getEvent(types?: EnvelopeItemType[]): Event | undefined; } @@ -61,8 +62,7 @@ export function makeMultiplexedTransport( async function send(envelope: Envelope): Promise { function getEvent(types?: EnvelopeItemType[]): Event | undefined { - const eventTypes: EnvelopeItemType[] = - types && types.length ? types : ['event', 'transaction', 'profile', 'replay_event']; + const eventTypes: EnvelopeItemType[] = types && types.length ? types : ['event']; return eventFromEnvelope(envelope, eventTypes); } diff --git a/packages/core/test/lib/transports/multiplexed.test.ts b/packages/core/test/lib/transports/multiplexed.test.ts index 0849af8a81f7..f4f0144e045e 100644 --- a/packages/core/test/lib/transports/multiplexed.test.ts +++ b/packages/core/test/lib/transports/multiplexed.test.ts @@ -1,4 +1,11 @@ -import type { BaseTransportOptions, ClientReport, EventEnvelope, EventItem, Transport } from '@sentry/types'; +import type { + BaseTransportOptions, + ClientReport, + EventEnvelope, + EventItem, + TransactionEvent, + Transport, +} from '@sentry/types'; import { createClientReportEnvelope, createEnvelope, dsnFromString } from '@sentry/utils'; import { TextEncoder } from 'util'; @@ -15,9 +22,10 @@ const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4b [{ type: 'event' }, ERROR_EVENT] as EventItem, ]); +const TRANSACTION_EVENT: TransactionEvent = { type: 'transaction', event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }; const TRANSACTION_ENVELOPE = createEnvelope( { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, - [[{ type: 'transaction' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem], + [[{ type: 'transaction' }, TRANSACTION_EVENT] as EventItem], ); const DEFAULT_DISCARDED_EVENTS: ClientReport['discarded_events'] = [ @@ -143,7 +151,7 @@ describe('makeMultiplexedTransport', () => { await transport.send(CLIENT_REPORT_ENVELOPE); }); - it('callback getEvent can ignore transactions', async () => { + it('callback getEvent ignores transactions by default', async () => { expect.assertions(2); const makeTransport = makeMultiplexedTransport( @@ -151,7 +159,24 @@ describe('makeMultiplexedTransport', () => { expect(url).toBe(DSN2_URL); }), ({ getEvent }) => { - expect(getEvent(['event'])).toBeUndefined(); + expect(getEvent()).toBeUndefined(); + return [DSN2]; + }, + ); + + const transport = makeTransport({ url: DSN1_URL, ...transportOptions }); + await transport.send(TRANSACTION_ENVELOPE); + }); + + it('callback getEvent can define envelope types', async () => { + expect.assertions(2); + + const makeTransport = makeMultiplexedTransport( + createTestTransport(url => { + expect(url).toBe(DSN2_URL); + }), + ({ getEvent }) => { + expect(getEvent(['event', 'transaction'])).toBe(TRANSACTION_EVENT); return [DSN2]; }, ); From 1683caf1a86cad1623bbfc069d877dd0eb2da56a Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 26 Apr 2023 14:31:14 +0200 Subject: [PATCH 22/28] feat(node): Make Undici a default integration. (#7967) --- packages/nextjs/src/server/index.ts | 2 -- packages/nextjs/test/serverSdk.test.ts | 9 --------- packages/node/src/sdk.ts | 2 ++ packages/sveltekit/src/server/sdk.ts | 3 +-- packages/sveltekit/test/server/sdk.test.ts | 15 --------------- 5 files changed, 3 insertions(+), 28 deletions(-) diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index b22a6c977a78..dc036921e436 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -146,8 +146,6 @@ function addServerIntegrations(options: NodeOptions): void { }); } - integrations = addOrUpdateIntegration(new Integrations.Undici(), integrations); - options.integrations = integrations; } diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 5867132075fd..1d2dd60d053c 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -164,15 +164,6 @@ describe('Server init()', () => { expect(consoleIntegration).toBeDefined(); }); - it('adds the Undici integration', () => { - init({}); - - const nodeInitOptions = nodeInit.mock.calls[0][0] as ModifiedInitOptions; - const undiciIntegration = findIntegrationByName(nodeInitOptions.integrations, 'Undici'); - - expect(undiciIntegration).toBeDefined(); - }); - describe('`Http` integration', () => { it('adds `Http` integration with tracing enabled if `tracesSampleRate` is set', () => { init({ tracesSampleRate: 1.0 }); diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index 2f6ec6787655..d0a02c746247 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -28,6 +28,7 @@ import { OnUncaughtException, OnUnhandledRejection, RequestData, + Undici, } from './integrations'; import { getModule } from './module'; import { makeNodeTransport } from './transports'; @@ -40,6 +41,7 @@ export const defaultIntegrations = [ // Native Wrappers new Console(), new Http(), + new Undici(), // Global Handlers new OnUncaughtException(), new OnUnhandledRejection(), diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts index 670f7879e7ba..613fe8d834f0 100644 --- a/packages/sveltekit/src/server/sdk.ts +++ b/packages/sveltekit/src/server/sdk.ts @@ -1,7 +1,7 @@ import { configureScope } from '@sentry/core'; import { RewriteFrames } from '@sentry/integrations'; import type { NodeOptions } from '@sentry/node'; -import { init as initNodeSdk, Integrations } from '@sentry/node'; +import { init as initNodeSdk } from '@sentry/node'; import { addOrUpdateIntegration } from '@sentry/utils'; import { applySdkMetadata } from '../common/metadata'; @@ -24,7 +24,6 @@ export function init(options: NodeOptions): void { } function addServerIntegrations(options: NodeOptions): void { - options.integrations = addOrUpdateIntegration(new Integrations.Undici(), options.integrations || []); options.integrations = addOrUpdateIntegration( new RewriteFrames({ iteratee: rewriteFramesIteratee }), options.integrations || [], diff --git a/packages/sveltekit/test/server/sdk.test.ts b/packages/sveltekit/test/server/sdk.test.ts index 5b1a924b6f1a..c68be548c91c 100644 --- a/packages/sveltekit/test/server/sdk.test.ts +++ b/packages/sveltekit/test/server/sdk.test.ts @@ -47,20 +47,5 @@ describe('Sentry server SDK', () => { // @ts-ignore need access to protected _tags attribute expect(currentScope._tags).toEqual({ runtime: 'node' }); }); - - it('adds the Undici integration', () => { - init({}); - - expect(nodeInit).toHaveBeenCalledTimes(1); - expect(nodeInit).toHaveBeenCalledWith( - expect.objectContaining({ - integrations: expect.arrayContaining([ - expect.objectContaining({ - name: 'Undici', - }), - ]), - }), - ); - }); }); }); From a5b92845614d9f71d40ee3a648679e4e80ae1a87 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 26 Apr 2023 09:40:24 -0400 Subject: [PATCH 23/28] feat(replay): Change the behavior of error-based sampling (#7768) * feat: Add `startBuffering()` API to begin replay buffering. This is useful if you turn off `replaysSessionSampleRate` and `replaysOnErrorSampleRate` and want to manually decide when to start replay buffering. You can then call `flush()` to save the replay. * fix: Sample at a per error rate instead of per session rate. Previously we were sampling at a per-session rather than per-error. This means the sampling decision happened prior to any error occurrence. The sampling rate was not accurate if you had sessions with many errors. This is now changed so that in the case of capturing replays only on error, we begin buffering the replay immediately and only run the sampling decision when an error occurs. --- .../suites/replay/bufferMode/init.js | 16 + .../suites/replay/bufferMode/subject.js | 18 + .../suites/replay/bufferMode/template.html | 12 + .../suites/replay/bufferMode/test.ts | 420 ++++++++++++++++++ .../suites/replay/errors/droppedError/test.ts | 2 +- .../suites/replay/errors/errorMode/test.ts | 8 +- .../suites/replay/errors/errorNotSent/test.ts | 2 +- .../suites/replay/sampling/test.ts | 9 +- .../core/test/lib/transports/offline.test.ts | 2 +- packages/replay/src/constants.ts | 2 +- .../src/coreHandlers/handleAfterSendEvent.ts | 7 +- packages/replay/src/integration.ts | 51 ++- packages/replay/src/replay.ts | 154 +++++-- packages/replay/src/session/Session.ts | 9 +- packages/replay/src/session/createSession.ts | 16 +- packages/replay/src/session/getSession.ts | 9 +- packages/replay/src/types.ts | 56 ++- .../replay/src/util/handleRecordingEmit.ts | 4 +- .../coreHandlers/handleAfterSendEvent.test.ts | 20 +- .../coreHandlers/handleGlobalEvent.test.ts | 11 +- .../test/integration/errorSampleRate.test.ts | 158 +++++-- .../replay/test/integration/sampling.test.ts | 68 ++- .../replay/test/integration/session.test.ts | 1 + packages/replay/test/mocks/mockSdk.ts | 7 +- packages/replay/test/mocks/resetSdkMock.ts | 5 +- .../test/unit/session/createSession.test.ts | 4 +- .../test/unit/session/fetchSession.test.ts | 2 + .../test/unit/session/getSession.test.ts | 23 +- .../test/unit/session/sessionSampling.test.ts | 11 +- .../unit/util/createReplayEnvelope.test.ts | 6 +- packages/types/src/replay.ts | 2 +- 31 files changed, 942 insertions(+), 173 deletions(-) create mode 100644 packages/browser-integration-tests/suites/replay/bufferMode/init.js create mode 100644 packages/browser-integration-tests/suites/replay/bufferMode/subject.js create mode 100644 packages/browser-integration-tests/suites/replay/bufferMode/template.html create mode 100644 packages/browser-integration-tests/suites/replay/bufferMode/test.ts diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/init.js b/packages/browser-integration-tests/suites/replay/bufferMode/init.js new file mode 100644 index 000000000000..c75a803ae33e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/bufferMode/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 1000, + flushMaxDelay: 1000, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/subject.js b/packages/browser-integration-tests/suites/replay/bufferMode/subject.js new file mode 100644 index 000000000000..be51a4baf6db --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/bufferMode/subject.js @@ -0,0 +1,18 @@ +document.getElementById('go-background').addEventListener('click', () => { + Object.defineProperty(document, 'hidden', { value: true, writable: true }); + const ev = document.createEvent('Event'); + ev.initEvent('visibilitychange'); + document.dispatchEvent(ev); +}); + +document.getElementById('error').addEventListener('click', () => { + throw new Error('Ooops'); +}); + +document.getElementById('error2').addEventListener('click', () => { + throw new Error('Another error'); +}); + +document.getElementById('log').addEventListener('click', () => { + console.log('Some message'); +}); diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/template.html b/packages/browser-integration-tests/suites/replay/bufferMode/template.html new file mode 100644 index 000000000000..91b5ef47723d --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/bufferMode/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts new file mode 100644 index 000000000000..4e98cd49c28c --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts @@ -0,0 +1,420 @@ +import { expect } from '@playwright/test'; +import type { Replay } from '@sentry/replay'; +import type { ReplayContainer } from '@sentry/replay/build/npm/types/types'; + +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { expectedClickBreadcrumb, getExpectedReplayEvent } from '../../../utils/replayEventTemplates'; +import { + getReplayEvent, + getReplayRecordingContent, + isReplayEvent, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../utils/replayHelpers'; + +sentryTest( + '[buffer-mode] manually start buffer mode and capture buffer', + async ({ getLocalTestPath, page, browserName }) => { + // This was sometimes flaky on firefox/webkit, so skipping for now + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + let callsToSentry = 0; + 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 => { + const event = envelopeRequestParser(route.request()); + // error events have no type field + if (event && !event.type && event.event_id) { + errorEventId = event.event_id; + } + // We only want to count errors & replays here + if (event && (!event.type || isReplayEvent(event))) { + callsToSentry++; + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await page.click('#go-background'); + await page.click('#error'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // error, no replays + await reqErrorPromise; + expect(callsToSentry).toEqual(1); + + expect( + await page.evaluate(() => { + const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay; + const replay = replayIntegration._replay; + return replay.isEnabled(); + }), + ).toBe(false); + + // Start buffering and assert that it is enabled + expect( + await page.evaluate(() => { + const replayIntegration = (window as unknown as Window & { Replay: InstanceType }).Replay; + // @ts-ignore private + const replay = replayIntegration._replay; + replayIntegration.startBuffering(); + return replay.isEnabled(); + }), + ).toBe(true); + + await page.click('#log'); + await page.click('#go-background'); + await page.click('#error2'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 2 errors + await reqErrorPromise; + expect(callsToSentry).toEqual(2); + + await page.evaluate(async () => { + const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay; + await replayIntegration.flush(); + }); + + const req0 = await reqPromise0; + + // 2 errors, 1 flush + await reqErrorPromise; + expect(callsToSentry).toEqual(3); + + await page.click('#log'); + await page.click('#go-background'); + + // Switches to session mode and then goes to background + const req1 = await reqPromise1; + const req2 = await reqPromise2; + expect(callsToSentry).toEqual(5); + + const event0 = getReplayEvent(req0); + const content0 = getReplayRecordingContent(req0); + + const event1 = getReplayEvent(req1); + const content1 = getReplayRecordingContent(req1); + + const event2 = getReplayEvent(req2); + const content2 = getReplayRecordingContent(req2); + + expect(event0).toEqual( + getExpectedReplayEvent({ + contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } }, + error_ids: [errorEventId!], + replay_type: 'buffer', + }), + ); + + // The first event should have both, full and incremental snapshots, + // as we recorded and kept all events in the buffer + expect(content0.fullSnapshots).toHaveLength(1); + // We don't know how many incremental snapshots we'll have (also browser-dependent), + // but we know that we have at least 5 + expect(content0.incrementalSnapshots.length).toBeGreaterThan(5); + // We want to make sure that the event that triggered the error was recorded. + expect(content0.breadcrumbs).toEqual( + expect.arrayContaining([ + { + ...expectedClickBreadcrumb, + message: 'body > button#error2', + data: { + nodeId: expect.any(Number), + node: { + attributes: { + id: 'error2', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* *****', + }, + }, + }, + ]), + ); + + expect(event1).toEqual( + getExpectedReplayEvent({ + contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } }, + replay_type: 'buffer', // although we're in session mode, we still send 'buffer' as replay_type + segment_id: 1, + urls: [], + }), + ); + + // From switching to session mode + expect(content1.fullSnapshots).toHaveLength(1); + + expect(event2).toEqual( + getExpectedReplayEvent({ + contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } }, + 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])); + }, +); + +sentryTest( + '[buffer-mode] manually start buffer mode and capture buffer, but do not continue as session', + async ({ getLocalTestPath, page, browserName }) => { + // This was sometimes flaky on firefox/webkit, so skipping for now + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + let callsToSentry = 0; + let errorEventId: string | undefined; + const reqPromise0 = waitForReplayRequest(page, 0); + const reqErrorPromise = waitForErrorRequest(page); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + const event = envelopeRequestParser(route.request()); + // error events have no type field + if (event && !event.type && event.event_id) { + errorEventId = event.event_id; + } + // We only want to count errors & replays here + if (event && (!event.type || isReplayEvent(event))) { + callsToSentry++; + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await page.click('#go-background'); + await page.click('#error'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // error, no replays + await reqErrorPromise; + expect(callsToSentry).toEqual(1); + + expect( + await page.evaluate(() => { + const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay; + const replay = replayIntegration._replay; + return replay.isEnabled(); + }), + ).toBe(false); + + // Start buffering and assert that it is enabled + expect( + await page.evaluate(() => { + const replayIntegration = (window as unknown as Window & { Replay: InstanceType }).Replay; + // @ts-ignore private + const replay = replayIntegration._replay; + replayIntegration.startBuffering(); + return replay.isEnabled(); + }), + ).toBe(true); + + await page.click('#log'); + await page.click('#go-background'); + await page.click('#error2'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 2 errors + await reqErrorPromise; + expect(callsToSentry).toEqual(2); + + await page.evaluate(async () => { + const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay; + await replayIntegration.flush({ continueRecording: false }); + }); + + const req0 = await reqPromise0; + + // 2 errors, 1 flush + await reqErrorPromise; + expect(callsToSentry).toEqual(3); + + await page.click('#log'); + await page.click('#go-background'); + + // Has stopped recording, should make no more calls to Sentry + expect(callsToSentry).toEqual(3); + + const event0 = getReplayEvent(req0); + const content0 = getReplayRecordingContent(req0); + + expect(event0).toEqual( + getExpectedReplayEvent({ + contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } }, + error_ids: [errorEventId!], + replay_type: 'buffer', + }), + ); + + // The first event should have both, full and incremental snapshots, + // as we recorded and kept all events in the buffer + expect(content0.fullSnapshots).toHaveLength(1); + // We don't know how many incremental snapshots we'll have (also browser-dependent), + // but we know that we have at least 5 + expect(content0.incrementalSnapshots.length).toBeGreaterThan(5); + // We want to make sure that the event that triggered the error was recorded. + expect(content0.breadcrumbs).toEqual( + expect.arrayContaining([ + { + ...expectedClickBreadcrumb, + message: 'body > button#error2', + data: { + nodeId: expect.any(Number), + node: { + attributes: { + id: 'error2', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* *****', + }, + }, + }, + ]), + ); + }, +); + +// Doing this in buffer mode to test changing error sample rate after first +// error happens. +sentryTest('[buffer-mode] can sample on each error event', async ({ getLocalTestPath, page, browserName }) => { + // This was sometimes flaky on firefox/webkit, so skipping for now + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + let callsToSentry = 0; + const errorEventIds: string[] = []; + const reqPromise0 = waitForReplayRequest(page, 0); + const reqErrorPromise = waitForErrorRequest(page); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + const event = envelopeRequestParser(route.request()); + // error events have no type field + if (event && !event.type && event.event_id) { + errorEventIds.push(event.event_id); + } + // We only want to count errors & replays here + if (event && (!event.type || isReplayEvent(event))) { + callsToSentry++; + } + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + // Start buffering and assert that it is enabled + expect( + await page.evaluate(() => { + const replayIntegration = (window as unknown as Window & { Replay: InstanceType }).Replay; + const replay = replayIntegration['_replay']; + replayIntegration.startBuffering(); + return replay.isEnabled(); + }), + ).toBe(true); + + await page.click('#go-background'); + await page.click('#error'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 1 error, no replay + await reqErrorPromise; + expect(callsToSentry).toEqual(1); + + await page.evaluate(async () => { + const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay; + replayIntegration['_replay'].getOptions().errorSampleRate = 1.0; + }); + + // Error sample rate is now at 1.0, this error should create a replay + await page.click('#error2'); + + const req0 = await reqPromise0; + + // 2 errors, 1 flush + await reqErrorPromise; + expect(callsToSentry).toEqual(3); + + const event0 = getReplayEvent(req0); + const content0 = getReplayRecordingContent(req0); + + expect(event0).toEqual( + getExpectedReplayEvent({ + contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, + error_ids: errorEventIds, + replay_type: 'buffer', + }), + ); + + // The first event should have both, full and incremental snapshots, + // as we recorded and kept all events in the buffer + expect(content0.fullSnapshots).toHaveLength(1); + // We want to make sure that the event that triggered the error was + // recorded, as well as the first error that did not get sampled. + expect(content0.breadcrumbs).toEqual( + expect.arrayContaining([ + { + ...expectedClickBreadcrumb, + message: 'body > button#error', + data: { + nodeId: expect.any(Number), + node: { + attributes: { + id: 'error', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '***** *****', + }, + }, + }, + { + ...expectedClickBreadcrumb, + message: 'body > button#error2', + data: { + nodeId: expect.any(Number), + node: { + attributes: { + id: 'error2', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* *****', + }, + }, + }, + ]), + ); +}); diff --git a/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts b/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts index b63ca9a8b61f..9698326a082f 100644 --- a/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts +++ b/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts @@ -42,6 +42,6 @@ sentryTest( expect(callsToSentry).toEqual(0); const replay = await getReplaySnapshot(page); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }, ); diff --git a/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts b/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts index 18dd4b40e2a1..fee9e05d4a49 100644 --- a/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts @@ -59,6 +59,8 @@ sentryTest( await page.click('#error'); const req0 = await reqPromise0; + expect(callsToSentry).toEqual(2); // 1 error, 1 replay event + await page.click('#go-background'); const req1 = await reqPromise1; await reqErrorPromise; @@ -84,7 +86,7 @@ sentryTest( getExpectedReplayEvent({ contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, error_ids: [errorEventId!], - replay_type: 'error', + replay_type: 'buffer', }), ); @@ -118,7 +120,7 @@ sentryTest( expect(event1).toEqual( getExpectedReplayEvent({ contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, - replay_type: 'error', // although we're in session mode, we still send 'error' as replay_type + replay_type: 'buffer', // although we're in session mode, we still send 'error' as replay_type segment_id: 1, urls: [], }), @@ -133,7 +135,7 @@ sentryTest( expect(event2).toEqual( getExpectedReplayEvent({ contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, - replay_type: 'error', + replay_type: 'buffer', segment_id: 2, urls: [], }), diff --git a/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts b/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts index 963f47e9919d..89c6d0342983 100644 --- a/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts +++ b/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts @@ -36,6 +36,6 @@ sentryTest( expect(callsToSentry).toEqual(1); const replay = await getReplaySnapshot(page); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }, ); diff --git a/packages/browser-integration-tests/suites/replay/sampling/test.ts b/packages/browser-integration-tests/suites/replay/sampling/test.ts index 78ca3d8fcf6a..752c9c89a431 100644 --- a/packages/browser-integration-tests/suites/replay/sampling/test.ts +++ b/packages/browser-integration-tests/suites/replay/sampling/test.ts @@ -1,5 +1,4 @@ import { expect } from '@playwright/test'; -import type { ReplayContainer } from '@sentry/replay/build/npm/types/types'; import { sentryTest } from '../../../utils/fixtures'; import { getReplaySnapshot, shouldSkipReplayTest } from '../../../utils/replayHelpers'; @@ -25,13 +24,11 @@ sentryTest('should not send replays if both sample rates are 0', async ({ getLoc await page.click('button'); - await page.waitForFunction(() => { - const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay; - return !!replayIntegration._replay.session; - }); const replay = await getReplaySnapshot(page); - expect(replay.session?.sampled).toBe(false); + expect(replay.session).toBe(undefined); + expect(replay._isEnabled).toBe(false); + expect(replay.recordingMode).toBe('session'); // Cannot wait on getFirstSentryEnvelopeRequest, as that never resolves }); diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index 0779dff6a8d7..d7d2ee7a90ae 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -35,7 +35,7 @@ const REPLAY_EVENT: ReplayEvent = { urls: ['https://example.com'], replay_id: 'MY_REPLAY_ID', segment_id: 3, - replay_type: 'error', + replay_type: 'buffer', }; const DSN = dsnFromString('https://public@dsn.ingest.sentry.io/1337'); diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index f1f8627c120c..f9b452d3f04f 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -27,7 +27,7 @@ export const DEFAULT_FLUSH_MIN_DELAY = 5_000; export const DEFAULT_FLUSH_MAX_DELAY = 5_500; /* How long to wait for error checkouts */ -export const ERROR_CHECKOUT_TIME = 60_000; +export const BUFFER_CHECKOUT_TIME = 60_000; export const RETRY_BASE_INTERVAL = 5000; export const RETRY_MAX_COUNT = 3; diff --git a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts index dc94022e5b4a..5c8e59d6be4e 100644 --- a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts @@ -4,6 +4,7 @@ import type { Event, Transport, TransportMakeRequestResponse } from '@sentry/typ import { UNABLE_TO_SEND_REPLAY } from '../constants'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isTransactionEvent } from '../util/eventUtils'; +import { isSampled } from '../util/isSampled'; type AfterSendEventCallback = (event: Event, sendResponse: TransportMakeRequestResponse | void) => void; @@ -49,10 +50,14 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal // Trigger error recording // Need to be very careful that this does not cause an infinite loop if ( - replay.recordingMode === 'error' && + replay.recordingMode === 'buffer' && event.exception && event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing ) { + if (!isSampled(replay.getOptions().errorSampleRate)) { + return; + } + setTimeout(() => { // Capture current event buffer as new replay void replay.sendBufferedReplayOrFlush(); diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index 5103d4e4af8e..81279947b969 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -181,14 +181,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, } /** - * We previously used to create a transaction in `setupOnce` and it would - * potentially create a transaction before some native SDK integrations have run - * and applied their own global event processor. An example is: - * https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts - * - * So we call `replay.setup` in next event loop as a workaround to wait for other - * global event processors to finish. This is no longer needed, but keeping it - * here to avoid any future issues. + * Setup and initialize replay container */ public setupOnce(): void { if (!isBrowser()) { @@ -197,12 +190,20 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, this._setup(); - // XXX: See method comments above - setTimeout(() => this.start()); + // Once upon a time, we tried to create a transaction in `setupOnce` and it would + // potentially create a transaction before some native SDK integrations have run + // and applied their own global event processor. An example is: + // https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts + // + // So we call `this._initialize()` in next event loop as a workaround to wait for other + // global event processors to finish. This is no longer needed, but keeping it + // here to avoid any future issues. + setTimeout(() => this._initialize()); } /** - * Initializes the plugin. + * Start a replay regardless of sampling rate. Calling this will always + * create a new session. Will throw an error if replay is already in progress. * * Creates or loads a session, attaches listeners to varying events (DOM, * PerformanceObserver, Recording, Sentry SDK, etc) @@ -215,6 +216,18 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, this._replay.start(); } + /** + * Start replay buffering. Buffers until `flush()` is called or, if + * `replaysOnErrorSampleRate` > 0, until an error occurs. + */ + public startBuffering(): void { + if (!this._replay) { + return; + } + + this._replay.startBuffering(); + } + /** * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown @@ -228,11 +241,11 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, } /** - * Immediately send all pending events. In buffer-mode, this should be used - * to capture the initial replay. - * + * If not in "session" recording mode, flush event buffer which will create a new replay. * Unless `continueRecording` is false, the replay will continue to record and * behave as a "session"-based replay. + * + * Otherwise, queue up a flush. */ public flush(options?: SendBufferedReplayOptions): Promise { if (!this._replay || !this._replay.isEnabled()) { @@ -252,6 +265,16 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`, return this._replay.getSessionId(); } + /** + * Initializes replay. + */ + protected _initialize(): void { + if (!this._replay) { + return; + } + + this._replay.initializeSampling(); + } /** Setup the integration. */ private _setup(): void { diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 241a711f20cd..24372cc1ab3d 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -5,7 +5,7 @@ import type { Breadcrumb, ReplayRecordingMode } from '@sentry/types'; import { logger } from '@sentry/utils'; import { - ERROR_CHECKOUT_TIME, + BUFFER_CHECKOUT_TIME, MAX_SESSION_LIFE, SESSION_IDLE_EXPIRE_DURATION, SESSION_IDLE_PAUSE_DURATION, @@ -56,9 +56,11 @@ export class ReplayContainer implements ReplayContainerInterface { public session: Session | undefined; /** - * Recording can happen in one of two modes: - * * session: Record the whole session, sending it continuously - * * error: Always keep the last 60s of recording, and when an error occurs, send it immediately + * Recording can happen in one of three modes: + * - session: Record the whole session, sending it continuously + * - buffer: Always keep the last 60s of recording, requires: + * - having replaysOnErrorSampleRate > 0 to capture replay when an error occurs + * - or calling `flush()` to send the replay */ public recordingMode: ReplayRecordingMode = 'session'; @@ -157,49 +159,102 @@ export class ReplayContainer implements ReplayContainerInterface { } /** - * Initializes the plugin. - * - * Creates or loads a session, attaches listeners to varying events (DOM, - * _performanceObserver, Recording, Sentry SDK, etc) + * Initializes the plugin based on sampling configuration. Should not be + * called outside of constructor. */ - public start(): void { - this.setInitialState(); + public initializeSampling(): void { + const { errorSampleRate, sessionSampleRate } = this._options; - if (!this._loadAndCheckSession()) { + // If neither sample rate is > 0, then do nothing - user will need to call one of + // `start()` or `startBuffering` themselves. + if (errorSampleRate <= 0 && sessionSampleRate <= 0) { return; } - // If there is no session, then something bad has happened - can't continue - if (!this.session) { - this._handleException(new Error('No session found')); + // Otherwise if there is _any_ sample rate set, try to load an existing + // session, or create a new one. + const isSessionSampled = this._loadAndCheckSession(); + + if (!isSessionSampled) { + // This should only occur if `errorSampleRate` is 0 and was unsampled for + // session-based replay. In this case there is nothing to do. return; } - if (!this.session.sampled) { - // If session was not sampled, then we do not initialize the integration at all. + if (!this.session) { + // This should not happen, something wrong has occurred + this._handleException(new Error('Unable to initialize and create session')); return; } - // If session is sampled for errors, then we need to set the recordingMode - // to 'error', which will configure recording with different options. - if (this.session.sampled === 'error') { - this.recordingMode = 'error'; + if (this.session.sampled && this.session.sampled !== 'session') { + // If not sampled as session-based, then recording mode will be `buffer` + // Note that we don't explicitly check if `sampled === 'buffer'` because we + // could have sessions from Session storage that are still `error` from + // prior SDK version. + this.recordingMode = 'buffer'; } - // setup() is generally called on page load or manually - in both cases we - // should treat it as an activity - this._updateSessionActivity(); + this._initializeRecording(); + } - this.eventBuffer = createEventBuffer({ - useCompression: this._options.useCompression, + /** + * Start a replay regardless of sampling rate. Calling this will always + * create a new session. Will throw an error if replay is already in progress. + * + * Creates or loads a session, attaches listeners to varying events (DOM, + * _performanceObserver, Recording, Sentry SDK, etc) + */ + public start(): void { + if (this._isEnabled && this.recordingMode === 'session') { + throw new Error('Replay recording is already in progress'); + } + + if (this._isEnabled && this.recordingMode === 'buffer') { + throw new Error('Replay buffering is in progress, call `flush()` to save the replay'); + } + + const previousSessionId = this.session && this.session.id; + + const { session } = getSession({ + timeouts: this.timeouts, + stickySession: Boolean(this._options.stickySession), + currentSession: this.session, + // This is intentional: create a new session-based replay when calling `start()` + sessionSampleRate: 1, + allowBuffering: false, }); - this._addListeners(); + session.previousSessionId = previousSessionId; + this.session = session; - // Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout - this._isEnabled = true; + this._initializeRecording(); + } - this.startRecording(); + /** + * Start replay buffering. Buffers until `flush()` is called or, if + * `replaysOnErrorSampleRate` > 0, an error occurs. + */ + public startBuffering(): void { + if (this._isEnabled) { + throw new Error('Replay recording is already in progress'); + } + + const previousSessionId = this.session && this.session.id; + + const { session } = getSession({ + timeouts: this.timeouts, + stickySession: Boolean(this._options.stickySession), + currentSession: this.session, + sessionSampleRate: 0, + allowBuffering: true, + }); + + session.previousSessionId = previousSessionId; + this.session = session; + + this.recordingMode = 'buffer'; + this._initializeRecording(); } /** @@ -214,7 +269,7 @@ export class ReplayContainer implements ReplayContainerInterface { // When running in error sampling mode, we need to overwrite `checkoutEveryNms` // Without this, it would record forever, until an error happens, which we don't want // instead, we'll always keep the last 60 seconds of replay before an error happened - ...(this.recordingMode === 'error' && { checkoutEveryNms: ERROR_CHECKOUT_TIME }), + ...(this.recordingMode === 'buffer' && { checkoutEveryNms: BUFFER_CHECKOUT_TIME }), emit: getHandleRecordingEmit(this), onMutation: this._onMutationHandler, }); @@ -340,6 +395,13 @@ export class ReplayContainer implements ReplayContainerInterface { // Reset all "capture on error" configuration before // starting a new recording this.recordingMode = 'session'; + + // Once this session ends, we do not want to refresh it + if (this.session) { + this.session.shouldRefresh = false; + this._maybeSaveSession(); + } + this.startRecording(); } @@ -352,12 +414,12 @@ export class ReplayContainer implements ReplayContainerInterface { * processing and hand back control to caller. */ public addUpdate(cb: AddUpdateCallback): void { - // We need to always run `cb` (e.g. in the case of `this.recordingMode == 'error'`) + // We need to always run `cb` (e.g. in the case of `this.recordingMode == 'buffer'`) const cbResult = cb(); // If this option is turned on then we will only want to call `flush` // explicitly - if (this.recordingMode === 'error') { + if (this.recordingMode === 'buffer') { return; } @@ -484,6 +546,30 @@ export class ReplayContainer implements ReplayContainerInterface { this._context.urls.push(url); } + /** + * Initialize and start all listeners to varying events (DOM, + * Performance Observer, Recording, Sentry SDK, etc) + */ + private _initializeRecording(): void { + this.setInitialState(); + + // this method is generally called on page load or manually - in both cases + // we should treat it as an activity + this._updateSessionActivity(); + + this.eventBuffer = createEventBuffer({ + useCompression: this._options.useCompression, + }); + + this._removeListeners(); + this._addListeners(); + + // Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout + this._isEnabled = true; + + this.startRecording(); + } + /** A wrapper to conditionally capture exceptions. */ private _handleException(error: unknown): void { __DEBUG_BUILD__ && logger.error('[Replay]', error); @@ -503,7 +589,7 @@ export class ReplayContainer implements ReplayContainerInterface { stickySession: Boolean(this._options.stickySession), currentSession: this.session, sessionSampleRate: this._options.sessionSampleRate, - errorSampleRate: this._options.errorSampleRate, + allowBuffering: this._options.errorSampleRate > 0, }); // If session was newly created (i.e. was not loaded from storage), then @@ -718,7 +804,7 @@ export class ReplayContainer implements ReplayContainerInterface { * Only flush if `this.recordingMode === 'session'` */ private _conditionalFlush(): void { - if (this.recordingMode === 'error') { + if (this.recordingMode === 'buffer') { return; } diff --git a/packages/replay/src/session/Session.ts b/packages/replay/src/session/Session.ts index 9089ea54c76c..b5ecddcbdb84 100644 --- a/packages/replay/src/session/Session.ts +++ b/packages/replay/src/session/Session.ts @@ -1,7 +1,6 @@ import { uuid4 } from '@sentry/utils'; import type { Sampled, Session } from '../types'; -import { isSampled } from '../util/isSampled'; /** * Get a session with defaults & applied sampling. @@ -21,12 +20,6 @@ export function makeSession(session: Partial & { sampled: Sampled }): S lastActivity, segmentId, sampled, + shouldRefresh: true, }; } - -/** - * Get the sampled status for a session based on sample rates & current sampled status. - */ -export function getSessionSampleType(sessionSampleRate: number, errorSampleRate: number): Sampled { - return isSampled(sessionSampleRate) ? 'session' : isSampled(errorSampleRate) ? 'error' : false; -} diff --git a/packages/replay/src/session/createSession.ts b/packages/replay/src/session/createSession.ts index bd6d18ad33e8..f5f2d120b92b 100644 --- a/packages/replay/src/session/createSession.ts +++ b/packages/replay/src/session/createSession.ts @@ -1,16 +1,24 @@ import { logger } from '@sentry/utils'; -import type { Session, SessionOptions } from '../types'; +import type { Sampled, Session, SessionOptions } from '../types'; +import { isSampled } from '../util/isSampled'; import { saveSession } from './saveSession'; -import { getSessionSampleType, makeSession } from './Session'; +import { makeSession } from './Session'; + +/** + * Get the sampled status for a session based on sample rates & current sampled status. + */ +export function getSessionSampleType(sessionSampleRate: number, allowBuffering: boolean): Sampled { + return isSampled(sessionSampleRate) ? 'session' : allowBuffering ? 'buffer' : false; +} /** * Create a new session, which in its current implementation is a Sentry event * that all replays will be saved to as attachments. Currently, we only expect * one of these Sentry events per "replay session". */ -export function createSession({ sessionSampleRate, errorSampleRate, stickySession = false }: SessionOptions): Session { - const sampled = getSessionSampleType(sessionSampleRate, errorSampleRate); +export function createSession({ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions): Session { + const sampled = getSessionSampleType(sessionSampleRate, allowBuffering); const session = makeSession({ sampled, }); diff --git a/packages/replay/src/session/getSession.ts b/packages/replay/src/session/getSession.ts index 150fbe12c871..ff993887e64b 100644 --- a/packages/replay/src/session/getSession.ts +++ b/packages/replay/src/session/getSession.ts @@ -23,7 +23,7 @@ export function getSession({ currentSession, stickySession, sessionSampleRate, - errorSampleRate, + allowBuffering, }: GetSessionParams): { type: 'new' | 'saved'; session: Session } { // If session exists and is passed, use it instead of always hitting session storage const session = currentSession || (stickySession && fetchSession()); @@ -36,8 +36,9 @@ export function getSession({ if (!isExpired) { return { type: 'saved', session }; - } else if (session.sampled === 'error') { - // Error samples should not be re-created when expired, but instead we stop when the replay is done + } else if (!session.shouldRefresh) { + // In this case, stop + // This is the case if we have an error session that is completed (=triggered an error) const discardedSession = makeSession({ sampled: false }); return { type: 'new', session: discardedSession }; } else { @@ -49,7 +50,7 @@ export function getSession({ const newSession = createSession({ stickySession, sessionSampleRate, - errorSampleRate, + allowBuffering, }); return { type: 'new', session: newSession }; diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index a443cede2e58..eb28b09a5d64 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -183,20 +183,6 @@ export interface WorkerResponse { export type AddEventResult = void; -export interface SampleRates { - /** - * The sample rate for session-long replays. 1.0 will record all sessions and - * 0 will record none. - */ - sessionSampleRate: number; - - /** - * The sample rate for sessions that has had an error occur. This is - * independent of `sessionSampleRate`. - */ - errorSampleRate: number; -} - export interface ReplayNetworkOptions { /** * Capture request/response details for XHR/Fetch requests that match the given URLs. @@ -230,18 +216,25 @@ export interface ReplayNetworkOptions { networkResponseHeaders: string[]; } -/** - * Session options that are configurable by the integration configuration - */ -export interface SessionOptions extends SampleRates { +export interface ReplayPluginOptions extends ReplayNetworkOptions { + /** + * The sample rate for session-long replays. 1.0 will record all sessions and + * 0 will record none. + */ + sessionSampleRate: number; + + /** + * The sample rate for sessions that has had an error occur. This is + * independent of `sessionSampleRate`. + */ + errorSampleRate: number; + /** * If false, will create a new session per pageload. Otherwise, saves session * to Session Storage. */ stickySession: boolean; -} -export interface ReplayPluginOptions extends SessionOptions, ReplayNetworkOptions { /** * The amount of time to wait before sending a replay */ @@ -279,6 +272,18 @@ export interface ReplayPluginOptions extends SessionOptions, ReplayNetworkOption }>; } +/** + * Session options that are configurable by the integration configuration + */ +export interface SessionOptions extends Pick { + /** + * Should buffer recordings to be saved later either by error sampling, or by + * manually calling `flush()`. This is only a factor if not sampled for a + * session-based replay. + */ + allowBuffering: boolean; +} + export interface ReplayIntegrationPrivacyOptions { /** * Mask text content for elements that match the CSS selectors in the list. @@ -397,7 +402,7 @@ export interface InternalEventContext extends CommonEventContext { earliestEvent: number | null; } -export type Sampled = false | 'session' | 'error'; +export type Sampled = false | 'session' | 'buffer'; export interface Session { id: string; @@ -424,9 +429,15 @@ export interface Session { previousSessionId?: string; /** - * Is the session sampled? `false` if not sampled, otherwise, `session` or `error` + * Is the session sampled? `false` if not sampled, otherwise, `session` or `buffer` */ sampled: Sampled; + + /** + * If this is false, the session should not be refreshed when it was inactive. + * This can be the case if you had a buffered session which is now recording because an error happened. + */ + shouldRefresh: boolean; } export interface EventBuffer { @@ -469,6 +480,7 @@ export interface ReplayContainer { isEnabled(): boolean; isPaused(): boolean; getContext(): InternalEventContext; + initializeSampling(): void; start(): void; stop(reason?: string): Promise; pause(): void; diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index e9a4a16b5018..8a31f86ebf23 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -34,7 +34,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa // when an error occurs. Clear any state that happens before this current // checkout. This needs to happen before `addEvent()` which updates state // dependent on this reset. - if (replay.recordingMode === 'error' && isCheckout) { + if (replay.recordingMode === 'buffer' && isCheckout) { replay.setInitialState(); } @@ -60,7 +60,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa // See note above re: session start needs to reflect the most recent // checkout. - if (replay.recordingMode === 'error' && replay.session) { + if (replay.recordingMode === 'buffer' && replay.session) { const { earliestEvent } = replay.getContext(); if (earliestEvent) { replay.session.started = earliestEvent; diff --git a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts index d46bacf4aede..9f4748253110 100644 --- a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -86,7 +86,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { expect(Array.from(replay.getContext().traceIds)).toEqual(['tr2']); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }); it('allows undefined send response when using custom transport', async () => { @@ -140,7 +140,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { const handler = handleAfterSendEvent(replay); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); handler(error1, { statusCode: 200 }); @@ -210,7 +210,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { const handler = handleAfterSendEvent(replay); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); handler(profileEvent, { statusCode: 200 }); handler(replayEvent, { statusCode: 200 }); @@ -224,7 +224,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { expect(Array.from(replay.getContext().errorIds)).toEqual([]); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }); it('does not flush in error mode when failing to send the error', async () => { @@ -244,7 +244,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { const handler = handleAfterSendEvent(replay); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); handler(error1, undefined); @@ -258,7 +258,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { expect(Array.from(replay.getContext().errorIds)).toEqual([]); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }); it('does not flush if error event has no exception', async () => { @@ -278,7 +278,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { const handler = handleAfterSendEvent(replay); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); handler(error1, { statusCode: 200 }); @@ -292,7 +292,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { expect(Array.from(replay.getContext().errorIds)).toEqual(['err1']); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }); it('does not flush if error is replay send error', async () => { @@ -312,7 +312,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { const handler = handleAfterSendEvent(replay); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); handler(error1, { statusCode: 200 }); @@ -326,6 +326,6 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { expect(Array.from(replay.getContext().errorIds)).toEqual(['err1']); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }); }); diff --git a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts index 24e709707033..375c79fb6d1e 100644 --- a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -1,5 +1,6 @@ import type { Event } from '@sentry/types'; +import type { Replay as ReplayIntegration } from '../../../src'; import { REPLAY_EVENT_NAME } from '../../../src/constants'; import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent'; import type { ReplayContainer } from '../../../src/replay'; @@ -84,8 +85,10 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { }); it('tags errors and transactions with replay id for session samples', async () => { - ({ replay } = await resetSdkMock({})); - replay.start(); + let integration: ReplayIntegration; + ({ replay, integration } = await resetSdkMock({})); + // @ts-ignore protected but ok to use for testing + integration._initialize(); const transaction = Transaction(); const error = Error(); expect(handleGlobalEventListener(replay)(transaction, {})).toEqual( @@ -163,7 +166,7 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { const handler = handleGlobalEventListener(replay); const handler2 = handleGlobalEventListener(replay, true); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); handler(profileEvent, {}); handler(replayEvent, {}); @@ -179,7 +182,7 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { expect(Array.from(replay.getContext().errorIds)).toEqual([]); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('error'); + expect(replay.recordingMode).toBe('buffer'); }); it('does not skip non-rrweb errors', () => { diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index b9da6b081bbc..16962bf5b2f8 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -1,8 +1,8 @@ import { captureException, getCurrentHub } from '@sentry/core'; import { + BUFFER_CHECKOUT_TIME, DEFAULT_FLUSH_MIN_DELAY, - ERROR_CHECKOUT_TIME, MAX_SESSION_LIFE, REPLAY_SESSION_KEY, SESSION_IDLE_EXPIRE_DURATION, @@ -71,7 +71,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ - replay_type: 'error', + replay_type: 'buffer', contexts: { replay: { error_sample_rate: 1, @@ -103,7 +103,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 1 }, replayEventPayload: expect.objectContaining({ - replay_type: 'error', + replay_type: 'buffer', contexts: { replay: { error_sample_rate: 1, @@ -182,7 +182,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ - replay_type: 'error', + replay_type: 'buffer', contexts: { replay: { error_sample_rate: 1, @@ -223,7 +223,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ - replay_type: 'error', + replay_type: 'buffer', contexts: { replay: { error_sample_rate: 1, @@ -373,9 +373,73 @@ describe('Integration | errorSampleRate', () => { // 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('stops replay if session exceeds MAX_SESSION_LIFE and does not start a new session thereafter', async () => { - // Idle for 15 minutes - jest.advanceTimersByTime(MAX_SESSION_LIFE + 1); + 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 new Promise(process.nextTick); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + // 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' }, @@ -383,23 +447,49 @@ describe('Integration | errorSampleRate', () => { type: 3, }; mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); 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).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(false); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + + expect(replay.isEnabled()).toBe(true); + expect(replay.isPaused()).toBe(false); + expect(replay.recordingMode).toBe('buffer'); domHandler({ name: 'click', }); - // Remains disabled! - expect(replay.isEnabled()).toBe(false); + await new Promise(process.nextTick); + jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + + 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); }); // Should behave the same as above test @@ -420,15 +510,29 @@ describe('Integration | errorSampleRate', () => { // We stop recording after SESSION_IDLE_EXPIRE_DURATION of inactivity in error mode expect(replay).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(false); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay.isEnabled()).toBe(true); + expect(replay.isPaused()).toBe(false); + expect(replay.recordingMode).toBe('buffer'); - domHandler({ - name: 'click', + // 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', + }), }); - // Remains disabled! - expect(replay.isEnabled()).toBe(false); + 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 () => { @@ -468,7 +572,7 @@ describe('Integration | errorSampleRate', () => { }); it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { - const ELAPSED = ERROR_CHECKOUT_TIME; + const ELAPSED = BUFFER_CHECKOUT_TIME; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); @@ -617,15 +721,19 @@ it('sends a replay after loading the session multiple times', async () => { // Pretend that a session is already saved before loading replay WINDOW.sessionStorage.setItem( REPLAY_SESSION_KEY, - `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"error","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`, + `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"buffer","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`, ); - const { mockRecord, replay } = await resetSdkMock({ + const { mockRecord, replay, integration } = await resetSdkMock({ replayOptions: { stickySession: true, }, + sentryOptions: { + replaysOnErrorSampleRate: 1.0, + }, autoStart: false, }); - replay.start(); + // @ts-ignore this is protected, but we want to call it for this test + integration._initialize(); jest.runAllTimers(); diff --git a/packages/replay/test/integration/sampling.test.ts b/packages/replay/test/integration/sampling.test.ts index 049329ebda3e..a038489280bb 100644 --- a/packages/replay/test/integration/sampling.test.ts +++ b/packages/replay/test/integration/sampling.test.ts @@ -1,12 +1,15 @@ -import { mockRrweb, mockSdk } from '../index'; +import { resetSdkMock } from '../mocks/resetSdkMock'; import { useFakeTimers } from '../utils/use-fake-timers'; useFakeTimers(); describe('Integration | sampling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('does nothing if not sampled', async () => { - const { record: mockRecord } = mockRrweb(); - const { replay } = await mockSdk({ + const { mockRecord, replay } = await resetSdkMock({ replayOptions: { stickySession: true, }, @@ -20,14 +23,59 @@ describe('Integration | sampling', () => { const spyAddListeners = jest.spyOn(replay, '_addListeners'); jest.runAllTimers(); - expect(replay.session?.sampled).toBe(false); - expect(replay.getContext()).toEqual( - expect.objectContaining({ - initialTimestamp: expect.any(Number), - initialUrl: 'http://localhost/', - }), - ); + expect(replay.session).toBe(undefined); + + // This is what the `_context` member is initialized with + expect(replay.getContext()).toEqual({ + errorIds: new Set(), + traceIds: new Set(), + urls: [], + earliestEvent: null, + initialTimestamp: expect.any(Number), + initialUrl: '', + }); expect(mockRecord).not.toHaveBeenCalled(); expect(spyAddListeners).not.toHaveBeenCalled(); + + // TODO(billy): Should we initialize recordingMode to something else? It's + // awkward that recordingMode is `session` when both sample rates are 0 + expect(replay.recordingMode).toBe('session'); + }); + + it('samples for error based session', async () => { + const { mockRecord, replay, integration } = await resetSdkMock({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + }, + autoStart: false, // Needs to be false in order to spy on replay + }); + + // @ts-ignore private API + const spyAddListeners = jest.spyOn(replay, '_addListeners'); + + // @ts-ignore protected + integration._initialize(); + + jest.runAllTimers(); + + expect(replay.session?.id).toBeDefined(); + + // This is what the `_context` member is initialized with + expect(replay.getContext()).toEqual({ + errorIds: new Set(), + earliestEvent: expect.any(Number), + initialTimestamp: expect.any(Number), + initialUrl: 'http://localhost/', + traceIds: new Set(), + urls: ['http://localhost/'], + }); + expect(replay.recordingMode).toBe('buffer'); + + expect(spyAddListeners).toHaveBeenCalledTimes(1); + expect(mockRecord).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index 1338566be9aa..c99326730574 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -119,6 +119,7 @@ describe('Integration | session', () => { it('creates a new session if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and comes back to click their mouse', async () => { const initialSession = { ...replay.session } as Session; + expect(mockRecord).toHaveBeenCalledTimes(1); expect(initialSession?.id).toBeDefined(); expect(replay.getContext()).toEqual( expect.objectContaining({ diff --git a/packages/replay/test/mocks/mockSdk.ts b/packages/replay/test/mocks/mockSdk.ts index af23a47cc5bc..4a27b7286d22 100644 --- a/packages/replay/test/mocks/mockSdk.ts +++ b/packages/replay/test/mocks/mockSdk.ts @@ -68,6 +68,10 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true } public setupOnce(): void { // do nothing } + + public initialize(): void { + return super._initialize(); + } } const replayIntegration = new TestReplayIntegration({ @@ -91,7 +95,8 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true } replayIntegration['_setup'](); if (autoStart) { - replayIntegration.start(); + // Only exists in our mock + replayIntegration.initialize(); } const replay = replayIntegration['_replay']!; diff --git a/packages/replay/test/mocks/resetSdkMock.ts b/packages/replay/test/mocks/resetSdkMock.ts index d0cbc1b6d049..5d9782dc457d 100644 --- a/packages/replay/test/mocks/resetSdkMock.ts +++ b/packages/replay/test/mocks/resetSdkMock.ts @@ -1,3 +1,4 @@ +import type { Replay as ReplayIntegration } from '../../src'; import type { ReplayContainer } from '../../src/replay'; import type { RecordMock } from './../index'; import { BASE_TIMESTAMP } from './../index'; @@ -9,6 +10,7 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }: domHandler: DomHandler; mockRecord: RecordMock; replay: ReplayContainer; + integration: ReplayIntegration; }> { let domHandler: DomHandler; @@ -27,7 +29,7 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }: const { mockRrweb } = await import('./mockRrweb'); const { record: mockRecord } = mockRrweb(); - const { replay } = await mockSdk({ + const { replay, integration } = await mockSdk({ replayOptions, sentryOptions, autoStart, @@ -43,5 +45,6 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }: domHandler, mockRecord, replay, + integration, }; } diff --git a/packages/replay/test/unit/session/createSession.test.ts b/packages/replay/test/unit/session/createSession.test.ts index afe2ce3df374..891cc012edb6 100644 --- a/packages/replay/test/unit/session/createSession.test.ts +++ b/packages/replay/test/unit/session/createSession.test.ts @@ -34,7 +34,7 @@ describe('Unit | session | createSession', () => { const newSession = createSession({ stickySession: false, sessionSampleRate: 1.0, - errorSampleRate: 0, + allowBuffering: false, }); expect(captureEventMock).not.toHaveBeenCalled(); @@ -49,7 +49,7 @@ describe('Unit | session | createSession', () => { const newSession = createSession({ stickySession: true, sessionSampleRate: 1.0, - errorSampleRate: 0, + allowBuffering: false, }); expect(captureEventMock).not.toHaveBeenCalled(); diff --git a/packages/replay/test/unit/session/fetchSession.test.ts b/packages/replay/test/unit/session/fetchSession.test.ts index 526c9c7969d1..cf1856e53356 100644 --- a/packages/replay/test/unit/session/fetchSession.test.ts +++ b/packages/replay/test/unit/session/fetchSession.test.ts @@ -28,6 +28,7 @@ describe('Unit | session | fetchSession', () => { segmentId: 0, sampled: 'session', started: 1648827162630, + shouldRefresh: true, }); }); @@ -43,6 +44,7 @@ describe('Unit | session | fetchSession', () => { segmentId: 0, sampled: false, started: 1648827162630, + shouldRefresh: true, }); }); diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts index be01f0602e51..2905e1bd72d6 100644 --- a/packages/replay/test/unit/session/getSession.test.ts +++ b/packages/replay/test/unit/session/getSession.test.ts @@ -17,9 +17,9 @@ jest.mock('@sentry/utils', () => { }; }); -const SAMPLE_RATES = { +const SAMPLE_OPTIONS = { sessionSampleRate: 1.0, - errorSampleRate: 0, + allowBuffering: false, }; function createMockSession(when: number = Date.now()) { @@ -29,6 +29,7 @@ function createMockSession(when: number = Date.now()) { lastActivity: when, started: when, sampled: 'session', + shouldRefresh: true, }); } @@ -53,7 +54,7 @@ describe('Unit | session | getSession', () => { maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, - ...SAMPLE_RATES, + ...SAMPLE_OPTIONS, }); expect(FetchSession.fetchSession).not.toHaveBeenCalled(); @@ -65,6 +66,7 @@ describe('Unit | session | getSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + shouldRefresh: true, }); // Should not have anything in storage @@ -81,7 +83,7 @@ describe('Unit | session | getSession', () => { maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, - ...SAMPLE_RATES, + ...SAMPLE_OPTIONS, }); expect(FetchSession.fetchSession).not.toHaveBeenCalled(); @@ -98,7 +100,7 @@ describe('Unit | session | getSession', () => { maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, - ...SAMPLE_RATES, + ...SAMPLE_OPTIONS, currentSession: makeSession({ id: 'old_session_id', lastActivity: Date.now() - 1001, @@ -126,7 +128,7 @@ describe('Unit | session | getSession', () => { }, stickySession: true, sessionSampleRate: 1.0, - errorSampleRate: 0.0, + allowBuffering: false, }); expect(FetchSession.fetchSession).toHaveBeenCalled(); @@ -138,6 +140,7 @@ describe('Unit | session | getSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + shouldRefresh: true, }); // Should not have anything in storage @@ -147,6 +150,7 @@ describe('Unit | session | getSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + shouldRefresh: true, }); }); @@ -162,7 +166,7 @@ describe('Unit | session | getSession', () => { }, stickySession: true, sessionSampleRate: 1.0, - errorSampleRate: 0.0, + allowBuffering: false, }); expect(FetchSession.fetchSession).toHaveBeenCalled(); @@ -174,6 +178,7 @@ describe('Unit | session | getSession', () => { lastActivity: now, sampled: 'session', started: now, + shouldRefresh: true, }); }); @@ -188,7 +193,7 @@ describe('Unit | session | getSession', () => { maxSessionLife: MAX_SESSION_LIFE, }, stickySession: true, - ...SAMPLE_RATES, + ...SAMPLE_OPTIONS, }); expect(FetchSession.fetchSession).toHaveBeenCalled(); @@ -208,7 +213,7 @@ describe('Unit | session | getSession', () => { maxSessionLife: MAX_SESSION_LIFE, }, stickySession: false, - ...SAMPLE_RATES, + ...SAMPLE_OPTIONS, currentSession: makeSession({ id: 'test_session_uuid_2', lastActivity: +new Date() - 500, diff --git a/packages/replay/test/unit/session/sessionSampling.test.ts b/packages/replay/test/unit/session/sessionSampling.test.ts index cb730006d572..7e5b27175011 100644 --- a/packages/replay/test/unit/session/sessionSampling.test.ts +++ b/packages/replay/test/unit/session/sessionSampling.test.ts @@ -1,9 +1,10 @@ -import { getSessionSampleType, makeSession } from '../../../src/session/Session'; +import { getSessionSampleType } from '../../../src/session/createSession'; +import { makeSession } from '../../../src/session/Session'; describe('Unit | session | sessionSampling', () => { it('does not sample', function () { const newSession = makeSession({ - sampled: getSessionSampleType(0, 0), + sampled: getSessionSampleType(0, false), }); expect(newSession.sampled).toBe(false); @@ -11,7 +12,7 @@ describe('Unit | session | sessionSampling', () => { it('samples using `sessionSampleRate`', function () { const newSession = makeSession({ - sampled: getSessionSampleType(1.0, 0), + sampled: getSessionSampleType(1.0, false), }); expect(newSession.sampled).toBe('session'); @@ -19,10 +20,10 @@ describe('Unit | session | sessionSampling', () => { it('samples using `errorSampleRate`', function () { const newSession = makeSession({ - sampled: getSessionSampleType(0, 1), + sampled: getSessionSampleType(0, true), }); - expect(newSession.sampled).toBe('error'); + expect(newSession.sampled).toBe('buffer'); }); it('does not run sampling function if existing session was sampled', function () { diff --git a/packages/replay/test/unit/util/createReplayEnvelope.test.ts b/packages/replay/test/unit/util/createReplayEnvelope.test.ts index e62c2f410d6a..76f22709b4cb 100644 --- a/packages/replay/test/unit/util/createReplayEnvelope.test.ts +++ b/packages/replay/test/unit/util/createReplayEnvelope.test.ts @@ -23,7 +23,7 @@ describe('Unit | util | createReplayEnvelope', () => { name: 'sentry.javascript.unknown', version: '7.25.0', }, - replay_type: 'error', + replay_type: 'buffer', contexts: { replay: { error_sample_rate: 0, @@ -68,7 +68,7 @@ describe('Unit | util | createReplayEnvelope', () => { event_id: REPLAY_ID, platform: 'javascript', replay_id: REPLAY_ID, - replay_type: 'error', + replay_type: 'buffer', sdk: { integrations: ['BrowserTracing', 'Replay'], name: 'sentry.javascript.unknown', version: '7.25.0' }, segment_id: 3, tags: {}, @@ -110,7 +110,7 @@ describe('Unit | util | createReplayEnvelope', () => { replay_id: REPLAY_ID, sdk: { integrations: ['BrowserTracing', 'Replay'], name: 'sentry.javascript.unknown', version: '7.25.0' }, segment_id: 3, - replay_type: 'error', + replay_type: 'buffer', tags: {}, timestamp: 1670837008.634, trace_ids: ['traceId'], diff --git a/packages/types/src/replay.ts b/packages/types/src/replay.ts index 975c1f0c8c59..65641ce011bd 100644 --- a/packages/types/src/replay.ts +++ b/packages/types/src/replay.ts @@ -24,4 +24,4 @@ export type ReplayRecordingData = string | Uint8Array; * NOTE: These types are still considered Beta and subject to change. * @hidden */ -export type ReplayRecordingMode = 'session' | 'error'; +export type ReplayRecordingMode = 'session' | 'buffer'; From 619c4b0112faef4f9b4d46c54239c4aa3ed52d38 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Apr 2023 16:01:34 +0200 Subject: [PATCH 24/28] Revert "doc(sveltekit): Promote the SDK to beta state (#7874)" (#7974) This reverts commit 13cbb1db0ca13cf3a5d229eedc71b95b4a189e01. --- packages/sveltekit/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index 48a13d757f48..21393e19c4e7 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -19,9 +19,9 @@ TODO: No docs yet, comment back in once we have docs ## SDK Status -This SDK is currently in **Beta state**. Bugs and issues might still appear and we're still actively working -on the SDK. Also, we're still adding features. -If you experience problems or have feedback, please open a [GitHub Issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). +This SDK is currently in **Alpha state** and we're still experimenting with APIs and functionality. +We therefore make no guarantees in terms of semver or breaking changes. +If you want to try this SDK and come across a problem, please open a [GitHub Issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). ## Compatibility @@ -31,7 +31,11 @@ Currently, the minimum supported version of SvelteKit is `1.0.0`. This package is a wrapper around `@sentry/node` for the server and `@sentry/svelte` for the client side, with added functionality related to SvelteKit. -## Setup +## Usage + +Although the SDK is not yet stable, you're more than welcome to give it a try and provide us with early feedback. + +**Here's how to get started:** ### 1. Prerequesits & Installation @@ -254,7 +258,7 @@ export default { ## Known Limitations -This SDK is still under active development. +This SDK is still under active development and several features are missing. Take a look at our [SvelteKit SDK Development Roadmap](https://github.com/getsentry/sentry-javascript/issues/6692) to follow the progress: - **Adapters** other than `@sveltejs/adapter-node` are currently not supported. From 5f9b9ceaa880352d9b77f9e06e184d0116f6a61c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Apr 2023 16:30:48 +0200 Subject: [PATCH 25/28] feat(sveltekit): Convert `sentryHandle` to a factory function (#7975) --- packages/sveltekit/README.md | 4 ++-- packages/sveltekit/src/server/handle.ts | 10 +++++++--- packages/sveltekit/test/server/handle.test.ts | 16 ++++++++-------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index 21393e19c4e7..188cbb776bf9 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -125,10 +125,10 @@ The Sentry SvelteKit SDK mostly relies on [SvelteKit Hooks](https://kit.svelte.d // hooks.server.(js|ts) import { sentryHandle } from '@sentry/sveltekit'; - export const handle = sentryHandle; + export const handle = sentryHandle(); // or alternatively, if you already have a handler defined, use the `sequence` function // see: https://kit.svelte.dev/docs/modules#sveltejs-kit-hooks-sequence - // export const handle = sequence(sentryHandle, yourHandler); + // export const handle = sequence(sentryHandle(), yourHandler()); ``` ### 4. Configuring `load` Functions diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts index 419e749d1732..88e0615d13bb 100644 --- a/packages/sveltekit/src/server/handle.ts +++ b/packages/sveltekit/src/server/handle.ts @@ -53,13 +53,17 @@ export const transformPageChunk: NonNullable { +export function sentryHandle(): Handle { + return sentryRequestHandler; +} + +const sentryRequestHandler: Handle = input => { // if there is an active transaction, we know that this handle call is nested and hence // we don't create a new domain for it. If we created one, nested server calls would // create new transactions instead of adding a child span to the currently active span. diff --git a/packages/sveltekit/test/server/handle.test.ts b/packages/sveltekit/test/server/handle.test.ts index 785aad1ca06e..94d471f20689 100644 --- a/packages/sveltekit/test/server/handle.test.ts +++ b/packages/sveltekit/test/server/handle.test.ts @@ -120,7 +120,7 @@ describe('handleSentry', () => { it('should return a response', async () => { let response: any = undefined; try { - response = await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) }); + response = await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); } catch (e) { expect(e).toBeInstanceOf(Error); expect(e.message).toEqual(type); @@ -136,7 +136,7 @@ describe('handleSentry', () => { }); try { - await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) }); + await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); } catch (e) { // } @@ -161,11 +161,11 @@ describe('handleSentry', () => { }); try { - await sentryHandle({ + await sentryHandle()({ event: mockEvent(), resolve: async _ => { // simulateing a nested load call: - await sentryHandle({ + await sentryHandle()({ event: mockEvent({ route: { id: 'api/users/details/[id]' } }), resolve: resolve(type, isError), }); @@ -216,7 +216,7 @@ describe('handleSentry', () => { }); try { - await sentryHandle({ event, resolve: resolve(type, isError) }); + await sentryHandle()({ event, resolve: resolve(type, isError) }); } catch (e) { // } @@ -256,7 +256,7 @@ describe('handleSentry', () => { }); try { - await sentryHandle({ event, resolve: resolve(type, isError) }); + await sentryHandle()({ event, resolve: resolve(type, isError) }); } catch (e) { // } @@ -280,7 +280,7 @@ describe('handleSentry', () => { }); try { - await sentryHandle({ event: mockEvent(), resolve: resolve(type, isError) }); + await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) }); } catch (e) { expect(mockCaptureException).toBeCalledTimes(1); expect(addEventProcessorSpy).toBeCalledTimes(1); @@ -296,7 +296,7 @@ describe('handleSentry', () => { const mockResolve = vi.fn().mockImplementation(resolve(type, isError)); const event = mockEvent(); try { - await sentryHandle({ event, resolve: mockResolve }); + await sentryHandle()({ event, resolve: mockResolve }); } catch (e) { expect(e).toBeInstanceOf(Error); expect(e.message).toEqual(type); From ec1a4415aa293cd7687838feac455fabce6958b2 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 16:32:15 +0200 Subject: [PATCH 26/28] test: Fix flaky replay DSC test (#7973) Also one test was actually incorrect. --- .github/workflows/build.yml | 2 +- .../suites/replay/dsc/test.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bac522026228..639ed4184099 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -511,7 +511,7 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_get_metadata.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-20.04 - timeout-minutes: 15 + timeout-minutes: 18 strategy: fail-fast: false matrix: diff --git a/packages/browser-integration-tests/suites/replay/dsc/test.ts b/packages/browser-integration-tests/suites/replay/dsc/test.ts index 810305711a15..f8f9560c00fc 100644 --- a/packages/browser-integration-tests/suites/replay/dsc/test.ts +++ b/packages/browser-integration-tests/suites/replay/dsc/test.ts @@ -5,8 +5,10 @@ import type { EventEnvelopeHeaders } from '@sentry/types'; import { sentryTest } from '../../../utils/fixtures'; import { envelopeHeaderRequestParser, + envelopeRequestParser, getFirstSentryEnvelopeRequest, shouldSkipTracingTest, + waitForTransactionRequest, } from '../../../utils/helpers'; import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../utils/replayHelpers'; @@ -21,6 +23,8 @@ sentryTest('should add replay_id to dsc of transactions', async ({ getLocalTestP const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); + await waitForReplayRunning(page); + await page.evaluate(() => { (window as unknown as TestWindow).Sentry.configureScope(scope => { scope.setUser({ id: 'user123', segment: 'segmentB' }); @@ -30,7 +34,6 @@ sentryTest('should add replay_id to dsc of transactions', async ({ getLocalTestP const envHeader = await getFirstSentryEnvelopeRequest(page, url, envelopeHeaderRequestParser); - await waitForReplayRunning(page); const replay = await getReplaySnapshot(page); expect(replay.session?.id).toBeDefined(); @@ -65,6 +68,10 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); + await waitForReplayRunning(page); + + const transactionReq = waitForTransactionRequest(page); + await page.evaluate(async () => { await (window as unknown as TestWindow).Replay.stop(); @@ -74,12 +81,13 @@ sentryTest( }); }); - const envHeader = await getFirstSentryEnvelopeRequest(page, url, envelopeHeaderRequestParser); + const req0 = await transactionReq; + + const envHeader = envelopeRequestParser(req0, 0) as EventEnvelopeHeaders; - await waitForReplayRunning(page); const replay = await getReplaySnapshot(page); - expect(replay.session?.id).toBeDefined(); + expect(replay.session).toBeUndefined(); expect(envHeader.trace).toBeDefined(); expect(envHeader.trace).toEqual({ From 54d588e10f21d6129027f9a0d885a9bdd31dda9c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Apr 2023 16:33:42 +0200 Subject: [PATCH 27/28] doc(sveltekit): Promote the SDK to beta state (#7976) --- packages/sveltekit/README.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index 188cbb776bf9..506e134168a3 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -19,9 +19,9 @@ TODO: No docs yet, comment back in once we have docs ## SDK Status -This SDK is currently in **Alpha state** and we're still experimenting with APIs and functionality. -We therefore make no guarantees in terms of semver or breaking changes. -If you want to try this SDK and come across a problem, please open a [GitHub Issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). +This SDK is currently in **Beta state**. Bugs and issues might still appear and we're still actively working +on the SDK. Also, we're still adding features. +If you experience problems or have feedback, please open a [GitHub Issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). ## Compatibility @@ -31,11 +31,7 @@ Currently, the minimum supported version of SvelteKit is `1.0.0`. This package is a wrapper around `@sentry/node` for the server and `@sentry/svelte` for the client side, with added functionality related to SvelteKit. -## Usage - -Although the SDK is not yet stable, you're more than welcome to give it a try and provide us with early feedback. - -**Here's how to get started:** +## Setup ### 1. Prerequesits & Installation @@ -258,7 +254,7 @@ export default { ## Known Limitations -This SDK is still under active development and several features are missing. +This SDK is still under active development. Take a look at our [SvelteKit SDK Development Roadmap](https://github.com/getsentry/sentry-javascript/issues/6692) to follow the progress: - **Adapters** other than `@sveltejs/adapter-node` are currently not supported. From da799e91298f8c2e73f11ccf80496b2bf254067c Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 26 Apr 2023 16:38:06 +0200 Subject: [PATCH 28/28] meta(changelog): Update Changelog for 7.50.0 --- CHANGELOG.md | 100 ++++++++++++++++++++++++++++++++++++++ packages/replay/README.md | 25 +++++++--- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2241d8cb8442..7c441a29b442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,106 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.50.0 + +### Important Changes + +- **doc(sveltekit): Promote the SDK to beta state (#7976)** + - feat(sveltekit): Convert `sentryHandle` to a factory function (#7975) + +With this release, the Sveltekit SDK ([@sentry/sveltekit](./packages/sveltekit/README.md)) is promoted to Beta. +This means that we do not expect any more breaking changes. + +The final breaking change is that `sentryHandle` is now a function. +So in order to update to 7.50.0, you have to update your `hooks.server.js` file: + +```js +// hooks.server.js + +// Old: +export const handle = sentryHandle; +// New: +export const handle = sentryHandle(); +``` + +- **feat(replay): Allow to configure URLs to capture network bodies/headers (#7953)** + +You can now capture request/response bodies & headers of network requests in Replay. +You have to define an allowlist of URLs you want to capture additional information for: + +```js +new Replay({ + networkDetailAllowUrls: ['https://sentry.io/api'], +}); +``` + +By default, we will capture request/response bodies, as well as the request/response headers `content-type`, `content-length` and `accept`. +You can configure this with some additional configuration: + +```js +new Replay({ + networkDetailAllowUrls: ['https://sentry.io/api'], + // opt-out of capturing bodies + networkCaptureBodies: false, + // These headers are captured _in addition to_ the default headers + networkRequestHeaders: ['X-Custom-Header'], + networkResponseHeaders: ['X-Custom-Header', 'X-Custom-Header-2'] +}); +``` + +Note that bodies will be truncated to a max length of ~150k characters. + +**- feat(replay): Changes of sampling behavior & public API** + - feat(replay): Change the behavior of error-based sampling (#7768) + - feat(replay): Change `flush()` API to record current event buffer (#7743) + - feat(replay): Change `stop()` to flush and remove current session (#7741) + +We have changed the behavior of error-based sampling, as well as adding & adjusting APIs a bit to be more aligned with expectations. +See [Sampling](./packages/replay/README.md#sampling) for details. + +We've also revamped some public APIs in order to be better aligned with expectations. See [Stoping & Starting Replays manually](./packages/replay/README.md#stopping--starting-replays-manually) for details. + +- **feat(core): Add multiplexed transport (#7926)** + +We added a new transport to support multiplexing. +With this, you can configure Sentry to send events to different DSNs, depending on a logic of your choosing: + +```js +import { makeMultiplexedTransport } from '@sentry/core'; +import { init, captureException, makeFetchTransport } from '@sentry/browser'; + +function dsnFromFeature({ getEvent }) { + const event = getEvent(); + switch(event?.tags?.feature) { + case 'cart': + return ['__CART_DSN__']; + case 'gallery': + return ['__GALLERY_DSN__']; + } + return [] +} + +init({ + dsn: '__FALLBACK_DSN__', + transport: makeMultiplexedTransport(makeFetchTransport, dsnFromFeature) +}); +``` + +### Additional Features and Fixes + +- feat(nextjs): Add `disableLogger` option that automatically tree shakes logger statements (#7908) +- feat(node): Make Undici a default integration. (#7967) +- feat(replay): Extend session idle time until expire to 15min (#7955) +- feat(tracing): Add `db.system` span data to DB spans (#7952) +- fix(core): Avoid crash when Function.prototype is frozen (#7899) +- fix(nextjs): Fix inject logic for Next.js 13.3.1 canary (#7921) +- fix(replay): Ensure console breadcrumb args are truncated (#7917) +- fix(replay): Ensure we do not set replayId on dsc if replay is disabled (#7939) +- fix(replay): Ensure we still truncate large bodies if they are failed JSON (#7923) +- fix(utils): default normalize() to a max. of 100 levels deep instead of Inifnity (#7957) + +Work in this release contributed by @Jack-Works. Thank you for your contribution! + ## 7.49.0 ### Important Changes diff --git a/packages/replay/README.md b/packages/replay/README.md index 8c0ea81e5cba..a790370c2d49 100644 --- a/packages/replay/README.md +++ b/packages/replay/README.md @@ -86,9 +86,9 @@ import * as Sentry from "@sentry/browser"; Sentry.setUser({ email: "jane.doe@example.com" }); ``` -### Stopping & re-starting replays +### Stopping & starting Replays manually -Replay recording only starts when it is included in the `integrations` array when calling `Sentry.init` or calling `addIntegration` from the a Sentry client instance. To stop recording you can call the `stop()`. +Replay recording only starts when it is included in the `integrations` array when calling `Sentry.init` or calling `addIntegration` from the a Sentry client instance. To stop recording you can call `stop()`. ```js import * as Sentry from "@sentry/react"; @@ -109,6 +109,16 @@ client?.addIntegration(replay); replay.stop(); ``` +When both `replaysSessionSampleRate` and `replaysOnErrorSampleRate` are `0`, recording will _not_ start. +In this case, you can manually start recording: + +```js +replay.start(); // Will start a session in "session" mode, regardless of sample rates +replay.startBuffering(); // Will start a session in "buffer" mode, regardless of sample rates +``` + + + ## Loading Replay as a CDN Bundle As an alternative to the NPM package, you can use Replay as a CDN bundle. @@ -154,8 +164,11 @@ Sampling allows you to control how much of your website's traffic will result in - `replaysSessionSampleRate` - The sample rate for replays that begin recording immediately and last the entirety of the user's session. - `replaysOnErrorSampleRate` - The sample rate for replays that are recorded when an error happens. This type of replay will record up to a minute of events prior to the error and continue recording until the session ends. -Sampling occurs when the session is first started. `replaysSessionSampleRate` is evaluated first. If it is sampled, then the replay recording begins. Otherwise, `replaysOnErrorSampleRate` is evaluated and if it is sampled, the integration will begin buffering the replay and will only upload a replay to Sentry when an error occurs. The remainder of the replay will behave similarly to a whole-session replay. - +When Replay is initialized, we check the `replaysSessionSampleRate`. +If it is sampled, then we start recording & sending Replay data immediately. +Else, if `replaysOnErrorSampleRate > 0`, we'll start recording in buffering mode. +In this mode, whenever an error occurs we'll check `replaysOnErrorSampleRate`. +If it is sampled, when we'll upload the Replay to Sentry and continue recording normally. ## Configuration @@ -234,5 +247,5 @@ This should not happen to often, but be aware that it is theoretically possible. ## Manually sending replay data You can use `replay.flush()` to immediately send all currently captured replay data. -This can be combined with `replaysOnErrorSampleRate: 1` -in order to be able to send the last 60 seconds of replay data on-demand. +When Replay is currently in buffering mode, this will send up to the last 60 seconds of replay data, +and also continue sending afterwards, similar to when an error happens & is recorded.