diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 8212b1b8489e..c0db9f483eb2 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -78,6 +78,7 @@ Keep reviews high-signal. Prefer actionable, high-confidence findings over specu - What to do instead: Use an existing reference to the scope or client. For example, this is possible in most `Integration` hooks. - Flag hard-coded attribute names and span ops (e.g. `'sentry.origin'`, `'sentry.op'`, `'http.request.method'`, `'db.system.name'`) when `@sentry/conventions` exports a constant for them. Use the constant from `@sentry/conventions/attributes` (e.g. `SENTRY_ORIGIN`, `SENTRY_OP`) or `@sentry/conventions/op` instead. Do not flag values that have no constant in `@sentry/conventions`. - Flag unnecessary `span.setAttribute(s)` calls: If data is already available at span start, it must be set via the `attributes` option of `startSpan`, `startSpanManual`, `startInactiveSpan` or `startIdleSpan` calls. This ensures that as much context as possible is available when `tracesSampler` or `ignoreSpans` SDK options are applied. If a `span.setAttribute(s)` call happens at a later time than right after span start and the attribute value can only be computed at that time, do not flag it. +- Flag a new option on an integration for incoming HTTP requests that is not in `ServerRequestOptions` (`packages/core/src/integrations/http/server-request-options.ts`), when it applies to more than one runtime. These integrations are Node `httpIntegration`, Deno `denoHttpIntegration` and `denoServeIntegration`, Bun `bunHttpServerIntegration` and `bunServerIntegration`, and Cloudflare `httpServerIntegration`. Add the option to `ServerRequestOptions`, then support it in each runtime or list it as unsupported in that runtime's `CheckServerRequestOptions` check. Also flag a runtime option that matches a shared option in purpose but has a different name or meaning. ### Code quality diff --git a/CHANGELOG.md b/CHANGELOG.md index 18182d0b8e1d..3803a5d99c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,53 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph `denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate. +- **feat(bun): Add incoming request options and request sessions to `bunServerIntegration`** + + `bunServerIntegration` now accepts the same options as `denoServeIntegration`: `ignoreRequestBody`, `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated`, `onSpanEnd`, `sessions` and `sessionFlushingDelayMS`. The callbacks receive the web `Request`. + + `Bun.serve` now records a request session for each incoming request. Sessions are sent only when a `release` is set. To turn them off, set `bunServerIntegration({ sessions: false })`. + + `ignoreStaticAssets` defaults to `true`, so `Bun.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `bunServerIntegration({ ignoreStaticAssets: false })`. `Bun.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled. Errors thrown for `OPTIONS` and `HEAD` requests are now captured. + +- **feat(node): Add `onSpanEnd` to `httpIntegration`** + + `httpIntegration` and `httpServerSpansIntegration` now accept `onSpanEnd`, a hook that runs when the response for an incoming request finishes, before its span ends. Use it to change the span based on the response. Deno and Bun already have this option. + +- **feat(cloudflare): Add incoming request span options to `httpServerIntegration`** + + Cloudflare's `httpServerIntegration` now accepts `ignoreIncomingRequests`, `ignoreStaticAssets`, `spans`, `onSpanCreated` and `onSpanEnd`, with the same meaning as in the Node, Deno and Bun integrations. The callbacks receive the web `Request`. + + `ignoreStaticAssets` defaults to `true`, so workers no longer create spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `httpServerIntegration({ ignoreStaticAssets: false })`. Request spans also follow the client's tracing configuration by default, so the SDK does not start them when tracing is disabled. If `ignoreRequestBody` throws, the request no longer fails, and the SDK skips the body. + +- **feat(node, bun, deno, cloudflare): Align portable exports across server SDKs** + + Node, Bun, Deno and Cloudflare now export the same helpers from `@sentry/core` and `@sentry/server-utils` where they work on that runtime: + + - Node and Bun now export `moduleMetadataIntegration`, `instrumentPostgresJsSql`, `instrumentCreateReactAgent`, `awsIntegration` and the `FlueOptions` type. `awsIntegration` was already in their default integrations. + - Deno and Cloudflare now export `parameterize` and `instrumentStateGraphCompile`. + - Bun and Cloudflare now export the `Log` and `LogSeverityLevel` types. + - Deno now exports `fastifyIntegration`, which was already in its default integrations. + +- **feat(cloudflare): Export the channel integrations** + + When the `@sentry/cloudflare/vite` plugin bundles an instrumented package (for example `pg` or `openai`), the SDK adds the matching integration with its default options. `@sentry/cloudflare` now exports these integrations, so you can set their options, for example: + + ```ts + Sentry.withSentry( + env => ({ + dsn: env.SENTRY_DSN, + integrations: [Sentry.openAIIntegration({ recordInputs: false, recordOutputs: false })], + }), + handler, + ); + ``` + + These integrations need the vite plugin. Without it, they record nothing, and with `debug: true` the SDK logs a warning. + +- **fix(cloudflare): Do not add back channel integrations the user removed** + + When an instrumented package loaded after `init()` (for example through a dynamic `import()`), the SDK added its integration even if the user had removed it. This applied to an `integrations` function that filtered it out, `defaultIntegrations: false`, and a custom `defaultIntegrations` list. The SDK now applies the same rules to a late-loaded integration as to the defaults at `init()`. To do this, it calls an `integrations` function again, once for each package that loads after `init()`. + ## 11.8.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts new file mode 100644 index 000000000000..ce625f75cd49 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/index.ts @@ -0,0 +1,41 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(request: Request): Promise { + const { pathname } = new URL(request.url); + + if (pathname === '/health') { + Sentry.captureMessage('Health check'); + return new Response('ok'); + } + + if (pathname === '/favicon.ico') { + Sentry.captureMessage('Static asset'); + return new Response('icon'); + } + + if (pathname === '/health/stream') { + // No span for this request. The message is captured after the handler returns, while the + // body streams, so it is only sent if the SDK waits for the stream to finish. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('a')); + await new Promise(resolve => setTimeout(resolve, 50)); + Sentry.captureMessage('Streamed after handler return'); + controller.enqueue(encoder.encode('b')); + controller.close(); + }, + }); + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }); + } + + return new Response('ok', { status: 201 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts new file mode 100644 index 000000000000..95122b1cba8a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/instrument.server.ts @@ -0,0 +1,18 @@ +import { defineCloudflareOptions, httpServerIntegration } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'static', + integrations: [ + httpServerIntegration({ + ignoreIncomingRequests: urlPath => urlPath.startsWith('/health'), + onSpanCreated: (span, request) => { + span.setAttribute('test.on_span_created', new URL(request.url).pathname); + }, + onSpanEnd: (span, _request, response) => { + span.setAttribute('test.on_span_end', response.status); + }, + }), + ], +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts new file mode 100644 index 000000000000..35946ea9ac45 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/test.ts @@ -0,0 +1,71 @@ +import type { Envelope, Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +function itemOf(envelope: Envelope): { type: string; event: Event } { + const [header, event] = envelope[1][0] as [{ type: string }, Event]; + return { type: header.type, event }; +} + +function messageEvent(message: string): (envelope: Envelope) => void { + return envelope => { + const { type, event } = itemOf(envelope); + expect(type).toBe('event'); + expect(event.message).toBe(message); + }; +} + +// The `onSpanCreated` and `onSpanEnd` hooks of the worker set these attributes. +function apiTransaction(envelope: Envelope): void { + const { type, event } = itemOf(envelope); + expect(type).toBe('transaction'); + expect(event.transaction).toBe('GET /api'); + expect(event.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'test.on_span_created': '/api', + 'test.on_span_end': 201, + }), + ); +} + +// Each test makes a request without a span, then a request with one. With `failOnUnexpected`, a +// transaction for the first request fails the test, as it arrives before the second one. + +it('does not create a span for a request that ignoreIncomingRequests matches', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Health check')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/health'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('does not create a span for a static asset request by default', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Static asset')) + .expect(apiTransaction) + .start(signal); + + await runner.makeRequest('get', '/favicon.ico'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); + +it('sends events from a streamed response without a span after the stream finishes', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(messageEvent('Streamed after handler return')) + .expect(apiTransaction) + .start(signal); + + expect(await runner.makeRequest('get', '/health/stream')).toBe('ab'); + await runner.makeRequest('get', '/api'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc new file mode 100644 index 000000000000..790abe9762d8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-spans/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-integrations-http-server-spans", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts index 013bf552d772..12cf754e08b6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -12,7 +12,6 @@ it('should add server.address attribute to metrics when serverName is set', asyn name: 'test.counter', type: 'counter', value: 1, - span_id: expect.any(String), timestamp: expect.any(Number), trace_id: expect.any(String), attributes: { diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts new file mode 100644 index 000000000000..55fb7b4466b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/index.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +// A plain worker. The `@sentry/cloudflare/vite` plugin wraps it with `withSentry` at build time, +// with the options from `instrument.server.ts`. +export default { + async fetch(): Promise { + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + return new Response('ok'); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts new file mode 100644 index 000000000000..9de8aec879bb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/instrument.server.ts @@ -0,0 +1,5 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts new file mode 100644 index 000000000000..b8dc67c3e462 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/test.ts @@ -0,0 +1,15 @@ +import type { Event } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('parameterize from the public entry sends a message template and its values', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.logentry).toEqual({ message: 'User %s logged in', params: [42] }); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc new file mode 100644 index 000000000000..cbfb247dd034 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/parameterize/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-public-api-parameterize", + // `main` points at the source entry. The Sentry Vite plugin builds from it, and the runner serves + // the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts new file mode 100644 index 000000000000..1f3692d73837 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/index.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/cloudflare'; +import OpenAI from 'openai'; + +interface Env { + SENTRY_DSN: string; +} + +// Return a canned response so the `openai` SDK runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from OpenAI!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + // `dataCollection` records inputs by default. The integration options must win over it. + integrations: [Sentry.openAIIntegration({ recordInputs: false })], + }), + { + async fetch(_request, _env, _ctx) { + // Not wrapped with `instrumentOpenAiClient`: the vite plugin instruments the `openai` package. + const client = new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch }); + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + return new Response('ok'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts new file mode 100644 index 000000000000..8aab877f22f8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/test.ts @@ -0,0 +1,25 @@ +import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { GEN_AI_CHAT } from '@sentry/conventions/op'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('applies the options of an openAIIntegration the user configured', async ({ signal }) => { + const runner = createRunner(__dirname).ignore('event').start(signal); + + // The segment span and the gen_ai child. The segment can arrive before the child's envelope. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 2); + + await runner.makeRequest('get', '/'); + + const genAiSpans = (await spansPromise).filter(span => !span.is_segment); + expect(genAiSpans).toHaveLength(1); + const attributes = genAiSpans[0]?.attributes; + + // The vite plugin instrumented `openai`, so the channel integration created the span. + expect(attributes?.[SENTRY_OP]).toEqual({ type: 'string', value: GEN_AI_CHAT }); + expect(attributes?.[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'auto.ai.openai' }); + // `recordInputs: false` from the configured integration wins over `dataCollection`, which records + // inputs by default. Outputs keep the default. + expect(attributes?.[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(attributes?.[GEN_AI_RESPONSE_TEXT]).toEqual({ type: 'string', value: '["Hello from OpenAI!"]' }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts new file mode 100644 index 000000000000..cee0e8c0b4b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin({ autoInstrumentation: false })], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc new file mode 100644 index 000000000000..4c475b2fe276 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/openai-options/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "openai-options-worker", + "compatibility_date": "2026-04-26", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/deno-integration-tests/suites/parameterize/test.ts b/dev-packages/deno-integration-tests/suites/parameterize/test.ts new file mode 100644 index 000000000000..16272b3db672 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/parameterize/test.ts @@ -0,0 +1,25 @@ +// + +import * as Sentry from '@sentry/deno'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; +import { errorSink, resetGlobals, withTimeout } from '../../src/index.ts'; + +Deno.test('parameterize from the public entry sends a message template and its values', async () => { + resetGlobals(); + const sink = errorSink(); + + Sentry.init({ + dsn: 'https://username@domain/123', + beforeSend: sink.beforeSend, + }); + + const userId = 42; + Sentry.captureMessage(Sentry.parameterize`User ${userId} logged in`); + + const event = await withTimeout( + sink.waitFor(e => e.logentry?.message === 'User %s logged in'), + 5_000, + 'parameterized message event', + ); + assertEquals(event.logentry, { message: 'User %s logged in', params: [42] }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts index 6893bd77fdf7..791edf34afd2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -20,7 +20,7 @@ export default withSentryConfig(nextConfig, { silent: true, applicationKey: 'nextjs-16-e2e', reactComponentAnnotation: { - enabled: true, + enabled: false, }, _experimental: { vercelCronsMonitoring: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 34b8d7f62dc9..17e3518402ce 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -7,7 +7,7 @@ "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "dev:webpack": "next dev --webpack", - "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && node print-build-diagnostics.mjs; echo 'Building again with --debug-prerender to show the real error' && next build --webpack --debug-prerender; exit 1)", "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs new file mode 100644 index 000000000000..edf84fe170a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/print-build-diagnostics.mjs @@ -0,0 +1,103 @@ +// Temporary: prints what CI needs to explain the "not in the React Client +// Manifest" build failure. Runs after a failed `next build`. +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +function section(title) { + console.log(`\n=== ${title}`); +} + +function walk(dir) { + if (!fs.existsSync(dir)) { + return []; + } + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? walk(full) : [full]; + }); +} + +section('Environment'); +console.log({ + platform: process.platform, + arch: process.arch, + machine: os.machine(), + cpus: os.cpus().length, + node: process.version, + cwd: process.cwd(), +}); + +section('oxc-parser'); +const pnpmDir = path.join('node_modules', '.pnpm'); +const oxcDirs = fs.existsSync(pnpmDir) ? fs.readdirSync(pnpmDir).filter(name => name.includes('oxc-parser')) : []; +console.log('installed:', oxcDirs.length ? oxcDirs : '(none found in node_modules/.pnpm)'); +try { + const oxcDir = oxcDirs.find(name => name.startsWith('oxc-parser@')); + const { parseSync } = createRequire(path.resolve(pnpmDir, oxcDir, 'node_modules/oxc-parser/package.json'))( + 'oxc-parser', + ); + const source = fs.readFileSync('app/global-error.tsx', 'utf8'); + const { program, errors } = parseSync('global-error.tsx', source); + console.log('loads: yes, parse errors:', errors.length); + console.log( + 'directives:', + program.body.filter(node => node.directive).map(node => node.directive), + ); +} catch (error) { + console.log('loads: no', error); +} + +section('Component annotation transform'); +try { + const pluginsDir = fs.readdirSync(pnpmDir).find(name => name.startsWith('@sentry+bundler-plugins@')); + const { createComponentNameAnnotateHooks } = createRequire( + path.resolve(pnpmDir, pluginsDir, 'node_modules/@sentry/bundler-plugins/package.json'), + )('@sentry/bundler-plugins/core'); + const file = path.resolve('app/global-error.tsx'); + const source = fs.readFileSync(file, 'utf8'); + const result = await createComponentNameAnnotateHooks([], false).transform(source, file); + const code = typeof result === 'string' ? result : result?.code; + console.log(code === undefined ? 'no change' : `start: ${JSON.stringify(code.slice(0, 120))}`); +} catch (error) { + console.log('failed:', error); +} + +section('Client reference manifest'); +const manifest = '.next/server/app/_global-error/page_client-reference-manifest.js'; +if (fs.existsSync(manifest)) { + const content = fs.readFileSync(manifest, 'utf8'); + console.log( + `${manifest}: ${content.length} bytes, "global-error" appears ${content.split('global-error').length - 1} times`, + ); + let index = -1; + while ((index = content.indexOf('global-error', index + 1)) !== -1) { + console.log(` - ${content.slice(Math.max(0, index - 90), index + 90).replace(/\s+/g, ' ')}`); + } +} else { + console.log(`${manifest}: missing`); +} + +section('Compiled global-error modules'); +const clientMarker = '__next_internal_client_entry_do_not_use__'; +let otherFiles = 0; +for (const file of [...walk('.next/server'), ...walk('.next/static/chunks')]) { + if (!file.endsWith('.js') || file.endsWith('_client-reference-manifest.js')) { + continue; + } + const content = fs.readFileSync(file, 'utf8'); + if (!content.includes('global-error')) { + continue; + } + if (!file.includes('global-error')) { + otherFiles++; + continue; + } + console.log(`${file}: client entry marker ${content.includes(clientMarker) ? 'present' : 'absent'}`); + const index = content.indexOf('global-error.tsx'); + console.log( + ` near "global-error.tsx": ${content.slice(Math.max(0, index - 150), index + 150).replace(/\s+/g, ' ')}`, + ); +} +console.log(`${otherFiles} other files mention "global-error"`); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs index d283ccff7951..6a02d7d4a26c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs @@ -18,6 +18,12 @@ Sentry.init({ 'onSpanCreated.resMethod': res.req.method, }); }, + onSpanEnd: (span, req, res) => { + span.setAttributes({ + 'onSpanEnd.reqUrl': req.url, + 'onSpanEnd.resStatusCode': res.statusCode, + }); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs index c2d7c4763781..6672b0dc99eb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-throwing-callbacks.mjs @@ -15,6 +15,9 @@ Sentry.init({ onSpanCreated: () => { throw new Error('onSpanCreated error'); }, + onSpanEnd: () => { + throw new Error('onSpanEnd error'); + }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 28ae77d23617..70618d73671d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,9 +1,62 @@ import { HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import { createTestServer } from '@sentry-internal/test-utils'; -import { HTTP_RESPONSE_STATUS_CODE, SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { + CLIENT_ADDRESS, + CLIENT_PORT, + HTTP_REQUEST_METHOD, + HTTP_RESPONSE_STATUS_CODE, + HTTP_RESPONSE_STATUS_TEXT, + HTTP_ROUTE, + NETWORK_LOCAL_ADDRESS, + NETWORK_LOCAL_PORT, + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_NAME, + NETWORK_PROTOCOL_VERSION, + NETWORK_TRANSPORT, + SENTRY_ENVIRONMENT, + SENTRY_IS_LOCALHOST, + SENTRY_KIND, + SENTRY_OP, + SENTRY_ORIGIN, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_TRACE_LIFECYCLE, + SERVER_ADDRESS, + SERVER_PORT, + URL_FULL, + URL_PATH, + URL_SCHEME, + USER_AGENT_ORIGINAL, + USER_IP_ADDRESS, +} from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; -import { supports } from '../../../utils'; +import { EXPECTED_SDK_NAME, supports } from '../../../utils'; + +const LOOPBACK_ADDRESS = /^(::1|127\.0\.0\.1|::ffff:127\.0\.0\.1)$/; + +// Attributes from the context of the host (app, device, OS, culture, runtime, cloud). Which of them +// the SDK sends depends on the platform, so tests only check that they are well formed. +const HOST_CONTEXT_ATTRIBUTE = /^(app|device|os|culture|process\.runtime|cloud)\./; + +function splitHostContextAttributes(attributes: Record): { + hostContext: Record; + rest: Record; +} { + const hostContext: Record = {}; + const rest: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + (HOST_CONTEXT_ATTRIBUTE.test(key) ? hostContext : rest)[key] = value; + } + return { hostContext, rest }; +} function getCommonHttpRequestHeaders(): Record { return { @@ -22,30 +75,76 @@ describe('httpIntegration', () => { cleanupChildProcesses(); }); - describe('onSpanCreated option', () => { + describe('onSpanCreated and onSpanEnd options', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-options.mjs', (createRunner, test) => { - test('allows to configure onSpanCreated', async () => { + test('allows to configure onSpanCreated and onSpanEnd', async () => { const runner = createRunner() .expect({ - span: { - items: expect.arrayContaining([ + span: container => { + const segmentSpan = container.items.find(span => span.is_segment); + expect(segmentSpan).toEqual( expect.objectContaining({ - is_segment: true, span_id: expect.stringMatching(/[a-f\d]{16}/), trace_id: expect.stringMatching(/[a-f\d]{32}/), status: 'ok', - attributes: expect.objectContaining({ - [URL_FULL]: { type: 'string', value: expect.stringMatching(/\/test$/) }, - [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, - [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, - onSpanCreated: { type: 'string', value: 'yes' }, - 'onSpanCreated.reqUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, - 'onSpanCreated.resUrl': { type: 'string', value: expect.stringMatching(/\/test$/) }, - 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, - }), }), - ]), + ); + + // Every other attribute is listed, so the test fails when the span gets a new one. + const { hostContext, rest } = splitHostContextAttributes(segmentSpan!.attributes); + expect(rest).toEqual({ + onSpanCreated: { type: 'string', value: 'yes' }, + 'onSpanCreated.reqUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.reqMethod': { type: 'string', value: 'GET' }, + 'onSpanCreated.resUrl': { type: 'string', value: '/test' }, + 'onSpanCreated.resMethod': { type: 'string', value: 'GET' }, + 'onSpanEnd.reqUrl': { type: 'string', value: '/test' }, + 'onSpanEnd.resStatusCode': { type: 'integer', value: 200 }, + [SENTRY_OP]: { type: 'string', value: HTTP_SERVER }, + [SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.http_server' }, + [SENTRY_KIND]: { type: 'string', value: 'server' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan!.span_id }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'GET /test' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SENTRY_IS_LOCALHOST]: { type: 'boolean', value: true }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['Http']) }, + [HTTP_REQUEST_METHOD]: { type: 'string', value: 'GET' }, + [HTTP_ROUTE]: { type: 'string', value: '/test' }, + [HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 }, + [HTTP_RESPONSE_STATUS_TEXT]: { type: 'string', value: 'OK' }, + [URL_FULL]: { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test$/) }, + [URL_PATH]: { type: 'string', value: '/test' }, + [URL_SCHEME]: { type: 'string', value: 'http' }, + [SERVER_ADDRESS]: { type: 'string', value: 'localhost' }, + [SERVER_PORT]: { type: 'integer', value: expect.any(Number) }, + [CLIENT_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [CLIENT_PORT]: { type: 'integer', value: expect.any(Number) }, + [USER_IP_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_LOCAL_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PEER_ADDRESS]: { type: 'string', value: expect.stringMatching(LOOPBACK_ADDRESS) }, + [NETWORK_PEER_PORT]: { type: 'integer', value: expect.any(Number) }, + [NETWORK_PROTOCOL_NAME]: { type: 'string', value: 'http' }, + [NETWORK_PROTOCOL_VERSION]: { type: 'string', value: '1.1' }, + [NETWORK_TRANSPORT]: { type: 'string', value: 'tcp' }, + [USER_AGENT_ORIGINAL]: { type: 'string', value: 'node' }, + 'http.request.header.accept': { type: 'array', value: ['*/*'] }, + 'http.request.header.accept-encoding': { type: 'array', value: ['gzip, deflate'] }, + 'http.request.header.accept-language': { type: 'array', value: ['*'] }, + 'http.request.header.connection': { type: 'array', value: ['keep-alive'] }, + 'http.request.header.host': { type: 'array', value: [expect.stringMatching(/^localhost:\d+$/)] }, + 'http.request.header.sec-fetch-mode': { type: 'array', value: ['cors'] }, + 'http.request.header.user-agent': { type: 'array', value: ['node'] }, + }); + for (const attribute of Object.values(hostContext)) { + expect(attribute).toEqual({ type: expect.any(String), value: expect.anything() }); + } }, }) .start(); @@ -57,7 +156,7 @@ describe('httpIntegration', () => { describe('user callbacks that throw', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-throwing-callbacks.mjs', (createRunner, test) => { - test('still handles requests and sends the span when `onSpanCreated` and `ignoreIncomingRequests` throw', async () => { + test('still handles requests and sends the span when `onSpanCreated`, `onSpanEnd` and `ignoreIncomingRequests` throw', async () => { const runner = createRunner() .expect({ span: { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 80158e311e49..86fd2127c724 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -13,6 +13,7 @@ export { addIntegration, amqplibIntegration, anthropicAIIntegration, + awsIntegration, // eslint-disable-next-line typescript/no-deprecated anrIntegration, googleGenAIIntegration, @@ -102,6 +103,7 @@ export { langChainIntegration, langGraphIntegration, createFlueInstrumentation, + instrumentPostgresJsSql, mastraIntegration, piDurableIntegration, mcpServerIntegration, @@ -177,6 +179,8 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + moduleMetadataIntegration, zodErrorsIntegration, profiler, logger, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index fd07e76a3fba..994aabb838ce 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -2,6 +2,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -71,6 +72,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, @@ -159,6 +161,7 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4e91068d21f8..f7c8a286287b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -10,6 +10,8 @@ export type { Exception, Session, SeverityLevel, + Log, + LogSeverityLevel, Span, StackFrame, Stacktrace, @@ -25,6 +27,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -93,6 +96,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, contextLinesIntegration, nodeContextIntegration, @@ -176,6 +180,8 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + awsIntegration, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts index f21d47e93f67..bb423dc5773d 100644 --- a/packages/bun/src/integrations/bunHttpServer.ts +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -3,7 +3,12 @@ import * as http from 'node:http'; import * as https from 'node:https'; import type { IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import type { HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core/server'; const INTEGRATION_NAME = 'BunHttpServer' as const; @@ -37,8 +42,12 @@ interface BunHttpServerOptions { /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * + * The `request` parameter is the incoming `node:http` `IncomingMessage`. The `RequestOptions` part of the type is + * deprecated and will be removed in v12. Its fields that `IncomingMessage` does not have (such as `path` or + * `hostname`) are always `undefined`. */ - ignoreRequestBody?: (url: string, request: http.RequestOptions) => boolean; + ignoreRequestBody?: (url: string, request: http.RequestOptions & HttpIncomingMessage) => boolean; /** * Controls the maximum size of incoming HTTP request bodies attached to events. @@ -73,6 +82,13 @@ interface BunHttpServerOptions { onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions +>; + let hasPatched = false; const _bunHttpServerIntegration = ((options: BunHttpServerOptions = {}) => { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 1bd340703aec..35015447cce9 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,12 +1,15 @@ -import type { Integration, IntegrationFn, MaxRequestBodySize, SpanAttributes } from '@sentry/core'; +/* eslint-disable max-lines */ +import type { Client, Integration, IntegrationFn, MaxRequestBodySize, Span, SpanAttributes } from '@sentry/core'; import { captureBodyFromWinterCGRequest, captureException, continueTrace, + debug, defineIntegration, getClient, getUrlFragment, getUrlQuery, + hasSpansEnabled, hasSpanStreamingEnabled, httpHeadersToSpanAttributes, HTTP_SPAN_NAME_FALLBACK, @@ -20,7 +23,13 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import { classifyResponseStreaming, getClientIPAddress } from '@sentry/core/server'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; +import { + classifyResponseStreaming, + getClientIPAddress, + isStaticAssetRequest, + startRequestSession, +} from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { CLIENT_ADDRESS, @@ -53,12 +62,86 @@ export type BunServerIntegrationOptions = { * @default 'medium' */ maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. `request` is the incoming + * {@link Request}. The callback runs before the SDK reads the body. + */ + ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not capture server spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating server spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for + * incoming requests to track the health and crash-free rate of your releases in Sentry. + * + * For a streamed response, the session ends when the stream finishes. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; }; +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue>; + const _bunServerIntegration = ((options: BunServerIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, maxRequestBodySize: options.maxRequestBodySize, + ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS, setupOnce() { instrumentBunServe(); }, @@ -208,6 +291,59 @@ type RouteHandler = Extract< Function >; +function captureServeError(error: unknown): void { + captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); +} + +// A user callback that throws must not fail the request or leave the span open. +function callUserCallback(name: string, callback: () => T, fallback: T): T { + try { + return callback(); + } catch (e) { + debug.error(`Error in the Bun.serve ${name} callback.`, e); + return fallback; + } +} + +function shouldCreateSpan( + client: Client | undefined, + integration: BunServerIntegrationOptions | undefined, + request: Request, + urlPath: string, +): boolean { + if (!(integration?.spans ?? (client ? hasSpansEnabled(client.getOptions()) : false))) { + return false; + } + if (integration?.ignoreStaticAssets && request.method.toUpperCase() === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + callUserCallback('ignoreIncomingRequests', () => ignoreIncomingRequests(urlPath, request), false) + ); +} + +/** + * Calls `onDone` when the response is done: at once, or when a streamed body finishes. + * Returns the response to send. + */ +function onResponseDone(response: T, onDone: () => void, onStreamError: () => void): T { + if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { + const body = monitorStream(response.body, onDone, error => { + onStreamError(); + captureServeError(error); + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) as T; + } + onDone(); + return response; +} + function wrapRequestHandler( target: T, thisArg: unknown, @@ -217,8 +353,26 @@ function wrapRequestHandler( return withIsolationScope(async isolationScope => { const request = args[0]; const upperCaseMethod = request.method.toUpperCase(); + + const client = getClient(); + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + const endSession = + client && integration?.sessions + ? startRequestSession(client, { + requestIsolationScope: isolationScope, + sessionFlushingDelayMS: integration.sessionFlushingDelayMS, + }) + : undefined; + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { - return target.apply(thisArg, args); + try { + return await target.apply(thisArg, args); + } catch (e) { + captureServeError(e); + throw e; + } finally { + endSession?.(); + } } const parsedUrl = parseStringToURLObject(request.url); @@ -245,7 +399,6 @@ function wrapRequestHandler( routeName = route; } - const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); let socketAddress: { address: string; port: number } | undefined; @@ -279,24 +432,52 @@ function wrapRequestHandler( ipAddress: socketAddress?.address, }); - if (client && dataCollection) { - const configuredBodySize = client.getIntegrationByName( - INTEGRATION_NAME, - )?.maxRequestBodySize; + if (dataCollection) { const effectiveBodySize = - configuredBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); - if (upperCaseMethod !== 'GET' && effectiveBodySize !== 'none') { + integration?.maxRequestBodySize ?? (dataCollection.httpBodies.includes('incomingRequest') ? 'medium' : 'none'); + const ignoreRequestBody = integration?.ignoreRequestBody; + if ( + upperCaseMethod !== 'GET' && + effectiveBodySize !== 'none' && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + !( + ignoreRequestBody && + callUserCallback('ignoreRequestBody', () => ignoreRequestBody(request.url, request), true) + ) + ) { await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } } + const urlPath = parsedUrl ? `${parsedUrl.pathname}${parsedUrl.search}` : '/'; + const createSpan = shouldCreateSpan(client, integration, request, urlPath); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') ?? '', baggage: request.headers.get('baggage'), }, - () => - startSpanManual( + async () => { + if (!createSpan) { + let response: Response | undefined; + try { + response = (await target.apply(thisArg, args)) as Response | undefined; + } catch (e) { + captureServeError(e); + endSession?.(); + throw e; + } + if (response?.status) { + isolationScope.setContext('response', { status_code: response.status }); + } + return onResponseDone( + response, + () => endSession?.(), + () => undefined, + ); + } + + return startSpanManual( { attributes: { ...attributes, [SENTRY_OP]: HTTP_SERVER }, // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. @@ -306,47 +487,49 @@ function wrapRequestHandler( : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK, }, async (span, endSpan) => { + const { onSpanCreated, onSpanEnd } = integration ?? {}; + if (onSpanCreated) { + callUserCallback('onSpanCreated', () => onSpanCreated(span, request), undefined); + } + + let response: Response | undefined; try { - const response = (await target.apply(thisArg, args)) as Response | undefined; - if (response?.status) { - setHttpStatus(span, response.status); - - isolationScope.setContext('response', { - status_code: response.status, - }); - - if (dataCollection) { - span.setAttributes( - httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response'), - ); - } - } - if (response?.body && !response.body.locked && classifyResponseStreaming(response).isStreaming) { - const body = monitorStream(response.body, endSpan, error => { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { mechanism: { type: 'auto.http.bun.serve', handled: false } }); - }); - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - } - endSpan(); - return response; + response = (await target.apply(thisArg, args)) as Response | undefined; } catch (e) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(e, { - mechanism: { - type: 'auto.http.bun.serve', - handled: false, - }, - }); + captureServeError(e); endSpan(); + endSession?.(); throw e; } + + if (response?.status) { + setHttpStatus(span, response.status); + + isolationScope.setContext('response', { + status_code: response.status, + }); + + if (dataCollection) { + span.setAttributes(httpHeadersToSpanAttributes(response.headers.toJSON(), dataCollection, 'response')); + } + } + + const finalResponse = response; + return onResponseDone( + response, + () => { + if (onSpanEnd && finalResponse) { + callUserCallback('onSpanEnd', () => onSpanEnd(span, request, finalResponse), undefined); + } + endSpan(); + endSession?.(); + }, + () => span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }), + ); }, - ), + ); + }, ); }); } diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index bec2b892ea3a..5d846d3667d6 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,4 +1,4 @@ -import type { RequestEventData } from '@sentry/core'; +import type { AggregationCounts, Envelope, RequestEventData } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import type { BunOptions } from '../../src'; @@ -21,9 +21,11 @@ describe('Bun Serve Integration', () => { init({ dsn: 'https://username@domain/123', defaultIntegrations: false, - ...options, + // Spans follow the client's tracing configuration. + tracesSampleRate: 1, transport: () => SentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => SentryCore.resolvedSyncPromise({})), + ...options, }); }; @@ -1195,4 +1197,475 @@ describe('Bun Serve Integration', () => { expect(normalizedRequest?.data).toBeUndefined(); }); }); + + describe('options', () => { + const captureBodySpy = spyOn(SentryCore, 'captureBodyFromWinterCGRequest'); + + beforeEach(() => { + captureBodySpy.mockClear(); + }); + + test('does not capture request bodies that ignoreRequestBody matches', async () => { + const ignoreRequestBody = (url: string, request: Request): boolean => + url.endsWith('/upload?id=1') && request.method === 'POST'; + setupClient({ integrations: [bunServerIntegration({ ignoreRequestBody })] }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const ignored = await fetch(`http://localhost:${port}/upload?id=1`, { method: 'POST', body: 'secret' }); + expect(await ignored.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + + const captured = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'hello' }); + expect(await captured.text()).toBe('hello'); + expect(captureBodySpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not capture the body and still responds when ignoreRequestBody throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreRequestBody: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: async req => new Response(await req.text()) }); + try { + const response = await fetch(`http://localhost:${port}/login`, { method: 'POST', body: 'secret' }); + expect(await response.text()).toBe('secret'); + expect(captureBodySpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for requests that ignoreIncomingRequests matches', async () => { + const urlPaths: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + urlPaths.push(urlPath); + return urlPath.startsWith('/health') && request instanceof Request; + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/health?probe=1`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(urlPaths).toEqual(['/health?probe=1', '/users']); + } finally { + await server.stop(true); + } + }); + + test('creates the span and still responds when ignoreIncomingRequests throws', async () => { + setupClient({ + integrations: [ + bunServerIntegration({ + ignoreIncomingRequests: () => { + throw new Error('filter failed'); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }); + + test('does not create spans for static assets by default', async () => { + setupClient({ integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(await (await fetch(`http://localhost:${port}/assets/app.js`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + + // The filter only applies to GET. + await (await fetch(`http://localhost:${port}/favicon.ico`, { method: 'POST', body: 'x' })).text(); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('creates spans for static assets when ignoreStaticAssets is false', async () => { + setupClient({ integrations: [bunServerIntegration({ ignoreStaticAssets: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/favicon.ico`)).text()).toBe('ok'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when spans is false, but still captures errors and request data', async () => { + setupClient({ integrations: [bunServerIntegration({ spans: false })] }); + let normalizedRequest: RequestEventData | undefined; + const error = new Error('handler failed'); + const server = Bun.serve({ + port, + fetch: () => { + normalizedRequest = SentryCore.getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest; + throw error; + }, + }); + try { + // `server.fetch` returns the handler error, which `bun:test` would report as unhandled from a real request. + await expect(server.fetch(new Request(`http://localhost:${port}/users`))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(normalizedRequest?.url).toBe(`http://localhost:${port}/users`); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + + test('does not create spans when tracing is disabled', async () => { + setupClient({ tracesSampleRate: undefined, integrations: [bunServerIntegration()] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(continueTraceSpy).toHaveBeenCalledTimes(1); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanCreated and onSpanEnd', async () => { + const calls: string[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ + onSpanCreated: (span, request) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`created ${request.method} ${new URL(request.url).pathname}`); + }, + onSpanEnd: (span, request, response) => { + expect(span).toBe(mockSpan as unknown as SentryCore.Span); + calls.push(`end ${request.method} ${response.status}`); + }, + }), + ], + }); + const server = Bun.serve({ + port, + fetch: () => { + calls.push('handler'); + return new Response('ok', { status: 201 }); + }, + }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(calls).toEqual(['created GET /users', 'handler', 'end GET 201']); + } finally { + await server.stop(true); + } + }); + + test('calls onSpanEnd when a streamed response finishes', async () => { + const onSpanEndCalls: number[] = []; + setupClient({ + integrations: [ + bunServerIntegration({ onSpanEnd: (_span, _request, response) => onSpanEndCalls.push(response.status) }), + ], + }); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(onSpanEndCalls).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + expect(onSpanEndCalls).toEqual([200]); + } finally { + await server.stop(true); + } + }); + + test.each(['onSpanCreated', 'onSpanEnd'] as const)( + 'still responds and ends the span when %s throws', + async hook => { + setupClient({ + integrations: [ + bunServerIntegration({ + [hook]: () => { + throw new Error(`${hook} failed`); + }, + }), + ], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + expect(await (await fetch(`http://localhost:${port}/users`)).text()).toBe('ok'); + expect(endSpanSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + } finally { + await server.stop(true); + } + }, + ); + + test.each(['OPTIONS', 'HEAD'])('captures errors thrown for %s requests', async method => { + const error = new Error(`${method} failed`); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/`, { method }))).rejects.toBe(error); + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }); + } finally { + await server.stop(true); + } + }); + }); + + describe('sessions', () => { + type SessionCounts = Omit; + + function setupSessionClient(options?: BunOptions): { + client: SentryCore.Client; + sessions: SessionCounts[]; + firstSession: Promise; + } { + const sessions: SessionCounts[] = []; + let resolveFirstSession: () => void; + const firstSession = new Promise(resolve => (resolveFirstSession = resolve)); + setupClient({ + release: '1.0.0', + integrations: [bunServerIntegration()], + ...options, + transport: () => + SentryCore.createTransport({ recordDroppedEvent: () => undefined }, request => { + const envelope = SentryCore.parseEnvelope(request.body) as Envelope; + SentryCore.forEachEnvelopeItem(envelope, (item, type) => { + if (type === 'sessions') { + for (const { started: _started, ...counts } of (item[1] as { aggregates: AggregationCounts[] }) + .aggregates) { + sessions.push(counts); + } + resolveFirstSession(); + } + }); + return SentryCore.resolvedSyncPromise({}); + }), + }); + return { client: SentryCore.getClient()!, sessions, firstSession }; + } + + test('records an exited session for a successful request', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test('records an errored session for a handled exception', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ + port, + fetch: () => { + SentryCore.captureException(new Error('handled')); + return new Response('ok'); + }, + }); + try { + await (await fetch(`http://localhost:${port}/handled`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 1, crashed: 0 }]); + }); + + test('records a crashed session when the handler throws', async () => { + const { client, sessions } = setupSessionClient(); + const error = new Error('unhandled'); + const server = Bun.serve({ + port, + fetch: () => { + throw error; + }, + }); + try { + await expect(server.fetch(new Request(`http://localhost:${port}/throws`))).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a crashed session when a streamed response source fails %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + const error = new Error('stream interrupted'); + const text = response.text(); + controller!.error(error); + await expect(text).rejects.toBe(error); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 0, errored: 0, crashed: 1 }]); + }); + + test('records sessions for OPTIONS and HEAD requests', async () => { + const { client, sessions } = setupSessionClient(); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/cors`, { method: 'OPTIONS' })).text(); + await (await fetch(`http://localhost:${port}/cors`, { method: 'HEAD' })).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('records sessions for requests without a span', async () => { + const { client, sessions } = setupSessionClient({ + integrations: [bunServerIntegration({ ignoreIncomingRequests: urlPath => urlPath === '/health' })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/health`)).text(); + await (await fetch(`http://localhost:${port}/favicon.ico`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(sessions).toEqual([{ exited: 2, errored: 0, crashed: 0 }]); + }); + + test('does not record sessions when sessions is false', async () => { + const { client, sessions } = setupSessionClient({ integrations: [bunServerIntegration({ sessions: false })] }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([]); + }); + + test('sends the session aggregate after sessionFlushingDelayMS', async () => { + const { sessions, firstSession } = setupSessionClient({ + integrations: [bunServerIntegration({ sessionFlushingDelayMS: 50 })], + }); + const server = Bun.serve({ port, fetch: () => new Response('ok') }); + try { + await (await fetch(`http://localhost:${port}/ok`)).text(); + } finally { + await server.stop(true); + } + + // No flush here. The timeout only bounds a failure. With the default 60s delay, no session + // arrives within it. + let timeoutId: ReturnType | undefined; + const timedOut = new Promise<'timed out'>(resolve => { + timeoutId = setTimeout(() => resolve('timed out'), 2_000); + }); + const result = await Promise.race([firstSession.then(() => 'sent' as const), timedOut]); + clearTimeout(timeoutId); + + expect(result).toBe('sent'); + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + + test.each([ + ['when tracing is enabled', {}], + ['when tracing is disabled', { tracesSampleRate: undefined }], + ] as const)('records a streamed response session when the stream finishes %s', async (_label, options) => { + const { client, sessions } = setupSessionClient(options); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const server = Bun.serve({ + port, + fetch: () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + }); + try { + const response = await server.fetch(new Request(`http://localhost:${port}/events`)); + controller!.enqueue(new TextEncoder().encode('chunk\n')); + await client.flush(); + expect(sessions).toEqual([]); + + controller!.close(); + expect(await response.text()).toBe('chunk\n'); + } finally { + await server.stop(true); + } + await client.flush(); + + expect(sessions).toEqual([{ exited: 1, errored: 0, crashed: 0 }]); + }); + }); }); diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 0375e7f94601..0a9f6a1cf709 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { _INTERNAL_isClientClosed, debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { _INTERNAL_isClientClosed, debug, getCurrentScope, safeCallback, setCurrentClient } from '@sentry/core'; import { consoleIntegration, conversationIdIntegration, @@ -107,6 +107,9 @@ export function initWithDefaultIntegrations( return cached; } + // `false` or a custom list turns off the SDK defaults, late-loaded ones + // included, unless an SDK built the list from the defaults. + const usesSdkDefaults = options.defaultIntegrations === undefined || !!options.extendsDefaultIntegrations; if (options.defaultIntegrations === undefined) { options.defaultIntegrations = getDefaultIntegrationsImpl(options); } @@ -153,12 +156,36 @@ export function initWithDefaultIntegrations( // lazily required on first use) stores its subscriber factory on the global // marker too late for the default-integrations snapshot above. Its injected // snippet emits this event right after storing the factory, so install the - // integration on the live client here. `addIntegration` dedupes by - // integration name, so already-installed integrations are no-ops. + // integration on the live client here, with the same rules as the defaults. + // `addIntegration` dedupes by integration name, so an integration the user + // configured stays in place. + const userIntegrations = options.integrations; client.on('orchestrion.module-injected', moduleName => { const factory = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); - if (factory) { - client.addIntegration(factory()); + if (!usesSdkDefaults || !factory) { + return; + } + + const integration = factory(); + if (typeof userIntegrations !== 'function') { + client.addIntegration(integration); + return; + } + + // Pass the late default through the user's `integrations` function, so a + // filter that removed it at init removes it here too. This calls the + // function again, once for each late module. If the function throws, skip + // the integration, so the module load that emitted the event still works. + const kept = safeCallback( + DEBUG_BUILD ? 'Error in the `integrations` function, skipping a late-loaded integration:' : '', + () => + getIntegrationsToSetup({ defaultIntegrations: [integration], integrations: userIntegrations }).find( + ({ name }) => name === integration.name, + ), + () => undefined, + ); + if (kept) { + client.addIntegration(kept); } }); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 051ffd8627d2..be45095e1481 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -351,6 +351,16 @@ interface BaseCloudflareOptions { */ enableDedupe?: boolean; + /** + * @ignore Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults. + * @hidden Used internally by SDKs that build `defaultIntegrations` from the Cloudflare defaults. + * + * Marks the `defaultIntegrations` list as an extension of the SDK defaults, so the SDK also adds + * the integrations of instrumented modules that load after `init()`. + * @default false + */ + extendsDefaultIntegrations?: boolean; + /** * The Cloudflare SDK is not OpenTelemetry native. By default (`false`) it does not set up a tracer * provider; spans are emitted via the SDK's own instrumentation and scopes are isolated with diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 88ce8deaac01..e1fb6e60cee3 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -11,6 +11,8 @@ export type { FeatureFlagsIntegration, Session, SeverityLevel, + Log, + LogSeverityLevel, Span, StackFrame, Stacktrace, @@ -30,6 +32,7 @@ export { captureException, captureEvent, captureMessage, + parameterize, captureFeedback, close, createTransport, @@ -139,6 +142,7 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, + instrumentStateGraphCompile, instrumentCreateReactAgent, vercelAIIntegration, honoIntegration, @@ -148,6 +152,48 @@ export { getInstrumentedModuleNames, } from '@sentry/server-utils'; +// The orchestrion channel integrations. When the `@sentry/cloudflare/vite` +// plugin bundles an instrumented package, the SDK adds the matching +// integration with its default options. Pass one in `integrations` to set +// options. Without the plugin, these integrations record nothing. +export { + amqplibIntegration, + anthropicAIIntegration, + awsIntegration, + dataloaderIntegration, + expressIntegration, + firebaseIntegration, + genericPoolIntegration, + googleGenAIIntegration, + graphqlIntegration, + groqIntegration, + hapiIntegration, + kafkaIntegration, + knexIntegration, + koaIntegration, + langChainIntegration, + langGraphIntegration, + lruMemoizerIntegration, + mastraIntegration, + mcpServerIntegration, + mistralAIIntegration, + mongoIntegration, + mongooseIntegration, + mysql2Integration, + mysqlIntegration, + neonIntegration, + nitroIntegration, + openAIIntegration, + piDurableIntegration, + postgresIntegration, + postgresJsIntegration, + redisIntegration, + SentryMastraExporter, + tediousIntegration, + togetherAIIntegration, + typesafeIntegration, +} from '@sentry/server-utils'; + export { instrumentWorkflowWithSentry } from './workflows'; export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/cloudflare/src/integrations/httpServer.ts b/packages/cloudflare/src/integrations/httpServer.ts index 97e960f916d2..1975f168819e 100644 --- a/packages/cloudflare/src/integrations/httpServer.ts +++ b/packages/cloudflare/src/integrations/httpServer.ts @@ -1,5 +1,15 @@ -import type { Client, IntegrationFn, MaxRequestBodySize } from '@sentry/core'; -import { captureBodyFromWinterCGRequest, defineIntegration, getIsolationScope } from '@sentry/core'; +import type { Client, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; +import { + captureBodyFromWinterCGRequest, + defineIntegration, + getIsolationScope, + hasSpansEnabled, + parseStringToURLObject, + safeCallback, +} from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; +import { isStaticAssetRequest } from '@sentry/core/server'; +import { DEBUG_BUILD } from '../debug-build'; const INTEGRATION_NAME = 'HttpServer' as const; @@ -38,12 +48,61 @@ export interface HttpServerIntegrationOptions { * ``` */ ignoreRequestBody?: (url: string, request: Request) => boolean; + + /** + * Do not create spans for incoming requests whose URL path makes the given callback return `true`. + * + * `urlPath` is the path of the request, including the query string. `request` is the incoming + * {@link Request}. Errors thrown by the handler are still captured. + */ + ignoreIncomingRequests?: (urlPath: string, request: Request) => boolean; + + /** + * Whether to automatically ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Whether to create spans for incoming requests. + * Errors are still captured and request data is still attached when this is `false`. + * Defaults to the client's tracing configuration (`hasSpansEnabled`). + */ + spans?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, before the request handler runs. + */ + onSpanCreated?: (span: Span, request: Request) => void; + + /** + * A hook that can be used to mutate the span one last time before it ends. + * For a streamed response, this runs when the stream finishes. + * It is not called when the request handler throws. + */ + onSpanEnd?: (span: Span, request: Request, response: Response) => void; } -interface HttpServerIntegrationInstance { +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions< + HttpServerIntegrationOptions, + Request, + Response, + // Not supported. A worker has no long-lived process to run the timer + // that flushes session aggregates. + 'sessions' | 'sessionFlushingDelayMS' + > +>; + +interface HttpServerIntegrationInstance extends HttpServerIntegrationOptions { name: string; maxRequestBodySize: MaxRequestBodySize | undefined; - ignoreRequestBody?: (url: string, request: Request) => boolean; } const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): HttpServerIntegrationInstance => { @@ -51,6 +110,11 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}): Ht name: INTEGRATION_NAME, maxRequestBodySize: options.maxRequestBodySize, ignoreRequestBody: options.ignoreRequestBody, + ignoreIncomingRequests: options.ignoreIncomingRequests, + ignoreStaticAssets: options.ignoreStaticAssets ?? true, + spans: options.spans, + onSpanCreated: options.onSpanCreated, + onSpanEnd: options.onSpanEnd, }; }) satisfies IntegrationFn; @@ -100,10 +164,75 @@ export async function captureIncomingRequestBody(client: Client, request: Reques return; } - if (integration.ignoreRequestBody?.(request.url, request)) { + const ignoreRequestBody = integration.ignoreRequestBody; + if ( + ignoreRequestBody && + // If the filter throws, skip the body so it cannot leak data the filter was meant to hide. + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreRequestBody`, skipping the body:' : '', + () => ignoreRequestBody(request.url, request), + () => true, + ) + ) { return; } const isolationScope = getIsolationScope(); await captureBodyFromWinterCGRequest(request, isolationScope, effectiveBodySize); } + +/** + * Whether to create a span for the incoming request, based on the HttpServer integration config. + * Called internally by `wrapRequestHandler`. + */ +export function shouldCreateIncomingRequestSpan(client: Client | undefined, request: Request): boolean { + if (!client) { + return false; + } + + const integration = client.getIntegrationByName(INTEGRATION_NAME); + if (!(integration?.spans ?? hasSpansEnabled(client.getOptions()))) { + return false; + } + + const urlObject = parseStringToURLObject(request.url); + const urlPath = urlObject ? `${urlObject.pathname}${urlObject.search}` : '/'; + if (integration?.ignoreStaticAssets && request.method === 'GET' && isStaticAssetRequest(urlPath)) { + return false; + } + + const ignoreIncomingRequests = integration?.ignoreIncomingRequests; + return !( + ignoreIncomingRequests && + safeCallback( + DEBUG_BUILD ? 'Error in `ignoreIncomingRequests`, creating the span:' : '', + () => ignoreIncomingRequests(urlPath, request), + () => false, + ) + ); +} + +/** + * The `onSpanCreated` and `onSpanEnd` hooks of the HttpServer integration, guarded so that a hook + * that throws does not fail the request. Called internally by `wrapRequestHandler`. + */ +export function getIncomingRequestSpanHooks(client: Client | undefined): { + onSpanCreated: (span: Span, request: Request) => void; + onSpanEnd: (span: Span, request: Request, response: Response) => void; +} { + const integration = client?.getIntegrationByName(INTEGRATION_NAME); + return { + onSpanCreated: (span, request) => + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanCreated`:' : '', + () => integration?.onSpanCreated?.(span, request), + () => undefined, + ), + onSpanEnd: (span, request, response) => + safeCallback( + DEBUG_BUILD ? 'Error in `onSpanEnd`:' : '', + () => integration?.onSpanEnd?.(span, request, response), + () => undefined, + ), + }; +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 71444c9a87e3..e852ad1cf661 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -6,6 +6,7 @@ import { SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; import { captureException, continueTrace, @@ -21,7 +22,11 @@ import { } from '@sentry/core'; import { classifyResponseStreaming } from '@sentry/core/server'; import { DEBUG_BUILD } from './debug-build'; -import { captureIncomingRequestBody } from './integrations/httpServer'; +import { + captureIncomingRequestBody, + getIncomingRequestSpanHooks, + shouldCreateIncomingRequestSpan, +} from './integrations/httpServer'; import { flushDeferredChannelEvents } from './orchestrion-deferred-channels'; import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; @@ -155,98 +160,123 @@ export function wrapRequestHandlerWithInit( await captureIncomingRequestBody(client, request); } + const { onSpanCreated, onSpanEnd } = getIncomingRequestSpanHooks(client); + + // Runs the handler. Ends the span (if any) and flushes when the response is done. + const respond = async (span: Span | undefined): Promise => { + let res: Response; + + try { + res = await handler(); + if (span) { + setHttpStatus(span, res.status); + } + + // After the handler runs, the span name might have been updated by nested instrumentation + // (e.g., Remix parameterizing routes). The span should already have the correct name + // from that instrumentation, so we don't need to do anything here. + } catch (e) { + span?.end(); + if (captureErrors) { + captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); + } + waitUntil?.(flushAndDispose(client)); + throw e; + } + + let ended = false; + const endSpan = (): void => { + if (ended) return; + + ended = true; + if (span) { + onSpanEnd(span, request, res); + span.end(); + } + }; + + const userClassification = safeCallback( + DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', + () => options.isStreamingResponse?.(res, request), + () => undefined, + ); + // An async callback returns a promise, which would otherwise count as `true`. + const isStreaming = + typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; + + if (isStreaming && res.body) { + try { + let transformerUsed = false; + + const endSpanOnce = (): void => { + if (ended) return; + + endSpan(); + waitUntil?.(flushAndDispose(client)); + }; + + // Workers with a compatibility date before 2022-11-30 and without the + // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen + // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a + // used transformer runs `start`. + const transform = new TransformStream({ + start() { + transformerUsed = true; + }, + flush() { + // Source stream completed normally. + endSpanOnce(); + }, + cancel() { + // Client disconnected (or downstream cancelled). The `cancel` + // is being called while the response is still considered + // active, so this is a safe place to end the span. + endSpanOnce(); + }, + }); + + if (transformerUsed) { + // Passing the original response as the init keeps `encodeBody: 'manual'`, which a + // Response does not expose as a property. Without it, workerd compresses a + // pre-compressed body a second time. + return new Response(res.body.pipeThrough(transform), res); + } + } catch { + // Falls back to ending the span at handler return. + } + } + + // Non-streaming response - end span immediately and return original + endSpan(); + + // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. + // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers + // will still be called and may need the client to capture errors. + if (res.status === 101) { + waitUntil?.(client?.flush(2000)); + } else { + waitUntil?.(flushAndDispose(client)); + } + return res; + }; + + const createSpan = shouldCreateIncomingRequestSpan(client, request); + return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { + if (!createSpan) { + return respond(undefined); + } + // Note: This span will not have a duration unless I/O happens in the handler. This is // because of how the cloudflare workers runtime works. // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ // Use startSpanManual to control when span ends (needed for streaming responses) - return startSpanManual({ name, attributes }, async span => { - let res: Response; - - try { - res = await handler(); - setHttpStatus(span, res.status); - - // After the handler runs, the span name might have been updated by nested instrumentation - // (e.g., Remix parameterizing routes). The span should already have the correct name - // from that instrumentation, so we don't need to do anything here. - } catch (e) { - span.end(); - if (captureErrors) { - captureException(e, { mechanism: { handled: false, type: errorMechanismType } }); - } - waitUntil?.(flushAndDispose(client)); - throw e; - } - - const userClassification = safeCallback( - DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', - () => options.isStreamingResponse?.(res, request), - () => undefined, - ); - // An async callback returns a promise, which would otherwise count as `true`. - const isStreaming = - typeof userClassification === 'boolean' ? userClassification : classifyResponseStreaming(res).isStreaming; - - if (isStreaming && res.body) { - try { - let ended = false; - let transformerUsed = false; - - const endSpanOnce = (): void => { - if (ended) return; - - ended = true; - span.end(); - waitUntil?.(flushAndDispose(client)); - }; - - // Workers with a compatibility date before 2022-11-30 and without the - // `transformstream_enable_standard_constructor` flag (for example under older mini-oxygen - // releases) ignore the transformer, so `flush` and `cancel` would never end the span. Only a - // used transformer runs `start`. - const transform = new TransformStream({ - start() { - transformerUsed = true; - }, - flush() { - // Source stream completed normally. - endSpanOnce(); - }, - cancel() { - // Client disconnected (or downstream cancelled). The `cancel` - // is being called while the response is still considered - // active, so this is a safe place to end the span. - endSpanOnce(); - }, - }); - - if (transformerUsed) { - // Passing the original response as the init keeps `encodeBody: 'manual'`, which a - // Response does not expose as a property. Without it, workerd compresses a - // pre-compressed body a second time. - return new Response(res.body.pipeThrough(transform), res); - } - } catch { - // Falls back to ending the span at handler return. - } - } - - // Non-streaming response - end span immediately and return original - span.end(); - - // Don't dispose for protocol upgrades (101 Switching Protocols) - the connection stays alive. - // This includes WebSocket upgrades where webSocketMessage/webSocketClose handlers - // will still be called and may need the client to capture errors. - if (res.status === 101) { - waitUntil?.(client?.flush(2000)); - } else { - waitUntil?.(flushAndDispose(client)); - } - return res; + return startSpanManual({ name, attributes }, span => { + onSpanCreated(span, request); + return respond(span); }); }, ); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a8c9a3f735f0..972180c62a8c 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -356,6 +356,45 @@ describe('withSentry', () => { expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toEqual(JSON.stringify({ key: 'value' })); }); + test.each([ + ['matches', () => true], + [ + 'throws', + () => { + throw new Error('filter failed'); + }, + ], + ])('does not capture the body when ignoreRequestBody %s', async (_label, ignoreRequestBody) => { + let sentryEvent: Event = {}; + + const response = await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + integrations: [httpServerIntegration({ ignoreRequestBody })], + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: 'secret' }), + }), + context: createMockExecutionContext(), + }, + () => { + SentryCore.captureMessage('request body'); + return new Response('test'); + }, + ); + + expect(await response.text()).toBe('test'); + expect(sentryEvent.message).toBe('request body'); + expect(sentryEvent.sdkProcessingMetadata?.normalizedRequest?.data).toBeUndefined(); + }); + test('does not capture cookies when dataCollection.cookies is disabled', async () => { let sentryEvent: Event = {}; @@ -649,6 +688,220 @@ describe('withSentry', () => { }); }); }); + + describe('httpServerIntegration span options', () => { + // Whether a span is active in the handler. + async function hasActiveSpan( + options: Partial, + url = 'https://example.com/users?id=1', + init?: RequestInit, + ): Promise { + let active = false; + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: 1, ...options }, + request: new Request(url, init), + context: createMockExecutionContext(), + }, + () => { + active = !!SentryCore.getActiveSpan(); + return new Response('ok'); + }, + ); + expect(await response.text()).toBe('ok'); + return active; + } + + test('creates a span by default', async () => { + expect(await hasActiveSpan({})).toBe(true); + }); + + test('does not create spans for requests that ignoreIncomingRequests matches', async () => { + const calls: [string, string][] = []; + const integrations = [ + httpServerIntegration({ + ignoreIncomingRequests: (urlPath, request) => { + calls.push([urlPath, request.method]); + return urlPath.startsWith('/health'); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations }, 'https://example.com/health?probe=1')).toBe(false); + expect(await hasActiveSpan({ integrations }, 'https://example.com/users')).toBe(true); + expect(calls).toEqual([ + ['/health?probe=1', 'GET'], + ['/users', 'GET'], + ]); + }); + + test('creates the span when ignoreIncomingRequests throws', async () => { + const integrations = [ + httpServerIntegration({ + ignoreIncomingRequests: () => { + throw new Error('filter failed'); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations })).toBe(true); + }); + + test('does not create spans for GET requests to static assets by default', async () => { + expect(await hasActiveSpan({}, 'https://example.com/favicon.ico')).toBe(false); + expect(await hasActiveSpan({}, 'https://example.com/assets/app.js')).toBe(false); + expect(await hasActiveSpan({}, 'https://example.com/favicon.ico', { method: 'POST', body: 'x' })).toBe(true); + }); + + test('creates spans for static assets when ignoreStaticAssets is false', async () => { + const integrations = [httpServerIntegration({ ignoreStaticAssets: false })]; + + expect(await hasActiveSpan({ integrations }, 'https://example.com/favicon.ico')).toBe(true); + }); + + test('does not create spans when tracing is disabled', async () => { + expect(await hasActiveSpan({ tracesSampleRate: undefined })).toBe(false); + }); + + test('does not create spans when spans is false, but still captures errors and continues the trace', async () => { + const events: Event[] = []; + const request = new Request('https://example.com/users'); + request.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012-1'); + const error = new Error('handler failed'); + + await expect( + wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tracesSampleRate: 1, + integrations: [httpServerIntegration({ spans: false })], + beforeSend(event) { + events.push(event); + return null; + }, + }, + request, + context: createMockExecutionContext(), + }, + () => { + expect(SentryCore.getActiveSpan()).toBeUndefined(); + throw error; + }, + ), + ).rejects.toBe(error); + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.cloudflare' }); + expect(events[0]?.contexts?.trace?.trace_id).toBe('12312012123120121231201212312012'); + }); + + test('calls onSpanCreated and onSpanEnd', async () => { + const calls: string[] = []; + let transaction: Event | undefined; + + await wrapRequestHandler( + { + options: { + ...MOCK_OPTIONS, + tracesSampleRate: 1, + integrations: [ + httpServerIntegration({ + onSpanCreated: (span, request) => { + calls.push(`created ${request.method} ${new URL(request.url).pathname}`); + span.setAttribute('onSpanCreated', 'yes'); + }, + onSpanEnd: (span, request, response) => { + calls.push(`end ${request.method} ${response.status}`); + span.setAttribute('onSpanEnd', response.status); + }, + }), + ], + beforeSendTransaction(event) { + transaction = event; + return null; + }, + }, + request: new Request('https://example.com/users'), + context: createMockExecutionContext(), + }, + () => { + calls.push('handler'); + return new Response('ok', { status: 201 }); + }, + ).then(response => response.text()); + + await vi.waitFor(() => expect(transaction).toBeDefined()); + expect(calls).toEqual(['created GET /users', 'handler', 'end GET 201']); + expect(transaction?.contexts?.trace?.data).toEqual( + expect.objectContaining({ onSpanCreated: 'yes', onSpanEnd: 201 }), + ); + }); + + test('calls onSpanEnd when a streamed response finishes', async () => { + const onSpanEnd = vi.fn(); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: 1, integrations: [httpServerIntegration({ onSpanEnd })] }, + request: new Request('https://example.com/events'), + context: createMockExecutionContext(), + }, + () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + ); + + controller!.enqueue(new TextEncoder().encode('data: one\n\n')); + expect(onSpanEnd).not.toHaveBeenCalled(); + + controller!.close(); + expect(await response.text()).toBe('data: one\n\n'); + expect(onSpanEnd).toHaveBeenCalledTimes(1); + expect(onSpanEnd).toHaveBeenCalledWith(expect.anything(), expect.any(Request), expect.any(Response)); + }); + + test.each(['onSpanCreated', 'onSpanEnd'] as const)('still responds when %s throws', async hook => { + const integrations = [ + httpServerIntegration({ + [hook]: () => { + throw new Error(`${hook} failed`); + }, + }), + ]; + + expect(await hasActiveSpan({ integrations })).toBe(true); + }); + + test('flushes after a streamed response finishes when no span is created', async () => { + const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); + let controller: ReadableStreamDefaultController; + const source = new ReadableStream({ + start(value) { + controller = value; + }, + }); + + const response = await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, tracesSampleRate: undefined }, + request: new Request('https://example.com/events'), + context, + }, + () => new Response(source, { headers: { 'content-type': 'text/event-stream' } }), + ); + expect(waitUntilSpy).not.toHaveBeenCalled(); + + controller!.close(); + await response.text(); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + }); + }); }); function createMockExecutionContext(): ExecutionContext { diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index dcbbb7089da4..60254f504a66 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -6,7 +6,7 @@ import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; import { getDefaultIntegrations, init } from '../src/sdk'; import { resetSdk } from './testUtils'; -import { spanStreamingIntegration } from '../src/'; +import { mysqlIntegration as exportedMysqlIntegration, spanStreamingIntegration } from '../src/'; describe('init', () => { beforeEach(() => { @@ -443,4 +443,135 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBeDefined(); }); + + test('keeps a channel integration the user configured instead of the registered default', async () => { + resetSdk(); + const { mysqlIntegration } = await import('@sentry/server-utils'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + const userInstance = exportedMysqlIntegration(); + + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] }); + + expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); + }); + + describe('when a channel integration module loads after init', () => { + const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + + async function loadMysqlLate(client: CloudflareClient | undefined): Promise { + const { mysqlIntegration } = await import('@sentry/server-utils'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + client?.emit('orchestrion.module-injected', 'mysql'); + } + + test('does not add it when the integrations function filtered it out', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'), + }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test('adds it when the integrations function keeps it', async () => { + resetSdk(); + const client = init({ dsn: DSN, integrations: defaults => defaults }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + }); + + test('keeps the instance the integrations function configured', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + integrations: defaults => [...defaults, exportedMysqlIntegration()], + }); + const configured = client?.getIntegrationByName('Mysql'); + expect(configured).toBeDefined(); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBe(configured); + }); + + test('does not add it and does not throw when the integrations function throws', async () => { + resetSdk(); + let calls = 0; + const client = init({ + dsn: DSN, + integrations: defaults => { + calls++; + if (calls > 1) { + throw new Error('integrations failed'); + } + return defaults; + }, + }); + + await expect(loadMysqlLate(client)).resolves.toBeUndefined(); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test('adds it when a custom defaultIntegrations list extends the SDK defaults', async () => { + resetSdk(); + const client = init({ dsn: DSN, defaultIntegrations: [], extendsDefaultIntegrations: true }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + }); + + test('does not add it when the integrations function filtered it out of a list that extends the SDK defaults', async () => { + resetSdk(); + const client = init({ + dsn: DSN, + defaultIntegrations: [], + extendsDefaultIntegrations: true, + integrations: defaults => defaults.filter(integration => integration.name !== 'Mysql'), + }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + + test.each([ + ['false', false as const], + ['a custom list', [] as Integration[]], + ])('does not add it when defaultIntegrations is %s', async (_label, defaultIntegrations) => { + resetSdk(); + const client = init({ dsn: DSN, defaultIntegrations }); + + await loadMysqlLate(client); + + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + }); + }); + + test('keeps a channel integration the user configured when its module loads after init', async () => { + resetSdk(); + const { mysqlIntegration } = await import('@sentry/server-utils'); + const userInstance = exportedMysqlIntegration(); + const client = init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [userInstance] }); + + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['mysql']), + integrations: new Map([['mysql', mysqlIntegration]]), + }; + client?.emit('orchestrion.module-injected', 'mysql'); + + expect(client?.getIntegrationByName('Mysql')).toBe(userInstance); + }); }); diff --git a/packages/core/src/integrations/http/server-request-options.ts b/packages/core/src/integrations/http/server-request-options.ts new file mode 100644 index 000000000000..7125978ad603 --- /dev/null +++ b/packages/core/src/integrations/http/server-request-options.ts @@ -0,0 +1,128 @@ +import type { Span } from '../../types/span'; +import type { MaxRequestBodySize } from '../../utils/request'; + +/** + * Options that every integration for incoming HTTP requests should + * support, in each runtime. + * + * `TRequest` and `TResponse` are the request and response types that the + * runtime passes to callbacks: `HttpIncomingMessage` and + * `HttpServerResponse` for `node:http`, or the web `Request` and + * `Response` for `Deno.serve`, `Bun.serve` and Cloudflare. + * + * Runtime options types do not extend this type. Each one declares its + * own fields, with docs for that runtime, and uses + * {@link CheckServerRequestOptions} to check them against this type. + * Then a new field here fails to compile in each runtime, until the + * runtime supports it or lists it as unsupported. + */ +export interface ServerRequestOptions { + /** + * Whether to create spans for incoming requests. + * Defaults to the client's tracing configuration. + */ + spans?: boolean; + + /** + * Whether to create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming + * requests, to track the health and crash-free rate of releases. + * + * @default `true` + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default `60000` (60s) + */ + sessionFlushingDelayMS?: number; + + /** + * Controls the maximum size of incoming request bodies attached to events. + * + * @default 'medium' + */ + maxRequestBodySize?: MaxRequestBodySize; + + /** + * Do not capture the request body for incoming requests where the given callback returns `true`. + * + * `url` is the full URL of the request, including the query string. + */ + ignoreRequestBody?: (url: string, request: TRequest) => boolean; + + /** + * Do not create spans for incoming requests where the given callback returns `true`. + * + * `urlPath` is the path of the request, including the query string. + */ + ignoreIncomingRequests?: (urlPath: string, request: TRequest) => boolean; + + /** + * Whether to ignore common static asset requests (favicon.ico, robots.txt, etc.) + * when creating spans. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * A hook to change the span for an incoming request, after the span is created. + */ + onSpanCreated?: (span: Span, request: TRequest) => void; + + /** + * A hook to change the span for an incoming request one last time, before it ends. + */ + onSpanEnd?: (span: Span, request: TRequest, response: TResponse) => void; +} + +/** + * `true` when `TOptions` matches {@link ServerRequestOptions}, else a + * string that names the problem. Use it with a type that accepts only + * `true`, so a mismatch fails to compile: + * + * ```ts + * type _Check = AssertTrue>; + * ``` + * + * `TUnsupported` lists the fields that the runtime does not support. + * + * The check fails when: + * - `TOptions` does not declare a field, and `TUnsupported` does not + * list it. + * - `TUnsupported` lists a field that `TOptions` declares. + * - A callback written for the shared type does not fit the field in + * `TOptions`. + */ +export type CheckServerRequestOptions< + TOptions, + TRequest, + TResponse, + TUnsupported extends keyof ServerRequestOptions = never, +> = [MissingFields] extends [never] + ? [Extract] extends [never] + ? SupportedFields< + ServerRequestOptions, + TRequest, + TResponse, + TUnsupported + > extends SupportedFields + ? true + : 'A field type does not match ServerRequestOptions' + : `Listed as unsupported, but declared: ${Extract & string}` + : `Missing field: ${MissingFields & string}`; + +type MissingFields = Exclude< + keyof ServerRequestOptions, + TUnsupported | keyof TOptions +>; + +type SupportedFields = Pick< + T, + Extract, TUnsupported>> +>; + +/** Accepts only `true`. Use it with {@link CheckServerRequestOptions}. */ +export type AssertTrue = T; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index c6d78b00f52d..626858543c77 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -43,6 +43,11 @@ export type { HttpServerResponse, HttpModuleExport, } from './integrations/http/types'; +export type { + AssertTrue, + CheckServerRequestOptions, + ServerRequestOptions, +} from './integrations/http/server-request-options'; export { createFetchIntegration } from './integrations/fetch'; export type { FetchIntegrationOptions } from './integrations/fetch'; export { classifyResponseStreaming } from './utils/responseStreaming'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f55fe14de14f..59b68d31034c 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -30,6 +30,7 @@ export { captureException, captureEvent, captureMessage, + parameterize, captureFeedback, close, createTransport, @@ -122,6 +123,7 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, + instrumentStateGraphCompile, instrumentCreateReactAgent, } from '@sentry/server-utils'; export type { FlueOptions } from '@sentry/server-utils'; @@ -156,6 +158,7 @@ export { genericPoolIntegration, googleGenAIIntegration, graphqlIntegration, + fastifyIntegration, hapiIntegration, honoIntegration, honoMiddleware, diff --git a/packages/deno/src/integrations/deno-serve.ts b/packages/deno/src/integrations/deno-serve.ts index 9d20e9084848..8c8a4038e5b5 100644 --- a/packages/deno/src/integrations/deno-serve.ts +++ b/packages/deno/src/integrations/deno-serve.ts @@ -1,5 +1,6 @@ import type { Event, IntegrationFn, MaxRequestBodySize, Span } from '@sentry/core'; import { debug, defineIntegration } from '@sentry/core'; +import type { AssertTrue, CheckServerRequestOptions } from '@sentry/core/server'; import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; import type { RequestHandlerWrapperOptions } from '../wrap-deno-request-handler'; import { wrapDenoRequestHandler } from '../wrap-deno-request-handler'; @@ -99,6 +100,11 @@ export type DenoServeIntegrationOptions = { ignoreStatusCodes?: (number | [number, number])[]; }; +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue>; + export type ServeParams = // [(Request) => Response] | [Deno.ServeHandler] diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index 34dc1ff4e5f4..ea6a4fbae6c0 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -3,7 +3,13 @@ import { errorMonitor } from 'node:events'; import type { RequestOptions } from 'node:http'; import type { Event, Integration, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import type { HttpClientRequest, HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { DEFAULT_IGNORE_STATUS_CODES, getHttpClientSubscriptions, @@ -151,6 +157,13 @@ export interface DenoHttpIntegrationOptions { onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions +>; + const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => { const breadcrumbs = options.breadcrumbs ?? true; const tracePropagation = options.tracePropagation ?? true; diff --git a/packages/deno/test/exports.test.ts b/packages/deno/test/exports.test.ts index c1468653c566..76b4cce1cd18 100644 --- a/packages/deno/test/exports.test.ts +++ b/packages/deno/test/exports.test.ts @@ -12,6 +12,7 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'createConsolaReporter', 'growthbookIntegration', 'moduleMetadataIntegration', + 'parameterize', // @sentry/core/server 'trpcMiddleware', // @sentry/server-utils @@ -24,7 +25,10 @@ const EXPECTED_FUNCTION_EXPORTS = [ 'createLangChainCallbackHandler', 'instrumentLangChainEmbeddings', 'instrumentStateGraph', + 'instrumentStateGraphCompile', 'instrumentCreateReactAgent', + // In the default integrations, so users who set `defaultIntegrations` can add it back + 'fastifyIntegration', // ./sdk 'initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance', diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index b90e609bbfc1..2c4cb5726769 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -2,6 +2,7 @@ export { addEventProcessor, addBreadcrumb, addIntegration, + moduleMetadataIntegration, captureException, captureEvent, captureMessage, @@ -71,6 +72,7 @@ export { mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, + instrumentPostgresJsSql, modulesIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, @@ -156,6 +158,8 @@ export { instrumentGoogleGenAIClient, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + awsIntegration, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 130194f72cf5..7cc711e110f0 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -6,6 +6,7 @@ export { expressErrorHandler, setupExpressErrorHandler } from '@sentry/server-ut export { amqplibIntegration, anthropicAIIntegration, + awsIntegration, dataloaderIntegration, expressIntegration, type ExpressIntegrationOptions, @@ -57,6 +58,8 @@ export { instrumentLangChainEmbeddings, instrumentStateGraph, instrumentStateGraphCompile, + instrumentCreateReactAgent, + instrumentPostgresJsSql, fastifyIntegration, // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, @@ -148,6 +151,7 @@ export { captureSession, endSession, addIntegration, + moduleMetadataIntegration, startSpan, startSpanManual, startInactiveSpan, diff --git a/packages/node/src/integrations/http/httpServerIntegration.ts b/packages/node/src/integrations/http/httpServerIntegration.ts index 6322cd0169bd..b3c0c939d971 100644 --- a/packages/node/src/integrations/http/httpServerIntegration.ts +++ b/packages/node/src/integrations/http/httpServerIntegration.ts @@ -45,9 +45,11 @@ export interface HttpServerIntegrationOptions { * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. * * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. - * @param request Contains the {@type RequestOptions} object used to make the incoming request. + * @param request The incoming request, a `node:http` `IncomingMessage`. Use `request.url`, `request.method`, + * `request.headers`, etc. The `RequestOptions` part of the type is deprecated and will be removed in v12. Its + * fields that `IncomingMessage` does not have (such as `path` or `hostname`) are always `undefined`. */ - ignoreRequestBody?: (url: string, request: RequestOptions) => boolean; + ignoreRequestBody?: (url: string, request: RequestOptions & HttpIncomingMessage) => boolean; /** * Controls the maximum size of incoming HTTP request bodies attached to events. @@ -84,13 +86,7 @@ const _httpServerIntegration = ((options: HttpServerIntegrationOptions = {}) => // core subscription helper. Explicitly opt out so the helper does not // double-create spans when the client has tracing enabled. spans: false as const, - // Cast: core uses HttpIncomingMessage; node consumers pass - // RequestOptions-typed callbacks. - // The two are structurally compatible for the fields the callback reads - // (url, method, headers). - ignoreRequestBody: options.ignoreRequestBody as - | ((url: string, request: HttpIncomingMessage) => boolean) - | undefined, + ignoreRequestBody: options.ignoreRequestBody, /** * Hook called by core's `instrumentServer` to wrap the upstream diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index 5765629bdee9..db9c231a810f 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -36,6 +36,7 @@ import { } from '@sentry/core/server'; import { debug, + safeCallback, getSpanStatusFromHttpCode, httpHeadersToSpanAttributes, getContentLengthFromHeaders, @@ -52,7 +53,6 @@ import { filterCollectedUrlQuery, hasSpanStreamingEnabled, HTTP_SPAN_NAME_FALLBACK, - safeCallback, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; @@ -105,6 +105,12 @@ export interface HttpServerSpansIntegrationOptions { * This is triggered after the span is created, but before it is recorded. */ onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * A hook that can be used to mutate the span one last time when the response is finished. + * This is triggered after the response status is set on the span, before the span ends. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => { @@ -113,7 +119,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions // oxlint-disable-next-line typescript/no-deprecated const ignoreStatusCodes = options.ignoreStatusCodes ?? DEFAULT_IGNORE_STATUS_CODES; - const { onSpanCreated } = options; + const { onSpanCreated, onSpanEnd } = options; return { name: INTEGRATION_NAME, @@ -214,6 +220,15 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions ); span.setAttributes(newAttributes); span.setStatus(status); + if (onSpanEnd) { + // A hook that throws must not skip `span.end()` or crash the process from the + // response's event listener. + safeCallback( + DEBUG_BUILD ? 'The `onSpanEnd` callback threw an error:' : '', + () => onSpanEnd(span, request, response), + () => undefined, + ); + } span.end(); } diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index f0cfc28cdc87..40c07a09ad40 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -8,6 +8,12 @@ import { hasSpanStreamingEnabled, stripDataUrlContent, } from '@sentry/core'; +import type { + AssertTrue, + CheckServerRequestOptions, + HttpIncomingMessage, + HttpServerResponse, +} from '@sentry/core/server'; import { getRequestUrlFromClientRequest } from '@sentry/core/server'; import type { NodeClient } from '../../sdk/client'; import type { HttpServerIntegrationOptions } from './httpServerIntegration'; @@ -83,6 +89,13 @@ interface HttpOptions extends HttpServerIntegrationOptions, HttpServerSpansInteg outgoingRequestApplyCustomAttributes?: OutgoingHttpRequestInstrumentationOptions['outgoingRequestApplyCustomAttributes']; } +// Checks these options against the shared `ServerRequestOptions`. A new +// shared field fails to compile here, until this integration supports +// it or lists it as unsupported. +type _ServerRequestOptionsCheck = AssertTrue< + CheckServerRequestOptions +>; + /** * The http integration instruments Node's internal http and https modules. * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index cc2fcb1c3315..46c18d6f4aa8 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -71,7 +71,11 @@ export const sentryCloudflareNitroPlugin = const requestHandlerOptions = { // `wrapRequestHandler` only sets up the integrations that work without `nodejs_compat`. // Nitro's Cloudflare presets require the flag anyway, so opt into the full set. - options: { defaultIntegrations: getDefaultIntegrations(cloudflareOptions), ...cloudflareOptions }, + options: { + defaultIntegrations: getDefaultIntegrations(cloudflareOptions), + extendsDefaultIntegrations: cloudflareOptions.defaultIntegrations === undefined, + ...cloudflareOptions, + }, request, context: getCloudflareProperties(event).context, }; diff --git a/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts new file mode 100644 index 000000000000..b2e720545c8a --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/sentry-cloudflare.server.test.ts @@ -0,0 +1,56 @@ +import type { CloudflareOptions } from '@sentry/cloudflare'; +import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import type { NitroApp } from 'nitropack'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryCloudflareNitroPlugin } from '../../../src/runtime/plugins/sentry-cloudflare.server'; + +vi.mock('@sentry/cloudflare/request', () => ({ + wrapRequestHandler: vi.fn((_options: unknown, callback: () => unknown) => callback()), +})); + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +async function handleRequest(options: CloudflareOptions): Promise { + const nitroApp = { localFetch: vi.fn(), hooks: { hook: vi.fn() } } as unknown as NitroApp; + sentryCloudflareNitroPlugin(options)(nitroApp); + + const event = { + protocol: 'https:', + host: 'example.com', + method: 'GET', + headers: {}, + context: { cf: {}, cloudflare: { context: {} } }, + }; + await nitroApp.localFetch('/api/db-mysql', event as unknown as RequestInit); +} + +describe('sentryCloudflareNitroPlugin', () => { + beforeEach(() => { + vi.mocked(wrapRequestHandler).mockClear(); + }); + + it('passes a defaultIntegrations list that extends the SDK defaults', async () => { + await handleRequest({ dsn: DSN }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + defaultIntegrations: expect.any(Array), + extendsDefaultIntegrations: true, + }), + }), + expect.any(Function), + ); + }); + + it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => { + await handleRequest({ dsn: DSN, defaultIntegrations: [] }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }), + }), + expect.any(Function), + ); + }); +}); diff --git a/packages/server-utils/src/orchestrion/instrumentation.ts b/packages/server-utils/src/orchestrion/instrumentation.ts index ded8aa30ac68..0dbddefc4da7 100644 --- a/packages/server-utils/src/orchestrion/instrumentation.ts +++ b/packages/server-utils/src/orchestrion/instrumentation.ts @@ -1,6 +1,6 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import type { Client } from '@sentry/core'; -import { addNonEnumerableProperty, debug, waitForTracingChannelBinding } from '@sentry/core'; +import { addNonEnumerableProperty, debug, GLOBAL_OBJ, waitForTracingChannelBinding } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { getOrchestrionInjectedModules } from './detect'; @@ -13,10 +13,13 @@ const INSTRUMENTED = Symbol.for('SentryOrchestrionInstrumented'); // oxlint-disable-next-line typescript/no-explicit-any type InstrumentationFn = ((...args: any[]) => void) & { [INSTRUMENTED]?: boolean }; -const globalAny = globalThis as { Bun?: unknown; Deno?: unknown }; +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } }; const isBun = typeof globalAny.Bun !== 'undefined'; const isDeno = typeof globalAny.Deno !== 'undefined'; +// Labels already warned about, so a client per request does not repeat the warning. +const warnedMissingCloudflarePlugin = new Set(); + /** * Run an integration's channel-subscription callback once one of its modules is * orchestrion-injected, and never more than once. @@ -113,6 +116,21 @@ export function invokeOrchestrionInstrumentation { diff --git a/packages/server-utils/test/orchestrion/instrumentation.test.ts b/packages/server-utils/test/orchestrion/instrumentation.test.ts index fce7ffc3bf26..07c61fd5bb7a 100644 --- a/packages/server-utils/test/orchestrion/instrumentation.test.ts +++ b/packages/server-utils/test/orchestrion/instrumentation.test.ts @@ -1,5 +1,5 @@ import type { AsyncContextStrategy } from '@sentry/core'; -import { GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; +import { debug, GLOBAL_OBJ, setAsyncContextStrategy } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { invokeOrchestrionInstrumentation } from '../../src/orchestrion/instrumentation'; @@ -190,4 +190,43 @@ describe('invokeOrchestrionInstrumentation', () => { expect(callback).not.toHaveBeenCalled(); expect(client.listenerCount()).toBe(1); }); + + describe('on Cloudflare', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('warns once when the vite plugin did not run', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + invokeOrchestrionInstrumentation(makeClient() as never, ['openai'], vi.fn(), []); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('`@sentry/cloudflare/vite` plugin did not run')); + }); + + it('does not warn when the vite plugin ran, even before any module loaded', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() }; + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['pg'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + it('does not warn about the Cloudflare plugin on other runtimes', () => { + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + + invokeOrchestrionInstrumentation(makeClient() as never, ['redis'], vi.fn(), []); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); }); diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 651cc6af6f37..f7298f1539b5 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -23,6 +23,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { rewriteFramesIntegration(), svelteKitSpansIntegration(), ], + extendsDefaultIntegrations: options.defaultIntegrations === undefined, // SvelteKit emits its own OpenTelemetry spans (Kit tracing), so, like the Node SvelteKit SDK, it // defaults to registering the tracer provider instead of inheriting Cloudflare's no-provider default. // Only the `init`-backed wrapper from the main entry point honors this; `@sentry/cloudflare/request` diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts index dea694b292ec..f4b24b641b4e 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -63,7 +63,11 @@ describe('initCloudflareSentryHandle', () => { { // SvelteKit emits its own OpenTelemetry spans, so it opts into the tracer provider rather than // inheriting Cloudflare's no-provider default. - options: expect.objectContaining({ dsn: options.dsn, enableOpenTelemetrySetup: true }), + options: expect.objectContaining({ + dsn: options.dsn, + enableOpenTelemetrySetup: true, + extendsDefaultIntegrations: true, + }), request, context, captureErrors: false, @@ -74,6 +78,25 @@ describe('initCloudflareSentryHandle', () => { expect(resolve).toHaveBeenCalledTimes(1); }); + it('does not mark a defaultIntegrations list the user passed as extending the SDK defaults', async () => { + const { options, event, resolve } = getHandlerInput(); + + // @ts-expect-error - resolving an empty object is enough for this test + vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb()); + + const handle = initCloudflareSentryHandle({ ...options, defaultIntegrations: [] }); + + // @ts-expect-error - only passing a partial event object + await handle({ event, resolve }); + + expect(wrapRequestHandler).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ defaultIntegrations: [], extendsDefaultIntegrations: false }), + }), + expect.any(Function), + ); + }); + it('adds flag to skip request isolation in subsequent sentry handler', async () => { const { options, event, resolve } = getHandlerInput(); const locals = {};