Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/repeated-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ When `init()` replaces a client, nothing closes the old one:
| Nuxt server | Keeps the first client and returns it. Logs that a `--import` preload is no longer needed. |
| Cloudflare (default) | Keeps the first client of the isolate and returns it, unless that client is closed or closing. `cacheClient: false` makes a new client on each call, with no warning. |
| Next.js edge | Warns, then replaces the client. Returns `void`. |
| Next.js server and edge in a request of `withSentry` (Cloudflare) | Creates no client, because the client of `withSentry` handles the request. Returns `undefined`. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have this file in here? 馃槄

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This got added via #24962 and acts as a list of defined behaviors for our init() function. This will be then aligned in v12.


The shared warning lives in `warnIfClientIsActive()` in
`packages/core/src/sdk.ts`. Core exports it as
Expand Down
19 changes: 18 additions & 1 deletion packages/nextjs/src/common/utils/responseEnd.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -105,3 +105,20 @@ export function cloudflareWaitUntil(task: Promise<unknown>): 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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just pointing out that this detection might be brittle bc @sentry/nextjs and @sentry/cloudlfare need to be on the exact same version for this to work

@JPeer264 JPeer264 Oct 6, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it does. But I guess this will be solved when we add this as a optional peer dependency and is documented for the, soon to be added, @sentry/nextjs/cloudflare entrypoint

Edit: Ok maybe a direct dependency might be better. But that is for another PR

// 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.navigator?.userAgent === 'Cloudflare-Workers'
);
}
39 changes: 27 additions & 12 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/* eslint-disable import/export */
import {
applySdkMetadata,
debug,
getGlobalScope,
getIsolationScope,
getRootSpan,
Expand All @@ -28,7 +29,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';
Expand Down Expand Up @@ -105,6 +111,26 @@ export function init(options: VercelEdgeOptions = {}): void {
];
opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans];

// Before the Cloudflare check, so that the events of the client of `withSentry` get the tag too.
try {
// @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js
if (process.turbopack) {
getGlobalScope().setTag('turbopack', true);
getGlobalScope().setAttribute('turbopack', true);
}
} catch {
// Noop
// The statement above can throw because process is not defined on the client
}

if (isAsyncContextOwnedByCloudflare()) {
DEBUG_BUILD &&
debug.log(
'The client of `withSentry` handles this Worker, so `init` creates no client. Set the options in `withSentry`.',
);
return;
}
Comment thread
JPeer264 marked this conversation as resolved.

// Use appropriate SDK metadata based on the runtime environment
if (isRunningOnCloudflare) {
applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']);
Expand Down Expand Up @@ -213,15 +239,4 @@ export function init(options: VercelEdgeOptions = {}): void {

waitUntil(flushSafelyWithTimeout());
});

try {
// @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js
if (process.turbopack) {
getGlobalScope().setTag('turbopack', true);
getGlobalScope().setAttribute('turbopack', true);
}
} catch {
// Noop
// The statement above can throw because process is not defined on the client
}
}
36 changes: 28 additions & 8 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703
/* eslint-disable import/export */
import { HTTP_TARGET, URL_QUERY } from '@sentry/conventions/attributes';
import type { EventProcessor } from '@sentry/core';
import type { EventProcessor, Scope } from '@sentry/core';
import {
_INTERNAL_getActiveClient,
applySdkMetadata,
Expand All @@ -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';
Expand Down Expand Up @@ -182,16 +182,19 @@ export function init(options: NodeOptions): NodeClient | undefined {

DEBUG_BUILD && debug.log('Initializing SDK...');

// The client of `withSentry` from `@sentry/cloudflare` handles this request, so `init` creates none. The global
// parts below still apply.
const isOwnedByCloudflare = isAsyncContextOwnedByCloudflare();
const existingClient = _INTERNAL_getActiveClient<NodeClient>();
if (existingClient) {
if (existingClient && !isOwnedByCloudflare) {
DEBUG_BUILD && debug.log('SDK already initialized');
return existingClient;
}

// 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
Expand Down Expand Up @@ -233,8 +236,18 @@ export function init(options: NodeOptions): NodeClient | undefined {
client?.on('spanEnd', maybeCleanupQueueSpan);

// On the client, not the global scope, so a later `init()` after
// `close()` does not stack another copy.
client?.addEventProcessor(
// `close()` does not stack another copy. In a request of `withSentry` on
// Workers, `init` creates no client, so the global scope gets them, which
// reaches every client of `withSentry`, unless an earlier `init` added them.
let eventProcessorTarget: NodeClient | Scope | undefined = client;
if (isOwnedByCloudflare) {
const globalScope = getGlobalScope();
const isAdded = globalScope
.getScopeData()
.eventProcessors.some(processor => processor.id === 'DropReactControlFlowErrors');
eventProcessorTarget = isAdded ? undefined : globalScope;
}
eventProcessorTarget?.addEventProcessor(
Object.assign(
((event, hint) => {
if (event.type !== undefined) {
Expand Down Expand Up @@ -281,7 +294,7 @@ export function init(options: NodeOptions): NodeClient | undefined {
});

if (process.env.NODE_ENV === 'development') {
client?.addEventProcessor(devErrorSymbolicationEventProcessor);
eventProcessorTarget?.addEventProcessor(devErrorSymbolicationEventProcessor);
}

try {
Expand All @@ -295,7 +308,14 @@ export function init(options: NodeOptions): NodeClient | undefined {
// The statement above can throw because process is not defined on the client
}

DEBUG_BUILD && debug.log('SDK successfully initialized');
if (isOwnedByCloudflare) {
DEBUG_BUILD &&
debug.log(
'The client of `withSentry` handles this Worker, so `init` creates no client. Set the options in `withSentry`.',
);
} else {
DEBUG_BUILD && debug.log('SDK successfully initialized');
}

return client;
}
Expand Down
27 changes: 26 additions & 1 deletion packages/nextjs/test/edgeSdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Integration } from '@sentry/core';
import { GLOBAL_OBJ } from '@sentry/core';
import { getGlobalScope, 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';
Expand All @@ -22,6 +23,11 @@ describe('Edge init()', () => {
afterEach(() => {
SentryVercelEdge.getCurrentScope().setClient(undefined);
vi.clearAllMocks();
vi.unstubAllGlobals();

getMainCarrier().__SENTRY__ = undefined;

delete (process as { turbopack?: boolean }).turbopack;
});

it('inits the Vercel Edge SDK', () => {
Expand Down Expand Up @@ -53,6 +59,25 @@ 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();
});

it('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(getGlobalScope().getScopeData().tags.turbopack).toBe(true);
});

describe('integrations', () => {
// Options passed by `@sentry/nextjs`'s `init` to `@sentry/vercel-edge`'s `init` after modifying them
type ModifiedInitOptions = { integrations?: Integration[]; defaultIntegrations: Integration[] };
Expand Down
57 changes: 56 additions & 1 deletion packages/nextjs/test/serverSdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Integration } from '@sentry/core';
import { GLOBAL_OBJ, getMainCarrier } from '@sentry/core';
import { GLOBAL_OBJ, getMainCarrier, withIsolationScope } from '@sentry/core';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils';
import { close, getCurrentScope, getGlobalScope } from '@sentry/node';
import * as SentryNode from '@sentry/node';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -91,6 +94,58 @@ describe('Server init()', () => {
expect(second).toBe(first);
});

it('skips init on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => {
vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' });
setAsyncLocalStorageAsyncContextStrategy();

withIsolationScope(() => init({}));

expect(nodeInit).not.toHaveBeenCalled();
});

it('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(getGlobalScope().getScopeData().tags.turbopack).toBe(true);
});

it('adds its event processors to the global scope once on Cloudflare Workers in a request of `withSentry` from `@sentry/cloudflare`', () => {
vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' });
setAsyncLocalStorageAsyncContextStrategy();

withIsolationScope(() => {
init({});
init({});
});

expect(
getGlobalScope()
.getScopeData()
.eventProcessors.filter(processor => processor.id === 'DropReactControlFlowErrors'),
).toHaveLength(1);
});

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.
Expand Down
Loading