diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts new file mode 100644 index 000000000000..c3eb18887301 --- /dev/null +++ b/packages/nextjs/src/client/browserTracingIntegration.ts @@ -0,0 +1,26 @@ +import { BrowserTracing as OriginalBrowserTracing, defaultRequestInstrumentationOptions } from '@sentry/react'; +import { nextRouterInstrumentation } from '../index.client'; + +/** + * A custom BrowserTracing integration for Next.js. + */ +export class BrowserTracing extends OriginalBrowserTracing { + public constructor(options?: ConstructorParameters[0]) { + super({ + // eslint-disable-next-line deprecation/deprecation + tracingOrigins: + process.env.NODE_ENV === 'development' + ? [ + // Will match any URL that contains "localhost" but not "webpack.hot-update.json" - The webpack dev-server + // has cors and it doesn't like extra headers when it's accessed from a different URL. + // TODO(v8): Ideally we rework our tracePropagationTargets logic so this hack won't be necessary anymore (see issue #9764) + /^(?=.*localhost)(?!.*webpack\.hot-update\.json).*/, + /^\/(?!\/)/, + ] + : // eslint-disable-next-line deprecation/deprecation + [...defaultRequestInstrumentationOptions.tracingOrigins, /^(api\/)/], + routingInstrumentation: nextRouterInstrumentation, + ...options, + }); + } +} diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 46719e47f4f7..f9a2fe5c9b97 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -1,27 +1,28 @@ import { hasTracingEnabled } from '@sentry/core'; -import { RewriteFrames } from '@sentry/integrations'; import type { BrowserOptions } from '@sentry/react'; import { - BrowserTracing, - Integrations, - defaultRequestInstrumentationOptions, + Integrations as OriginalIntegrations, getCurrentScope, + getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit, } from '@sentry/react'; -import type { EventProcessor } from '@sentry/types'; -import { addOrUpdateIntegration } from '@sentry/utils'; +import type { EventProcessor, Integration } from '@sentry/types'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; import { buildMetadata } from '../common/metadata'; -import { nextRouterInstrumentation } from './routing/nextRoutingInstrumentation'; +import { BrowserTracing } from './browserTracingIntegration'; +import { rewriteFramesIntegration } from './rewriteFramesIntegration'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; export { nextRouterInstrumentation } from './routing/nextRoutingInstrumentation'; export { captureUnderscoreErrorException } from '../common/_error'; -export { Integrations }; +export const Integrations = { + ...OriginalIntegrations, + BrowserTracing, +}; // Previously we expected users to import `BrowserTracing` like this: // @@ -33,27 +34,24 @@ export { Integrations }; // // import { BrowserTracing } from '@sentry/nextjs'; // const instance = new BrowserTracing(); -export { BrowserTracing }; +export { BrowserTracing, rewriteFramesIntegration }; // Treeshakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; -const globalWithInjectedValues = global as typeof global & { - __rewriteFramesAssetPrefixPath__: string; -}; - /** Inits the Sentry NextJS SDK on the browser with the React SDK. */ export function init(options: BrowserOptions): void { const opts = { environment: getVercelEnv(true) || process.env.NODE_ENV, + defaultIntegrations: getDefaultIntegrations(options), ...options, }; + fixBrowserTracingIntegration(opts); + applyTunnelRouteOption(opts); buildMetadata(opts, ['nextjs', 'react']); - addClientIntegrations(opts); - reactInit(opts); const scope = getCurrentScope(); @@ -68,72 +66,53 @@ export function init(options: BrowserOptions): void { } } -function addClientIntegrations(options: BrowserOptions): void { - let integrations = options.integrations || []; - - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const assetPrefixPath = globalWithInjectedValues.__rewriteFramesAssetPrefixPath__ || ''; - - // eslint-disable-next-line deprecation/deprecation - const defaultRewriteFramesIntegration = new RewriteFrames({ - // Turn `//_next/static/...` into `app:///_next/static/...` - iteratee: frame => { - try { - const { origin } = new URL(frame.filename as string); - frame.filename = frame.filename?.replace(origin, 'app://').replace(assetPrefixPath, ''); - } catch (err) { - // Filename wasn't a properly formed URL, so there's nothing we can do - } - - // We need to URI-decode the filename because Next.js has wildcard routes like "/users/[id].js" which show up as "/users/%5id%5.js" in Error stacktraces. - // The corresponding sources that Next.js generates have proper brackets so we also need proper brackets in the frame so that source map resolving works. - if (frame.filename && frame.filename.startsWith('app:///_next')) { - frame.filename = decodeURI(frame.filename); - } - - if ( - frame.filename && - frame.filename.match( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, - ) - ) { - // We don't care about these frames. It's Next.js internal code. - frame.in_app = false; - } - - return frame; - }, - }); - integrations = addOrUpdateIntegration(defaultRewriteFramesIntegration, integrations); +// TODO v8: Remove this again +// We need to handle BrowserTracing passed to `integrations` that comes from `@sentry/tracing`, not `@sentry/sveltekit` :( +function fixBrowserTracingIntegration(options: BrowserOptions): void { + const { integrations } = options; + if (!integrations) { + return; + } + + if (Array.isArray(integrations)) { + options.integrations = maybeUpdateBrowserTracingIntegration(integrations); + } else { + options.integrations = defaultIntegrations => { + const userFinalIntegrations = integrations(defaultIntegrations); + + return maybeUpdateBrowserTracingIntegration(userFinalIntegrations); + }; + } +} + +function maybeUpdateBrowserTracingIntegration(integrations: Integration[]): Integration[] { + const browserTracing = integrations.find(integration => integration.name === 'BrowserTracing'); + // If BrowserTracing was added, but it is not our forked version, + // replace it with our forked version with the same options + if (browserTracing && !(browserTracing instanceof BrowserTracing)) { + const options: ConstructorParameters[0] = (browserTracing as BrowserTracing).options; + // These two options are overwritten by the custom integration + delete options.routingInstrumentation; + // eslint-disable-next-line deprecation/deprecation + delete options.tracingOrigins; + integrations[integrations.indexOf(browserTracing)] = new BrowserTracing(options); + } + + return integrations; +} + +function getDefaultIntegrations(options: BrowserOptions): Integration[] { + const customDefaultIntegrations = [...getReactDefaultIntegrations(options), rewriteFramesIntegration()]; // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", in which case everything inside // will get treeshaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { if (hasTracingEnabled(options)) { - const defaultBrowserTracingIntegration = new BrowserTracing({ - // eslint-disable-next-line deprecation/deprecation - tracingOrigins: - process.env.NODE_ENV === 'development' - ? [ - // Will match any URL that contains "localhost" but not "webpack.hot-update.json" - The webpack dev-server - // has cors and it doesn't like extra headers when it's accessed from a different URL. - // TODO(v8): Ideally we rework our tracePropagationTargets logic so this hack won't be necessary anymore (see issue #9764) - /^(?=.*localhost)(?!.*webpack\.hot-update\.json).*/, - /^\/(?!\/)/, - ] - : // eslint-disable-next-line deprecation/deprecation - [...defaultRequestInstrumentationOptions.tracingOrigins, /^(api\/)/], - routingInstrumentation: nextRouterInstrumentation, - }); - - integrations = addOrUpdateIntegration(defaultBrowserTracingIntegration, integrations, { - 'options.routingInstrumentation': nextRouterInstrumentation, - }); + customDefaultIntegrations.push(new BrowserTracing()); } } - options.integrations = integrations; + return customDefaultIntegrations; } /** diff --git a/packages/nextjs/src/client/rewriteFramesIntegration.ts b/packages/nextjs/src/client/rewriteFramesIntegration.ts new file mode 100644 index 000000000000..5c45ff63d983 --- /dev/null +++ b/packages/nextjs/src/client/rewriteFramesIntegration.ts @@ -0,0 +1,54 @@ +import { defineIntegration } from '@sentry/core'; +import { rewriteFramesIntegration as originalRewriteFramesIntegration } from '@sentry/integrations'; +import type { IntegrationFn, StackFrame } from '@sentry/types'; + +const globalWithInjectedValues = global as typeof global & { + __rewriteFramesAssetPrefixPath__: string; +}; + +type StackFrameIteratee = (frame: StackFrame) => StackFrame; + +interface RewriteFramesOptions { + root?: string; + prefix?: string; + iteratee?: StackFrameIteratee; +} + +export const customRewriteFramesIntegration = ((options?: RewriteFramesOptions) => { + // This value is injected at build time, based on the output directory specified in the build config. Though a default + // is set there, we set it here as well, just in case something has gone wrong with the injection. + const assetPrefixPath = globalWithInjectedValues.__rewriteFramesAssetPrefixPath__ || ''; + + return originalRewriteFramesIntegration({ + // Turn `//_next/static/...` into `app:///_next/static/...` + iteratee: frame => { + try { + const { origin } = new URL(frame.filename as string); + frame.filename = frame.filename?.replace(origin, 'app://').replace(assetPrefixPath, ''); + } catch (err) { + // Filename wasn't a properly formed URL, so there's nothing we can do + } + + // We need to URI-decode the filename because Next.js has wildcard routes like "/users/[id].js" which show up as "/users/%5id%5.js" in Error stacktraces. + // The corresponding sources that Next.js generates have proper brackets so we also need proper brackets in the frame so that source map resolving works. + if (frame.filename && frame.filename.startsWith('app:///_next')) { + frame.filename = decodeURI(frame.filename); + } + + if ( + frame.filename && + frame.filename.match( + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + ) + ) { + // We don't care about these frames. It's Next.js internal code. + frame.in_app = false; + } + + return frame; + }, + ...options, + }); +}) satisfies IntegrationFn; + +export const rewriteFramesIntegration = defineIntegration(customRewriteFramesIntegration); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index fa0e49f7ab59..a322dbed07df 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,18 +1,14 @@ import { SDK_VERSION, addTracingExtensions } from '@sentry/core'; -import { RewriteFrames } from '@sentry/integrations'; import type { SdkMetadata } from '@sentry/types'; -import { GLOBAL_OBJ, addOrUpdateIntegration, escapeStringForRegex } from '@sentry/utils'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; -import { init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { isBuild } from '../common/utils/isBuild'; +import { rewriteFramesIntegration } from './rewriteFramesIntegration'; export type EdgeOptions = VercelEdgeOptions; -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __rewriteFramesDistDir__?: string; - fetch: (...args: unknown[]) => unknown; -}; +export { rewriteFramesIntegration }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ export function init(options: VercelEdgeOptions = {}): void { @@ -22,8 +18,11 @@ export function init(options: VercelEdgeOptions = {}): void { return; } + const customDefaultIntegrations = [...getDefaultIntegrations(options), rewriteFramesIntegration()]; + const opts = { _metadata: {} as SdkMetadata, + defaultIntegrations: customDefaultIntegrations, ...options, }; @@ -38,32 +37,6 @@ export function init(options: VercelEdgeOptions = {}): void { version: SDK_VERSION, }; - let integrations = opts.integrations || []; - - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; - if (distDirName) { - const distDirAbsPath = distDirName.replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one - - // Normally we would use `path.resolve` to obtain the absolute path we will strip from the stack frame to align with - // the uploaded artifacts, however we don't have access to that API in edge so we need to be a bit more lax. - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped - const SOURCEMAP_FILENAME_REGEX = new RegExp(`.*${escapeStringForRegex(distDirAbsPath)}`); - - // eslint-disable-next-line deprecation/deprecation - const defaultRewriteFramesIntegration = new RewriteFrames({ - iteratee: frame => { - frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); - return frame; - }, - }); - - integrations = addOrUpdateIntegration(defaultRewriteFramesIntegration, integrations); - } - - opts.integrations = integrations; - vercelEdgeInit(opts); } diff --git a/packages/nextjs/src/edge/rewriteFramesIntegration.ts b/packages/nextjs/src/edge/rewriteFramesIntegration.ts new file mode 100644 index 000000000000..96e626178c4b --- /dev/null +++ b/packages/nextjs/src/edge/rewriteFramesIntegration.ts @@ -0,0 +1,52 @@ +import { defineIntegration } from '@sentry/core'; +import { + RewriteFrames as OriginalRewriteFrames, + rewriteFramesIntegration as originalRewriteFramesIntegration, +} from '@sentry/integrations'; +import type { IntegrationFn, StackFrame } from '@sentry/types'; +import { GLOBAL_OBJ, escapeStringForRegex } from '@sentry/utils'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __rewriteFramesDistDir__?: string; +}; + +type StackFrameIteratee = (frame: StackFrame) => StackFrame; +interface RewriteFramesOptions { + root?: string; + prefix?: string; + iteratee?: StackFrameIteratee; +} + +export const customRewriteFramesIntegration = ((options?: RewriteFramesOptions) => { + // This value is injected at build time, based on the output directory specified in the build config. Though a default + // is set there, we set it here as well, just in case something has gone wrong with the injection. + const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; + + if (distDirName) { + const distDirAbsPath = distDirName.replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one + + // Normally we would use `path.resolve` to obtain the absolute path we will strip from the stack frame to align with + // the uploaded artifacts, however we don't have access to that API in edge so we need to be a bit more lax. + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped + const SOURCEMAP_FILENAME_REGEX = new RegExp(`.*${escapeStringForRegex(distDirAbsPath)}`); + + return originalRewriteFramesIntegration({ + iteratee: frame => { + frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); + return frame; + }, + ...options, + }); + } + + // Do nothing if we can't find a distDirName + return { + // eslint-disable-next-line deprecation/deprecation + name: OriginalRewriteFrames.id, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setupOnce: () => {}, + processEvent: event => event, + }; +}) satisfies IntegrationFn; + +export const rewriteFramesIntegration = defineIntegration(customRewriteFramesIntegration); diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 8914296cd3a1..b15bb6c40bab 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -28,6 +28,9 @@ export declare const defaultIntegrations: Integration[]; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; +// eslint-disable-next-line deprecation/deprecation +export declare const rewriteFramesIntegration: typeof clientSdk.rewriteFramesIntegration; + export declare function getSentryRelease(fallback?: string): string | undefined; export declare const ErrorBoundary: typeof clientSdk.ErrorBoundary; diff --git a/packages/nextjs/src/server/httpIntegration.ts b/packages/nextjs/src/server/httpIntegration.ts new file mode 100644 index 000000000000..d4e405586e96 --- /dev/null +++ b/packages/nextjs/src/server/httpIntegration.ts @@ -0,0 +1,14 @@ +import { Integrations } from '@sentry/node'; +const { Http: OriginalHttp } = Integrations; + +/** + * A custom HTTP integration where we always enable tracing. + */ +export class Http extends OriginalHttp { + public constructor(options?: ConstructorParameters[0]) { + super({ + ...options, + tracing: true, + }); + } +} diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index fed85cb3adb8..ca1167ecfcb0 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,22 +1,35 @@ -import * as path from 'path'; import { addTracingExtensions, getClient } from '@sentry/core'; -import { RewriteFrames } from '@sentry/integrations'; import type { NodeOptions } from '@sentry/node'; -import { Integrations, getCurrentScope, init as nodeInit } from '@sentry/node'; +import { + Integrations as OriginalIntegrations, + getCurrentScope, + getDefaultIntegrations, + init as nodeInit, +} from '@sentry/node'; import type { EventProcessor } from '@sentry/types'; -import type { IntegrationWithExclusionOption } from '@sentry/utils'; -import { addOrUpdateIntegration, escapeStringForRegex, logger } from '@sentry/utils'; +import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; import { buildMetadata } from '../common/metadata'; import { isBuild } from '../common/utils/isBuild'; +import { Http } from './httpIntegration'; +import { OnUncaughtException } from './onUncaughtExceptionIntegration'; +import { rewriteFramesIntegration } from './rewriteFramesIntegration'; export { createReduxEnhancer } from '@sentry/react'; export * from '@sentry/node'; export { captureUnderscoreErrorException } from '../common/_error'; +export const Integrations = { + ...OriginalIntegrations, + Http, + OnUncaughtException, +}; + +export { rewriteFramesIntegration }; + /** * A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors * so they should simply be a passthrough. @@ -52,10 +65,6 @@ export function showReportDialog(): void { return; } -const globalWithInjectedValues = global as typeof global & { - __rewriteFramesDistDir__?: string; -}; - // TODO (v8): Remove this /** * @deprecated This constant will be removed in the next major update. @@ -72,8 +81,18 @@ export function init(options: NodeOptions): void { return; } + const customDefaultIntegrations = [ + ...getDefaultIntegrations(options).filter( + integration => !['Http', 'OnUncaughtException'].includes(integration.name), + ), + rewriteFramesIntegration(), + new Http(), + new OnUncaughtException(), + ]; + const opts = { environment: process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV, + defaultIntegrations: customDefaultIntegrations, ...options, // Right now we only capture frontend sessions for Next.js autoSessionTracking: false, @@ -92,8 +111,6 @@ export function init(options: NodeOptions): void { buildMetadata(opts, ['nextjs', 'node']); - addServerIntegrations(opts); - nodeInit(opts); const filterTransactions: EventProcessor = event => { @@ -121,45 +138,6 @@ function sdkAlreadyInitialized(): boolean { return !!getClient(); } -function addServerIntegrations(options: NodeOptions): void { - let integrations = options.integrations || []; - - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; - if (distDirName) { - // nextjs always puts the build directory at the project root level, which is also where you run `next start` from, so - // we can read in the project directory from the currently running process - const distDirAbsPath = path.resolve(distDirName).replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped - const SOURCEMAP_FILENAME_REGEX = new RegExp(escapeStringForRegex(distDirAbsPath)); - - // eslint-disable-next-line deprecation/deprecation - const defaultRewriteFramesIntegration = new RewriteFrames({ - iteratee: frame => { - frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); - return frame; - }, - }); - integrations = addOrUpdateIntegration(defaultRewriteFramesIntegration, integrations); - } - - const defaultOnUncaughtExceptionIntegration: IntegrationWithExclusionOption = new Integrations.OnUncaughtException({ - exitEvenIfOtherHandlersAreRegistered: false, - }); - defaultOnUncaughtExceptionIntegration.allowExclusionByUser = true; - integrations = addOrUpdateIntegration(defaultOnUncaughtExceptionIntegration, integrations, { - _options: { exitEvenIfOtherHandlersAreRegistered: false }, - }); - - const defaultHttpTracingIntegration = new Integrations.Http({ tracing: true }); - integrations = addOrUpdateIntegration(defaultHttpTracingIntegration, integrations, { - _tracing: {}, - }); - - options.integrations = integrations; -} - // TODO (v8): Remove this /** * @deprecated This constant will be removed in the next major update. diff --git a/packages/nextjs/src/server/onUncaughtExceptionIntegration.ts b/packages/nextjs/src/server/onUncaughtExceptionIntegration.ts new file mode 100644 index 000000000000..4659eb682fdb --- /dev/null +++ b/packages/nextjs/src/server/onUncaughtExceptionIntegration.ts @@ -0,0 +1,14 @@ +import { Integrations } from '@sentry/node'; +const { OnUncaughtException: OriginalOnUncaughtException } = Integrations; + +/** + * A custom OnUncaughtException integration that does not exit by default. + */ +export class OnUncaughtException extends OriginalOnUncaughtException { + public constructor(options?: ConstructorParameters[0]) { + super({ + exitEvenIfOtherHandlersAreRegistered: false, + ...options, + }); + } +} diff --git a/packages/nextjs/src/server/rewriteFramesIntegration.ts b/packages/nextjs/src/server/rewriteFramesIntegration.ts new file mode 100644 index 000000000000..f27ff9a9993d --- /dev/null +++ b/packages/nextjs/src/server/rewriteFramesIntegration.ts @@ -0,0 +1,52 @@ +import * as path from 'path'; +import { defineIntegration } from '@sentry/core'; +import { + RewriteFrames as OriginalRewriteFrames, + rewriteFramesIntegration as originalRewriteFramesIntegration, +} from '@sentry/integrations'; +import type { IntegrationFn, StackFrame } from '@sentry/types'; +import { escapeStringForRegex } from '@sentry/utils'; + +const globalWithInjectedValues = global as typeof global & { + __rewriteFramesDistDir__?: string; +}; + +type StackFrameIteratee = (frame: StackFrame) => StackFrame; +interface RewriteFramesOptions { + root?: string; + prefix?: string; + iteratee?: StackFrameIteratee; +} + +export const customRewriteFramesIntegration = ((options?: RewriteFramesOptions) => { + // This value is injected at build time, based on the output directory specified in the build config. Though a default + // is set there, we set it here as well, just in case something has gone wrong with the injection. + const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; + + if (distDirName) { + // nextjs always puts the build directory at the project root level, which is also where you run `next start` from, so + // we can read in the project directory from the currently running process + const distDirAbsPath = path.resolve(distDirName).replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped + const SOURCEMAP_FILENAME_REGEX = new RegExp(escapeStringForRegex(distDirAbsPath)); + + return originalRewriteFramesIntegration({ + iteratee: frame => { + frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); + return frame; + }, + ...options, + }); + } + + // Do nothing if we can't find a distDirName + return { + // eslint-disable-next-line deprecation/deprecation + name: OriginalRewriteFrames.id, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setupOnce: () => {}, + processEvent: event => event, + }; +}) satisfies IntegrationFn; + +export const rewriteFramesIntegration = defineIntegration(customRewriteFramesIntegration); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 16ee177d4294..d0f0de959170 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,12 +1,12 @@ -import { BaseClient, getClient } from '@sentry/core'; +import { BaseClient } from '@sentry/core'; import * as SentryReact from '@sentry/react'; -import { BrowserTracing, WINDOW, getCurrentScope } from '@sentry/react'; +import type { BrowserClient } from '@sentry/react'; +import { WINDOW, getClient, getCurrentScope } from '@sentry/react'; import type { Integration } from '@sentry/types'; -import type { UserIntegrationsFunction } from '@sentry/utils'; import { logger } from '@sentry/utils'; import { JSDOM } from 'jsdom'; -import { Integrations, init, nextRouterInstrumentation } from '../src/client'; +import { BrowserTracing, Integrations, init, nextRouterInstrumentation } from '../src/client'; const reactInit = jest.spyOn(SentryReact, 'init'); const captureEvent = jest.spyOn(BaseClient.prototype, 'captureEvent'); @@ -31,6 +31,8 @@ function findIntegrationByName(integrations: Integration[] = [], name: string): return integrations.find(integration => integration.name === name); } +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + describe('Client init()', () => { afterEach(() => { jest.clearAllMocks(); @@ -60,7 +62,7 @@ describe('Client init()', () => { }, }, environment: 'test', - integrations: expect.arrayContaining([ + defaultIntegrations: expect.arrayContaining([ expect.objectContaining({ name: 'RewriteFrames', }), @@ -103,8 +105,7 @@ describe('Client init()', () => { describe('integrations', () => { // Options passed by `@sentry/nextjs`'s `init` to `@sentry/react`'s `init` after modifying them - type ModifiedInitOptionsIntegrationArray = { integrations: Integration[] }; - type ModifiedInitOptionsIntegrationFunction = { integrations: UserIntegrationsFunction }; + type ModifiedInitOptionsIntegrationArray = { defaultIntegrations: Integration[]; integrations: Integration[] }; it('supports passing unrelated integrations through options', () => { init({ integrations: [new Integrations.Breadcrumbs({ console: false })] }); @@ -117,83 +118,87 @@ describe('Client init()', () => { describe('`BrowserTracing` integration', () => { it('adds `BrowserTracing` integration if `tracesSampleRate` is set', () => { - init({ tracesSampleRate: 1.0 }); + init({ + dsn: TEST_DSN, + tracesSampleRate: 1.0, + }); - const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray; - const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing'); + const client = getClient()!; + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); expect(browserTracingIntegration).toBeDefined(); - expect(browserTracingIntegration).toEqual( + expect(browserTracingIntegration?.options).toEqual( expect.objectContaining({ - options: expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - }), + routingInstrumentation: nextRouterInstrumentation, }), ); }); it('adds `BrowserTracing` integration if `tracesSampler` is set', () => { - init({ tracesSampler: () => true }); + init({ + dsn: TEST_DSN, + tracesSampler: () => true, + }); - const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray; - const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing'); + const client = getClient()!; + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); expect(browserTracingIntegration).toBeDefined(); - expect(browserTracingIntegration).toEqual( + expect(browserTracingIntegration?.options).toEqual( expect.objectContaining({ - options: expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - }), + routingInstrumentation: nextRouterInstrumentation, }), ); }); it('does not add `BrowserTracing` integration if tracing not enabled in SDK', () => { - init({}); + init({ + dsn: TEST_DSN, + }); - const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray; - const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing'); + const client = getClient()!; + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); expect(browserTracingIntegration).toBeUndefined(); }); it('forces correct router instrumentation if user provides `BrowserTracing` in an array', () => { init({ + dsn: TEST_DSN, tracesSampleRate: 1.0, integrations: [new BrowserTracing({ startTransactionOnLocationChange: false })], }); - const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationArray; - const browserTracingIntegration = findIntegrationByName(reactInitOptions.integrations, 'BrowserTracing'); + const client = getClient()!; + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); - expect(browserTracingIntegration).toEqual( + expect(browserTracingIntegration).toBeDefined(); + expect(browserTracingIntegration?.options).toEqual( expect.objectContaining({ - options: expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - // This proves it's still the user's copy - startTransactionOnLocationChange: false, - }), + routingInstrumentation: nextRouterInstrumentation, + // This proves it's still the user's copy + startTransactionOnLocationChange: false, }), ); }); it('forces correct router instrumentation if user provides `BrowserTracing` in a function', () => { init({ + dsn: TEST_DSN, tracesSampleRate: 1.0, integrations: defaults => [...defaults, new BrowserTracing({ startTransactionOnLocationChange: false })], }); - const reactInitOptions = reactInit.mock.calls[0][0] as ModifiedInitOptionsIntegrationFunction; - const materializedIntegrations = reactInitOptions.integrations(SentryReact.getDefaultIntegrations({})); - const browserTracingIntegration = findIntegrationByName(materializedIntegrations, 'BrowserTracing'); + const client = getClient()!; - expect(browserTracingIntegration).toEqual( + const browserTracingIntegration = client.getIntegrationByName('BrowserTracing'); + + expect(browserTracingIntegration).toBeDefined(); + expect(browserTracingIntegration?.options).toEqual( expect.objectContaining({ - options: expect.objectContaining({ - routingInstrumentation: nextRouterInstrumentation, - // This proves it's still the user's copy - startTransactionOnLocationChange: false, - }), + routingInstrumentation: nextRouterInstrumentation, + // This proves it's still the user's copy + startTransactionOnLocationChange: false, }), ); }); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 5b69f3034d55..1c7c9e384657 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -4,9 +4,7 @@ import { NodeClient, getClient, getCurrentHub, getCurrentScope } from '@sentry/n import type { Integration } from '@sentry/types'; import { GLOBAL_OBJ, logger } from '@sentry/utils'; -import { init } from '../src/server'; - -const { Integrations } = SentryNode; +import { Integrations, init } from '../src/server'; // normally this is set as part of the build process, so mock it here (GLOBAL_OBJ as typeof GLOBAL_OBJ & { __rewriteFramesDistDir__: string }).__rewriteFramesDistDir__ = '.next'; @@ -57,7 +55,7 @@ describe('Server init()', () => { // TODO: If we upgrde to Jest 28+, we can follow Jest's example matcher and create an // `expect.ArrayContainingInAnyOrder`. See // https://github.com/facebook/jest/blob/main/examples/expect-extend/toBeWithinRange.ts. - integrations: expect.any(Array), + defaultIntegrations: expect.any(Array), }), ); }); @@ -155,15 +153,22 @@ describe('Server init()', () => { describe('integrations', () => { // Options passed by `@sentry/nextjs`'s `init` to `@sentry/node`'s `init` after modifying them - type ModifiedInitOptions = { integrations: Integration[] }; + type ModifiedInitOptions = { integrations: Integration[]; defaultIntegrations: Integration[] }; it('adds default integrations', () => { init({}); const nodeInitOptions = nodeInit.mock.calls[0][0] as ModifiedInitOptions; - const rewriteFramesIntegration = findIntegrationByName(nodeInitOptions.integrations, 'RewriteFrames'); + const rewriteFramesIntegration = findIntegrationByName(nodeInitOptions.defaultIntegrations, 'RewriteFrames'); + const httpIntegration = findIntegrationByName(nodeInitOptions.defaultIntegrations, 'Http'); + const onUncaughtExceptionIntegration = findIntegrationByName( + nodeInitOptions.defaultIntegrations, + 'OnUncaughtException', + ); expect(rewriteFramesIntegration).toBeDefined(); + expect(httpIntegration).toBeDefined(); + expect(onUncaughtExceptionIntegration).toBeDefined(); }); it('supports passing unrelated integrations through options', () => { @@ -176,42 +181,18 @@ describe('Server init()', () => { }); describe('`Http` integration', () => { - it('adds `Http` integration with tracing enabled if `tracesSampleRate` is set', () => { + it('adds `Http` integration with tracing enabled by default', () => { init({ tracesSampleRate: 1.0 }); const nodeInitOptions = nodeInit.mock.calls[0][0] as ModifiedInitOptions; - const httpIntegration = findIntegrationByName(nodeInitOptions.integrations, 'Http'); - - expect(httpIntegration).toBeDefined(); - expect(httpIntegration).toEqual(expect.objectContaining({ _tracing: {} })); - }); - - it('adds `Http` integration with tracing enabled if `tracesSampler` is set', () => { - init({ tracesSampler: () => true }); - - const nodeInitOptions = nodeInit.mock.calls[0][0] as ModifiedInitOptions; - const httpIntegration = findIntegrationByName(nodeInitOptions.integrations, 'Http'); - - expect(httpIntegration).toBeDefined(); - expect(httpIntegration).toEqual(expect.objectContaining({ _tracing: {} })); - }); - - it('forces `_tracing = true` if `tracesSampleRate` is set', () => { - init({ - tracesSampleRate: 1.0, - integrations: [new Integrations.Http({ tracing: false })], - }); - - const nodeInitOptions = nodeInit.mock.calls[0][0] as ModifiedInitOptions; - const httpIntegration = findIntegrationByName(nodeInitOptions.integrations, 'Http'); + const httpIntegration = findIntegrationByName(nodeInitOptions.defaultIntegrations, 'Http'); expect(httpIntegration).toBeDefined(); expect(httpIntegration).toEqual(expect.objectContaining({ _tracing: {} })); }); - it('forces `_tracing = true` if `tracesSampler` is set', () => { + it('forces `_tracing = true` even if set to false', () => { init({ - tracesSampler: () => true, integrations: [new Integrations.Http({ tracing: false })], });