diff --git a/packages/nextjs/src/common/utils/responseEnd.ts b/packages/nextjs/src/common/utils/responseEnd.ts index 31e3fa698433..8fb70736989f 100644 --- a/packages/nextjs/src/common/utils/responseEnd.ts +++ b/packages/nextjs/src/common/utils/responseEnd.ts @@ -1,5 +1,5 @@ import type { Span } from '@sentry/core'; -import { debug, fill, flush, GLOBAL_OBJ, setHttpStatus } from '@sentry/core'; +import { debug, fill, flush, getAsyncContextStrategy, getMainCarrier, GLOBAL_OBJ, setHttpStatus } from '@sentry/core'; import { vercelWaitUntil } from '@sentry/core/server'; import type { ServerResponse } from 'http'; import { DEBUG_BUILD } from '../debug-build'; @@ -105,3 +105,20 @@ export function cloudflareWaitUntil(task: Promise): void { export function isCloudflareWaitUntilAvailable(): boolean { return typeof _getOpenNextCloudflareContext()?.waitUntil === 'function'; } + +/** + * Whether a request of `withSentry` from `@sentry/cloudflare` runs. A client of `init` would then replace its async + * context strategy while the request runs (#24603). + */ +export function isAsyncContextOwnedByCloudflare(): boolean { + const strategy = getAsyncContextStrategy(getMainCarrier()); + // The AsyncLocalStorage strategy of `@sentry/cloudflare` has no `withActiveSpan`. + const asyncLocalStorage = strategy.getTracingChannelBinding?.()?.asyncLocalStorage as + | { getStore(): unknown } + | undefined; + return ( + !strategy.withActiveSpan && + asyncLocalStorage?.getStore() !== undefined && + (GLOBAL_OBJ as { navigator?: { userAgent?: string } }).navigator?.userAgent === 'Cloudflare-Workers' + ); +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index c971bbafec91..fbfb548d3539 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -30,7 +30,12 @@ import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunn import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; import { isBuild } from '../common/utils/isBuild'; -import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; +import { + flushSafelyWithTimeout, + isAsyncContextOwnedByCloudflare, + isCloudflareWaitUntilAvailable, + waitUntil, +} from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan'; @@ -106,6 +111,10 @@ export function init(options: VercelEdgeOptions = {}): void { ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + if (isAsyncContextOwnedByCloudflare()) { + return; + } + // Use appropriate SDK metadata based on the runtime environment if (isRunningOnCloudflare) { applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index d7ce162236fb..1e773fb36a10 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -19,7 +19,7 @@ import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolica import { isPrerenderControlFlowError } from '../common/nextNavigationErrorUtils'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { isBuild } from '../common/utils/isBuild'; -import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; +import { isAsyncContextOwnedByCloudflare, isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan'; @@ -182,7 +182,10 @@ export function init(options: NodeOptions): NodeClient | undefined { DEBUG_BUILD && debug.log('Initializing SDK...'); - if (sdkAlreadyInitialized()) { + // The client of `withSentry` from `@sentry/cloudflare` handles this request, so `init` creates none. The global + // parts below still apply. + const isOwnedByCloudflare = isAsyncContextOwnedByCloudflare(); + if (sdkAlreadyInitialized() && !isOwnedByCloudflare) { DEBUG_BUILD && debug.log('SDK already initialized'); return; } @@ -190,7 +193,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // Use appropriate SDK metadata based on the runtime environment applySdkMetadata(opts, 'nextjs', ['nextjs', cloudflareConfig ? 'cloudflare' : 'node']); - const client = nodeInit(opts); + const client = isOwnedByCloudflare ? undefined : nodeInit(opts); client?.on('beforeSampling', ({ spanAttributes }, samplingDecision) => { // There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index 8d4fee1f926e..8b95f3022fde 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; +import { getMainCarrier, GLOBAL_OBJ, withIsolationScope } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; @@ -21,6 +22,9 @@ describe('Edge init()', () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); + + getMainCarrier().__SENTRY__ = undefined; }); it('inits the Vercel Edge SDK', () => { @@ -52,6 +56,15 @@ describe('Edge init()', () => { ); }); + it('skips init on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + setAsyncLocalStorageAsyncContextStrategy(); + + withIsolationScope(() => init({})); + + expect(vercelEdgeInit).not.toHaveBeenCalled(); + }); + describe('integrations', () => { // Options passed by `@sentry/nextjs`'s `init` to `@sentry/vercel-edge`'s `init` after modifying them type ModifiedInitOptions = { integrations?: Integration[]; defaultIntegrations: Integration[] }; diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 25ba979026b3..a84a456a63ee 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -1,5 +1,6 @@ import type { Integration } from '@sentry/core'; -import { GLOBAL_OBJ, getMainCarrier } from '@sentry/core'; +import { GLOBAL_OBJ, getGlobalScope, getMainCarrier, withIsolationScope } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import { getCurrentScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -18,10 +19,12 @@ function findIntegrationByName(integrations: Integration[] = [], name: string): describe('Server init()', () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); getMainCarrier().__SENTRY__ = undefined; delete process.env.VERCEL; + delete (process as { turbopack?: boolean }).turbopack; }); it('inits the Node SDK', () => { @@ -67,6 +70,34 @@ describe('Server init()', () => { expect(nodeInit).toHaveBeenCalledTimes(1); }); + it('skips init and sets the `turbopack` tag on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + setAsyncLocalStorageAsyncContextStrategy(); + (process as { turbopack?: boolean }).turbopack = true; + + withIsolationScope(() => init({})); + + expect(nodeInit).not.toHaveBeenCalled(); + expect(getGlobalScope().getScopeData().tags.turbopack).toBe(true); + }); + + it('inits on Cloudflare Workers outside of a request of `withSentry` from `@sentry/cloudflare`', () => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + setAsyncLocalStorageAsyncContextStrategy(); + + init({}); + + expect(nodeInit).toHaveBeenCalledTimes(1); + }); + + it('inits outside of Cloudflare Workers when an AsyncLocalStorage strategy is installed', () => { + setAsyncLocalStorageAsyncContextStrategy(); + + withIsolationScope(() => init({})); + + expect(nodeInit).toHaveBeenCalledTimes(1); + }); + // TODO: test `vercel` tag when running on Vercel // Can't just add the test and set env variables, since the value in `index.server.ts` // is resolved when importing.