From 2c6e710b9c54a015ec7b8e9c28257895fddde881 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 24 Oct 2025 10:31:35 +0200 Subject: [PATCH 001/186] feat(react-router): Align options with shared build time options type (#18014) closes https://github.com/getsentry/sentry-javascript/issues/17066 Also updates the shared `BuildTimeOptionsBase` type a bit to align with the latest changes. --- .../buildTimeOptionsBase.ts | 171 ++++++++++-------- packages/react-router/.eslintrc.js | 2 +- .../src/vite/buildEnd/handleOnBuildEnd.ts | 33 +++- packages/react-router/src/vite/plugin.ts | 4 +- packages/react-router/src/vite/types.ts | 156 +++------------- .../test/client/tracingIntegration.test.ts | 1 + .../server/createSentryHandleRequest.test.ts | 7 +- .../instrumentation/reactRouterServer.test.ts | 3 + .../vite/buildEnd/handleOnBuildEnd.test.ts | 13 ++ .../react-router/test/vite/types.test-d.ts | 114 ++++++++++++ packages/react-router/tsconfig.test.json | 2 +- packages/react-router/tsconfig.vite.json | 9 + packages/react-router/vite.config.ts | 4 + 13 files changed, 299 insertions(+), 220 deletions(-) create mode 100644 packages/react-router/test/vite/types.test-d.ts create mode 100644 packages/react-router/tsconfig.vite.json diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index e43160cc03ab..67f74f696dcf 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -243,6 +243,37 @@ interface SourceMapsOptions { filesToDeleteAfterUpload?: string | Array; } +type AutoSetCommitsOptions = { + /** + * Automatically sets `commit` and `previousCommit`. Sets `commit` to `HEAD` + * and `previousCommit` as described in the option's documentation. + * + * If you set this to `true`, manually specified `commit` and `previousCommit` + * options will be overridden. It is best to not specify them at all if you + * set this option to `true`. + */ + auto: true; + repo?: undefined; + commit?: undefined; +}; + +type ManualSetCommitsOptions = { + auto?: false | undefined; + /** + * The full repo name as defined in Sentry. + * + * Required if the `auto` option is not set to `true`. + */ + repo: string; + + /** + * The current (last) commit in the release. + * + * Required if the `auto` option is not set to `true`. + */ + commit: string; +}; + interface ReleaseOptions { /** * Unique identifier for the release you want to create. @@ -299,101 +330,81 @@ interface ReleaseOptions { /** * Configuration for associating the release with its commits in Sentry. + * + * Set to `false` to disable commit association. + * + * @default { auto: true } */ - setCommits?: ( - | { + setCommits?: + | false + | ((AutoSetCommitsOptions | ManualSetCommitsOptions) & { /** - * Automatically sets `commit` and `previousCommit`. Sets `commit` to `HEAD` - * and `previousCommit` as described in the option's documentation. + * The commit before the beginning of this release (in other words, + * the last commit of the previous release). + * + * Defaults to the last commit of the previous release in Sentry. * - * If you set this to `true`, manually specified `commit` and `previousCommit` - * options will be overridden. It is best to not specify them at all if you - * set this option to `true`. + * If there was no previous release, the last 10 commits will be used. */ - auto: true; - repo?: undefined; - commit?: undefined; - } - | { - auto?: false | undefined; + previousCommit?: string; + /** - * The full repo name as defined in Sentry. + * If the flag is to `true` and the previous release commit was not found + * in the repository, the plugin creates a release with the default commits + * count instead of failing the command. * - * Required if the `auto` option is not set to `true`. + * @default false */ - repo: string; + ignoreMissing?: boolean; /** - * The current (last) commit in the release. + * If this flag is set, the setCommits step will not fail and just exit + * silently if no new commits for a given release have been found. * - * Required if the `auto` option is not set to `true`. + * @default false */ - commit: string; - } - ) & { - /** - * The commit before the beginning of this release (in other words, - * the last commit of the previous release). - * - * Defaults to the last commit of the previous release in Sentry. - * - * If there was no previous release, the last 10 commits will be used. - */ - previousCommit?: string; - - /** - * If the flag is to `true` and the previous release commit was not found - * in the repository, the plugin creates a release with the default commits - * count instead of failing the command. - * - * @default false - */ - ignoreMissing?: boolean; - - /** - * If this flag is set, the setCommits step will not fail and just exit - * silently if no new commits for a given release have been found. - * - * @default false - */ - ignoreEmpty?: boolean; - }; + ignoreEmpty?: boolean; + }); /** * Configuration for adding deployment information to the release in Sentry. + * + * Set to `false` to disable automatic deployment detection and creation. */ - deploy?: { - /** - * Environment for this release. Values that make sense here would - * be `production` or `staging`. - */ - env: string; - - /** - * Deployment start time in Unix timestamp (in seconds) or ISO 8601 format. - */ - started?: number | string; - - /** - * Deployment finish time in Unix timestamp (in seconds) or ISO 8601 format. - */ - finished?: number | string; - - /** - * Deployment duration (in seconds). Can be used instead of started and finished. - */ - time?: number; - - /** - * Human-readable name for the deployment. - */ - name?: string; - - /** - * URL that points to the deployment. - */ - url?: string; - }; + deploy?: + | false + | { + /** + * Environment for this release. Values that make sense here would + * be `production` or `staging`. + */ + env: string; + + /** + * Deployment start time in Unix timestamp (in seconds) or ISO 8601 format. + */ + started?: number | string; + + /** + * Deployment finish time in Unix timestamp (in seconds) or ISO 8601 format. + */ + finished?: number | string; + + /** + * Deployment duration (in seconds). Can be used instead of started and finished. + */ + time?: number; + + /** + * Human-readable name for the deployment. + */ + name?: string; + + /** + * URL that points to the deployment. + */ + url?: string; + }; } interface BundleSizeOptimizationsOptions { diff --git a/packages/react-router/.eslintrc.js b/packages/react-router/.eslintrc.js index a22f9710cf6b..e6ea40d78d05 100644 --- a/packages/react-router/.eslintrc.js +++ b/packages/react-router/.eslintrc.js @@ -7,7 +7,7 @@ module.exports = { { files: ['vite.config.ts'], parserOptions: { - project: ['tsconfig.test.json'], + project: ['tsconfig.vite.json'], }, }, ], diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 959578b6d644..a3d1e78cb285 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -1,6 +1,7 @@ import { rm } from 'node:fs/promises'; import type { Config } from '@react-router/dev/config'; import SentryCli from '@sentry/cli'; +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import { glob } from 'glob'; import type { SentryReactRouterBuildOptions } from '../types'; @@ -23,17 +24,31 @@ function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions { export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteConfig }) => { const sentryConfig = getSentryConfig(viteConfig); + // todo(v11): Remove deprecated sourceMapsUploadOptions support (no need for spread/pick anymore) + const { + sourceMapsUploadOptions, // extract to exclude from rest config + ...sentryConfigWithoutDeprecatedSourceMapOption + } = sentryConfig; + const { authToken, org, project, release, - sourceMapsUploadOptions = { enabled: true }, + sourcemaps = { disable: false }, debug = false, - unstable_sentryVitePluginOptions, - }: SentryReactRouterBuildOptions = { + }: Omit & + // Pick 'sourcemaps' from Vite plugin options as the types allow more (e.g. Promise values for `deleteFilesAfterUpload`) + Pick = { ...sentryConfig.unstable_sentryVitePluginOptions, - ...sentryConfig, + ...sentryConfigWithoutDeprecatedSourceMapOption, // spread in the config without the deprecated sourceMapsUploadOptions + sourcemaps: { + ...sentryConfig.unstable_sentryVitePluginOptions?.sourcemaps, + ...sentryConfig.sourcemaps, + ...sourceMapsUploadOptions, + // eslint-disable-next-line deprecation/deprecation + disable: sourceMapsUploadOptions?.enabled === false ? true : sentryConfig.sourcemaps?.disable, + }, release: { ...sentryConfig.unstable_sentryVitePluginOptions?.release, ...sentryConfig.release, @@ -44,8 +59,9 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo authToken, org, project, - ...unstable_sentryVitePluginOptions, + ...sentryConfig.unstable_sentryVitePluginOptions, }); + // check if release should be created if (release?.name) { try { @@ -56,7 +72,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo } } - if (sourceMapsUploadOptions?.enabled ?? (true && viteConfig.build.sourcemap !== false)) { + if (!sourcemaps?.disable && viteConfig.build.sourcemap !== false) { // inject debugIds try { await cliInstance.execute( @@ -84,9 +100,10 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo } } // delete sourcemaps after upload - let updatedFilesToDeleteAfterUpload = sourceMapsUploadOptions?.filesToDeleteAfterUpload; + let updatedFilesToDeleteAfterUpload = await sourcemaps?.filesToDeleteAfterUpload; + // set a default value no option was set - if (typeof sourceMapsUploadOptions?.filesToDeleteAfterUpload === 'undefined') { + if (typeof updatedFilesToDeleteAfterUpload === 'undefined') { updatedFilesToDeleteAfterUpload = [`${reactRouterConfig.buildDirectory}/**/*.map`]; debug && // eslint-disable-next-line no-console diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 98405771ee1b..8163ee4c6a1b 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -14,13 +14,13 @@ import type { SentryReactRouterBuildOptions } from './types'; */ export async function sentryReactRouter( options: SentryReactRouterBuildOptions = {}, - config: ConfigEnv, + viteConfig: ConfigEnv, ): Promise { const plugins: Plugin[] = []; plugins.push(makeConfigInjectorPlugin(options)); - if (process.env.NODE_ENV !== 'development' && config.command === 'build' && config.mode !== 'development') { + if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(makeEnableSourceMapsPlugin(options)); plugins.push(...(await makeCustomSentryVitePlugins(options))); } diff --git a/packages/react-router/src/vite/types.ts b/packages/react-router/src/vite/types.ts index fb488d2ca8bc..c7555630c4fa 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -1,3 +1,4 @@ +import type { BuildTimeOptionsBase, UnstableVitePluginOptions } from '@sentry/core'; import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; type SourceMapsOptions = { @@ -6,6 +7,7 @@ type SourceMapsOptions = { * automatically generate and upload source maps to Sentry during a production build. * * @default true + * @deprecated Use `sourcemaps.disable` option instead of `sourceMapsUploadOptions.enabled`. */ enabled?: boolean; @@ -16,6 +18,8 @@ type SourceMapsOptions = { * @default [] - By default no files are deleted. * * The globbing patterns follow the implementation of the glob package. (https://www.npmjs.com/package/glob) + * + * @deprecated Use `sourcemaps.filesToDeleteAfterUpload` option instead of `sourceMapsUploadOptions.filesToDeleteAfterUpload`. */ filesToDeleteAfterUpload?: string | Array; @@ -23,7 +27,10 @@ type SourceMapsOptions = { * Options related to managing the Sentry releases for a build. * * More info: https://docs.sentry.io/product/releases/ + * + * @deprecated Use the `release` option at the root of `SentryVitePluginOptions` instead. */ + // todo(v11): Remove this option (currently it's not in use either, but it's kept to not cause a breaking change) release?: { /** * Unique identifier for the release you want to create. @@ -40,136 +47,31 @@ type SourceMapsOptions = { }; }; -type BundleSizeOptimizationOptions = { - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) any debugging code within the Sentry SDK. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * Setting this option to `true` will disable features like the SDK's `debug` option. - */ - excludeDebugStatements?: boolean; - - /** - * If set to true, the plugin will try to tree-shake tracing statements out. - * Note that the success of this depends on tree shaking generally being enabled in your build. - * Attention: DO NOT enable this when you're using any performance monitoring-related SDK features (e.g. Sentry.startSpan()). - */ - excludeTracing?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay Shadow DOM recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * This option is safe to be used when you do not want to capture any Shadow DOM activity via Sentry Session Replay. - */ - excludeReplayShadowDom?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay `iframe` recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * You can safely do this when you do not want to capture any `iframe` activity via Sentry Session Replay. - */ - excludeReplayIframe?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay's Compression Web Worker. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * **Notice:** You should only do use this option if you manually host a compression worker and configure it in your Sentry Session Replay integration config via the `workerUrl` option. - */ - excludeReplayWorker?: boolean; -}; - -export type SentryReactRouterBuildOptions = { - /** - * Options for configuring the Sentry release. - */ - release?: { - /** - * The name of the release to create in Sentry - */ - name?: string; - }; - - /** - * The auth token to use when uploading source maps to Sentry. - * - * Instead of specifying this option, you can also set the `SENTRY_AUTH_TOKEN` environment variable. - * - * To create an auth token, follow this guide: - * @see https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens - */ - authToken?: string; - - /** - * The organization slug of your Sentry organization. - * Instead of specifying this option, you can also set the `SENTRY_ORG` environment variable. - */ - org?: string; - - /** - * The project slug of your Sentry project. - * Instead of specifying this option, you can also set the `SENTRY_PROJECT` environment variable. - */ - project?: string; - - /** - * Options for the Sentry Vite plugin to customize bundle size optimizations. - */ - bundleSizeOptimizations?: BundleSizeOptimizationOptions; - - /** - * If this flag is `true`, Sentry will log debug information during build time. - * @default false. - */ - debug?: boolean; - - /** - * Options related to react component name annotations. - * Disabled by default, unless a value is set for this option. - * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. - * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. - * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components - */ - reactComponentAnnotation?: { +export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & + UnstableVitePluginOptions> & { /** - * Whether the component name annotate plugin should be enabled or not. + * Options related to react component name annotations. + * Disabled by default, unless a value is set for this option. + * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. + * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. + * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components */ - enabled?: boolean; + reactComponentAnnotation?: { + /** + * Whether the component name annotate plugin should be enabled or not. + */ + enabled?: boolean; + + /** + * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. + */ + ignoredComponents?: string[]; + }; /** - * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. + * Options for the Sentry Vite plugin to customize the source maps upload process. + * */ - ignoredComponents?: string[]; + sourceMapsUploadOptions?: SourceMapsOptions; + // todo(v11): Remove this option (all options already exist in BuildTimeOptionsBase) }; - - /** - * Options for the Sentry Vite plugin to customize the source maps upload process. - * - */ - sourceMapsUploadOptions?: SourceMapsOptions; - - /** - * If this flag is `true`, the Sentry plugin will collect some telemetry data and send it to Sentry. - * It will not collect any sensitive or user-specific data. - * - * @default true - */ - telemetry?: boolean; - - /** - * Options to further customize the Sentry Vite Plugin (@sentry/vite-plugin) behavior directly. - * Options specified in this object take precedence over the options specified in - * the `sourcemaps` and `release` objects. - * - * @see https://www.npmjs.com/package/@sentry/vite-plugin/v/2.22.2#options which lists all available options. - * - * Warning: Options within this object are subject to change at any time. - * We DO NOT guarantee semantic versioning for these options, meaning breaking - * changes can occur at any time within a major SDK version. - * - * Furthermore, some options are untested with SvelteKit specifically. Use with caution. - */ - unstable_sentryVitePluginOptions?: Partial; -}; diff --git a/packages/react-router/test/client/tracingIntegration.test.ts b/packages/react-router/test/client/tracingIntegration.test.ts index 9d511b4c6bde..2469c9b29db6 100644 --- a/packages/react-router/test/client/tracingIntegration.test.ts +++ b/packages/react-router/test/client/tracingIntegration.test.ts @@ -17,6 +17,7 @@ describe('reactRouterTracingIntegration', () => { it('calls instrumentHydratedRouter and browserTracingIntegrationInstance.afterAllSetup in afterAllSetup', () => { const browserTracingSpy = vi.spyOn(sentryBrowser, 'browserTracingIntegration').mockImplementation(() => ({ + setup: vi.fn(), afterAllSetup: vi.fn(), name: 'BrowserTracing', })); diff --git a/packages/react-router/test/server/createSentryHandleRequest.test.ts b/packages/react-router/test/server/createSentryHandleRequest.test.ts index 19e6d9542cbb..f87de2f3b0ea 100644 --- a/packages/react-router/test/server/createSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/createSentryHandleRequest.test.ts @@ -42,8 +42,13 @@ describe('createSentryHandleRequest', () => { url: '/test', version: '1.0.0', }, + ssr: true, + routeDiscovery: { + mode: 'lazy', + manifestPath: '/path/to/manifest', + }, routeModules: {}, - future: {}, + future: { unstable_subResourceIntegrity: false, v8_middleware: false }, isSpaMode: false, staticHandlerContext: { matches: [ diff --git a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts index 473ad1272ca4..fb5141f8830d 100644 --- a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts +++ b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts @@ -88,10 +88,13 @@ describe('ReactRouterInstrumentation', () => { it('should start a span for data requests with active root span', async () => { vi.spyOn(Util, 'isDataRequest').mockReturnValue(true); + // @ts-expect-error MockSpan just for testing vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(mockSpan as Span); + // @ts-expect-error MockSpan just for testing vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue(mockSpan as Span); vi.spyOn(SentryCore, 'spanToJSON').mockReturnValue({ data: {} } as SpanJSON); vi.spyOn(Util, 'getSpanName').mockImplementation((pathname, method) => `span:${pathname}:${method}`); + // @ts-expect-error MockSpan just for testing vi.spyOn(SentryCore, 'startSpan').mockImplementation((_opts, fn) => fn(mockSpan as Span)); const proxy = (instrumentation as any)._createPatchedModuleProxy(mockModule); diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index 29bb5c0527d6..8be1d7764219 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -81,6 +81,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.new).toHaveBeenCalledWith('v1.0.0'); @@ -102,6 +103,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.new).toHaveBeenCalledWith('v1.0.0-unstable'); @@ -126,6 +128,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.new).toHaveBeenCalledWith('v1.0.0'); @@ -145,6 +148,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.uploadSourceMaps).toHaveBeenCalledTimes(1); @@ -168,12 +172,14 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); }); it('should delete source maps after upload with default pattern', async () => { + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); expect(glob).toHaveBeenCalledWith(['/build/**/*.map'], { @@ -196,6 +202,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(glob).toHaveBeenCalledWith('/custom/**/*.map', { @@ -221,6 +228,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not create release', expect.any(Error)); @@ -241,6 +249,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); @@ -250,6 +259,7 @@ describe('sentryOnBuildEnd', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockSentryCliInstance.execute.mockRejectedValueOnce(new Error('Injection failed')); + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); expect(mockSentryCliInstance.execute).toHaveBeenCalledTimes(1); expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); @@ -262,6 +272,7 @@ describe('sentryOnBuildEnd', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockSentryCliInstance.releases.uploadSourceMaps.mockRejectedValueOnce(new Error('Upload failed')); + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', expect.any(Error)); @@ -282,6 +293,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Sentry] Automatically setting')); @@ -312,6 +324,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(SentryCli).toHaveBeenCalledWith(null, expect.objectContaining(customOptions)); diff --git a/packages/react-router/test/vite/types.test-d.ts b/packages/react-router/test/vite/types.test-d.ts new file mode 100644 index 000000000000..c6a50e92b275 --- /dev/null +++ b/packages/react-router/test/vite/types.test-d.ts @@ -0,0 +1,114 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { SentryReactRouterBuildOptions } from '../../src/vite/types'; + +describe('Sentry React-Router build-time options type', () => { + it('includes all options based on type BuildTimeOptionsBase', () => { + const completeOptions: SentryReactRouterBuildOptions = { + // --- BuildTimeOptionsBase options --- + org: 'test-org', + project: 'test-project', + authToken: 'test-auth-token', + sentryUrl: 'https://sentry.io', + headers: { Authorization: ' Bearer test-auth-token' }, + telemetry: true, + silent: false, + // eslint-disable-next-line no-console + errorHandler: (err: Error) => console.warn(err), + debug: false, + sourcemaps: { + disable: false, + assets: ['./dist/**/*'], + ignore: ['./dist/*.map'], + filesToDeleteAfterUpload: ['./dist/*.map'], + }, + release: { + name: 'test-release-1.0.0', + create: true, + finalize: true, + dist: 'test-dist', + vcsRemote: 'origin', + setCommits: { + auto: false, + repo: 'test/repo', + commit: 'abc123', + previousCommit: 'def456', + ignoreMissing: false, + ignoreEmpty: false, + }, + deploy: { + env: 'production', + started: 1234567890, + finished: 1234567900, + time: 10, + name: 'deployment-name', + url: 'https://example.com', + }, + }, + bundleSizeOptimizations: { + excludeDebugStatements: true, + excludeTracing: false, + excludeReplayShadowDom: true, + excludeReplayIframe: true, + excludeReplayWorker: true, + }, + + // --- SentryReactRouterBuildOptions specific options --- + reactComponentAnnotation: { enabled: true, ignoredComponents: ['IgnoredComponent1', 'IgnoredComponent2'] }, + + unstable_sentryVitePluginOptions: { + // Rollup plugin options + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + // Vite plugin options + sourcemaps: { + assets: './dist/**/*', + }, + }, + }; + + expectTypeOf(completeOptions).toEqualTypeOf(); + }); + + it('includes all deprecated options', () => { + const completeOptions: SentryReactRouterBuildOptions = { + // SentryNuxtModuleOptions specific options + reactComponentAnnotation: { enabled: true, ignoredComponents: ['IgnoredComponent1', 'IgnoredComponent2'] }, + + unstable_sentryVitePluginOptions: { + // Rollup plugin options + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + // Vite plugin options + sourcemaps: { + assets: './dist/**/*', + }, + }, + + // Deprecated sourceMapsUploadOptions + sourceMapsUploadOptions: { + release: { + name: 'deprecated-release', + }, + enabled: true, + filesToDeleteAfterUpload: ['./build/*.map'], + }, + }; + + expectTypeOf(completeOptions).toEqualTypeOf(); + }); + + it('allows partial configuration', () => { + const minimalOptions: SentryReactRouterBuildOptions = { reactComponentAnnotation: { enabled: true } }; + + expectTypeOf(minimalOptions).toEqualTypeOf(); + + const partialOptions: SentryReactRouterBuildOptions = { + reactComponentAnnotation: { enabled: true }, + debug: false, + }; + + expectTypeOf(partialOptions).toEqualTypeOf(); + }); +}); diff --git a/packages/react-router/tsconfig.test.json b/packages/react-router/tsconfig.test.json index 508cf3ea381b..0bb16039ac48 100644 --- a/packages/react-router/tsconfig.test.json +++ b/packages/react-router/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*", "vite.config.ts"], + "include": ["test/**/*"], "compilerOptions": { "types": ["node"] diff --git a/packages/react-router/tsconfig.vite.json b/packages/react-router/tsconfig.vite.json new file mode 100644 index 000000000000..3e2d75a55e61 --- /dev/null +++ b/packages/react-router/tsconfig.vite.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + + "include": ["vite.config.ts"], + + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts index 1094fe0d79da..d05c71037c75 100644 --- a/packages/react-router/vite.config.ts +++ b/packages/react-router/vite.config.ts @@ -5,5 +5,9 @@ export default { test: { ...baseConfig.test, environment: 'jsdom', + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + }, }, }; From 2e78b06dc374d57228f08cfa764f3e08bcfbd1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 24 Oct 2025 10:52:23 +0200 Subject: [PATCH 002/186] feat: Add a note to save changes before starting (#17987) During the release I figured that most of the times you might work on features/bugfixes. In that case the current release process this scenario isn't specified and who knows what it might or might not do. I added point 1. and 9. where we ask Cursor to save the changes (1) and go back to the state it was before (9). This would make the release process more complete. --------- Co-authored-by: Charly Gomez --- .cursor/rules/publishing_release.mdc | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.cursor/rules/publishing_release.mdc b/.cursor/rules/publishing_release.mdc index 4d6fecca5d2a..f50a5ea57f93 100644 --- a/.cursor/rules/publishing_release.mdc +++ b/.cursor/rules/publishing_release.mdc @@ -12,13 +12,18 @@ Use these guidelines when publishing a new Sentry JavaScript SDK release. The release process is outlined in [publishing-a-release.md](mdc:docs/publishing-a-release.md). -1. Make sure you are on the latest version of the `develop` branch. To confirm this, run `git pull origin develop` to get the latest changes from the repo. +1. Ensure you're on the `develop` branch with the latest changes: + - If you have unsaved changes, stash them with `git stash -u`. + - If you're on a different branch than `develop`, check out the develop branch using `git checkout develop`. + - Pull the latest updates from the remote repository by running `git pull origin develop`. + 2. Run `yarn changelog` on the `develop` branch and copy the output. You can use `yarn changelog | pbcopy` to copy the output of `yarn changelog` into your clipboard. 3. Decide on a version for the release based on [semver](mdc:https://semver.org). The version should be decided based on what is in included in the release. For example, if the release includes a new feature, we should increment the minor version. If it includes only bug fixes, we should increment the patch version. You can find the latest version in [CHANGELOG.md](mdc:CHANGELOG.md) at the very top. 4. Create a branch `prepare-release/VERSION`, eg. `prepare-release/8.1.0`, off `develop`. -5. Update [CHANGELOG.md](mdc:CHANGELOG.md) to add an entry for the next release number and a list of changes since the last release from the output of `yarn changelog`. See the `Updating the Changelog` section in [publishing-a-release.md](mdc:docs/publishing-a-release.md) for more details. If you remove changelog entries because they are not applicable, please let the user know. +5. Update [CHANGELOG.md](mdc:CHANGELOG.md) to add an entry for the next release number and a list of changes since the last release from the output of `yarn changelog`. See the `Updating the Changelog` section in [publishing-a-release.md](mdc:docs/publishing-a-release.md) for more details. Do not remove any changelog entries. 6. Commit the changes to [CHANGELOG.md](mdc:CHANGELOG.md) with `meta(changelog): Update changelog for VERSION` where `VERSION` is the version of the release, e.g. `meta(changelog): Update changelog for 8.1.0` 7. Push the `prepare-release/VERSION` branch to origin and remind the user that the release PR needs to be opened from the `master` branch. +8. In case you were working on a different branch, you can checkout back to the branch you were working on and continue your work by unstashing the changes you stashed earlier with the command `git stash pop` (only if you stashed changes). ## Key Commands From d29de56a1ba13af46e7f9ccb27c74097ce3b3806 Mon Sep 17 00:00:00 2001 From: HanSu Lee Date: Fri, 24 Oct 2025 20:32:40 +0900 Subject: [PATCH 003/186] chore(aws-serverless): Fix typo in timeout warning function name (#18031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While exploring the Timeout Warning feature in `Sentry.wrapHandler`, I fount a type in the function name. It seems `setupTimeoutWarning` was intended name, so I've corrected it accordingly. ## Changes - Renamed `setupTimeoutWatning` → `setupTimeoutWarning` - Updated all 3 call sites to use the corrected function name ## Checklist - [x] Code lints successfully (`yarn lint`) - [x] No new tests required (typo fix only, no functional changes) --- packages/aws-serverless/src/sdk.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index b7ac8927813c..5b0100ae4460 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -108,7 +108,7 @@ function enhanceScopeWithEnvironmentData(scope: Scope, context: Context, startTi }); } -function setupTimeoutWatning(context: Context, options: WrapperOptions): NodeJS.Timeout | undefined { +function setupTimeoutWarning(context: Context, options: WrapperOptions): NodeJS.Timeout | undefined { // In seconds. You cannot go any more granular than this in AWS Lambda. const configuredTimeout = Math.ceil(tryGetRemainingTimeInMillis(context) / 1000); const configuredTimeoutMinutes = Math.floor(configuredTimeout / 60); @@ -220,7 +220,7 @@ export function wrapHandler( return async (event: TEvent, context: Context) => { context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; - timeoutWarningTimer = setupTimeoutWatning(context, options); + timeoutWarningTimer = setupTimeoutWarning(context, options); async function processResult(): Promise { const scope = getCurrentScope(); @@ -272,7 +272,7 @@ function wrapStreamingHandler( ): Promise => { context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; - timeoutWarningTimer = setupTimeoutWatning(context, options); + timeoutWarningTimer = setupTimeoutWarning(context, options); async function processStreamingResult(): Promise { const scope = getCurrentScope(); From 749638766641b552e1193353c3f0430cf970787d Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Fri, 24 Oct 2025 13:51:41 +0200 Subject: [PATCH 004/186] chore: Add external contributor to CHANGELOG.md (#18032) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18031 Co-authored-by: andreiborza <168741329+andreiborza@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d91a753f6544..5b3b35020083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @hanseo0507. Thank you for your contribution! + ## 10.22.0 ### Important Changes From fe97d67a96d7e7611fe65301edb5c44b6d965924 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Oct 2025 16:00:38 +0300 Subject: [PATCH 005/186] fix(nuxt): check for H3 error cause before re-capturing (#18035) The flakey tests in nuxt-4 pointed out that we have a race condition where a middleware error can bubble up as an H3 event error, which wraps the original error we caught. This means the [`checkOrSetAlreadyCaught`](https://github.com/getsentry/sentry-javascript/blob/749638766641b552e1193353c3f0430cf970787d/packages/core/src/utils/misc.ts#L212-L232) won't actually detect it since it doesn't check the [`.cause` property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause). I added the logic needed for that for the Nuxt SDK but feels like this may come up later if it hadn't already. Note that this does not affect the spans created, just the mechanism of the caught error, the spans would still be marked correctly as errored. --- .../src/runtime/hooks/captureErrorHook.ts | 11 ++ .../runtime/hooks/captureErrorHook.test.ts | 155 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 9c2bc1011277..b8e53f0ca0c3 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -25,6 +25,17 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture if (error.statusCode >= 300 && error.statusCode < 500) { return; } + + // Check if the cause (original error) was already captured by middleware instrumentation + // H3 wraps errors, so we need to check the cause property + if ( + 'cause' in error && + typeof error.cause === 'object' && + error.cause !== null && + '__sentry_captured__' in error.cause + ) { + return; + } } const { method, path } = { diff --git a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts new file mode 100644 index 000000000000..30776c4d932c --- /dev/null +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -0,0 +1,155 @@ +import * as SentryCore from '@sentry/core'; +import { H3Error } from 'h3'; +import type { CapturedErrorContext } from 'nitropack/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryCaptureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; + +vi.mock('@sentry/core', async importOriginal => { + const mod = await importOriginal(); + return { + ...(mod as any), + captureException: vi.fn(), + flushIfServerless: vi.fn(), + getClient: vi.fn(), + getCurrentScope: vi.fn(() => ({ + setTransactionName: vi.fn(), + })), + }; +}); + +vi.mock('../../../src/runtime/utils', () => ({ + extractErrorContext: vi.fn(() => ({ test: 'context' })), +})); + +describe('sentryCaptureErrorHook', () => { + const mockErrorContext: CapturedErrorContext = { + event: { + _method: 'GET', + _path: '/test-path', + } as any, + }; + + beforeEach(() => { + vi.clearAllMocks(); + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({}), + }); + (SentryCore.flushIfServerless as any).mockResolvedValue(undefined); + }); + + it('should capture regular errors', async () => { + const error = new Error('Test error'); + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should skip H3Error with 4xx status codes', async () => { + const error = new H3Error('Not found'); + error.statusCode = 404; + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should skip H3Error with 3xx status codes', async () => { + const error = new H3Error('Redirect'); + error.statusCode = 302; + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should capture H3Error with 5xx status codes', async () => { + const error = new H3Error('Server error'); + error.statusCode = 500; + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should skip H3Error when cause has __sentry_captured__ flag', async () => { + const originalError = new Error('Original error'); + // Mark the original error as already captured by middleware + Object.defineProperty(originalError, '__sentry_captured__', { + value: true, + enumerable: false, + }); + + const h3Error = new H3Error('Wrapped error', { cause: originalError }); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should capture H3Error when cause does not have __sentry_captured__ flag', async () => { + const originalError = new Error('Original error'); + const h3Error = new H3Error('Wrapped error', { cause: originalError }); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + h3Error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should capture H3Error when cause is not an object', async () => { + const h3Error = new H3Error('Error with string cause', { cause: 'string cause' }); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + h3Error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should capture H3Error when there is no cause', async () => { + const h3Error = new H3Error('Error without cause'); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + h3Error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should skip when enableNitroErrorHandler is false', async () => { + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({ enableNitroErrorHandler: false }), + }); + + const error = new Error('Test error'); + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); +}); From 14888ab451a140d3aa2c8428388eb03d4781c9b3 Mon Sep 17 00:00:00 2001 From: Kev <6111995+k-fish@users.noreply.github.com> Date: Tue, 28 Oct 2025 08:16:13 -0400 Subject: [PATCH 006/186] fix(tracemetrics): Bump metrics buffer to 1k (#18039) --- packages/core/src/metrics/internal.ts | 2 +- packages/core/test/lib/metrics/internal.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 676814f4d4e6..efa204cac5a3 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -12,7 +12,7 @@ import { timestampInSeconds } from '../utils/time'; import { _getTraceInfoFromScope } from '../utils/trace-info'; import { createMetricEnvelope } from './envelope'; -const MAX_METRIC_BUFFER_SIZE = 100; +const MAX_METRIC_BUFFER_SIZE = 1000; /** * Converts a metric attribute to a serialized metric attribute. diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index bb2ddcc413c3..c0279c9a270b 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -249,12 +249,12 @@ describe('_INTERNAL_captureMetric', () => { const scope = new Scope(); scope.setClient(client); - // Fill the buffer to max size (100 is the MAX_METRIC_BUFFER_SIZE constant) - for (let i = 0; i < 100; i++) { + // Fill the buffer to max size (1000 is the MAX_METRIC_BUFFER_SIZE constant) + for (let i = 0; i < 1000; i++) { _INTERNAL_captureMetric({ type: 'counter', name: `metric.${i}`, value: i }, { scope }); } - expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(100); + expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(1000); // Add one more to trigger flush _INTERNAL_captureMetric({ type: 'counter', name: 'trigger.flush', value: 999 }, { scope }); From e35ca9dd0e5cf4025b1b54912b3a8aaee6dd22c3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 28 Oct 2025 13:52:32 +0100 Subject: [PATCH 007/186] feat(core): Send `user-agent` header with envelope requests in server SDKs (#17929) As a follow-up of an incident, SDKs were asked to send a `user-agent` Http header to determine by just looking at incoming request headers which SDK the request is coming from. See [develop specification](https://develop.sentry.dev/sdk/overview/#user-agent). This PR makes the following changes to sending user agent HTTP headers with envelope requests made by the transport: - Send `user-agent` in all server-runtime SDKs - Extract the `headers` option from individual transport options to `BaseTransportOptions`. This allows us to type-safely add the user agent header to the SDKs transport options which is the easiest way to pass a header to the transport without having to modify or extend any of the existing transport APIs. I checked and every transport implementation we currently export exposed a `headers` option anyway, so this just unifies it. Given this is an optional property, custom transport implementations extending `BaseTransportOptions` won't break either. The problem here is only that they might not actually support this option which all things considered I think is fine. If reviewers have different opinions, I'm happy to revisit this. - Unit and integration/e2e tests for node and cloudflare --- .size-limit.js | 2 +- .../cloudflare-workers/tests/index.test.ts | 15 ++++++++- .../node-express/tests/misc.test.ts | 15 +++++++++ .../test-utils/src/event-proxy-server.ts | 2 ++ packages/browser/src/transports/types.ts | 2 -- packages/bun/src/transports/index.ts | 7 +--- packages/bun/src/types.ts | 7 ++-- packages/cloudflare/src/transport.ts | 2 -- packages/core/src/server-runtime-client.ts | 3 ++ packages/core/src/transports/userAgent.ts | 22 +++++++++++++ packages/core/src/types-hoist/transport.ts | 5 +++ .../test/lib/server-runtime-client.test.ts | 33 ++++++++++++++++++- packages/deno/src/transports/index.ts | 7 +--- packages/deno/src/types.ts | 7 ++-- packages/node-core/src/transports/http.ts | 2 -- packages/node-core/test/sdk/client.test.ts | 5 +++ packages/node/test/sdk/client.test.ts | 5 +++ packages/vercel-edge/src/transports/index.ts | 2 -- 18 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts create mode 100644 packages/core/src/transports/userAgent.ts diff --git a/.size-limit.js b/.size-limit.js index 7106f2e29b03..ed7fbc7ccc80 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -157,7 +157,7 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '27 KB', + limit: '27.5 KB', }, { name: 'CDN Bundle (incl. Tracing)', diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts index ad63c1a0d307..8c09693c81ed 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForRequest } from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/cloudflare'; import { WebSocket } from 'ws'; test('Index page', async ({ baseURL }) => { @@ -69,3 +70,15 @@ test('Websocket.webSocketClose', async ({ baseURL }) => { expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose'); expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); }); + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('cloudflare-workers', () => true); + + await fetch(`${baseURL}/throwException`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts new file mode 100644 index 000000000000..427c70b6fa21 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/node'; + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('node-express', () => true); + + await fetch(`${baseURL}/test-exception/123`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }); +}); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 0becf5a743f2..08fa39db950f 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -24,6 +24,7 @@ interface EventProxyServerOptions { interface SentryRequestCallbackData { envelope: Envelope; rawProxyRequestBody: string; + rawProxyRequestHeaders: Record; rawSentryResponseBody: string; sentryResponseStatusCode?: number; } @@ -182,6 +183,7 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P const data: SentryRequestCallbackData = { envelope: parseEnvelope(proxyRequestBody), rawProxyRequestBody: proxyRequestBody, + rawProxyRequestHeaders: proxyRequest.headers, rawSentryResponseBody: '', sentryResponseStatusCode: 200, }; diff --git a/packages/browser/src/transports/types.ts b/packages/browser/src/transports/types.ts index fd8c4a93fdd6..a304e9f93d66 100644 --- a/packages/browser/src/transports/types.ts +++ b/packages/browser/src/transports/types.ts @@ -3,6 +3,4 @@ import type { BaseTransportOptions } from '@sentry/core'; export interface BrowserTransportOptions extends BaseTransportOptions { /** Fetch API init parameters. Used by the FetchTransport */ fetchOptions?: RequestInit; - /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ - headers?: { [key: string]: string }; } diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index 7a27846548b3..20df5bb4b521 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -1,15 +1,10 @@ import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; import { createTransport, suppressTracing } from '@sentry/core'; -export interface BunTransportOptions extends BaseTransportOptions { - /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ - headers?: { [key: string]: string }; -} - /** * Creates a Transport that uses the Fetch API to send events to Sentry. */ -export function makeFetchTransport(options: BunTransportOptions): Transport { +export function makeFetchTransport(options: BaseTransportOptions): Transport { function makeRequest(request: TransportRequest): PromiseLike { const requestOptions: RequestInit = { body: request.body, diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index 755eb3e48a0a..afec75d1ee8d 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -1,5 +1,4 @@ -import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; -import type { BunTransportOptions } from './transports'; +import type { BaseTransportOptions, ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; export interface BaseBunOptions { /** @@ -43,10 +42,10 @@ export interface BaseBunOptions { * Configuration options for the Sentry Bun SDK * @see @sentry/core Options for more information. */ -export interface BunOptions extends Options, BaseBunOptions {} +export interface BunOptions extends Options, BaseBunOptions {} /** * Configuration options for the Sentry Bun SDK Client class * @see BunClient for more information. */ -export interface BunClientOptions extends ClientOptions, BaseBunOptions {} +export interface BunClientOptions extends ClientOptions, BaseBunOptions {} diff --git a/packages/cloudflare/src/transport.ts b/packages/cloudflare/src/transport.ts index 2ac401505fbb..eee54bd7a790 100644 --- a/packages/cloudflare/src/transport.ts +++ b/packages/cloudflare/src/transport.ts @@ -4,8 +4,6 @@ import { createTransport, SENTRY_BUFFER_FULL_ERROR, suppressTracing } from '@sen export interface CloudflareTransportOptions extends BaseTransportOptions { /** Fetch API init parameters. */ fetchOptions?: RequestInit; - /** Custom headers for the transport. */ - headers?: { [key: string]: string }; } const DEFAULT_TRANSPORT_BUFFER_SIZE = 30; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 9d037eb3b7c3..988e642d0a27 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -4,6 +4,7 @@ import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { Scope } from './scope'; import { registerSpanErrorInstrumentation } from './tracing'; +import { addUserAgentToTransportHeaders } from './transports/userAgent'; import type { CheckIn, MonitorConfig, SerializedCheckIn } from './types-hoist/checkin'; import type { Event, EventHint } from './types-hoist/event'; import type { ClientOptions } from './types-hoist/options'; @@ -36,6 +37,8 @@ export class ServerRuntimeClient< // Server clients always support tracing registerSpanErrorInstrumentation(); + addUserAgentToTransportHeaders(options); + super(options); } diff --git a/packages/core/src/transports/userAgent.ts b/packages/core/src/transports/userAgent.ts new file mode 100644 index 000000000000..5508640a855c --- /dev/null +++ b/packages/core/src/transports/userAgent.ts @@ -0,0 +1,22 @@ +import type { ClientOptions } from '../types-hoist/options'; + +/** + * Takes the SDK metadata and adds the user-agent header to the transport options. + * This ensures that the SDK sends the user-agent header with SDK name and version to + * all requests made by the transport. + * + * @see https://develop.sentry.dev/sdk/overview/#user-agent + */ +export function addUserAgentToTransportHeaders(options: ClientOptions): void { + const sdkMetadata = options._metadata?.sdk; + const sdkUserAgent = + sdkMetadata?.name && sdkMetadata?.version ? `${sdkMetadata?.name}/${sdkMetadata?.version}` : undefined; + + options.transportOptions = { + ...options.transportOptions, + headers: { + ...(sdkUserAgent && { 'user-agent': sdkUserAgent }), + ...options.transportOptions?.headers, + }, + }; +} diff --git a/packages/core/src/types-hoist/transport.ts b/packages/core/src/types-hoist/transport.ts index 8e0035c93137..320ed98b00e4 100644 --- a/packages/core/src/types-hoist/transport.ts +++ b/packages/core/src/types-hoist/transport.ts @@ -30,6 +30,11 @@ export interface BaseTransportOptions extends InternalBaseTransportOptions { // transport does not care about dsn specific - client should take care of // parsing and figuring that out url: string; + + /** + * Custom HTTP headers to be added to requests made by the transport. + */ + headers?: { [key: string]: string }; } export interface Transport { diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 525ee514c1a2..3c5fe874af9f 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, test, vi } from 'vitest'; -import { createTransport, Scope } from '../../src'; +import { applySdkMetadata, createTransport, Scope } from '../../src'; import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; import { ServerRuntimeClient } from '../../src/server-runtime-client'; import type { Event, EventHint } from '../../src/types-hoist/event'; @@ -205,4 +205,35 @@ describe('ServerRuntimeClient', () => { ]); }); }); + + describe('user-agent header', () => { + it('sends user-agent header with SDK name and version', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + + // this is done in all `init` functions of the respective SDKs: + applySdkMetadata(options, 'core'); + + client = new ServerRuntimeClient(options); + + expect(client.getOptions().transportOptions?.headers).toEqual({ + 'user-agent': 'sentry.javascript.core/0.0.0-unknown.0', + }); + }); + + it('prefers user-passed headers (including user-agent)', () => { + const options = getDefaultClientOptions({ + dsn: PUBLIC_DSN, + transportOptions: { headers: { 'x-custom-header': 'custom-value', 'user-agent': 'custom-user-agent' } }, + }); + + applySdkMetadata(options, 'core'); + + client = new ServerRuntimeClient(options); + + expect(client.getOptions().transportOptions?.headers).toEqual({ + 'user-agent': 'custom-user-agent', + 'x-custom-header': 'custom-value', + }); + }); + }); }); diff --git a/packages/deno/src/transports/index.ts b/packages/deno/src/transports/index.ts index c5b6594b1c4d..521011fea6b8 100644 --- a/packages/deno/src/transports/index.ts +++ b/packages/deno/src/transports/index.ts @@ -1,15 +1,10 @@ import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; import { consoleSandbox, createTransport, debug, suppressTracing } from '@sentry/core'; -export interface DenoTransportOptions extends BaseTransportOptions { - /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ - headers?: { [key: string]: string }; -} - /** * Creates a Transport that uses the Fetch API to send events to Sentry. */ -export function makeFetchTransport(options: DenoTransportOptions): Transport { +export function makeFetchTransport(options: BaseTransportOptions): Transport { const url = new URL(options.url); Deno.permissions diff --git a/packages/deno/src/types.ts b/packages/deno/src/types.ts index 1659e7a635e1..69eee4ae6313 100644 --- a/packages/deno/src/types.ts +++ b/packages/deno/src/types.ts @@ -1,5 +1,4 @@ -import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; -import type { DenoTransportOptions } from './transports'; +import type { BaseTransportOptions, ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; export interface BaseDenoOptions { /** @@ -44,10 +43,10 @@ export interface BaseDenoOptions { * Configuration options for the Sentry Deno SDK * @see @sentry/core Options for more information. */ -export interface DenoOptions extends Options, BaseDenoOptions {} +export interface DenoOptions extends Options, BaseDenoOptions {} /** * Configuration options for the Sentry Deno SDK Client class * @see DenoClient for more information. */ -export interface DenoClientOptions extends ClientOptions, BaseDenoOptions {} +export interface DenoClientOptions extends ClientOptions, BaseDenoOptions {} diff --git a/packages/node-core/src/transports/http.ts b/packages/node-core/src/transports/http.ts index 49897dfa22b1..3319353aff14 100644 --- a/packages/node-core/src/transports/http.ts +++ b/packages/node-core/src/transports/http.ts @@ -14,8 +14,6 @@ import { HttpsProxyAgent } from '../proxy'; import type { HTTPModule } from './http-module'; export interface NodeTransportOptions extends BaseTransportOptions { - /** Define custom headers */ - headers?: Record; /** Set a proxy that should be used for outbound requests. */ proxy?: string; /** HTTPS proxy CA certificates */ diff --git a/packages/node-core/test/sdk/client.test.ts b/packages/node-core/test/sdk/client.test.ts index 01623f49f0a3..0bcef2669095 100644 --- a/packages/node-core/test/sdk/client.test.ts +++ b/packages/node-core/test/sdk/client.test.ts @@ -32,6 +32,11 @@ describe('NodeClient', () => { dsn: expect.any(String), integrations: [], transport: options.transport, + transportOptions: { + headers: { + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }, + }, stackParser: options.stackParser, _metadata: { sdk: { diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index 7f57d4772212..ff58698a7931 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -31,6 +31,11 @@ describe('NodeClient', () => { dsn: expect.any(String), integrations: [], transport: options.transport, + transportOptions: { + headers: { + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }, + }, stackParser: options.stackParser, _metadata: { sdk: { diff --git a/packages/vercel-edge/src/transports/index.ts b/packages/vercel-edge/src/transports/index.ts index bb8ea807764c..668fb6a4c236 100644 --- a/packages/vercel-edge/src/transports/index.ts +++ b/packages/vercel-edge/src/transports/index.ts @@ -4,8 +4,6 @@ import { createTransport, SENTRY_BUFFER_FULL_ERROR, suppressTracing } from '@sen export interface VercelEdgeTransportOptions extends BaseTransportOptions { /** Fetch API init parameters. */ fetchOptions?: RequestInit; - /** Custom headers for the transport. */ - headers?: { [key: string]: string }; } const DEFAULT_TRANSPORT_BUFFER_SIZE = 30; From 0e15b3decd3903844211f30eb02632903030692e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 28 Oct 2025 12:58:06 -0400 Subject: [PATCH 008/186] chore(browser): upgrade fake-indexeddb to v6 (#17975) changelog: https://github.com/dumbmatter/fakeIndexedDB/blob/master/CHANGELOG.md removes a structured clone polyfill, which is fine because we are node 18+ in the SDK. This helps reduce dep count in `yarn.lock`. --- packages/browser/package.json | 2 +- yarn.lock | 73 +++-------------------------------- 2 files changed, 6 insertions(+), 69 deletions(-) diff --git a/packages/browser/package.json b/packages/browser/package.json index 2f18a2573c8b..b6f2fa570ce1 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -47,7 +47,7 @@ }, "devDependencies": { "@sentry-internal/integration-shims": "10.22.0", - "fake-indexeddb": "^4.0.1" + "fake-indexeddb": "^6.2.4" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", diff --git a/yarn.lock b/yarn.lock index 06f8d3741128..0142b73605c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11447,11 +11447,6 @@ bare-stream@^2.6.4: dependencies: streamx "^2.21.0" -base64-arraybuffer-es6@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86" - integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw== - base64-arraybuffer@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" @@ -14501,13 +14496,6 @@ domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domexception@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" - integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== - dependencies: - webidl-conversions "^4.0.2" - domexception@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" @@ -16720,12 +16708,10 @@ extract-zip@^2.0.1: optionalDependencies: "@types/yauzl" "^2.9.1" -fake-indexeddb@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.2.tgz#e7a884158fa576e00f03e973b9874619947013e4" - integrity sha512-SdTwEhnakbgazc7W3WUXOJfGmhH0YfG4d+dRPOFoYDRTL6U5t8tvrmkf2W/C3W1jk2ylV7Wrnj44RASqpX/lEw== - dependencies: - realistic-structured-clone "^3.0.0" +fake-indexeddb@^6.2.4: + version "6.2.4" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-6.2.4.tgz#cf3860b6b37ddc3b33e7840be00a61ed094486a5" + integrity sha512-INKeIKEtSViN4yVtEWEUqbsqmaIy7Ls+MfU0yxQVXg67pOJ/sH1ZxcVrP8XrKULUFohcPD9gnmym+qBfEybACw== fast-check@^3.23.1: version "3.23.2" @@ -21093,7 +21079,7 @@ lodash.uniq@^4.2.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0, lodash@~4.17.21: +lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@~4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -26302,15 +26288,6 @@ real-require@^0.2.0: resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== -realistic-structured-clone@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-3.0.0.tgz#7b518049ce2dad41ac32b421cd297075b00e3e35" - integrity sha512-rOjh4nuWkAqf9PWu6JVpOWD4ndI+JHfgiZeMmujYcPi+fvILUu7g6l26TC1K5aBIp34nV+jE1cDO75EKOfHC5Q== - dependencies: - domexception "^1.0.1" - typeson "^6.1.0" - typeson-registry "^1.0.0-alpha.20" - recast@0.23.11, recast@^0.23.4: version "0.23.11" resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.11.tgz#8885570bb28cf773ba1dc600da7f502f7883f73f" @@ -29432,13 +29409,6 @@ tough-cookie@^4.1.2: universalify "^0.2.0" url-parse "^1.5.3" -tr46@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" - integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== - dependencies: - punycode "^2.1.1" - tr46@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" @@ -29787,20 +29757,6 @@ typescript@~5.8.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== -typeson-registry@^1.0.0-alpha.20: - version "1.0.0-alpha.39" - resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" - integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw== - dependencies: - base64-arraybuffer-es6 "^0.7.0" - typeson "^6.0.0" - whatwg-url "^8.4.0" - -typeson@^6.0.0, typeson@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" - integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== - ua-parser-js@^0.7.18: version "0.7.33" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" @@ -31115,16 +31071,6 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== -webidl-conversions@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" - integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== - -webidl-conversions@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" - integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== - webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" @@ -31379,15 +31325,6 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" -whatwg-url@^8.4.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" - integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== - dependencies: - lodash "^4.7.0" - tr46 "^2.1.0" - webidl-conversions "^6.1.0" - which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" From 993303c935bde04b11a1f081f2981a0684176ab8 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 28 Oct 2025 16:24:04 -0400 Subject: [PATCH 009/186] fix(replay): Linked errors not resetting session id (#17854) This PR fixes a case where we [correctly] tag an error event w/ replay id, but something occurs where the replay event does not end up being flushed. This means the existing session is still in a buffered state, and will keep its session id until a new error event is sampled and a replay is created. When this does happen, we can have a replay with a super long duration (e.g. the time between the two error replays). We now update the session immediately when we tag an error event w/ replay id so that if the replay event does not successfully flush, the session will respect its expiration date. --- .../replay/bufferStalledRequests/init.js | 18 ++ .../replay/bufferStalledRequests/subject.js | 11 + .../bufferStalledRequests/template.html | 11 + .../replay/bufferStalledRequests/test.ts | 270 ++++++++++++++++++ .../src/coreHandlers/handleGlobalEvent.ts | 15 + packages/replay-internal/src/replay.ts | 1 + .../replay-internal/src/session/Session.ts | 2 + packages/replay-internal/src/types/replay.ts | 7 + .../src/util/handleRecordingEmit.ts | 2 +- .../test/integration/errorSampleRate.test.ts | 23 +- .../test/unit/session/fetchSession.test.ts | 4 +- .../unit/session/loadOrCreateSession.test.ts | 9 +- 12 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js new file mode 100644 index 000000000000..f9dccbffb530 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + stickySession: true, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js new file mode 100644 index 000000000000..1c9b22455261 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js @@ -0,0 +1,11 @@ +document.getElementById('error1').addEventListener('click', () => { + throw new Error('First Error'); +}); + +document.getElementById('error2').addEventListener('click', () => { + throw new Error('Second Error'); +}); + +document.getElementById('click').addEventListener('click', () => { + // Just a click for interaction +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html new file mode 100644 index 000000000000..1beb4b281b28 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts new file mode 100644 index 000000000000..11154caaaa8b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -0,0 +1,270 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { + getReplaySnapshot, + isReplayEvent, + shouldSkipReplayTest, + waitForReplayRunning, +} from '../../../utils/replayHelpers'; + +sentryTest( + 'buffer mode remains after interrupting error event ingest', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + + if (errorCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + waitForErrorRequest(page); + await page.locator('#error1').click(); + + // This resolves, but the route doesn't get fulfilled as we want the reload to "interrupt" this flow + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(0); + expect(replayIds).toHaveLength(1); + + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.recordingMode).toBe('buffer'); + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); + +sentryTest('buffer mode remains after interrupting replay flush', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + await page.reload(); + await waitForReplayRunning(page); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(1); + // Because a flush attempt was made and not allowed to complete, segmentId increased from 0, + // so we resume in session mode + expect(secondSession.recordingMode).toBe('session'); +}); + +sentryTest( + 'starts a new session after interrupting replay flush and session "expires"', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + // Trigger first error - this should change session sampled to "session" + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + // Now expire the session by manipulating session storage + // Simulate session expiry by setting lastActivity to a time in the past + await page.evaluate(() => { + const replayIntegration = (window as any).Replay; + const replay = replayIntegration['_replay']; + + // Set session as expired (15 minutes ago) + if (replay.session) { + const fifteenMinutesAgo = Date.now() - 15 * 60 * 1000; + replay.session.lastActivity = fifteenMinutesAgo; + replay.session.started = fifteenMinutesAgo; + + // Also update session storage if sticky sessions are enabled + const sessionKey = 'sentryReplaySession'; + const sessionData = sessionStorage.getItem(sessionKey); + if (sessionData) { + const session = JSON.parse(sessionData); + session.lastActivity = fifteenMinutesAgo; + session.started = fifteenMinutesAgo; + sessionStorage.setItem(sessionKey, JSON.stringify(session)); + } + } + }); + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.recordingMode).toBe('buffer'); + expect(secondSession.session?.id).not.toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index 55559c0d4c01..b28d4547265e 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -1,5 +1,6 @@ import type { Event, EventHint } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { saveSession } from '../session/saveSession'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils'; import { isRrwebError } from '../util/isRrwebError'; @@ -69,6 +70,20 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even event.tags = { ...event.tags, replayId: replay.getSessionId() }; } + // If we sampled this error in buffer mode, immediately mark the session as "sampled" + // by changing the sampled state from 'buffer' to 'session'. Otherwise, if the application is interrupte + // before `afterSendEvent` occurs, then the session would remain as "buffer" but we have an error event + // that is tagged with a replay id. This could end up creating replays w/ excessive durations because + // of the linked error. + if (isErrorEventSampled && replay.recordingMode === 'buffer' && replay.session?.sampled === 'buffer') { + const session = replay.session; + session.dirty = true; + // Save the session if sticky sessions are enabled to persist the state change + if (replay.getOptions().stickySession) { + saveSession(session); + } + } + return event; }, { id: 'Replay' }, diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 61676f790b4d..49e8ce092edd 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -606,6 +606,7 @@ export class ReplayContainer implements ReplayContainerInterface { // Once this session ends, we do not want to refresh it if (this.session) { + this.session.dirty = false; this._updateUserActivity(activityTime); this._updateSessionActivity(activityTime); this._maybeSaveSession(); diff --git a/packages/replay-internal/src/session/Session.ts b/packages/replay-internal/src/session/Session.ts index 554f625cc8e9..59e6b09ed43c 100644 --- a/packages/replay-internal/src/session/Session.ts +++ b/packages/replay-internal/src/session/Session.ts @@ -13,6 +13,7 @@ export function makeSession(session: Partial & { sampled: Sampled }): S const segmentId = session.segmentId || 0; const sampled = session.sampled; const previousSessionId = session.previousSessionId; + const dirty = session.dirty || false; return { id, @@ -21,5 +22,6 @@ export function makeSession(session: Partial & { sampled: Sampled }): S segmentId, sampled, previousSessionId, + dirty, }; } diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 1e7891a84e76..a2c84d6c4bbe 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -383,6 +383,13 @@ export interface Session { * Is the session sampled? `false` if not sampled, otherwise, `session` or `buffer` */ sampled: Sampled; + + /** + * Session is dirty when its id has been linked to an event (e.g. error event). + * This is helpful when a session is mistakenly stuck in "buffer" mode (e.g. network issues preventing it from being converted to "session" mode). + * The dirty flag is used to prevent updating the session start time to the earliest event in the buffer so that it can be refreshed if it's been expired. + */ + dirty?: boolean; } export type EventBufferType = 'sync' | 'worker'; diff --git a/packages/replay-internal/src/util/handleRecordingEmit.ts b/packages/replay-internal/src/util/handleRecordingEmit.ts index 0ae87601637b..aeb49f0cd259 100644 --- a/packages/replay-internal/src/util/handleRecordingEmit.ts +++ b/packages/replay-internal/src/util/handleRecordingEmit.ts @@ -72,7 +72,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa // When in buffer mode, make sure we adjust the session started date to the current earliest event of the buffer // this should usually be the timestamp of the checkout event, but to be safe... - if (replay.recordingMode === 'buffer' && session && replay.eventBuffer) { + if (replay.recordingMode === 'buffer' && session && replay.eventBuffer && !session.dirty) { const earliestEvent = replay.eventBuffer.getEarliestTimestamp(); if (earliestEvent) { DEBUG_BUILD && diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index f79e393df7e3..b49882b72034 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -80,8 +80,21 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); + // session gets immediately marked as dirty since error will + // be linked to current session (replay) id. there's a possibility + // that replay never gets flushed so we must mark as dirty so we + // know to refresh session in the future. + expect(replay.recordingMode).toBe('buffer'); + expect(replay.session?.dirty).toBe(true); + + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); + // dirty gets reset after replay is flushed + expect(replay.recordingMode).toBe('session'); + expect(replay.session?.dirty).toBe(false); + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ @@ -158,6 +171,7 @@ describe('Integration | errorSampleRate', () => { segmentId: 0, sampled: 'buffer', previousSessionId: 'previoussessionid', + dirty: false, }), })); @@ -179,6 +193,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents + await vi.advanceTimersToNextTimerAsync(); // Converts to session mode expect(replay.recordingMode).toBe('session'); @@ -508,6 +524,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); expect(replay).toHaveLastSentReplay({ @@ -604,6 +622,8 @@ describe('Integration | errorSampleRate', () => { // should still react to errors later on captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); expect(replay.session?.id).toBe(oldSessionId); @@ -739,7 +759,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); await vi.advanceTimersToNextTimerAsync(); - // await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents + await vi.advanceTimersToNextTimerAsync(); // This is still the timestamp from the full snapshot we took earlier expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED); diff --git a/packages/replay-internal/test/unit/session/fetchSession.test.ts b/packages/replay-internal/test/unit/session/fetchSession.test.ts index 46f0f05f5c9a..9dee5cb5cee0 100644 --- a/packages/replay-internal/test/unit/session/fetchSession.test.ts +++ b/packages/replay-internal/test/unit/session/fetchSession.test.ts @@ -28,6 +28,7 @@ describe('Unit | session | fetchSession', () => { ); expect(fetchSession()).toEqual({ + dirty: false, id: 'fd09adfc4117477abc8de643e5a5798a', lastActivity: 1648827162658, segmentId: 0, @@ -39,10 +40,11 @@ describe('Unit | session | fetchSession', () => { it('fetches an unsampled session', function () { WINDOW.sessionStorage.setItem( REPLAY_SESSION_KEY, - '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658}', + '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658,"dirty":true}', ); expect(fetchSession()).toEqual({ + dirty: true, id: 'fd09adfc4117477abc8de643e5a5798a', lastActivity: 1648827162658, segmentId: 0, diff --git a/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts b/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts index 273d401a7afc..dee44638344b 100644 --- a/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts +++ b/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts @@ -77,6 +77,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }); // Should not have anything in storage @@ -104,6 +105,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }); // Should not have anything in storage @@ -129,10 +131,10 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'previous_session_id', + dirty: false, }); }); }); - describe('stickySession: true', () => { it('creates new session if none exists', function () { const session = loadOrCreateSession( @@ -151,6 +153,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }; expect(session).toEqual(expectedSession); @@ -181,6 +184,7 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'test_old_session_uuid', + dirty: false, }; expect(session).toEqual(expectedSession); expect(session.lastActivity).toBeGreaterThanOrEqual(now); @@ -209,6 +213,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: date, sampled: 'session', started: date, + dirty: false, }); }); @@ -250,6 +255,7 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'previous_session_id', + dirty: false, }; expect(session).toEqual(expectedSession); @@ -347,6 +353,7 @@ describe('Unit | session | loadOrCreateSession', () => { segmentId: 0, lastActivity: expect.any(Number), sampled: false, + dirty: false, started: expect.any(Number), }; expect(session).toEqual(expectedSession); From f7295093e11884ee738964ad6818336d04842c50 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:32:10 +0100 Subject: [PATCH 010/186] test(browser): Add test for INP target name after navigation or DOM changes (#18033) This test displays the current behavior of getting the element target name for INP when the DOM changes after clicking on a navigation link. When the DOM changes after clicking on an element, the element name from before the navigation is not captured: ```js description: '', // FIXME: currently unable to get the target name when element is removed from DOM ``` --- .../metrics/web-vitals-inp-navigate/init.js | 27 +++ .../web-vitals-inp-navigate/subject.js | 44 +++++ .../web-vitals-inp-navigate/template.html | 16 ++ .../metrics/web-vitals-inp-navigate/test.ts | 174 ++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js new file mode 100644 index 000000000000..1044a4b68bda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-url', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js new file mode 100644 index 000000000000..730caa3b381e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js @@ -0,0 +1,44 @@ +const simulateNavigationKeepDOM = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 100) { + // Block UI for 100ms to simulate some processing work during navigation + } + + const contentDiv = document.getElementById('content'); + contentDiv.innerHTML = '

Page 1

Successfully navigated!

'; + + contentDiv.classList.add('navigated'); +}; + +const simulateNavigationChangeDOM = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 100) { + // Block UI for 100ms to simulate some processing work during navigation + } + + const navigationHTML = + ' '; + + const body = document.querySelector('body'); + body.innerHTML = `${navigationHTML}

Page 2

Successfully navigated!

`; + + body.classList.add('navigated'); +}; + +document.querySelector('[data-test-id=nav-link-keepDOM]').addEventListener('click', simulateNavigationKeepDOM); +document.querySelector('[data-test-id=nav-link-changeDOM]').addEventListener('click', simulateNavigationChangeDOM); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html new file mode 100644 index 000000000000..de677aa9a838 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html @@ -0,0 +1,16 @@ + + + + + + + +
+

Home Page

+

Click the navigation link to simulate a route change

+
+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts new file mode 100644 index 000000000000..ad7862926ebf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -0,0 +1,174 @@ +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, SpanEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + hidePage, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +const supportedBrowsers = ['chromium']; + +sentryTest( + 'should capture INP with correct target name when navigation keeps DOM element', + async ({ browserName, getLocalTestUrl, page }) => { + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); // wait for page load + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + // Simulating route change (keeping