From 8298495671f08e1641be963c01fa2f7039c22597 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 22 Sep 2025 14:16:00 +0200 Subject: [PATCH 1/6] feat(cloudflare,vercel-edge): Add support for Google Gen AI instrumentation (#17723) Adds support for Google GenAI manual instrumentation in @sentry/cloudflare and @sentry/vercel-edge. To instrument the Google GenAI client, wrap it with Sentry.instrumentGoogleGenAIClient and set recording settings. ``` import * as Sentry from '@sentry/cloudflare'; import { GoogleGenAI } from '@google/genai'; const genAI = new GoogleGenAI({ apiKey: 'your-api-key' }); const client = Sentry.instrumentGoogleGenAIClient(genAI, { recordInputs: true, recordOutputs: true }); // use the wrapped client with models api const model = client.models.generateContent({ model: 'gemini-1.5-pro', contents: [{ role: 'user', parts: [{ text: 'Hello!' }] }] }); // or use chat functionality const chat = client.chats.create({ model: 'gemini-1.5-flash' }); const response = await chat.sendMessage({ message: 'Tell me a joke' }); ``` --- .../suites/tracing/google-genai/index.ts | 61 +++++++++ .../suites/tracing/google-genai/mocks.ts | 128 ++++++++++++++++++ .../suites/tracing/google-genai/test.ts | 75 ++++++++++ .../tracing/google-genai/wrangler.jsonc | 6 + packages/cloudflare/src/index.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/utils/google-genai/index.ts | 4 +- packages/vercel-edge/src/index.ts | 1 + 8 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts new file mode 100644 index 000000000000..4759ec9a107b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts @@ -0,0 +1,61 @@ +import * as Sentry from '@sentry/cloudflare'; +import type { GoogleGenAIClient } from '@sentry/core'; +import { MockGoogleGenAI } from './mocks'; + +interface Env { + SENTRY_DSN: string; +} + +const mockClient = new MockGoogleGenAI({ + apiKey: 'mock-api-key', +}); + +const client: GoogleGenAIClient = Sentry.instrumentGoogleGenAIClient(mockClient); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + // Test 1: chats.create and sendMessage flow + const chat = client.chats.create({ + model: 'gemini-1.5-pro', + config: { + temperature: 0.8, + topP: 0.9, + maxOutputTokens: 150, + }, + history: [ + { + role: 'user', + parts: [{ text: 'Hello, how are you?' }], + }, + ], + }); + + const chatResponse = await chat.sendMessage({ + message: 'Tell me a joke', + }); + + // Test 2: models.generateContent + const modelResponse = await client.models.generateContent({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'What is the capital of France?' }], + }, + ], + }); + + return new Response(JSON.stringify({ chatResponse, modelResponse })); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts new file mode 100644 index 000000000000..22ccba15bc36 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts @@ -0,0 +1,128 @@ +import type { GoogleGenAIChat, GoogleGenAIClient, GoogleGenAIResponse } from '@sentry/core'; + +export class MockGoogleGenAI implements GoogleGenAIClient { + public models: { + generateContent: (...args: unknown[]) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateContentStream: (...args: unknown[]) => Promise>; + }; + public chats: { + create: (...args: unknown[]) => GoogleGenAIChat; + }; + public apiKey: string; + + public constructor(config: { apiKey: string }) { + this.apiKey = config.apiKey; + + // models.generateContent functionality + this.models = { + generateContent: async (...args: unknown[]) => { + const params = args[0] as { model: string; contents?: unknown }; + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + (error as unknown as { status: number }).status = 404; + (error as unknown as { headers: Record }).headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + candidates: [ + { + content: { + parts: [ + { + text: 'Hello from Google GenAI mock!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + }; + }, + generateContentStream: async () => { + // Return a promise that resolves to an async generator + return (async function* (): AsyncGenerator { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Streaming response' }], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + }; + })(); + }, + }; + + // chats.create implementation + this.chats = { + create: (...args: unknown[]) => { + const params = args[0] as { model: string; config?: Record }; + const model = params.model; + + return { + modelVersion: model, + sendMessage: async (..._messageArgs: unknown[]) => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + candidates: [ + { + content: { + parts: [ + { + text: 'This is a joke from the chat!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + modelVersion: model, // Include model version in response + }; + }, + sendMessageStream: async () => { + // Return a promise that resolves to an async generator + return (async function* (): AsyncGenerator { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Streaming chat response' }], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + }; + })(); + }, + }; + }, + }; + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts new file mode 100644 index 000000000000..3c36e832a17a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -0,0 +1,75 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not break in our +// cloudflare SDK. + +it('traces Google GenAI chat creation and message sending', async () => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent.transaction).toBe('GET /'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + // First span - chats.create + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 150, + }), + description: 'chat gemini-1.5-pro create', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + }), + // Second span - chat.sendMessage + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }), + description: 'chat gemini-1.5-pro', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + }), + // Third span - models.generateContent + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }), + description: 'models gemini-1.5-flash', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + }), + ]), + ); + }) + .start(); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5a35a994b641..9c7738e1522b 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -70,6 +70,7 @@ export { // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration, instrumentOpenAiClient, + instrumentGoogleGenAIClient, instrumentAnthropicAiClient, eventFiltersIntegration, linkedErrorsIntegration, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 631181ccacc8..8a5566948f6e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -134,6 +134,7 @@ export { instrumentAnthropicAiClient } from './utils/anthropic-ai'; export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; export { instrumentGoogleGenAIClient } from './utils/google-genai'; export { GOOGLE_GENAI_INTEGRATION_NAME } from './utils/google-genai/constants'; +export type { GoogleGenAIResponse } from './utils/google-genai/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; export type { AnthropicAiClient, diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index cdad221ac60f..13079cb34b49 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -292,10 +292,10 @@ function createDeepProxy(target: T, currentPath = '', options: * * @example * ```typescript - * import { GoogleGenerativeAI } from '@google/genai'; + * import { GoogleGenAI } from '@google/genai'; * import { instrumentGoogleGenAIClient } from '@sentry/core'; * - * const genAI = new GoogleGenerativeAI({ apiKey: process.env.GOOGLE_GENAI_API_KEY }); + * const genAI = new GoogleGenAI({ apiKey: process.env.GOOGLE_GENAI_API_KEY }); * const instrumentedClient = instrumentGoogleGenAIClient(genAI); * * // Now both chats.create and sendMessage will be instrumented diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 032e39f1b203..d8362ff31c98 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -70,6 +70,7 @@ export { // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration, instrumentOpenAiClient, + instrumentGoogleGenAIClient, instrumentAnthropicAiClient, eventFiltersIntegration, linkedErrorsIntegration, From 34f3479087cdb8918272a219c34d3ebc7993842c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 22 Sep 2025 14:51:59 +0200 Subject: [PATCH 2/6] test(node): Avoid using specific port for node-integration-tests (#17729) This may flake, so just using any available port instead. Closes https://github.com/getsentry/sentry-javascript/issues/17716 --- .../suites/tracing/anthropic/scenario.mjs | 13 ++++++++----- .../suites/tracing/google-genai/scenario.mjs | 12 +++++++----- .../suites/tracing/openai/scenario-root-span.mjs | 12 +++++++----- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs index d0acf5c42b79..577c63dc3d08 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs @@ -2,8 +2,6 @@ import Anthropic from '@anthropic-ai/sdk'; import * as Sentry from '@sentry/node'; import express from 'express'; -const PORT = 3333; - function startMockAnthropicServer() { const app = express(); app.use(express.json()); @@ -50,16 +48,21 @@ function startMockAnthropicServer() { }, }); }); - return app.listen(PORT); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); } async function run() { - const server = startMockAnthropicServer(); + const server = await startMockAnthropicServer(); await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { const client = new Anthropic({ apiKey: 'mock-api-key', - baseURL: `http://localhost:${PORT}/anthropic`, + baseURL: `http://localhost:${server.address().port}/anthropic`, }); // First test: basic message completion diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs index cfae135b6878..ddb9e16b8254 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs @@ -2,8 +2,6 @@ import { GoogleGenAI } from '@google/genai'; import * as Sentry from '@sentry/node'; import express from 'express'; -const PORT = 3333; - function startMockGoogleGenAIServer() { const app = express(); app.use(express.json()); @@ -39,16 +37,20 @@ function startMockGoogleGenAIServer() { }); }); - return app.listen(PORT); + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); } async function run() { - const server = startMockGoogleGenAIServer(); + const server = await startMockGoogleGenAIServer(); await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { const client = new GoogleGenAI({ apiKey: 'mock-api-key', - httpOptions: { baseUrl: `http://localhost:${PORT}` }, + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, }); // Test 1: chats.create and sendMessage flow diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs index d1a06e5ccbb2..2aaca0700312 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-root-span.mjs @@ -1,8 +1,6 @@ import express from 'express'; import OpenAI from 'openai'; -const PORT = 3333; - function startMockOpenAiServer() { const app = express(); app.use(express.json()); @@ -31,14 +29,18 @@ function startMockOpenAiServer() { }, }); }); - return app.listen(PORT); + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); } async function run() { - const server = startMockOpenAiServer(); + const server = await startMockOpenAiServer(); const client = new OpenAI({ - baseURL: `http://localhost:${PORT}/openai`, + baseURL: `http://localhost:${server.address().port}/openai`, apiKey: 'mock-api-key', }); From b685be6741b4e47e1d9c216dbd0ce7407382d463 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:07:41 +0200 Subject: [PATCH 3/6] ref(core): Wrap isolationscope in `WeakRef` when storing it on spans (#17712) We often store isolationscopes on spans via `setCapturedScopesOnSpan` and retrieve them via `getCapturedScopesOnSpan`. This change wraps the scopes in `WeakRef` to attempt fixing a potential memory leak when spans hold on to scopes indefinitely. The downside is spans might end up with undefined scopes on them if the scope was garbage collected, we'll have to see if that's an issue or not. --- packages/core/src/tracing/utils.ts | 50 +++- packages/core/test/lib/tracing/utils.test.ts | 244 +++++++++++++++++++ 2 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/lib/tracing/utils.test.ts diff --git a/packages/core/src/tracing/utils.ts b/packages/core/src/tracing/utils.ts index 5fa0a4b34420..6ca5594b3da6 100644 --- a/packages/core/src/tracing/utils.ts +++ b/packages/core/src/tracing/utils.ts @@ -1,29 +1,71 @@ import type { Scope } from '../scope'; import type { Span } from '../types-hoist/span'; import { addNonEnumerableProperty } from '../utils/object'; +import { GLOBAL_OBJ } from '../utils/worldwide'; const SCOPE_ON_START_SPAN_FIELD = '_sentryScope'; const ISOLATION_SCOPE_ON_START_SPAN_FIELD = '_sentryIsolationScope'; +type ScopeWeakRef = { deref(): Scope | undefined } | Scope; + type SpanWithScopes = Span & { [SCOPE_ON_START_SPAN_FIELD]?: Scope; - [ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: Scope; + [ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: ScopeWeakRef; }; +/** Wrap a scope with a WeakRef if available, falling back to a direct scope. */ +function wrapScopeWithWeakRef(scope: Scope): ScopeWeakRef { + try { + // @ts-expect-error - WeakRef is not available in all environments + const WeakRefClass = GLOBAL_OBJ.WeakRef; + if (typeof WeakRefClass === 'function') { + return new WeakRefClass(scope); + } + } catch { + // WeakRef not available or failed to create + // We'll fall back to a direct scope + } + + return scope; +} + +/** Try to unwrap a scope from a potential WeakRef wrapper. */ +function unwrapScopeFromWeakRef(scopeRef: ScopeWeakRef | undefined): Scope | undefined { + if (!scopeRef) { + return undefined; + } + + if (typeof scopeRef === 'object' && 'deref' in scopeRef && typeof scopeRef.deref === 'function') { + try { + return scopeRef.deref(); + } catch { + return undefined; + } + } + + // Fallback to a direct scope + return scopeRef as Scope; +} + /** Store the scope & isolation scope for a span, which can the be used when it is finished. */ export function setCapturedScopesOnSpan(span: Span | undefined, scope: Scope, isolationScope: Scope): void { if (span) { - addNonEnumerableProperty(span, ISOLATION_SCOPE_ON_START_SPAN_FIELD, isolationScope); + addNonEnumerableProperty(span, ISOLATION_SCOPE_ON_START_SPAN_FIELD, wrapScopeWithWeakRef(isolationScope)); + // We don't wrap the scope with a WeakRef here because webkit aggressively garbage collects + // and scopes are not held in memory for long periods of time. addNonEnumerableProperty(span, SCOPE_ON_START_SPAN_FIELD, scope); } } /** * Grabs the scope and isolation scope off a span that were active when the span was started. + * If WeakRef was used and scopes have been garbage collected, returns undefined for those scopes. */ export function getCapturedScopesOnSpan(span: Span): { scope?: Scope; isolationScope?: Scope } { + const spanWithScopes = span as SpanWithScopes; + return { - scope: (span as SpanWithScopes)[SCOPE_ON_START_SPAN_FIELD], - isolationScope: (span as SpanWithScopes)[ISOLATION_SCOPE_ON_START_SPAN_FIELD], + scope: spanWithScopes[SCOPE_ON_START_SPAN_FIELD], + isolationScope: unwrapScopeFromWeakRef(spanWithScopes[ISOLATION_SCOPE_ON_START_SPAN_FIELD]), }; } diff --git a/packages/core/test/lib/tracing/utils.test.ts b/packages/core/test/lib/tracing/utils.test.ts new file mode 100644 index 000000000000..63aba8c35529 --- /dev/null +++ b/packages/core/test/lib/tracing/utils.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Scope } from '../../../src/scope'; +import { getCapturedScopesOnSpan, setCapturedScopesOnSpan } from '../../../src/tracing/utils'; +import type { Span } from '../../../src/types-hoist/span'; + +// Mock span object that implements the minimum needed interface +function createMockSpan(): Span { + return {} as Span; +} + +describe('tracing utils', () => { + describe('setCapturedScopesOnSpan / getCapturedScopesOnSpan', () => { + it('stores and retrieves scopes correctly', () => { + const span = createMockSpan(); + const scope = new Scope(); + const isolationScope = new Scope(); + + scope.setTag('test-scope', 'value1'); + isolationScope.setTag('test-isolation-scope', 'value2'); + + setCapturedScopesOnSpan(span, scope, isolationScope); + const retrieved = getCapturedScopesOnSpan(span); + + expect(retrieved.scope).toBe(scope); + expect(retrieved.isolationScope).toBe(isolationScope); + expect(retrieved.scope?.getScopeData().tags).toEqual({ 'test-scope': 'value1' }); + expect(retrieved.isolationScope?.getScopeData().tags).toEqual({ 'test-isolation-scope': 'value2' }); + }); + + it('handles undefined span gracefully in setCapturedScopesOnSpan', () => { + const scope = new Scope(); + const isolationScope = new Scope(); + + expect(() => { + setCapturedScopesOnSpan(undefined, scope, isolationScope); + }).not.toThrow(); + }); + + it('returns undefined scopes when span has no captured scopes', () => { + const span = createMockSpan(); + const retrieved = getCapturedScopesOnSpan(span); + + expect(retrieved.scope).toBeUndefined(); + expect(retrieved.isolationScope).toBeUndefined(); + }); + + it('uses WeakRef only for isolation scopes', () => { + const span = createMockSpan(); + const scope = new Scope(); + const isolationScope = new Scope(); + + setCapturedScopesOnSpan(span, scope, isolationScope); + + // Check that only isolation scope is wrapped with WeakRef + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spanWithScopes = span as any; + expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope stored directly + expect(spanWithScopes._sentryIsolationScope).toBeInstanceOf(WeakRef); // Isolation scope wrapped + + // Verify we can still retrieve the scopes + const retrieved = getCapturedScopesOnSpan(span); + expect(retrieved.scope).toBe(scope); + expect(retrieved.isolationScope).toBe(isolationScope); + }); + + it('falls back to direct storage when WeakRef is not available', () => { + // Temporarily disable WeakRef + const originalWeakRef = globalThis.WeakRef; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).WeakRef = undefined; + + try { + const span = createMockSpan(); + const scope = new Scope(); + const isolationScope = new Scope(); + + setCapturedScopesOnSpan(span, scope, isolationScope); + + // Check that both scopes are stored directly when WeakRef is not available + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spanWithScopes = span as any; + expect(spanWithScopes._sentryScope).toBe(scope); // Regular scope always stored directly + expect(spanWithScopes._sentryIsolationScope).toBe(isolationScope); // Isolation scope falls back to direct storage + + // When WeakRef is available, ensure regular scope is not wrapped but isolation scope would be + if (originalWeakRef) { + expect(spanWithScopes._sentryScope).not.toBeInstanceOf(originalWeakRef); + expect(spanWithScopes._sentryIsolationScope).not.toBeInstanceOf(originalWeakRef); + } + + // Verify we can still retrieve the scopes + const retrieved = getCapturedScopesOnSpan(span); + expect(retrieved.scope).toBe(scope); + expect(retrieved.isolationScope).toBe(isolationScope); + } finally { + // Restore WeakRef + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).WeakRef = originalWeakRef; + } + }); + + it('handles WeakRef deref returning undefined gracefully', () => { + const span = createMockSpan(); + const scope = new Scope(); + const isolationScope = new Scope(); + + setCapturedScopesOnSpan(span, scope, isolationScope); + + // Mock WeakRef.deref to return undefined for isolation scope (simulating garbage collection) + // Regular scope is stored directly, so it should always be available + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spanWithScopes = span as any; + const mockIsolationScopeWeakRef = { + deref: vi.fn().mockReturnValue(undefined), + }; + + // Keep the regular scope as is (stored directly) + // Only replace the isolation scope with a mock WeakRef + spanWithScopes._sentryIsolationScope = mockIsolationScopeWeakRef; + + const retrieved = getCapturedScopesOnSpan(span); + expect(retrieved.scope).toBe(scope); // Regular scope should still be available + expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to GC + expect(mockIsolationScopeWeakRef.deref).toHaveBeenCalled(); + }); + + it('handles corrupted WeakRef objects gracefully', () => { + const span = createMockSpan(); + const scope = new Scope(); + + // Set up a regular scope (stored directly) and a corrupted isolation scope WeakRef + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spanWithScopes = span as any; + spanWithScopes._sentryScope = scope; // Regular scope stored directly + spanWithScopes._sentryIsolationScope = { + deref: vi.fn().mockImplementation(() => { + throw new Error('WeakRef deref failed'); + }), + }; + + const retrieved = getCapturedScopesOnSpan(span); + expect(retrieved.scope).toBe(scope); // Regular scope should still be available + expect(retrieved.isolationScope).toBeUndefined(); // Isolation scope should be undefined due to error + }); + + it('preserves scope data when using WeakRef', () => { + const span = createMockSpan(); + const scope = new Scope(); + const isolationScope = new Scope(); + + // Add various types of data to scopes + scope.setTag('string-tag', 'value'); + scope.setTag('number-tag', 123); + scope.setTag('boolean-tag', true); + scope.setContext('test-context', { key: 'value' }); + scope.setUser({ id: 'test-user' }); + + isolationScope.setExtra('extra-data', { complex: { nested: 'object' } }); + isolationScope.setLevel('warning'); + + setCapturedScopesOnSpan(span, scope, isolationScope); + const retrieved = getCapturedScopesOnSpan(span); + + // Verify all data is preserved + expect(retrieved.scope?.getScopeData().tags).toEqual({ + 'string-tag': 'value', + 'number-tag': 123, + 'boolean-tag': true, + }); + expect(retrieved.scope?.getScopeData().contexts).toEqual({ + 'test-context': { key: 'value' }, + }); + expect(retrieved.scope?.getScopeData().user).toEqual({ id: 'test-user' }); + + expect(retrieved.isolationScope?.getScopeData().extra).toEqual({ + 'extra-data': { complex: { nested: 'object' } }, + }); + expect(retrieved.isolationScope?.getScopeData().level).toBe('warning'); + }); + + it('handles multiple spans with different scopes', () => { + const span1 = createMockSpan(); + const span2 = createMockSpan(); + + const scope1 = new Scope(); + const scope2 = new Scope(); + const isolationScope1 = new Scope(); + const isolationScope2 = new Scope(); + + scope1.setTag('span', '1'); + scope2.setTag('span', '2'); + isolationScope1.setTag('isolation', '1'); + isolationScope2.setTag('isolation', '2'); + + setCapturedScopesOnSpan(span1, scope1, isolationScope1); + setCapturedScopesOnSpan(span2, scope2, isolationScope2); + + const retrieved1 = getCapturedScopesOnSpan(span1); + const retrieved2 = getCapturedScopesOnSpan(span2); + + expect(retrieved1.scope?.getScopeData().tags).toEqual({ span: '1' }); + expect(retrieved1.isolationScope?.getScopeData().tags).toEqual({ isolation: '1' }); + + expect(retrieved2.scope?.getScopeData().tags).toEqual({ span: '2' }); + expect(retrieved2.isolationScope?.getScopeData().tags).toEqual({ isolation: '2' }); + + // Ensure they are different scope instances + expect(retrieved1.scope).not.toBe(retrieved2.scope); + expect(retrieved1.isolationScope).not.toBe(retrieved2.isolationScope); + }); + + it('handles span reuse correctly', () => { + const span = createMockSpan(); + + // First use + const scope1 = new Scope(); + const isolationScope1 = new Scope(); + scope1.setTag('first', 'use'); + isolationScope1.setTag('first-isolation', 'use'); + + setCapturedScopesOnSpan(span, scope1, isolationScope1); + const retrieved1 = getCapturedScopesOnSpan(span); + + expect(retrieved1.scope?.getScopeData().tags).toEqual({ first: 'use' }); + expect(retrieved1.isolationScope?.getScopeData().tags).toEqual({ 'first-isolation': 'use' }); + + // Reuse with different scopes (overwrite) + const scope2 = new Scope(); + const isolationScope2 = new Scope(); + scope2.setTag('second', 'use'); + isolationScope2.setTag('second-isolation', 'use'); + + setCapturedScopesOnSpan(span, scope2, isolationScope2); + const retrieved2 = getCapturedScopesOnSpan(span); + + expect(retrieved2.scope?.getScopeData().tags).toEqual({ second: 'use' }); + expect(retrieved2.isolationScope?.getScopeData().tags).toEqual({ 'second-isolation': 'use' }); + + // Should be the new scopes, not the old ones + expect(retrieved2.scope).toBe(scope2); + expect(retrieved2.isolationScope).toBe(isolationScope2); + }); + }); +}); From d7538cd8998447d6850e215c22224db9e9c69a85 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:09:15 +0200 Subject: [PATCH 4/6] test(nuxt): Update Nuxt version and add Nitro $fetch test (#17713) This PR adds the following: - an E2E test case for Nitro `$fetch` requests - Upgrading the Nuxt 4 version (previously in alpha) - Deleting the `app.vue` file as this is not needed when using the `app/pages` directory (following the [Nuxt docs](https://nuxt.com/docs/4.x/guide/directory-structure/app/app)) --- .../test-applications/nuxt-4/app/app.vue | 20 ------------------- ...rver-error.vue => fetch-server-routes.vue} | 5 +++++ .../nuxt-4/app/pages/index.vue | 19 +++++++++++++++++- .../test-applications/nuxt-4/package.json | 2 +- .../nuxt-4/server/api/nitro-fetch.ts | 5 +++++ .../nuxt-4/tests/errors.server.test.ts | 2 +- .../nuxt-4/tests/tracing.server.test.ts | 20 ++++++++++++++++++- 7 files changed, 49 insertions(+), 24 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue rename dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/{fetch-server-error.vue => fetch-server-routes.vue} (63%) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nitro-fetch.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue deleted file mode 100644 index 6550bbe08887..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue similarity index 63% rename from dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-error.vue rename to dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue index 0e9aeb34b4fc..089d77a2eee9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue @@ -1,6 +1,7 @@ @@ -10,4 +11,8 @@ import { useFetch } from '#imports'; const fetchError = async () => { await useFetch('/api/server-error'); }; + +const fetchNitroFetch = async () => { + await useFetch('/api/nitro-fetch'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/index.vue index a3741b5111d0..57a583eb43b1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/index.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/index.vue @@ -1,3 +1,20 @@ + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index a68c4c823738..b16b7ee2b236 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -18,7 +18,7 @@ "dependencies": { "@pinia/nuxt": "^0.5.5", "@sentry/nuxt": "latest || *", - "nuxt": "^4.0.0-alpha.4" + "nuxt": "^4.1.2" }, "devDependencies": { "@playwright/test": "~1.53.2", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nitro-fetch.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nitro-fetch.ts new file mode 100644 index 000000000000..6864ce6efafc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/nitro-fetch.ts @@ -0,0 +1,5 @@ +import { defineEventHandler } from '#imports'; + +export default defineEventHandler(async () => { + return await $fetch('https://example.com'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 62933f29dd48..e0873c1f496e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -7,7 +7,7 @@ test.describe('server-side errors', async () => { return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; }); - await page.goto(`/fetch-server-error`); + await page.goto(`/fetch-server-routes`); await page.getByText('Fetch Server API Error', { exact: true }).click(); const error = await errorPromise; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts index b6453b5a11cd..91ccc021ceda 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.server.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; test('sends a server action transaction on pageload', async ({ page }) => { @@ -43,3 +43,21 @@ test('does not send transactions for build asset folder "_nuxt"', async ({ page expect(transactionEvent.transaction).toBe('GET /test-param/:param()'); }); + +test('captures server API calls made with Nitro $fetch', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const httpServerFetchSpan = await transactionPromise; + const httpClientSpan = httpServerFetchSpan.spans.find(span => span.description === 'GET https://example.com/'); + + expect(httpServerFetchSpan.transaction).toEqual('GET /api/nitro-fetch'); + expect(httpServerFetchSpan.contexts.trace.op).toEqual('http.server'); + + expect(httpClientSpan.parent_span_id).toEqual(httpServerFetchSpan.contexts.trace.span_id); + expect(httpClientSpan.op).toEqual('http.client'); +}); From bbb13241527bf37594dc2127f777f66082f25ccd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 23 Sep 2025 08:53:19 +0200 Subject: [PATCH 5/6] fix(nextjs): Display updated turbopack warnings (#17737) --- packages/nextjs/src/config/util.ts | 9 +- .../nextjs/src/config/withSentryConfig.ts | 53 ++-- packages/nextjs/test/config/util.test.ts | 115 ++------ .../test/config/withSentryConfig.test.ts | 267 ++++++++++++++++-- 4 files changed, 280 insertions(+), 164 deletions(-) diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index a5def59a66fe..de8ad68cac41 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -33,15 +33,16 @@ function resolveNextjsPackageJson(): string | undefined { * Checks if the current Next.js version supports the runAfterProductionCompile hook. * This hook was introduced in Next.js 15.4.1. (https://github.com/vercel/next.js/pull/77345) * + * @param version - version string to check. * @returns true if Next.js version is 15.4.1 or higher */ -export function supportsProductionCompileHook(): boolean { - const version = getNextjsVersion(); - if (!version) { +export function supportsProductionCompileHook(version: string): boolean { + const versionToCheck = version; + if (!versionToCheck) { return false; } - const { major, minor, patch } = parseSemver(version); + const { major, minor, patch } = parseSemver(versionToCheck); if (major === undefined || minor === undefined || patch === undefined) { return false; diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index b5c2be2f25bb..201c27dc5d0a 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -253,44 +253,25 @@ function getFinalConfigObject( } let nextMajor: number | undefined; - const isTurbopack = process.env.TURBOPACK; - let isTurbopackSupported = false; if (nextJsVersion) { - const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); + const { major } = parseSemver(nextJsVersion); nextMajor = major; - const isSupportedVersion = - major !== undefined && - minor !== undefined && - patch !== undefined && - (major > 15 || - (major === 15 && minor > 3) || - (major === 15 && minor === 3 && patch === 0 && prerelease === undefined) || - (major === 15 && minor === 3 && patch > 0)); - isTurbopackSupported = isSupportedVersion; - const isSupportedCanary = - major !== undefined && - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 3 && - patch === 0 && - prerelease.startsWith('canary.') && - parseInt(prerelease.split('.')[1] || '', 10) >= 28; - const supportsClientInstrumentation = isSupportedCanary || isSupportedVersion; + } - if (!supportsClientInstrumentation && isTurbopack) { - if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (\`next dev --turbo\`). The Sentry SDK is compatible with Turbopack on Next.js version 15.3.0 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack. Note that the SDK will continue to work for non-Turbopack production builds. This warning is only about dev-mode.`, - ); - } else if (process.env.NODE_ENV === 'production') { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (\`next build --turbo\`). The Sentry SDK is compatible with Turbopack on Next.js version 15.3.0 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack. Note that as Turbopack is still experimental for production builds, some of the Sentry SDK features like source maps will not work. Follow this issue for progress on Sentry + Turbopack: https://github.com/getsentry/sentry-javascript/issues/8105.`, - ); - } + const isTurbopack = process.env.TURBOPACK; + const isTurbopackSupported = supportsProductionCompileHook(nextJsVersion ?? ''); + + if (!isTurbopackSupported && isTurbopack) { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (\`next dev --turbopack\`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.`, + ); + } else if (process.env.NODE_ENV === 'production') { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (\`next build --turbopack\`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.`, + ); } } @@ -298,7 +279,7 @@ function getFinalConfigObject( const shouldUseRunAfterProductionCompileHook = userSentryOptions?.useRunAfterProductionCompileHook ?? (isTurbopack ? true : false); - if (shouldUseRunAfterProductionCompileHook && supportsProductionCompileHook()) { + if (shouldUseRunAfterProductionCompileHook && supportsProductionCompileHook(nextJsVersion ?? '')) { if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { incomingUserNextConfigObject.compiler ??= {}; incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 01b94480ea5f..b31f71705029 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -1,169 +1,98 @@ -import * as fs from 'fs'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import * as util from '../../src/config/util'; -// Mock fs to control what getNextjsVersion reads -vi.mock('fs'); - describe('util', () => { describe('supportsProductionCompileHook', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - describe('supported versions', () => { it('returns true for Next.js 15.4.1', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1' })); - - const result = util.supportsProductionCompileHook(); + const result = util.supportsProductionCompileHook('15.4.1'); expect(result).toBe(true); }); it('returns true for Next.js 15.4.2', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.2' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.4.2')).toBe(true); }); it('returns true for Next.js 15.5.0', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.5.0' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.5.0')).toBe(true); }); it('returns true for Next.js 16.0.0', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '16.0.0' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('16.0.0')).toBe(true); }); it('returns true for Next.js 17.0.0', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '17.0.0' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('17.0.0')).toBe(true); }); it('returns true for supported canary versions', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-canary.42' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.4.1-canary.42')).toBe(true); }); it('returns true for supported rc versions', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-rc.1' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.4.1-rc.1')).toBe(true); }); }); describe('unsupported versions', () => { it('returns false for Next.js 15.4.0', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15.4.0')).toBe(false); }); it('returns false for Next.js 15.3.9', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.3.9' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15.3.9')).toBe(false); }); it('returns false for Next.js 15.0.0', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.0.0' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15.0.0')).toBe(false); }); it('returns false for Next.js 14.2.0', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '14.2.0' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('14.2.0')).toBe(false); }); it('returns false for unsupported canary versions', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0-canary.42' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15.4.0-canary.42')).toBe(false); }); }); describe('edge cases', () => { it('returns false for invalid version strings', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: 'invalid.version' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('invalid.version')).toBe(false); }); it('handles versions with build metadata', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1+build.123' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.4.1+build.123')).toBe(true); }); it('handles versions with pre-release identifiers', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1-alpha.1' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.4.1-alpha.1')).toBe(true); }); it('returns false for versions missing patch number', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15.4')).toBe(false); }); it('returns false for versions missing minor number', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15')).toBe(false); }); }); describe('version boundary tests', () => { it('returns false for 15.4.0 (just below threshold)', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.0' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15.4.0')).toBe(false); }); it('returns true for 15.4.1 (exact threshold)', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.1' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.4.1')).toBe(true); }); it('returns true for 15.4.2 (just above threshold)', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.4.2' })); - - expect(util.supportsProductionCompileHook()).toBe(true); + expect(util.supportsProductionCompileHook('15.4.2')).toBe(true); }); it('returns false for 15.3.999 (high patch but wrong minor)', () => { - const mockReadFileSync = fs.readFileSync as any; - mockReadFileSync.mockReturnValue(JSON.stringify({ version: '15.3.999' })); - - expect(util.supportsProductionCompileHook()).toBe(false); + expect(util.supportsProductionCompileHook('15.3.999')).toBe(false); }); }); }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index 0697fc56b9e4..8fc0a81e3dd4 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -183,14 +183,14 @@ describe('withSentryConfig', () => { expect(finalConfigWithoutTurbopack.webpack).toBe(originalWebpackFunction); process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const finalConfigWithTurbopack = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptions); expect(finalConfigWithTurbopack.webpack).toBe(originalWebpackFunction); }); it('preserves original webpack config when Turbopack is enabled (ignores disableSentryWebpackConfig flag)', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const originalWebpackFunction = vi.fn(); const configWithWebpack = { @@ -216,7 +216,7 @@ describe('withSentryConfig', () => { it('preserves original webpack config when Turbopack is enabled and disableSentryWebpackConfig is true', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const sentryOptions = { disableSentryWebpackConfig: true, @@ -235,7 +235,7 @@ describe('withSentryConfig', () => { it('preserves undefined webpack when Turbopack is enabled, disableSentryWebpackConfig is true, and no original webpack config exists', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const sentryOptions = { disableSentryWebpackConfig: true, @@ -253,7 +253,7 @@ describe('withSentryConfig', () => { it('includes turbopack config when Turbopack is supported and enabled', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const finalConfig = materializeFinalNextConfig(exportedNextConfig); @@ -279,7 +279,7 @@ describe('withSentryConfig', () => { it('enables productionBrowserSourceMaps for supported turbopack builds when sourcemaps are not disabled', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const finalConfig = materializeFinalNextConfig(exportedNextConfig); @@ -288,7 +288,7 @@ describe('withSentryConfig', () => { it('does not enable productionBrowserSourceMaps when sourcemaps are disabled', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const cleanConfig = { ...exportedNextConfig }; delete cleanConfig.productionBrowserSourceMaps; @@ -329,7 +329,7 @@ describe('withSentryConfig', () => { it('preserves user-configured productionBrowserSourceMaps setting', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const configWithSourceMaps = { ...exportedNextConfig, @@ -343,7 +343,7 @@ describe('withSentryConfig', () => { it('preserves user-configured productionBrowserSourceMaps: true setting', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const configWithSourceMaps = { ...exportedNextConfig, @@ -363,7 +363,7 @@ describe('withSentryConfig', () => { it('automatically enables deleteSourcemapsAfterUpload for turbopack builds when not explicitly set', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); // Use a clean config without productionBrowserSourceMaps to ensure it gets auto-enabled const cleanConfig = { ...exportedNextConfig }; @@ -382,7 +382,7 @@ describe('withSentryConfig', () => { it('preserves explicitly configured deleteSourcemapsAfterUpload setting', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const sentryOptions = { sourcemaps: { @@ -397,7 +397,7 @@ describe('withSentryConfig', () => { it('does not modify deleteSourcemapsAfterUpload when sourcemaps are disabled', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const sentryOptions = { sourcemaps: { @@ -412,7 +412,7 @@ describe('withSentryConfig', () => { it('does not enable deleteSourcemapsAfterUpload when user pre-configured productionBrowserSourceMaps: true', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const configWithSourceMapsPreEnabled = { ...exportedNextConfig, @@ -431,7 +431,7 @@ describe('withSentryConfig', () => { it('does not enable sourcemaps or deletion when user explicitly sets productionBrowserSourceMaps: false', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const configWithSourceMapsDisabled = { ...exportedNextConfig, @@ -451,7 +451,7 @@ describe('withSentryConfig', () => { it('logs correct message when enabling sourcemaps for turbopack', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const cleanConfig = { ...exportedNextConfig }; @@ -472,7 +472,7 @@ describe('withSentryConfig', () => { it('warns about automatic sourcemap deletion for turbopack builds', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement @@ -494,22 +494,25 @@ describe('withSentryConfig', () => { }); describe('version compatibility', () => { - it('enables sourcemaps for Next.js 15.3.0', () => { + it('enables sourcemaps for Next.js 15.4.1', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const finalConfig = materializeFinalNextConfig(exportedNextConfig); expect(finalConfig.productionBrowserSourceMaps).toBe(true); }); - it('enables sourcemaps for Next.js 15.4.0', () => { + it('does not enable sourcemaps for Next.js 15.4.0', () => { process.env.TURBOPACK = '1'; vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const cleanConfig = { ...exportedNextConfig }; + delete cleanConfig.productionBrowserSourceMaps; - expect(finalConfig.productionBrowserSourceMaps).toBe(true); + const finalConfig = materializeFinalNextConfig(cleanConfig); + + expect(finalConfig.productionBrowserSourceMaps).toBeUndefined(); }); it('enables sourcemaps for Next.js 16.0.0', () => { @@ -535,7 +538,7 @@ describe('withSentryConfig', () => { it('enables sourcemaps for supported canary versions', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0-canary.28'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1-canary.1'); const finalConfig = materializeFinalNextConfig(exportedNextConfig); @@ -544,7 +547,7 @@ describe('withSentryConfig', () => { it('does not enable sourcemaps for unsupported canary versions', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0-canary.27'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.0-canary.999'); const cleanConfig = { ...exportedNextConfig }; delete cleanConfig.productionBrowserSourceMaps; @@ -558,7 +561,7 @@ describe('withSentryConfig', () => { describe('edge cases', () => { it('handles undefined sourcemaps option', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const sentryOptions = {}; // no sourcemaps property @@ -569,7 +572,7 @@ describe('withSentryConfig', () => { it('handles empty sourcemaps object', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement const cleanConfig = { ...exportedNextConfig }; @@ -586,7 +589,7 @@ describe('withSentryConfig', () => { it('works when TURBOPACK env var is truthy string', () => { process.env.TURBOPACK = 'true'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const finalConfig = materializeFinalNextConfig(exportedNextConfig); @@ -595,7 +598,7 @@ describe('withSentryConfig', () => { it('does not enable sourcemaps when TURBOPACK env var is falsy', () => { process.env.TURBOPACK = ''; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const cleanConfig = { ...exportedNextConfig }; delete cleanConfig.productionBrowserSourceMaps; @@ -607,7 +610,7 @@ describe('withSentryConfig', () => { it('works correctly with tunnel route configuration', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); // Use a clean config without productionBrowserSourceMaps to trigger automatic enablement const cleanConfig = { ...exportedNextConfig }; @@ -627,7 +630,7 @@ describe('withSentryConfig', () => { it('works correctly with custom release configuration', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); // Clear environment variable to test custom release name const originalSentryRelease = process.env.SENTRY_RELEASE; @@ -658,7 +661,7 @@ describe('withSentryConfig', () => { it('does not interfere with other Next.js configuration options', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const configWithOtherOptions = { ...exportedNextConfig, @@ -677,7 +680,7 @@ describe('withSentryConfig', () => { it('works correctly when turbopack config already exists', () => { process.env.TURBOPACK = '1'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); const configWithTurbopack = { ...exportedNextConfig, @@ -968,4 +971,206 @@ describe('withSentryConfig', () => { expect(finalConfig.compiler?.runAfterProductionCompile).toBeInstanceOf(Function); }); }); + + describe('turbopack version compatibility warnings', () => { + const originalTurbopack = process.env.TURBOPACK; + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + vi.restoreAllMocks(); + process.env.TURBOPACK = originalTurbopack; + // @ts-expect-error - NODE_ENV is read-only in types but we need to restore it in tests + process.env.NODE_ENV = originalNodeEnv; + }); + + it('warns in development mode when Turbopack is enabled with unsupported Next.js version', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.0'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (`next dev --turbopack`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.4.0. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', + ); + + consoleWarnSpy.mockRestore(); + }); + + it('warns in production mode when Turbopack is enabled with unsupported Next.js version', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'production'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.9'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (`next build --turbopack`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.3.9. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', + ); + + consoleWarnSpy.mockRestore(); + }); + + it('does not warn when Turbopack is enabled with supported Next.js version', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('does not warn when Turbopack is enabled with higher supported Next.js version', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'production'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.5.0'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('does not warn when Turbopack is enabled with Next.js 16+', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('does not warn when Turbopack is not enabled', () => { + delete process.env.TURBOPACK; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('warns even when Next.js version cannot be determined if Turbopack is unsupported', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + // Warning will still show because supportsProductionCompileHook returns false + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('You are currently on undefined')); + + consoleWarnSpy.mockRestore(); + }); + + it('warns with correct version in message for edge case versions', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.0-canary.15'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (`next dev --turbopack`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.4.0-canary.15. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', + ); + + consoleWarnSpy.mockRestore(); + }); + + it('does not warn in other environments besides development and production', () => { + process.env.TURBOPACK = '1'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'test'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('handles falsy TURBOPACK environment variable', () => { + process.env.TURBOPACK = ''; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('warns when TURBOPACK=0 (truthy string) with unsupported version', () => { + process.env.TURBOPACK = '0'; + // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing + process.env.NODE_ENV = 'development'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + materializeFinalNextConfig(exportedNextConfig); + + // Note: '0' is truthy in JavaScript, so this will trigger the warning + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + + consoleWarnSpy.mockRestore(); + }); + }); }); From dfd421b090278e2ba3a598518a74b856482ea918 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 23 Sep 2025 09:40:20 +0200 Subject: [PATCH 6/6] meta(changelog): Update changelog for 10.14.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d17a94e4e84..319465691cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.14.0 + +### Important Changes + +- **feat(cloudflare,vercel-edge): Add support for Google Gen AI instrumentation ([#17723](https://github.com/getsentry/sentry-javascript/pull/17723))** + + The SDK now automatically instruments Google's Generative AI operations in Cloudflare Workers and Vercel Edge Runtime environments, providing insights into your AI operations. + +### Other Changes + +- fix(nextjs): Display updated turbopack warnings ([#17737](https://github.com/getsentry/sentry-javascript/pull/17737)) +- ref(core): Wrap isolationscope in `WeakRef` when storing it on spans ([#17712](https://github.com/getsentry/sentry-javascript/pull/17712)) + +
+ Internal Changes + +- test(node): Avoid using specific port for node-integration-tests ([#17729](https://github.com/getsentry/sentry-javascript/pull/17729)) +- test(nuxt): Update Nuxt version and add Nitro $fetch test ([#17713](https://github.com/getsentry/sentry-javascript/pull/17713)) + +
+ ## 10.13.0 ### Important Changes