From 327594cd083b609fc26edec090e0f6e0cddf5ce6 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 25 Jun 2025 13:03:36 +0200 Subject: [PATCH 01/11] feat(cloudflare): Allow interop with OpenTelemetry emitted spans (#16714) While we eventually want to move the cloudflare SDK over to use OTEL fully under the hood, this PR is an attempt for an intermediate solution to allow us to still get access to e.g. the vercelAi integration, which emits spans via `@opentelemetry/core`. For this, we register a custom trace provider in the cloudflare SDK which then just calls our own `startSpan` APIs. This should translate spans to Sentry spans. The only downside is that it does not handle a `context` being passed in for spans, so this will _not_ work for all integrations and cases etc. But it should work for simple cases like the `ai` package using `trace.startActiveSpan`. TODO: Test this, verify this makes sense, ... --- packages/cloudflare/package.json | 3 +- packages/cloudflare/src/client.ts | 13 ++ .../cloudflare/src/opentelemetry/tracer.ts | 81 ++++++++++ packages/cloudflare/src/sdk.ts | 12 ++ .../cloudflare/test/opentelemetry.test.ts | 145 ++++++++++++++++++ packages/cloudflare/test/sdk.test.ts | 7 +- packages/cloudflare/test/testUtils.ts | 21 +++ 7 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 packages/cloudflare/src/opentelemetry/tracer.ts create mode 100644 packages/cloudflare/test/opentelemetry.test.ts create mode 100644 packages/cloudflare/test/testUtils.ts diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index e23ffd9383b4..8142b4914ef1 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -49,7 +49,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.31.0" + "@sentry/core": "9.31.0", + "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 9b4f18086658..07dc285e6b15 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -36,6 +36,19 @@ interface BaseCloudflareOptions { * @default true */ enableDedupe?: boolean; + + /** + * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility + * via a custom trace provider. + * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. + * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. + * This should be good enough for many, but not all integrations. + * + * If you want to opt-out of setting up the OpenTelemetry compatibility tracer, set this to `true`. + * + * @default false + */ + skipOpenTelemetrySetup?: boolean; } /** diff --git a/packages/cloudflare/src/opentelemetry/tracer.ts b/packages/cloudflare/src/opentelemetry/tracer.ts new file mode 100644 index 000000000000..94dc917c5070 --- /dev/null +++ b/packages/cloudflare/src/opentelemetry/tracer.ts @@ -0,0 +1,81 @@ +import type { Context, Span, SpanOptions, Tracer, TracerProvider } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; +import { startInactiveSpan, startSpanManual } from '@sentry/core'; + +/** + * Set up a mock OTEL tracer to allow inter-op with OpenTelemetry emitted spans. + * This is not perfect but handles easy/common use cases. + */ +export function setupOpenTelemetryTracer(): void { + trace.setGlobalTracerProvider(new SentryCloudflareTraceProvider()); +} + +class SentryCloudflareTraceProvider implements TracerProvider { + private readonly _tracers: Map = new Map(); + + public getTracer(name: string, version?: string, options?: { schemaUrl?: string }): Tracer { + const key = `${name}@${version || ''}:${options?.schemaUrl || ''}`; + if (!this._tracers.has(key)) { + this._tracers.set(key, new SentryCloudflareTracer()); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this._tracers.get(key)!; + } +} + +class SentryCloudflareTracer implements Tracer { + public startSpan(name: string, options?: SpanOptions): Span { + return startInactiveSpan({ + name, + ...options, + attributes: { + ...options?.attributes, + 'sentry.cloudflare_tracer': true, + }, + }); + } + + /** + * NOTE: This does not handle `context` being passed in. It will always put spans on the current scope. + */ + public startActiveSpan unknown>(name: string, fn: F): ReturnType; + public startActiveSpan unknown>(name: string, options: SpanOptions, fn: F): ReturnType; + public startActiveSpan unknown>( + name: string, + options: SpanOptions, + context: Context, + fn: F, + ): ReturnType; + public startActiveSpan unknown>( + name: string, + options: unknown, + context?: unknown, + fn?: F, + ): ReturnType { + const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions; + + const spanOpts = { + name, + ...opts, + attributes: { + ...opts.attributes, + 'sentry.cloudflare_tracer': true, + }, + }; + + const callback = ( + typeof options === 'function' + ? options + : typeof context === 'function' + ? context + : typeof fn === 'function' + ? fn + : // eslint-disable-next-line @typescript-eslint/no-empty-function + () => {} + ) as F; + + // In OTEL the semantic matches `startSpanManual` because spans are not auto-ended + return startSpanManual(spanOpts, callback) as ReturnType; + } +} diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 90ef3c0bedf9..96b3152e6480 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -13,6 +13,7 @@ import { import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; import { fetchIntegration } from './integrations/fetch'; +import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; @@ -50,5 +51,16 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { transport: options.transport || makeCloudflareTransport, }; + /** + * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility + * via a custom trace provider. + * This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry. + * HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope. + * This should be good enough for many, but not all integrations. + */ + if (!options.skipOpenTelemetrySetup) { + setupOpenTelemetryTracer(); + } + return initAndBind(CloudflareClient, clientOptions) as CloudflareClient; } diff --git a/packages/cloudflare/test/opentelemetry.test.ts b/packages/cloudflare/test/opentelemetry.test.ts new file mode 100644 index 000000000000..f918afff90cc --- /dev/null +++ b/packages/cloudflare/test/opentelemetry.test.ts @@ -0,0 +1,145 @@ +import { trace } from '@opentelemetry/api'; +import type { TransactionEvent } from '@sentry/core'; +import { startSpan } from '@sentry/core'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { init } from '../src/sdk'; +import { resetSdk } from './testUtils'; + +describe('opentelemetry compatibility', () => { + beforeEach(() => { + resetSdk(); + }); + + test('should not capture spans emitted via @opentelemetry/api when skipOpenTelemetrySetup is true', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + skipOpenTelemetrySetup: true, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); + span.end(); + + await client!.flush(); + + tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => { + const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } }); + span.end(); + span2.end(); + }); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(0); + }); + + test('should capture spans emitted via @opentelemetry/api', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + const span = tracer.startSpan('test'); + span.end(); + + await client!.flush(); + + tracer.startActiveSpan('test 2', { attributes: { 'test.attribute': 'test' } }, span2 => { + const span = tracer.startSpan('test 3', { attributes: { 'test.attribute': 'test2' } }); + span.end(); + span2.end(); + }); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(2); + const [transactionEvent, transactionEvent2] = transactionEvents; + + expect(transactionEvent?.spans?.length).toBe(0); + expect(transactionEvent?.transaction).toBe('test'); + expect(transactionEvent?.contexts?.trace?.data).toEqual({ + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + }); + + expect(transactionEvent2?.spans?.length).toBe(1); + expect(transactionEvent2?.transaction).toBe('test 2'); + expect(transactionEvent2?.contexts?.trace?.data).toEqual({ + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'test.attribute': 'test', + }); + + expect(transactionEvent2?.spans).toEqual([ + expect.objectContaining({ + description: 'test 3', + data: { + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + 'test.attribute': 'test2', + }, + }), + ]); + }); + + test('opentelemetry spans should interop with Sentry spans', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + + startSpan({ name: 'sentry span' }, () => { + const span = tracer.startSpan('otel span'); + span.end(); + }); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(1); + const [transactionEvent] = transactionEvents; + + expect(transactionEvent?.spans?.length).toBe(1); + expect(transactionEvent?.transaction).toBe('sentry span'); + expect(transactionEvent?.contexts?.trace?.data).toEqual({ + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + }); + + expect(transactionEvent?.spans).toEqual([ + expect.objectContaining({ + description: 'otel span', + data: { + 'sentry.cloudflare_tracer': true, + 'sentry.origin': 'manual', + }, + }), + ]); + }); +}); diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 5c876812b035..2f4ec7844559 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,9 +1,14 @@ import * as SentryCore from '@sentry/core'; -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; import { init } from '../src/sdk'; +import { resetSdk } from './testUtils'; describe('init', () => { + beforeEach(() => { + resetSdk(); + }); + test('should call initAndBind with the correct options', () => { const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind'); const client = init({}); diff --git a/packages/cloudflare/test/testUtils.ts b/packages/cloudflare/test/testUtils.ts new file mode 100644 index 000000000000..8dcd3d43a4d9 --- /dev/null +++ b/packages/cloudflare/test/testUtils.ts @@ -0,0 +1,21 @@ +import { context, propagation, trace } from '@opentelemetry/api'; +import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; + +function resetGlobals(): void { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); +} + +function cleanupOtel(): void { + // Disable all globally registered APIs + trace.disable(); + context.disable(); + propagation.disable(); +} + +export function resetSdk(): void { + resetGlobals(); + cleanupOtel(); +} From 293049904668bd03a7f43691a16cb0dec437053c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 25 Jun 2025 13:04:34 +0200 Subject: [PATCH 02/11] test(browser:) Add test about re-sampling new traces (#16730) Adding a test for a support case, to ensure that new trace are correctly sampled. --------- Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> --- .../startNewTraceSampling/init.js | 19 ++++ .../startNewTraceSampling/subject.js | 13 +++ .../startNewTraceSampling/template.html | 9 ++ .../startNewTraceSampling/test.ts | 89 +++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js new file mode 100644 index 000000000000..09af5f3e4ab4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +// Force this so that the initial sampleRand is consistent +Math.random = () => 0.45; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampler: ({ name }) => { + if (name === 'new-trace') { + return 0.9; + } + + return 0.5; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/subject.js new file mode 100644 index 000000000000..711620998cb2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/subject.js @@ -0,0 +1,13 @@ +const newTraceBtn = document.getElementById('newTrace'); +newTraceBtn.addEventListener('click', async () => { + Sentry.startNewTrace(() => { + // We want to ensure the new trace is sampled, so we force the sample_rand to a value above 0.9 + Sentry.getCurrentScope().setPropagationContext({ + ...Sentry.getCurrentScope().getPropagationContext(), + sampleRand: 0.85, + }); + Sentry.startSpan({ op: 'ui.interaction.click', name: 'new-trace' }, async () => { + await fetch('http://sentry-test-site.example'); + }); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/template.html new file mode 100644 index 000000000000..11b051919b55 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/test.ts new file mode 100644 index 000000000000..616c89cd66f8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/test.ts @@ -0,0 +1,89 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipTracingTest, + waitForTransactionRequest, +} from '../../../../utils/helpers'; + +sentryTest( + 'new trace started with `startNewTrace` is sampled according to the `tracesSampler`', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://sentry-test-site.example/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const [pageloadEvent, pageloadTraceHeaders] = await getFirstSentryEnvelopeRequest( + page, + url, + eventAndTraceHeaderRequestParser, + ); + + const pageloadTraceContext = pageloadEvent.contexts?.trace; + + expect(pageloadEvent.type).toEqual('transaction'); + + expect(pageloadTraceContext).toMatchObject({ + op: 'pageload', + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + data: { + 'sentry.sample_rate': 0.5, + }, + }); + expect(pageloadTraceContext).not.toHaveProperty('parent_span_id'); + + expect(pageloadTraceHeaders).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '0.5', + sampled: 'true', + trace_id: pageloadTraceContext?.trace_id, + sample_rand: '0.45', + }); + + const transactionPromise = waitForTransactionRequest(page, event => { + return event.transaction === 'new-trace'; + }); + + await page.locator('#newTrace').click(); + + const [newTraceTransactionEvent, newTraceTransactionTraceHeaders] = eventAndTraceHeaderRequestParser( + await transactionPromise, + ); + + const newTraceTransactionTraceContext = newTraceTransactionEvent.contexts?.trace; + expect(newTraceTransactionTraceContext).toMatchObject({ + op: 'ui.interaction.click', + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + data: { + 'sentry.sample_rate': 0.9, + }, + }); + + expect(newTraceTransactionTraceHeaders).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '0.9', + sampled: 'true', + trace_id: newTraceTransactionTraceContext?.trace_id, + transaction: 'new-trace', + sample_rand: '0.85', + }); + + expect(newTraceTransactionTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + }, +); From 2c4cb5c007916ebc80477d3e1b84e048a598524e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 25 Jun 2025 09:23:11 -0400 Subject: [PATCH 03/11] chore: Upgrade `next` dev and test dependency for security patch (#16724) resolves https://github.com/getsentry/sentry-javascript/security/dependabot/538 resolves https://github.com/getsentry/sentry-javascript/security/dependabot/534 resolves https://github.com/getsentry/sentry-javascript/security/dependabot/533 Next.js dependencies were upgraded to address a security vulnerability concerning authorization bypass in middleware. * The `next` dependency in `packages/nextjs/package.json` was updated from `13.2.0` to `13.5.9`. * Test applications were also patched: * `dev-packages/e2e-tests/test-applications/create-next-app/package.json` had `next` upgraded from `14.0.0` to `14.2.25`. * `dev-packages/e2e-tests/test-applications/nextjs-13/package.json` had `next` upgraded from `13.5.7` to `13.5.9`. * The `yarn.lock` file was subsequently updated by running `yarn install` to reflect these new dependency versions and their transitive updates, including `@swc/helpers` and `postcss`. * Code formatting issues were resolved with `yarn fix`. Validation checks were run, confirming the upgrades while noting pre-existing, unrelated failures in other packages. This directly addresses the vulnerability, ensuring the codebase uses patched Next.js versions. --------- Co-authored-by: Cursor Agent --- .../create-next-app/package.json | 4 +- .../test-applications/nextjs-13/package.json | 2 +- .../nextjs-app-dir/package.json | 2 +- packages/nextjs/package.json | 2 +- yarn.lock | 193 ++++++++---------- 5 files changed, 85 insertions(+), 118 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/package.json b/dev-packages/e2e-tests/test-applications/create-next-app/package.json index 3e8416b3aaee..dffcecc3abf3 100644 --- a/dev-packages/e2e-tests/test-applications/create-next-app/package.json +++ b/dev-packages/e2e-tests/test-applications/create-next-app/package.json @@ -8,7 +8,7 @@ "test:prod": "TEST_ENV=prod playwright test", "test:dev": "TEST_ENV=dev playwright test", "test:build": "pnpm install && pnpm build", - "test:build-13": "pnpm install && pnpm add next@13.4.19 && pnpm build", + "test:build-13": "pnpm install && pnpm add next@13.5.9 && pnpm build", "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { @@ -16,7 +16,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "14.0.0", + "next": "14.2.25", "react": "18.2.0", "react-dom": "18.2.0", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/package.json b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json index cb78ab4ecb4b..eea815ebb836 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json @@ -17,7 +17,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "13.5.7", + "next": "13.5.9", "react": "18.2.0", "react-dom": "18.2.0", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index f02e3c0138da..a06b718835bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -11,7 +11,7 @@ "test:test-build": "pnpm ts-node --script-mode assert-build.ts", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && pnpm build", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", - "test:build-13": "pnpm install && pnpm add next@13.4.19 && pnpm build", + "test:build-13": "pnpm install && pnpm add next@13.5.9 && pnpm build", "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" }, "dependencies": { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 0315988bbfe7..9ab46ee4b98b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -94,7 +94,7 @@ "devDependencies": { "@types/resolve": "1.20.3", "eslint-plugin-react": "^7.31.11", - "next": "13.2.0" + "next": "13.5.9" }, "peerDependencies": { "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0" diff --git a/yarn.lock b/yarn.lock index f696182ab447..bd6c7af98fde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4676,75 +4676,55 @@ "@netlify/node-cookies" "^0.1.0" urlpattern-polyfill "8.0.2" -"@next/env@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/env/-/env-13.2.0.tgz#1a597a885ce11860446c88e1098fd517dc0e84b1" - integrity sha512-yv9oaRVa+AxFa27uQOVecS931NrE+GcQSqcL2HaRxL8NunStLtPiyNm/VixvdzfiWLabMz4dXvbXfwCNaECzcw== - -"@next/swc-android-arm-eabi@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.2.0.tgz#241d007fdb2f06f70ab21d5a333e08a29de9cd7f" - integrity sha512-VMetUwBWtDBGzNiOkhiWTP+99ZYW5NVRpIGlUsldEtY8IQIqleaUgW9iamsO0kDSjhWNdCQCB+xu5HcCvmDTww== - -"@next/swc-android-arm64@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-13.2.0.tgz#924e79197f094a12ac3409b6a416f84c2f74341d" - integrity sha512-fAiP54Om3fSj5aKntxEvW5fWzyMUzLzjFrHuUt5jBnTRWM4QikhLy547OZDoxycyk4GoQVHmNMSA3hILsrV/dQ== - -"@next/swc-darwin-arm64@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.2.0.tgz#bfd3dfe90903b3bbf81f617f2b1d4f8b9e20e8aa" - integrity sha512-F4zbvPnq3zCTqyyM6WN8ledazzJx3OrxIdc2ewnqnfk6tjBZ/aq1M27GhEfylGjZG1KvbtJCxUqi7dR/6R94bA== - -"@next/swc-darwin-x64@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.2.0.tgz#3d159bb2889f093d173546a6e5edd6260df7e156" - integrity sha512-Y9+fB7TLAAnkCZQXWjwJg5bi1pT5NuNkI+HoKYp26U1J0SxW5vZWFGc31WFmmHIz3wA0zlaQfRa4mF7cpZL5yw== - -"@next/swc-freebsd-x64@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.2.0.tgz#33877ad933e1b3d7776dfb060f4e3b55f675523e" - integrity sha512-b9bCLlfznbV6e6Vg9wKYZJs7Uz8z/Py9105MYq95a3JlHiI3e/fvBpm1c7fe5QlvWJlqyNav6Clyu1W+lDk+IQ== - -"@next/swc-linux-arm-gnueabihf@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.2.0.tgz#31b81ff368e5337019f858c4a0d7ae7f80310728" - integrity sha512-jY/2JjDVVyktzRtMclAIVLgOxk5Ut9NKu8kKMCPdKMf9/ila37UpRfIh2fOXtRhv8AK7Lq/iSI/v2vjopZxZgQ== - -"@next/swc-linux-arm64-gnu@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.2.0.tgz#e46bd73d1ccc38be46009b88dc87df88d8c44fa1" - integrity sha512-EKjWU3/lSBhOwPQRQLbySUnATnXygCjGd8ag3rP6d7kTIhfuPO4pY+DYW+wHOt5qB1ULNRmW0sXZ/ZKnQrVszw== - -"@next/swc-linux-arm64-musl@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.2.0.tgz#54a4f0cc1431f3e4e24a5e017e473a5fc761b33b" - integrity sha512-T5R9r23Docwo6PYZRzndeFB5WUN3+smMbyk25K50MAngCiSydr82/YfAetcp7Ov7Shp4a8xXP9DHDIsBas6wbQ== - -"@next/swc-linux-x64-gnu@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.2.0.tgz#1c17a6121846decac2d701a040649f8f764ac671" - integrity sha512-FeXTc2KFvUSnTJmkpNMKoBHmNA1Ujr3QdfcKnVm/gXWqK+rfuEhAiRNOo+6mPcQ0noEge1j8Ai+W1LTbdDwPZQ== - -"@next/swc-linux-x64-musl@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.2.0.tgz#a4f39cfeb19123196a2ebc2061e60897b29ffa3f" - integrity sha512-7Y0XMUzWDWI94pxC0xWGMWrgTFKHu/myc+GTNVEwvLtI9WA0brKqZrL1tCQW/+t6J+5XqS7w+AHbViaF+muu1A== - -"@next/swc-win32-arm64-msvc@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.2.0.tgz#d21ef0d0c2757cee9b1d723aafb9e02ca25852eb" - integrity sha512-NM5h2gEMe8EtvOeRU3vRM83tq1xo6Qvhuz0xJem/176SAMxbqzAz4LLP3l9VyUI3SIzGyiztvF/1c0jqeq7UEA== - -"@next/swc-win32-ia32-msvc@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.2.0.tgz#529852721e3f00afb9385640cbac1a899342c6ba" - integrity sha512-G7YEJZX9wkcUaBOvXQSCF9Wb2sqP8hhsmFXF6po7M3llw4b+2ut2DXLf+UMdthOdUK0u+Ijhy5F7SbW9HOn2ig== - -"@next/swc-win32-x64-msvc@13.2.0": - version "13.2.0" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.2.0.tgz#49bc50b1865d20b29892a415fcbaab84217112a5" - integrity sha512-QTAjSuPevnZnlHfC4600+4NvxRuPar6tWdYbPum9vnk3OIH1xu9YLK+2ArPGFd0bB2K8AoY2SIMbs1dhK0GjQQ== +"@next/env@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.9.tgz#3298c57c9ad9f333774484e03cf20fba90cd79c4" + integrity sha512-h9+DconfsLkhHIw950Som5t5DC0kZReRRVhT4XO2DLo5vBK3PQK6CbFr8unxjHwvIcRdDvb8rosKleLdirfShQ== + +"@next/swc-darwin-arm64@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.9.tgz#46c3a525039171ff1a83c813d7db86fb7808a9b2" + integrity sha512-pVyd8/1y1l5atQRvOaLOvfbmRwefxLhqQOzYo/M7FQ5eaRwA1+wuCn7t39VwEgDd7Aw1+AIWwd+MURXUeXhwDw== + +"@next/swc-darwin-x64@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.9.tgz#b690452e9a6ce839f8738e27e9fd1a8567dd7554" + integrity sha512-DwdeJqP7v8wmoyTWPbPVodTwCybBZa02xjSJ6YQFIFZFZ7dFgrieKW4Eo0GoIcOJq5+JxkQyejmI+8zwDp3pwA== + +"@next/swc-linux-arm64-gnu@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.9.tgz#c3e335e2da3ba932c0b2f571f0672d1aa7af33df" + integrity sha512-wdQsKsIsGSNdFojvjW3Ozrh8Q00+GqL3wTaMjDkQxVtRbAqfFBtrLPO0IuWChVUP2UeuQcHpVeUvu0YgOP00+g== + +"@next/swc-linux-arm64-musl@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.9.tgz#54600d4917bace2508725cc963eeeb3b6432889e" + integrity sha512-6VpS+bodQqzOeCwGxoimlRoosiWlSc0C224I7SQWJZoyJuT1ChNCo+45QQH+/GtbR/s7nhaUqmiHdzZC9TXnXA== + +"@next/swc-linux-x64-gnu@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.9.tgz#f869c2066f13ff2818140e0a145dfea1ea7c0333" + integrity sha512-XxG3yj61WDd28NA8gFASIR+2viQaYZEFQagEodhI/R49gXWnYhiflTeeEmCn7Vgnxa/OfK81h1gvhUZ66lozpw== + +"@next/swc-linux-x64-musl@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.9.tgz#09295ea60a42a1b22d927802d6e543d8a8bbb186" + integrity sha512-/dnscWqfO3+U8asd+Fc6dwL2l9AZDl7eKtPNKW8mKLh4Y4wOpjJiamhe8Dx+D+Oq0GYVjuW0WwjIxYWVozt2bA== + +"@next/swc-win32-arm64-msvc@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.9.tgz#f39e3513058d7af6e9f6b1f296bf071301217159" + integrity sha512-T/iPnyurOK5a4HRUcxAlss8uzoEf5h9tkd+W2dSWAfzxv8WLKlUgbfk+DH43JY3Gc2xK5URLuXrxDZ2mGfk/jw== + +"@next/swc-win32-ia32-msvc@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.9.tgz#d567f471e182efa4ea29f47f3030613dd3fc68b5" + integrity sha512-BLiPKJomaPrTAb7ykjA0LPcuuNMLDVK177Z1xe0nAem33+9FIayU4k/OWrtSn9SAJW/U60+1hoey5z+KCHdRLQ== + +"@next/swc-win32-x64-msvc@13.5.9": + version "13.5.9" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.9.tgz#35c53bd6d33040ec0ce1dd613c59112aac06b235" + integrity sha512-/72/dZfjXXNY/u+n8gqZDjI6rxKMpYsgBBYNZKWOQw0BpBF7WCnPflRy3ZtvQ2+IYI3ZH2bPyj7K+6a6wNk90Q== "@ngtools/webpack@14.2.13": version "14.2.13" @@ -7409,10 +7389,10 @@ svelte-hmr "^0.15.1" vitefu "^0.2.2" -"@swc/helpers@0.4.14": - version "0.4.14" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74" - integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw== +"@swc/helpers@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" + integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw== dependencies: tslib "^2.4.0" @@ -11459,7 +11439,7 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" -busboy@^1.0.0: +busboy@1.6.0, busboy@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -21564,30 +21544,28 @@ new-find-package-json@^2.0.0: dependencies: debug "^4.3.4" -next@13.2.0: - version "13.2.0" - resolved "https://registry.yarnpkg.com/next/-/next-13.2.0.tgz#100b2d1dca120a3460c767ccdad80fc8e2463e31" - integrity sha512-vhByvKHedsaMwNTwXKzK4IMmNp7XI7vN4etcGUoIpLrIuDfoYA3bS0ImS4X9F6lKzopG5aVp7a1CjuzF2NGkvA== +next@13.5.9: + version "13.5.9" + resolved "https://registry.yarnpkg.com/next/-/next-13.5.9.tgz#a8c38254279eb30a264c1c640bf77340289ba6e3" + integrity sha512-h4ciD/Uxf1PwsiX0DQePCS5rMoyU5a7rQ3/Pg6HBLwpa/SefgNj1QqKSZsWluBrYyqdtEyqKrjeOszgqZlyzFQ== dependencies: - "@next/env" "13.2.0" - "@swc/helpers" "0.4.14" + "@next/env" "13.5.9" + "@swc/helpers" "0.5.2" + busboy "1.6.0" caniuse-lite "^1.0.30001406" - postcss "8.4.14" + postcss "8.4.31" styled-jsx "5.1.1" + watchpack "2.4.0" optionalDependencies: - "@next/swc-android-arm-eabi" "13.2.0" - "@next/swc-android-arm64" "13.2.0" - "@next/swc-darwin-arm64" "13.2.0" - "@next/swc-darwin-x64" "13.2.0" - "@next/swc-freebsd-x64" "13.2.0" - "@next/swc-linux-arm-gnueabihf" "13.2.0" - "@next/swc-linux-arm64-gnu" "13.2.0" - "@next/swc-linux-arm64-musl" "13.2.0" - "@next/swc-linux-x64-gnu" "13.2.0" - "@next/swc-linux-x64-musl" "13.2.0" - "@next/swc-win32-arm64-msvc" "13.2.0" - "@next/swc-win32-ia32-msvc" "13.2.0" - "@next/swc-win32-x64-msvc" "13.2.0" + "@next/swc-darwin-arm64" "13.5.9" + "@next/swc-darwin-x64" "13.5.9" + "@next/swc-linux-arm64-gnu" "13.5.9" + "@next/swc-linux-arm64-musl" "13.5.9" + "@next/swc-linux-x64-gnu" "13.5.9" + "@next/swc-linux-x64-musl" "13.5.9" + "@next/swc-win32-arm64-msvc" "13.5.9" + "@next/swc-win32-ia32-msvc" "13.5.9" + "@next/swc-win32-x64-msvc" "13.5.9" ng-packagr@^14.2.2: version "14.3.0" @@ -24120,15 +24098,6 @@ postcss-values-parser@^6.0.2: is-url-superb "^4.0.0" quote-unquote "^1.0.0" -postcss@8.4.14: - version "8.4.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" - integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - postcss@8.4.31: version "8.4.31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" @@ -24560,16 +24529,6 @@ quick-format-unescaped@^4.0.3: resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== -quick-format-unescaped@^4.0.3: - version "4.0.4" - resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" - integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== - -quick-format-unescaped@^4.0.3: - version "4.0.4" - resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" - integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== - quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -29485,6 +29444,14 @@ watch-detector@^1.0.0, watch-detector@^1.0.2: silent-error "^1.1.1" tmp "^0.1.0" +watchpack@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + watchpack@^2.4.0, watchpack@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff" From 43403dc2dcfbe3a976bde4000dde634c56b89d04 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 25 Jun 2025 15:39:00 +0200 Subject: [PATCH 04/11] fix(nextjs): Remove `ai` from default server external packages (#16736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have added the 'ai' package to our list of externalized packages for instrumentation purposes. Unfortunately this causes Next.js to incorrectly evaluate the Vercel AI SDK's conditional exports - specifically the `react-server` export condition isn't properly handled when the package is externalized, resulting in client-side code being loaded in server components instead of the server-side functions. --- packages/nextjs/src/config/withSentryConfig.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index c8eabd00d85c..88050713ec8c 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -20,8 +20,12 @@ let showedExperimentalBuildModeWarning = false; // Packages we auto-instrument need to be external for instrumentation to work // Next.js externalizes some packages by default, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages // Others we need to add ourselves +// +// NOTE: 'ai' (Vercel AI SDK) is intentionally NOT included in this list. +// When externalized, Next.js doesn't properly handle the package's conditional exports, +// specifically the "react-server" export condition. This causes client-side code to be +// loaded in server components instead of the appropriate server-side functions. export const DEFAULT_SERVER_EXTERNAL_PACKAGES = [ - 'ai', 'amqplib', 'connect', 'dataloader', From ae304718930a9d3f29cbba16926c20857ca232e8 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:58:44 +0200 Subject: [PATCH 05/11] test(nuxt): Add fake module and composable to E2E test (#16731) This adds a `modules` and `composables` folder to the Nuxt 4 E2E test. The main purpose is to check, that the build runs through without problems. Additionally, the versions were updated (as this is the Nuxt 4 test and it should use the latest versions for the compatibility mode). Related to this: https://github.com/getsentry/sentry-javascript/issues/15204#issuecomment-2948908130 --- .../e2e-tests/test-applications/nuxt-4/app/app.vue | 5 ++++- .../nuxt-4/app/composables/use-sentry-test-tag.ts | 8 ++++++++ .../test-applications/nuxt-4/modules/another-module.ts | 9 +++++++++ .../e2e-tests/test-applications/nuxt-4/nuxt.config.ts | 2 +- .../e2e-tests/test-applications/nuxt-4/package.json | 2 +- 5 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/app/composables/use-sentry-test-tag.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/modules/another-module.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue index 23283a522546..6550bbe08887 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/app.vue @@ -13,5 +13,8 @@ - diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/composables/use-sentry-test-tag.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/app/composables/use-sentry-test-tag.ts new file mode 100644 index 000000000000..0d6642ca3d8c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/composables/use-sentry-test-tag.ts @@ -0,0 +1,8 @@ +// fixme: this needs to be imported from @sentry/core, not @sentry/nuxt in dev mode (because of import-in-the-middle error) +// This could also be a problem with the specific setup of the pnpm E2E test setup, because this could not be reproduced outside of the E2E test. +// Related to this: https://github.com/getsentry/sentry-javascript/issues/15204#issuecomment-2948908130 +import { setTag } from '@sentry/nuxt'; + +export default function useSentryTestTag(): void { + setTag('test-tag', null); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/modules/another-module.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/modules/another-module.ts new file mode 100644 index 000000000000..9c1a3ca80487 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/modules/another-module.ts @@ -0,0 +1,9 @@ +import { defineNuxtModule } from 'nuxt/kit'; + +// Just a fake module to check if the SDK works alongside other local Nuxt modules without breaking the build +export default defineNuxtModule({ + meta: { name: 'another-module' }, + setup() { + console.log('another-module setup called'); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index da988a9ee003..ce3d681c963f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -1,7 +1,7 @@ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ future: { compatibilityVersion: 4 }, - compatibilityDate: '2024-04-03', + compatibilityDate: '2025-06-06', imports: { autoImport: false }, modules: ['@pinia/nuxt', '@sentry/nuxt/module'], diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index e3c7ec9c0a76..8e1074a04bc1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -18,7 +18,7 @@ "dependencies": { "@pinia/nuxt": "^0.5.5", "@sentry/nuxt": "latest || *", - "nuxt": "^3.13.2" + "nuxt": "^3.17.5" }, "devDependencies": { "@playwright/test": "~1.50.0", From 40f04bc671fff2b6cba01a4a505e721847fd654c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 26 Jun 2025 12:57:10 +0200 Subject: [PATCH 06/11] feat: Add opt-in `vercelAiIntegration` to cloudflare & vercel-edge (#16732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is on top of https://github.com/getsentry/sentry-javascript/pull/16714. This adds the `vercelAiIntegration` to the cloudflare SDK, as well as to the vercel-edge SDK. I moved the critical code from node to core package, so we can reuse this (the code to process spans). The integration is not added by default, but needs to be added manually. then it will "force" add the event processors etc. We cannot auto-detect the `ai` package, sadly, because this does not work in workers 😢 so for now it needs to be added manually to avoid overhead for users that don't need this. @andreiborza let's verify that this works when deployed to cloudflare, esp. also the auto-enablement via the modules integration etc 🤔 --------- Co-authored-by: Andrei Borza Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- packages/cloudflare/src/index.ts | 1 + .../src/integrations/tracing/vercelai.ts | 51 ++++ .../cloudflare/src/utils/addOriginToSpan.ts | 8 + packages/cloudflare/src/utils/commonjs.ts | 8 + packages/core/src/client.ts | 3 +- packages/core/src/index.ts | 1 + .../src/utils/vercel-ai-attributes.ts} | 0 packages/core/src/utils/vercel-ai.ts | 221 ++++++++++++++++++ packages/nextjs/src/index.types.ts | 3 + .../integrations/tracing/vercelai/index.ts | 210 +---------------- packages/sveltekit/src/index.types.ts | 3 + packages/sveltekit/src/worker/index.ts | 1 + packages/vercel-edge/src/index.ts | 1 + .../src/integrations/tracing/vercelai.ts | 51 ++++ 14 files changed, 359 insertions(+), 203 deletions(-) create mode 100644 packages/cloudflare/src/integrations/tracing/vercelai.ts create mode 100644 packages/cloudflare/src/utils/addOriginToSpan.ts create mode 100644 packages/cloudflare/src/utils/commonjs.ts rename packages/{node/src/integrations/tracing/vercelai/ai_sdk_attributes.ts => core/src/utils/vercel-ai-attributes.ts} (100%) create mode 100644 packages/core/src/utils/vercel-ai.ts create mode 100644 packages/vercel-edge/src/integrations/tracing/vercelai.ts diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 8efcc8439809..a5bb99d40818 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -107,6 +107,7 @@ export { CloudflareClient } from './client'; export { getDefaultIntegrations } from './sdk'; export { fetchIntegration } from './integrations/fetch'; +export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { instrumentD1WithSentry } from './d1'; diff --git a/packages/cloudflare/src/integrations/tracing/vercelai.ts b/packages/cloudflare/src/integrations/tracing/vercelai.ts new file mode 100644 index 000000000000..c513568997ab --- /dev/null +++ b/packages/cloudflare/src/integrations/tracing/vercelai.ts @@ -0,0 +1,51 @@ +/** + * This is a copy of the Vercel AI integration from the node SDK. + * + * The only difference is that it does not use `@opentelemetry/instrumentation` + * because Cloudflare Workers do not support it. + * + * Therefore, we cannot automatically patch setting `experimental_telemetry: { isEnabled: true }` + * and users have to manually set this to get spans. + */ + +import type { IntegrationFn } from '@sentry/core'; +import { addVercelAiProcessors, defineIntegration } from '@sentry/core'; + +const INTEGRATION_NAME = 'VercelAI'; + +const _vercelAIIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client) { + addVercelAiProcessors(client); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the [ai](https://www.npmjs.com/package/ai) library. + * This integration is not enabled by default, you need to manually add it. + * + * For more information, see the [`ai` documentation](https://sdk.vercel.ai/docs/ai-sdk-core/telemetry). + * + * You need to enable collecting spans for a specific call by setting + * `experimental_telemetry.isEnabled` to `true` in the first argument of the function call. + * + * ```javascript + * const result = await generateText({ + * model: openai('gpt-4-turbo'), + * experimental_telemetry: { isEnabled: true }, + * }); + * ``` + * + * If you want to collect inputs and outputs for a specific call, you must specifically opt-in to each + * function call by setting `experimental_telemetry.recordInputs` and `experimental_telemetry.recordOutputs` + * to `true`. + * + * ```javascript + * const result = await generateText({ + * model: openai('gpt-4-turbo'), + * experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true }, + * }); + */ +export const vercelAIIntegration = defineIntegration(_vercelAIIntegration); diff --git a/packages/cloudflare/src/utils/addOriginToSpan.ts b/packages/cloudflare/src/utils/addOriginToSpan.ts new file mode 100644 index 000000000000..2a23710fa7cf --- /dev/null +++ b/packages/cloudflare/src/utils/addOriginToSpan.ts @@ -0,0 +1,8 @@ +import type { Span } from '@opentelemetry/api'; +import type { SpanOrigin } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +/** Adds an origin to an OTEL Span. */ +export function addOriginToSpan(span: Span, origin: SpanOrigin): void { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, origin); +} diff --git a/packages/cloudflare/src/utils/commonjs.ts b/packages/cloudflare/src/utils/commonjs.ts new file mode 100644 index 000000000000..23a9b97f9fc1 --- /dev/null +++ b/packages/cloudflare/src/utils/commonjs.ts @@ -0,0 +1,8 @@ +/** Detect CommonJS. */ +export function isCjs(): boolean { + try { + return typeof module !== 'undefined' && typeof module.exports !== 'undefined'; + } catch { + return false; + } +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 3dd8bd66d023..7997bd3345a0 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -498,7 +498,8 @@ export abstract class Client { ): void; /** - * Register a callback for whenever a span is ended. + * Register a callback for after a span is ended. + * NOTE: The span cannot be mutated anymore in this callback. * Receives the span as argument. * @returns {() => void} A function that, when executed, removes the registered callback. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4f09d89f381..7551478c9c88 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -123,6 +123,7 @@ export { captureFeedback } from './feedback'; export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/exports'; export { consoleLoggingIntegration } from './logs/console-integration'; +export { addVercelAiProcessors } from './utils/vercel-ai'; export type { FeatureFlag } from './utils/featureFlags'; export { diff --git a/packages/node/src/integrations/tracing/vercelai/ai_sdk_attributes.ts b/packages/core/src/utils/vercel-ai-attributes.ts similarity index 100% rename from packages/node/src/integrations/tracing/vercelai/ai_sdk_attributes.ts rename to packages/core/src/utils/vercel-ai-attributes.ts diff --git a/packages/core/src/utils/vercel-ai.ts b/packages/core/src/utils/vercel-ai.ts new file mode 100644 index 000000000000..2a653addd805 --- /dev/null +++ b/packages/core/src/utils/vercel-ai.ts @@ -0,0 +1,221 @@ +import type { Client } from '../client'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; +import type { Event } from '../types-hoist/event'; +import type { Span, SpanAttributes, SpanJSON, SpanOrigin } from '../types-hoist/span'; +import { spanToJSON } from './spanUtils'; +import { + AI_MODEL_ID_ATTRIBUTE, + AI_MODEL_PROVIDER_ATTRIBUTE, + AI_PROMPT_ATTRIBUTE, + AI_PROMPT_MESSAGES_ATTRIBUTE, + AI_PROMPT_TOOLS_ATTRIBUTE, + AI_RESPONSE_TEXT_ATTRIBUTE, + AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, + AI_TOOL_CALL_ID_ATTRIBUTE, + AI_TOOL_CALL_NAME_ATTRIBUTE, + AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, +} from './vercel-ai-attributes'; + +function addOriginToSpan(span: Span, origin: SpanOrigin): void { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, origin); +} + +/** + * Post-process spans emitted by the Vercel AI SDK. + * This is supposed to be used in `client.on('spanStart', ...) + */ +function onVercelAiSpanStart(span: Span): void { + const { data: attributes, description: name } = spanToJSON(span); + + if (!name) { + return; + } + + // Tool call spans + // https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans + if (attributes[AI_TOOL_CALL_NAME_ATTRIBUTE] && attributes[AI_TOOL_CALL_ID_ATTRIBUTE] && name === 'ai.toolCall') { + processToolCallSpan(span, attributes); + return; + } + + // The AI and Provider must be defined for generate, stream, and embed spans. + // The id of the model + const aiModelId = attributes[AI_MODEL_ID_ATTRIBUTE]; + // the provider of the model + const aiModelProvider = attributes[AI_MODEL_PROVIDER_ATTRIBUTE]; + if (typeof aiModelId !== 'string' || typeof aiModelProvider !== 'string' || !aiModelId || !aiModelProvider) { + return; + } + + processGenerateSpan(span, name, attributes); +} + +const vercelAiEventProcessor = Object.assign( + (event: Event): Event => { + if (event.type === 'transaction' && event.spans) { + for (const span of event.spans) { + // this mutates spans in-place + processEndedVercelAiSpan(span); + } + } + return event; + }, + { id: 'VercelAiEventProcessor' }, +); + +/** + * Post-process spans emitted by the Vercel AI SDK. + */ +function processEndedVercelAiSpan(span: SpanJSON): void { + const { data: attributes, origin } = span; + + if (origin !== 'auto.vercelai.otel') { + return; + } + + renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); + renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); + + if ( + typeof attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] === 'number' && + typeof attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] === 'number' + ) { + attributes['gen_ai.usage.total_tokens'] = + attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] + attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]; + } + + // Rename AI SDK attributes to standardized gen_ai attributes + renameAttributeKey(attributes, AI_PROMPT_MESSAGES_ATTRIBUTE, 'gen_ai.request.messages'); + renameAttributeKey(attributes, AI_RESPONSE_TEXT_ATTRIBUTE, 'gen_ai.response.text'); + renameAttributeKey(attributes, AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, 'gen_ai.response.tool_calls'); + renameAttributeKey(attributes, AI_PROMPT_TOOLS_ATTRIBUTE, 'gen_ai.request.available_tools'); +} + +/** + * Renames an attribute key in the provided attributes object if the old key exists. + * This function safely handles null and undefined values. + */ +function renameAttributeKey(attributes: Record, oldKey: string, newKey: string): void { + if (attributes[oldKey] != null) { + attributes[newKey] = attributes[oldKey]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete attributes[oldKey]; + } +} + +function processToolCallSpan(span: Span, attributes: SpanAttributes): void { + addOriginToSpan(span, 'auto.vercelai.otel'); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.execute_tool'); + span.setAttribute('gen_ai.tool.call.id', attributes[AI_TOOL_CALL_ID_ATTRIBUTE]); + span.setAttribute('gen_ai.tool.name', attributes[AI_TOOL_CALL_NAME_ATTRIBUTE]); + span.updateName(`execute_tool ${attributes[AI_TOOL_CALL_NAME_ATTRIBUTE]}`); +} + +function processGenerateSpan(span: Span, name: string, attributes: SpanAttributes): void { + addOriginToSpan(span, 'auto.vercelai.otel'); + + const nameWthoutAi = name.replace('ai.', ''); + span.setAttribute('ai.pipeline.name', nameWthoutAi); + span.updateName(nameWthoutAi); + + // If a Telemetry name is set and it is a pipeline span, use that as the operation name + const functionId = attributes[AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE]; + if (functionId && typeof functionId === 'string' && name.split('.').length - 1 === 1) { + span.updateName(`${nameWthoutAi} ${functionId}`); + span.setAttribute('ai.pipeline.name', functionId); + } + + if (attributes[AI_PROMPT_ATTRIBUTE]) { + span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); + } + if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { + span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); + } + span.setAttribute('ai.streaming', name.includes('stream')); + + // Generate Spans + if (name === 'ai.generateText') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); + return; + } + + if (name === 'ai.generateText.doGenerate') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_text'); + span.updateName(`generate_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); + return; + } + + if (name === 'ai.streamText') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); + return; + } + + if (name === 'ai.streamText.doStream') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_text'); + span.updateName(`stream_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); + return; + } + + if (name === 'ai.generateObject') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); + return; + } + + if (name === 'ai.generateObject.doGenerate') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_object'); + span.updateName(`generate_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); + return; + } + + if (name === 'ai.streamObject') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); + return; + } + + if (name === 'ai.streamObject.doStream') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_object'); + span.updateName(`stream_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); + return; + } + + if (name === 'ai.embed') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); + return; + } + + if (name === 'ai.embed.doEmbed') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed'); + span.updateName(`embed ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); + return; + } + + if (name === 'ai.embedMany') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); + return; + } + + if (name === 'ai.embedMany.doEmbed') { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed_many'); + span.updateName(`embed_many ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); + return; + } + + if (name.startsWith('ai.stream')) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'ai.run'); + return; + } +} + +/** + * Add event processors to the given client to process Vercel AI spans. + */ +export function addVercelAiProcessors(client: Client): void { + client.on('spanStart', onVercelAiSpanStart); + // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point + client.addEventProcessor(vercelAiEventProcessor); +} diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index c630d545061c..04b73ea4c83e 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -22,6 +22,9 @@ export declare function init( export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +// Different implementation in server and worker +export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; + export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts index 8ba6cb5af905..9ee5fb29f11d 100644 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ b/packages/node/src/integrations/tracing/vercelai/index.ts @@ -1,27 +1,7 @@ -/* eslint-disable @typescript-eslint/no-dynamic-delete */ -/* eslint-disable complexity */ import type { Client, IntegrationFn } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON } from '@sentry/core'; +import { addVercelAiProcessors, defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '../../../otel/instrument'; -import { addOriginToSpan } from '../../../utils/addOriginToSpan'; import type { modulesIntegration } from '../../modules'; -import { - AI_MODEL_ID_ATTRIBUTE, - AI_MODEL_PROVIDER_ATTRIBUTE, - AI_PROMPT_ATTRIBUTE, - AI_PROMPT_MESSAGES_ATTRIBUTE, - AI_PROMPT_TOOLS_ATTRIBUTE, - AI_RESPONSE_TEXT_ATTRIBUTE, - AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, - AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, - AI_TOOL_CALL_ID_ATTRIBUTE, - AI_TOOL_CALL_NAME_ATTRIBUTE, - AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, - AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, - GEN_AI_RESPONSE_MODEL_ATTRIBUTE, - GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, - GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, -} from './ai_sdk_attributes'; import { INTEGRATION_NAME } from './constants'; import { SentryVercelAiInstrumentation } from './instrumentation'; import type { VercelAiOptions } from './types'; @@ -47,175 +27,14 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { instrumentation = instrumentVercelAi(); }, afterAllSetup(client) { - function registerProcessors(): void { - client.on('spanStart', span => { - const { data: attributes, description: name } = spanToJSON(span); - - if (!name) { - return; - } - - // Tool call spans - // https://ai-sdk.dev/docs/ai-sdk-core/telemetry#tool-call-spans - if ( - attributes[AI_TOOL_CALL_NAME_ATTRIBUTE] && - attributes[AI_TOOL_CALL_ID_ATTRIBUTE] && - name === 'ai.toolCall' - ) { - addOriginToSpan(span, 'auto.vercelai.otel'); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.execute_tool'); - span.setAttribute('gen_ai.tool.call.id', attributes[AI_TOOL_CALL_ID_ATTRIBUTE]); - span.setAttribute('gen_ai.tool.name', attributes[AI_TOOL_CALL_NAME_ATTRIBUTE]); - span.updateName(`execute_tool ${attributes[AI_TOOL_CALL_NAME_ATTRIBUTE]}`); - return; - } - - // The AI and Provider must be defined for generate, stream, and embed spans. - // The id of the model - const aiModelId = attributes[AI_MODEL_ID_ATTRIBUTE]; - // the provider of the model - const aiModelProvider = attributes[AI_MODEL_PROVIDER_ATTRIBUTE]; - if (typeof aiModelId !== 'string' || typeof aiModelProvider !== 'string' || !aiModelId || !aiModelProvider) { - return; - } - - addOriginToSpan(span, 'auto.vercelai.otel'); - - const nameWthoutAi = name.replace('ai.', ''); - span.setAttribute('ai.pipeline.name', nameWthoutAi); - span.updateName(nameWthoutAi); - - // If a Telemetry name is set and it is a pipeline span, use that as the operation name - const functionId = attributes[AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE]; - if (functionId && typeof functionId === 'string' && name.split('.').length - 1 === 1) { - span.updateName(`${nameWthoutAi} ${functionId}`); - span.setAttribute('ai.pipeline.name', functionId); - } - - if (attributes[AI_PROMPT_ATTRIBUTE]) { - span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); - } - if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { - span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); - } - span.setAttribute('ai.streaming', name.includes('stream')); - - // Generate Spans - if (name === 'ai.generateText') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.generateText.doGenerate') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_text'); - span.updateName(`generate_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.streamText') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.streamText.doStream') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_text'); - span.updateName(`stream_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.generateObject') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.generateObject.doGenerate') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_object'); - span.updateName(`generate_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.streamObject') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.streamObject.doStream') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_object'); - span.updateName(`stream_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.embed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.embed.doEmbed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed'); - span.updateName(`embed ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.embedMany') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.embedMany.doEmbed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed_many'); - span.updateName(`embed_many ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name.startsWith('ai.stream')) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'ai.run'); - return; - } - }); - - client.addEventProcessor(event => { - if (event.type === 'transaction' && event.spans?.length) { - for (const span of event.spans) { - const { data: attributes, description: name } = span; - - if (!name || span.origin !== 'auto.vercelai.otel') { - continue; - } - - renameAttributeKey( - attributes, - AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, - GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, - ); - renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); - if ( - typeof attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] === 'number' && - typeof attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] === 'number' - ) { - attributes['gen_ai.usage.total_tokens'] = - attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] + attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]; - } - - // Rename AI SDK attributes to standardized gen_ai attributes - renameAttributeKey(attributes, AI_PROMPT_MESSAGES_ATTRIBUTE, 'gen_ai.request.messages'); - renameAttributeKey(attributes, AI_RESPONSE_TEXT_ATTRIBUTE, 'gen_ai.response.text'); - renameAttributeKey(attributes, AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, 'gen_ai.response.tool_calls'); - renameAttributeKey(attributes, AI_PROMPT_TOOLS_ATTRIBUTE, 'gen_ai.request.available_tools'); - } - } - - return event; - }); - } - // Auto-detect if we should force the integration when running with 'ai' package available // Note that this can only be detected if the 'Modules' integration is available, and running in CJS mode const shouldForce = options.force ?? shouldForceIntegration(client); if (shouldForce) { - registerProcessors(); + addVercelAiProcessors(client); } else { - instrumentation?.callWhenPatched(registerProcessors); + instrumentation?.callWhenPatched(() => addVercelAiProcessors(client)); } }, }; @@ -223,6 +42,7 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { /** * Adds Sentry tracing instrumentation for the [ai](https://www.npmjs.com/package/ai) library. + * This integration is not enabled by default, you need to manually add it. * * For more information, see the [`ai` documentation](https://sdk.vercel.ai/docs/ai-sdk-core/telemetry). * @@ -235,17 +55,14 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { * }); * ``` * - * The integration automatically detects when to force registration in CommonJS environments - * when the 'ai' package is available. You can still manually set the `force` option if needed. - * - * By default this integration adds tracing support to all `ai` function calls. If you need to disable - * collecting spans for a specific call, you can do so by setting `experimental_telemetry.isEnabled` to - * `false` in the first argument of the function call. + * This integration adds tracing support to all `ai` function calls. + * You need to opt-in to collecting spans for a specific call, + * you can do so by setting `experimental_telemetry.isEnabled` to `true` in the first argument of the function call. * * ```javascript * const result = await generateText({ * model: openai('gpt-4-turbo'), - * experimental_telemetry: { isEnabled: false }, + * experimental_telemetry: { isEnabled: true }, * }); * ``` * @@ -260,14 +77,3 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { * }); */ export const vercelAIIntegration = defineIntegration(_vercelAIIntegration); - -/** - * Renames an attribute key in the provided attributes object if the old key exists. - * This function safely handles null and undefined values. - */ -function renameAttributeKey(attributes: Record, oldKey: string, newKey: string): void { - if (attributes[oldKey] != null) { - attributes[newKey] = attributes[oldKey]; - delete attributes[oldKey]; - } -} diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 03c63041e726..df788559d2f5 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -46,6 +46,9 @@ export declare function wrapLoadWithSentry any>(orig export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +// Different implementation in server and worker +export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; + export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index e49a493fb0b8..8e4645741456 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -84,6 +84,7 @@ export { instrumentSupabaseClient, zodErrorsIntegration, featureFlagsIntegration, + vercelAIIntegration, type FeatureFlagsIntegration, } from '@sentry/cloudflare'; diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 303d40144ec3..5325d1e62391 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -98,5 +98,6 @@ export { VercelEdgeClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { winterCGFetchIntegration } from './integrations/wintercg-fetch'; +export { vercelAIIntegration } from './integrations/tracing/vercelai'; export * as logger from './logs/exports'; diff --git a/packages/vercel-edge/src/integrations/tracing/vercelai.ts b/packages/vercel-edge/src/integrations/tracing/vercelai.ts new file mode 100644 index 000000000000..c513568997ab --- /dev/null +++ b/packages/vercel-edge/src/integrations/tracing/vercelai.ts @@ -0,0 +1,51 @@ +/** + * This is a copy of the Vercel AI integration from the node SDK. + * + * The only difference is that it does not use `@opentelemetry/instrumentation` + * because Cloudflare Workers do not support it. + * + * Therefore, we cannot automatically patch setting `experimental_telemetry: { isEnabled: true }` + * and users have to manually set this to get spans. + */ + +import type { IntegrationFn } from '@sentry/core'; +import { addVercelAiProcessors, defineIntegration } from '@sentry/core'; + +const INTEGRATION_NAME = 'VercelAI'; + +const _vercelAIIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup(client) { + addVercelAiProcessors(client); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the [ai](https://www.npmjs.com/package/ai) library. + * This integration is not enabled by default, you need to manually add it. + * + * For more information, see the [`ai` documentation](https://sdk.vercel.ai/docs/ai-sdk-core/telemetry). + * + * You need to enable collecting spans for a specific call by setting + * `experimental_telemetry.isEnabled` to `true` in the first argument of the function call. + * + * ```javascript + * const result = await generateText({ + * model: openai('gpt-4-turbo'), + * experimental_telemetry: { isEnabled: true }, + * }); + * ``` + * + * If you want to collect inputs and outputs for a specific call, you must specifically opt-in to each + * function call by setting `experimental_telemetry.recordInputs` and `experimental_telemetry.recordOutputs` + * to `true`. + * + * ```javascript + * const result = await generateText({ + * model: openai('gpt-4-turbo'), + * experimental_telemetry: { isEnabled: true, recordInputs: true, recordOutputs: true }, + * }); + */ +export const vercelAIIntegration = defineIntegration(_vercelAIIntegration); From f916be14ac28cf26fcab9fd5d65d09561c859043 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 26 Jun 2025 15:00:40 +0100 Subject: [PATCH 07/11] feat(node): Use diagnostics channel for Fastify v5 error handling (#16715) Resolves: #15936 --- .../node-fastify-5/src/app.ts | 2 - .../src/integrations/tracing/fastify/index.ts | 53 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts index 73ffafcfd04d..db2e9bf9cc5f 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts @@ -34,8 +34,6 @@ const app = fastify(); const port = 3030; const port2 = 3040; -Sentry.setupFastifyErrorHandler(app); - app.get('/test-success', function (_req, res) { res.send({ version: 'v1' }); }); diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts index 13805e18d575..b514cb80d32e 100644 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ b/packages/node/src/integrations/tracing/fastify/index.ts @@ -57,9 +57,42 @@ const INTEGRATION_NAME_V3 = 'Fastify-V3'; export const instrumentFastifyV3 = generateInstrumentOnce(INTEGRATION_NAME_V3, () => new FastifyInstrumentationV3()); +function handleFastifyError( + this: { + diagnosticsChannelExists?: boolean; + }, + error: Error, + request: FastifyRequest & { opentelemetry?: () => { span?: Span } }, + reply: FastifyReply, + shouldHandleError: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean, + handlerOrigin: 'diagnostics-channel' | 'onError-hook', +): void { + // Diagnostics channel runs before the onError hook, so we can use it to check if the handler was already registered + if (handlerOrigin === 'diagnostics-channel') { + this.diagnosticsChannelExists = true; + } + + if (this.diagnosticsChannelExists && handlerOrigin === 'onError-hook') { + DEBUG_BUILD && + logger.warn( + 'Fastify error handler was already registered via diagnostics channel.', + 'You can safely remove `setupFastifyErrorHandler` call.', + ); + + // If the diagnostics channel already exists, we don't need to handle the error again + return; + } + + if (shouldHandleError(error, request, reply)) { + captureException(error); + } +} + export const instrumentFastify = generateInstrumentOnce(INTEGRATION_NAME, () => { const fastifyOtelInstrumentationInstance = new FastifyOtelInstrumentation(); const plugin = fastifyOtelInstrumentationInstance.plugin(); + const options = fastifyOtelInstrumentationInstance.getConfig(); + const shouldHandleError = (options as FastifyHandlerOptions)?.shouldHandleError || defaultShouldHandleError; // This message handler works for Fastify versions 3, 4 and 5 diagnosticsChannel.subscribe('fastify.initialization', message => { @@ -78,8 +111,20 @@ export const instrumentFastify = generateInstrumentOnce(INTEGRATION_NAME, () => }); }); + // This diagnostics channel only works on Fastify version 5 + // For versions 3 and 4, we use `setupFastifyErrorHandler` instead + diagnosticsChannel.subscribe('tracing:fastify.request.handler:error', message => { + const { error, request, reply } = message as { + error: Error; + request: FastifyRequest & { opentelemetry?: () => { span?: Span } }; + reply: FastifyReply; + }; + + handleFastifyError.call(handleFastifyError, error, request, reply, shouldHandleError, 'diagnostics-channel'); + }); + // Returning this as unknown not to deal with the internal types of the FastifyOtelInstrumentation - return fastifyOtelInstrumentationInstance as Instrumentation; + return fastifyOtelInstrumentationInstance as Instrumentation; }); const _fastifyIntegration = (() => { @@ -143,15 +188,11 @@ function defaultShouldHandleError(_error: Error, _request: FastifyRequest, reply */ export function setupFastifyErrorHandler(fastify: FastifyInstance, options?: Partial): void { const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; - const plugin = Object.assign( function (fastify: FastifyInstance, _options: unknown, done: () => void): void { fastify.addHook('onError', async (request, reply, error) => { - if (shouldHandleError(error, request, reply)) { - captureException(error); - } + handleFastifyError.call(handleFastifyError, error, request, reply, shouldHandleError, 'onError-hook'); }); - done(); }, { From 510ba3eba94afe00ad27d084480a0da5656e0ace Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 26 Jun 2025 15:53:11 +0100 Subject: [PATCH 08/11] feat(node): Add postgresjs instrumentation (#16665) Resolves: https://github.com/getsentry/sentry-javascript/issues/15621 Adds instrumentation for https://github.com/porsager/postgres Sampled event: (Emitted from the integration tests added here): [Link](https://sentry-sdks.sentry.io/insights/backend/summary/trace/72c94a37c9907cc2c7f4bef9c56b0196/?fov=0%2C32.09936037659645&node=span-b3505cfada7dea73&project=5429215&query=transaction.op%3Atransaction&referrer=insights-backend-overview&source=performance_transaction_summary&statsPeriod=5m×tamp=1750718572&transaction=Test%20Transaction) This implementation patches `connection` and `query` classes to create database transactions: - From `connection`, we pick up the database `name`, `url` and `port` to use in the db query spans - For each `query` instance, we create a `db` span - This implementation does not create a separate span for each `cursor` used Initially, I implemented a way to capture `db.operation` (as `command` is available when the query resolves) but it seems the ingestion extracts the operation anyway, so I removed it. Also added sanitization/normalization for raw query, which we use as the span description, also seems to be normalized by the ingestion engine. We can remove it too if it's not worth having, as it creates a possibly-unnecessary performance overhead on the SDK side. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../node-integration-tests/package.json | 1 + .../tracing/postgresjs/docker-compose.yml | 13 + .../suites/tracing/postgresjs/scenario.js | 62 ++++ .../suites/tracing/postgresjs/test.ts | 225 ++++++++++++ packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + .../node/src/integrations/tracing/index.ts | 3 + .../src/integrations/tracing/postgresjs.ts | 327 ++++++++++++++++++ packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + yarn.lock | 5 + 14 files changed, 643 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/docker-compose.yml create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts create mode 100644 packages/node/src/integrations/tracing/postgresjs.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 40acc7510fda..30a8f784b1e9 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -61,6 +61,7 @@ "node-cron": "^3.0.3", "node-schedule": "^2.1.1", "pg": "8.16.0", + "postgres": "^3.4.7", "proxy": "^2.1.1", "redis-4": "npm:redis@^4.6.14", "reflect-metadata": "0.2.1", diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/postgresjs/docker-compose.yml new file mode 100644 index 000000000000..301280106faa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.9' + +services: + db: + image: postgres:13 + restart: always + container_name: integration-tests-postgresjs + ports: + - '5444:5432' + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js new file mode 100644 index 000000000000..e7cb92aabf27 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/scenario.js @@ -0,0 +1,62 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +// Stop the process from exiting before the transaction is sent +setInterval(() => {}, 1000); + +const postgres = require('postgres'); + +const sql = postgres({ port: 5444, user: 'test', password: 'test', database: 'test_db' }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await sql` + CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id")); + `; + + await sql` + INSERT INTO "User" ("email", "name") VALUES ('Foo', 'bar@baz.com'); + `; + + await sql` + UPDATE "User" SET "name" = 'Foo' WHERE "email" = 'bar@baz.com'; + `; + + await sql` + SELECT * FROM "User" WHERE "email" = 'bar@baz.com'; + `; + + await sql`SELECT * from generate_series(1,1000) as x `.cursor(10, async rows => { + await Promise.all(rows); + }); + + await sql` + DROP TABLE "User"; + `; + + // This will be captured as an error as the table no longer exists + await sql` + SELECT * FROM "User" WHERE "email" = 'foo@baz.com'; + `; + } finally { + await sql.end(); + } + }, + ); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts new file mode 100644 index 000000000000..68b1a82703a0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from 'vitest'; +import { createRunner } from '../../../utils/runner'; + +const EXISTING_TEST_EMAIL = 'bar@baz.com'; +const NON_EXISTING_TEST_EMAIL = 'foo@baz.com'; + +describe('postgresjs auto instrumentation', () => { + test('should auto-instrument `postgres` package', { timeout: 60_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'CREATE TABLE', + 'db.query.text': + 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: + 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))', + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': + "select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem where a.typcategory = 'A' group by b.oid, b.typarray order by b.oid", + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: + "select b.oid, b.typarray from pg_catalog.pg_type a left join pg_catalog.pg_type b on b.oid = a.typelem where a.typcategory = 'A' group by b.oid, b.typarray order by b.oid", + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'INSERT', + 'db.query.text': `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, + 'sentry.origin': 'auto.db.otel.postgres', + 'sentry.op': 'db', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `INSERT INTO "User" ("email", "name") VALUES ('Foo', '${EXISTING_TEST_EMAIL}')`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'UPDATE', + 'db.query.text': `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `UPDATE "User" SET "name" = 'Foo' WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'SELECT', + 'db.query.text': 'SELECT * from generate_series(?,?) as x', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'SELECT * from generate_series(?,?) as x', + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + 'db.operation.name': 'DROP TABLE', + 'db.query.text': 'DROP TABLE "User"', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: 'DROP TABLE "User"', + op: 'db', + status: 'ok', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'db.namespace': 'test_db', + 'db.system.name': 'postgres', + // No db.operation.name here, as this is an errored span + 'db.response.status_code': '42P01', + 'error.type': 'PostgresError', + 'db.query.text': `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_EMAIL}'`, + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.otel.postgres', + 'server.address': 'localhost', + 'server.port': 5444, + }), + description: `SELECT * FROM "User" WHERE "email" = '${NON_EXISTING_TEST_EMAIL}'`, + op: 'db', + status: 'unknown_error', + origin: 'auto.db.otel.postgres', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }), + ]), + }; + + const EXPECTED_ERROR_EVENT = { + event_id: expect.any(String), + contexts: { + trace: { + trace_id: expect.any(String), + span_id: expect.any(String), + }, + }, + exception: { + values: [ + { + type: 'PostgresError', + value: 'relation "User" does not exist', + stacktrace: expect.objectContaining({ + frames: expect.arrayContaining([ + expect.objectContaining({ + function: 'handle', + module: 'postgres.cjs.src:connection', + filename: expect.any(String), + lineno: expect.any(Number), + colno: expect.any(Number), + }), + ]), + }), + }, + ], + }, + }; + + await createRunner(__dirname, 'scenario.js') + .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['port 5432'] }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start() + .completed(); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 750eb05d8b10..83a135e71f21 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -84,6 +84,7 @@ export { onUnhandledRejectionIntegration, parameterize, postgresIntegration, + postgresJsIntegration, prismaIntegration, childProcessIntegration, createSentryWinstonTransport, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index b13f69a9b6ce..f64ee53dc47c 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -99,6 +99,7 @@ export { redisIntegration, tediousIntegration, postgresIntegration, + postgresJsIntegration, prismaIntegration, childProcessIntegration, createSentryWinstonTransport, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 14a44e2d38fc..4a9d7fd9d71c 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -120,6 +120,7 @@ export { redisIntegration, tediousIntegration, postgresIntegration, + postgresJsIntegration, prismaIntegration, hapiIntegration, setupHapiErrorHandler, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index e9586a9bd820..f0bed369acee 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -99,6 +99,7 @@ export { redisIntegration, tediousIntegration, postgresIntegration, + postgresJsIntegration, prismaIntegration, hapiIntegration, setupHapiErrorHandler, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index cf951c3db8b6..1c02da9fff2e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -23,6 +23,7 @@ export { mysqlIntegration } from './integrations/tracing/mysql'; export { mysql2Integration } from './integrations/tracing/mysql2'; export { redisIntegration } from './integrations/tracing/redis'; export { postgresIntegration } from './integrations/tracing/postgres'; +export { postgresJsIntegration } from './integrations/tracing/postgresjs'; export { prismaIntegration } from './integrations/tracing/prisma'; export { hapiIntegration, setupHapiErrorHandler } from './integrations/tracing/hapi'; export { koaIntegration, setupKoaErrorHandler } from './integrations/tracing/koa'; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 425710cae0ce..e7122562d619 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -15,6 +15,7 @@ import { instrumentMongoose, mongooseIntegration } from './mongoose'; import { instrumentMysql, mysqlIntegration } from './mysql'; import { instrumentMysql2, mysql2Integration } from './mysql2'; import { instrumentPostgres, postgresIntegration } from './postgres'; +import { instrumentPostgresJs, postgresJsIntegration } from './postgresjs'; import { prismaIntegration } from './prisma'; import { instrumentRedis, redisIntegration } from './redis'; import { instrumentTedious, tediousIntegration } from './tedious'; @@ -44,6 +45,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { amqplibIntegration(), lruMemoizerIntegration(), vercelAIIntegration(), + postgresJsIntegration(), ]; } @@ -75,5 +77,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentGenericPool, instrumentAmqplib, instrumentVercelAi, + instrumentPostgresJs, ]; } diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts new file mode 100644 index 000000000000..c5efb7f6bef7 --- /dev/null +++ b/packages/node/src/integrations/tracing/postgresjs.ts @@ -0,0 +1,327 @@ +// Instrumentation for https://github.com/porsager/postgres +import { context, trace } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_NAMESPACE, + ATTR_DB_OPERATION_NAME, + ATTR_DB_QUERY_TEXT, + ATTR_DB_RESPONSE_STATUS_CODE, + ATTR_DB_SYSTEM_NAME, + ATTR_ERROR_TYPE, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + defineIntegration, + getCurrentScope, + logger, + SDK_VERSION, + SPAN_STATUS_ERROR, + startSpanManual, +} from '@sentry/core'; +import { generateInstrumentOnce } from '../../otel/instrument'; +import { addOriginToSpan } from '../../utils/addOriginToSpan'; + +const INTEGRATION_NAME = 'PostgresJs'; +const SUPPORTED_VERSIONS = ['>=3.0.0 <4']; + +type PostgresConnectionContext = { + ATTR_DB_NAMESPACE?: string; // Database name + ATTR_SERVER_ADDRESS?: string; // Hostname or IP address of the database server + ATTR_SERVER_PORT?: string; // Port number of the database server +}; + +type PostgresJsInstrumentationConfig = InstrumentationConfig & { + /** + * Whether to require a parent span for the instrumentation. + * If set to true, the instrumentation will only create spans if there is a parent span + * available in the current scope. + * @default true + */ + requireParentSpan?: boolean; + /** + * Hook to modify the span before it is started. + * This can be used to set additional attributes or modify the span in any way. + */ + requestHook?: (span: Span, sanitizedSqlQuery: string, postgresConnectionContext?: PostgresConnectionContext) => void; +}; + +export const instrumentPostgresJs = generateInstrumentOnce( + INTEGRATION_NAME, + (options?: PostgresJsInstrumentationConfig) => + new PostgresJsInstrumentation({ + requireParentSpan: options?.requireParentSpan ?? true, + requestHook: options?.requestHook, + }), +); + +/** + * Instrumentation for the [postgres](https://www.npmjs.com/package/postgres) library. + * This instrumentation captures postgresjs queries and their attributes, + */ +export class PostgresJsInstrumentation extends InstrumentationBase { + public constructor(config: PostgresJsInstrumentationConfig) { + super('sentry-postgres-js', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation. + */ + public init(): InstrumentationNodeModuleDefinition[] { + const instrumentationModule = new InstrumentationNodeModuleDefinition('postgres', SUPPORTED_VERSIONS); + + ['src', 'cf/src', 'cjs/src'].forEach(path => { + instrumentationModule.files.push( + new InstrumentationNodeModuleFile( + `postgres/${path}/connection.js`, + ['*'], + this._patchConnection.bind(this), + this._unwrap.bind(this), + ), + ); + + instrumentationModule.files.push( + new InstrumentationNodeModuleFile( + `postgres/${path}/query.js`, + SUPPORTED_VERSIONS, + this._patchQuery.bind(this), + this._unwrap.bind(this), + ), + ); + }); + + return [instrumentationModule]; + } + + /** + * Determines whether a span should be created based on the current context. + * If `requireParentSpan` is set to true in the configuration, a span will + * only be created if there is a parent span available. + */ + private _shouldCreateSpans(): boolean { + const config = this.getConfig(); + const hasParentSpan = trace.getSpan(context.active()) !== undefined; + return hasParentSpan || !config.requireParentSpan; + } + + /** + * Patches the reject method of the Query class to set the span status and end it + */ + private _patchReject(rejectTarget: any, span: Span): any { + return new Proxy(rejectTarget, { + apply: ( + rejectTarget, + rejectThisArg, + rejectArgs: { + message?: string; + code?: string; + name?: string; + }[], + ) => { + span.setStatus({ + code: SPAN_STATUS_ERROR, + // This message is the error message from the rejectArgs, when available + // e.g "relation 'User' does not exist" + message: rejectArgs?.[0]?.message || 'unknown_error', + }); + + const result = Reflect.apply(rejectTarget, rejectThisArg, rejectArgs); + + // This status code is PG error code, e.g. '42P01' for "relation does not exist" + // https://www.postgresql.org/docs/current/errcodes-appendix.html + span.setAttribute(ATTR_DB_RESPONSE_STATUS_CODE, rejectArgs?.[0]?.code || 'Unknown error'); + // This is the error type, e.g. 'PostgresError' for a Postgres error + span.setAttribute(ATTR_ERROR_TYPE, rejectArgs?.[0]?.name || 'Unknown error'); + + span.end(); + return result; + }, + }); + } + + /** + * Patches the resolve method of the Query class to end the span when the query is resolved. + */ + private _patchResolve(resolveTarget: any, span: Span): any { + return new Proxy(resolveTarget, { + apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { + const result = Reflect.apply(resolveTarget, resolveThisArg, resolveArgs); + const sqlCommand = resolveArgs?.[0]?.command; + + if (sqlCommand) { + // SQL command is only available when the query is resolved successfully + span.setAttribute(ATTR_DB_OPERATION_NAME, sqlCommand); + } + span.end(); + return result; + }, + }); + } + + /** + * Patches the Query class to instrument the handle method. + */ + private _patchQuery(moduleExports: { + Query: { + prototype: { + handle: any; + }; + }; + }): any { + moduleExports.Query.prototype.handle = new Proxy(moduleExports.Query.prototype.handle, { + apply: async ( + handleTarget, + handleThisArg: { + resolve: any; + reject: any; + strings?: string[]; + }, + handleArgs, + ) => { + if (!this._shouldCreateSpans()) { + // If we don't need to create spans, just call the original method + return Reflect.apply(handleTarget, handleThisArg, handleArgs); + } + + const sanitizedSqlQuery = this._sanitizeSqlQuery(handleThisArg.strings?.[0]); + + return startSpanManual( + { + name: sanitizedSqlQuery || 'postgresjs.query', + op: 'db', + }, + (span: Span) => { + const scope = getCurrentScope(); + const postgresConnectionContext = scope.getScopeData().contexts['postgresjsConnection'] as + | PostgresConnectionContext + | undefined; + + addOriginToSpan(span, 'auto.db.otel.postgres'); + + const { requestHook } = this.getConfig(); + + if (requestHook) { + safeExecuteInTheMiddle( + () => requestHook(span, sanitizedSqlQuery, postgresConnectionContext), + error => { + if (error) { + logger.error(`Error in requestHook for ${INTEGRATION_NAME} integration:`, error); + } + }, + ); + } + + // ATTR_DB_NAMESPACE is used to indicate the database name and the schema name + // It's only the database name as we don't have the schema information + const databaseName = postgresConnectionContext?.ATTR_DB_NAMESPACE || ''; + const databaseHost = postgresConnectionContext?.ATTR_SERVER_ADDRESS || ''; + const databasePort = postgresConnectionContext?.ATTR_SERVER_PORT || ''; + + span.setAttribute(ATTR_DB_SYSTEM_NAME, 'postgres'); + span.setAttribute(ATTR_DB_NAMESPACE, databaseName); + span.setAttribute(ATTR_SERVER_ADDRESS, databaseHost); + span.setAttribute(ATTR_SERVER_PORT, databasePort); + span.setAttribute(ATTR_DB_QUERY_TEXT, sanitizedSqlQuery); + + handleThisArg.resolve = this._patchResolve(handleThisArg.resolve, span); + handleThisArg.reject = this._patchReject(handleThisArg.reject, span); + + try { + return Reflect.apply(handleTarget, handleThisArg, handleArgs); + } catch (error) { + span.setStatus({ + code: SPAN_STATUS_ERROR, + }); + span.end(); + throw error; // Re-throw the error to propagate it + } + }, + ); + }, + }); + + return moduleExports; + } + + /** + * Patches the Connection class to set the database, host, and port attributes + * when a new connection is created. + */ + private _patchConnection(Connection: any): any { + return new Proxy(Connection, { + apply: (connectionTarget, thisArg, connectionArgs: { database: string; host: string[]; port: number[] }[]) => { + const databaseName = connectionArgs[0]?.database || ''; + const databaseHost = connectionArgs[0]?.host?.[0] || ''; + const databasePort = connectionArgs[0]?.port?.[0] || ''; + + const scope = getCurrentScope(); + scope.setContext('postgresjsConnection', { + ATTR_DB_NAMESPACE: databaseName, + ATTR_SERVER_ADDRESS: databaseHost, + ATTR_SERVER_PORT: databasePort, + }); + + return Reflect.apply(connectionTarget, thisArg, connectionArgs); + }, + }); + } + + /** + * Sanitize SQL query as per the OTEL semantic conventions + * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext + */ + private _sanitizeSqlQuery(sqlQuery: string | undefined): string { + if (!sqlQuery) { + return 'Unknown SQL Query'; + } + + return ( + sqlQuery + .replace(/\s+/g, ' ') + .trim() // Remove extra spaces including newlines and trim + .substring(0, 1024) // Truncate to 1024 characters + .replace(/--.*?(\r?\n|$)/g, '') // Single line comments + .replace(/\/\*[\s\S]*?\*\//g, '') // Multi-line comments + .replace(/;\s*$/, '') // Remove trailing semicolons + .replace(/\b\d+\b/g, '?') // Replace standalone numbers + // Collapse whitespace to a single space + .replace(/\s+/g, ' ') + // Collapse IN and in clauses + // eg. IN (?, ?, ?, ?) to IN (?) + .replace(/\bIN\b\s*\(\s*\?(?:\s*,\s*\?)*\s*\)/g, 'IN (?)') + ); + } +} + +const _postgresJsIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentPostgresJs(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the [postgres](https://www.npmjs.com/package/postgres) library. + * + * For more information, see the [`postgresIntegration` documentation](https://docs.sentry.io/platforms/javascript/guides/node/configuration/integrations/postgres/). + * + * @example + * ```javascript + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [Sentry.postgresJsIntegration()], + * }); + * ``` + */ + +export const postgresJsIntegration = defineIntegration(_postgresJsIntegration); diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index f11b9bb51077..9574b38f1e47 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -75,6 +75,7 @@ export { onUnhandledRejectionIntegration, parameterize, postgresIntegration, + postgresJsIntegration, prismaIntegration, redisIntegration, requestDataIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 717dd7387c98..07d92d03c8ce 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -77,6 +77,7 @@ export { onUnhandledRejectionIntegration, parameterize, postgresIntegration, + postgresJsIntegration, prismaIntegration, redisIntegration, requestDataIntegration, diff --git a/yarn.lock b/yarn.lock index bd6c7af98fde..ac3970cfb153 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24165,6 +24165,11 @@ postgres-range@^1.1.1: resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.3.tgz#9ccd7b01ca2789eb3c2e0888b3184225fa859f76" integrity sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g== +postgres@^3.4.7: + version "3.4.7" + resolved "https://registry.yarnpkg.com/postgres/-/postgres-3.4.7.tgz#122f460a808fe300cae53f592108b9906e625345" + integrity sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw== + preact@^10.19.4: version "10.19.4" resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.4.tgz#735d331d5b1bd2182cc36f2ba481fd6f0da3fe3b" From c20a3c4b20f771909ba9d7ef392bbb0c6f1d5d78 Mon Sep 17 00:00:00 2001 From: 0xbad0c0d3 <0xbad0c0d3@gmail.com> Date: Thu, 26 Jun 2025 19:52:45 +0300 Subject: [PATCH 09/11] feat(cloudflare): Flush after `waitUntil` (#16681) This PR aim is to send events if all (if any was scheduled) waitUntil promises were finished. Otherwise you may loose events. This fixes: #16559 --------- Co-authored-by: cod1k --- packages/cloudflare/src/client.ts | 31 +++- packages/cloudflare/src/durableobject.ts | 8 +- packages/cloudflare/src/flush.ts | 38 ++++ packages/cloudflare/src/handler.ts | 21 ++- packages/cloudflare/src/request.ts | 8 +- packages/cloudflare/src/sdk.ts | 5 + .../cloudflare/test/durableobject.test.ts | 55 ++++++ packages/cloudflare/test/flush.test.ts | 30 +++ packages/cloudflare/test/handler.test.ts | 173 ++++++++++++++++-- .../test/integrations/fetch.test.ts | 3 +- packages/cloudflare/test/request.test.ts | 38 +++- 11 files changed, 367 insertions(+), 43 deletions(-) create mode 100644 packages/cloudflare/src/flush.ts create mode 100644 packages/cloudflare/test/durableobject.test.ts create mode 100644 packages/cloudflare/test/flush.test.ts diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 07dc285e6b15..b6b4695835ba 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,5 +1,6 @@ import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core'; import { applySdkMetadata, ServerRuntimeClient } from '@sentry/core'; +import type { makeFlushLock } from './flush'; import type { CloudflareTransportOptions } from './transport'; /** @@ -8,7 +9,9 @@ import type { CloudflareTransportOptions } from './transport'; * @see CloudflareClientOptions for documentation on configuration options. * @see ServerRuntimeClient for usage documentation. */ -export class CloudflareClient extends ServerRuntimeClient { +export class CloudflareClient extends ServerRuntimeClient { + private readonly _flushLock: ReturnType | void; + /** * Creates a new Cloudflare SDK instance. * @param options Configuration options for this SDK. @@ -16,9 +19,10 @@ export class CloudflareClient extends ServerRuntimeClient} A promise that resolves to a boolean indicating whether the flush operation was successful. + */ + public async flush(timeout?: number): Promise { + if (this._flushLock) { + await this._flushLock.finalize(); + } + return super.flush(timeout); } } @@ -56,11 +75,15 @@ interface BaseCloudflareOptions { * * @see @sentry/core Options for more information. */ -export interface CloudflareOptions extends Options, BaseCloudflareOptions {} +export interface CloudflareOptions extends Options, BaseCloudflareOptions { + ctx?: ExecutionContext; +} /** * Configuration options for the Sentry Cloudflare SDK Client class * * @see CloudflareClient for more information. */ -export interface CloudflareClientOptions extends ClientOptions, BaseCloudflareOptions {} +export interface CloudflareClientOptions extends ClientOptions, BaseCloudflareOptions { + flushLock?: ReturnType; +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 35fbb5096a41..0e919977025d 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -47,9 +47,11 @@ function wrapMethodWithSentry any>( // see: https://github.com/getsentry/sentry-javascript/issues/13217 const context = wrapperOptions.context as ExecutionContext | undefined; + const waitUntil = context?.waitUntil?.bind?.(context); + const currentClient = scope.getClient(); if (!currentClient) { - const client = init(wrapperOptions.options); + const client = init({ ...wrapperOptions.options, ctx: context }); scope.setClient(client); } @@ -68,7 +70,7 @@ function wrapMethodWithSentry any>( }); throw e; } finally { - context?.waitUntil(flush(2000)); + waitUntil?.(flush(2000)); } } @@ -92,7 +94,7 @@ function wrapMethodWithSentry any>( }); throw e; } finally { - context?.waitUntil(flush(2000)); + waitUntil?.(flush(2000)); } }); }); diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts new file mode 100644 index 000000000000..f38c805d0f8b --- /dev/null +++ b/packages/cloudflare/src/flush.ts @@ -0,0 +1,38 @@ +import type { ExecutionContext } from '@cloudflare/workers-types'; + +type FlushLock = { + readonly ready: Promise; + readonly finalize: () => Promise; +}; + +/** + * Enhances the given execution context by wrapping its `waitUntil` method with a proxy + * to monitor pending tasks, and provides a flusher function to ensure all tasks + * have been completed before executing any subsequent logic. + * + * @param {ExecutionContext} context - The execution context to be enhanced. If no context is provided, the function returns undefined. + * @return {FlushLock} Returns a flusher function if a valid context is provided, otherwise undefined. + */ +export function makeFlushLock(context: ExecutionContext): FlushLock { + let resolveAllDone: () => void = () => undefined; + const allDone = new Promise(res => { + resolveAllDone = res; + }); + let pending = 0; + const originalWaitUntil = context.waitUntil.bind(context) as typeof context.waitUntil; + context.waitUntil = promise => { + pending++; + return originalWaitUntil( + promise.finally(() => { + if (--pending === 0) resolveAllDone(); + }), + ); + }; + return Object.freeze({ + ready: allDone, + finalize: () => { + if (pending === 0) resolveAllDone(); + return allDone; + }, + }); +} diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index d3d1f80dbbd5..3640d3cf7229 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -74,8 +74,9 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); - const client = init(options); + const client = init({ ...options, ctx: context }); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -99,7 +100,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); - const client = init(options); + const client = init({ ...options, ctx: context }); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -139,7 +141,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); - const client = init(options); + const client = init({ ...options, ctx: context }); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -185,7 +188,7 @@ export function withSentry { const options = getFinalOptions(optionsCallback(env), env); - const client = init(options); + const waitUntil = context.waitUntil.bind(context); + + const client = init({ ...options, ctx: context }); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -215,7 +220,7 @@ export function withSentry { + it('instrumentDurableObjectWithSentry generic functionality', () => { + const options = vi.fn(); + const instrumented = instrumentDurableObjectWithSentry(options, vi.fn()); + expect(instrumented).toBeTypeOf('function'); + expect(() => Reflect.construct(instrumented, [])).not.toThrow(); + expect(options).toHaveBeenCalledOnce(); + }); + it('all available durable object methods are instrumented', () => { + const testClass = vi.fn(() => ({ + customMethod: vi.fn(), + fetch: vi.fn(), + alarm: vi.fn(), + webSocketMessage: vi.fn(), + webSocketClose: vi.fn(), + webSocketError: vi.fn(), + })); + const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); + const dObject: any = Reflect.construct(instrumented, []); + for (const method of Object.getOwnPropertyNames(dObject)) { + expect(isInstrumented(dObject[method]), `Method ${method} is instrumented`).toBeTruthy(); + } + }); + it('flush performs after all waitUntil promises are finished', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + const waitUntil = vi.fn(); + const testClass = vi.fn(context => ({ + fetch: () => { + context.waitUntil(new Promise(res => setTimeout(res))); + return new Response('test'); + }, + })); + const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); + const context = { + waitUntil, + } as unknown as ExecutionContext; + const dObject: any = Reflect.construct(instrumented, [context, {} as any]); + expect(() => dObject.fetch(new Request('https://example.com'))).not.toThrow(); + expect(flush).not.toBeCalled(); + expect(waitUntil).toHaveBeenCalledOnce(); + vi.advanceTimersToNextTimer(); + await Promise.all(waitUntil.mock.calls.map(([p]) => p)); + expect(flush).toBeCalled(); + }); +}); diff --git a/packages/cloudflare/test/flush.test.ts b/packages/cloudflare/test/flush.test.ts new file mode 100644 index 000000000000..34714711c682 --- /dev/null +++ b/packages/cloudflare/test/flush.test.ts @@ -0,0 +1,30 @@ +import { type ExecutionContext } from '@cloudflare/workers-types'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { makeFlushLock } from '../src/flush'; + +describe('Flush buffer test', () => { + const waitUntilPromises: Promise[] = []; + const mockExecutionContext: ExecutionContext = { + waitUntil: vi.fn(prmise => { + waitUntilPromises.push(prmise); + }), + passThroughOnException: vi.fn(), + }; + it('should flush buffer immediately if no waitUntil were called', async () => { + const { finalize } = makeFlushLock(mockExecutionContext); + await expect(finalize()).resolves.toBeUndefined(); + }); + it('should flush buffer only after all waitUntil were finished', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const task = new Promise(resolve => setTimeout(resolve, 100)); + const lock = makeFlushLock(mockExecutionContext); + mockExecutionContext.waitUntil(task); + void lock.finalize(); + vi.advanceTimersToNextTimer(); + await Promise.all(waitUntilPromises); + await expect(lock.ready).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index bced0fdbe277..2e5c0f836e89 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -1,10 +1,16 @@ // Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. -import type { ForwardableEmailMessage, MessageBatch, ScheduledController, TraceItem } from '@cloudflare/workers-types'; +import type { + ExecutionContext, + ForwardableEmailMessage, + MessageBatch, + ScheduledController, + TraceItem, +} from '@cloudflare/workers-types'; import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; import { withSentry } from '../src/handler'; import { markAsInstrumented } from '../src/instrument'; @@ -24,6 +30,10 @@ const MOCK_ENV = { SENTRY_RELEASE: '1.1.1', }; +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + describe('withSentry', () => { beforeEach(() => { vi.clearAllMocks(); @@ -122,6 +132,32 @@ describe('withSentry', () => { expect(sentryEvent.release).toEqual('2.0.0'); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + fetch(_request, _env, _context) { + addDelayedWaitUntil(_context); + return new Response('test'); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('scheduled handler', () => { @@ -198,13 +234,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -337,6 +372,32 @@ describe('withSentry', () => { }); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + scheduled(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('email handler', () => { @@ -413,13 +474,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -551,6 +611,32 @@ describe('withSentry', () => { }); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + email(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('queue handler', () => { @@ -627,13 +713,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -769,6 +854,32 @@ describe('withSentry', () => { }); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + queue(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('tail handler', () => { @@ -845,13 +956,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -941,6 +1051,33 @@ describe('withSentry', () => { expect(thrownError).toBe(error); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + flush.mockRestore(); + }); + const handler = { + tail(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer().runAllTimers(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('hono errorHandler', () => { diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index 795b3e8c931c..724ff39c7dde 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -1,6 +1,5 @@ import type { HandlerDataFetch, Integration } from '@sentry/core'; import * as sentryCore from '@sentry/core'; -import * as sentryUtils from '@sentry/core'; import { createStackParser } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CloudflareClient } from '../../src/client'; @@ -12,7 +11,7 @@ class FakeClient extends CloudflareClient { } } -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); +const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 4fc9b308ec54..32bc8068ba6d 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -1,9 +1,10 @@ // Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. +import type { ExecutionContext } from '@cloudflare/workers-types'; import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; @@ -13,6 +14,10 @@ const MOCK_OPTIONS: CloudflareOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337', }; +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + describe('withSentry', () => { beforeAll(() => { setAsyncLocalStorageAsyncContextStrategy(); @@ -33,15 +38,14 @@ describe('withSentry', () => { test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); await wrapRequestHandler( { options: MOCK_OPTIONS, request: new Request('https://example.com'), context }, () => new Response('test'), ); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test("doesn't error if context is undefined", () => { @@ -64,6 +68,30 @@ describe('withSentry', () => { expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); }); + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + + const context = { + waitUntil, + } as unknown as ExecutionContext; + + await wrapRequestHandler({ options: MOCK_OPTIONS, request: new Request('https://example.com'), context }, () => { + addDelayedWaitUntil(context); + return new Response('test'); + }); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimerAsync().then(() => vi.runAllTimers()); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); + describe('scope instrumentation', () => { test('adds cloud resource context', async () => { let sentryEvent: Event = {}; From e7891f749f271058ac05560f45834969de1fc35f Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 26 Jun 2025 20:10:39 +0200 Subject: [PATCH 10/11] chore: Add external contributor to CHANGELOG.md (#16747) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #16681 Co-authored-by: AbhiPrasad <18689448+AbhiPrasad@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a8397098d2..24882564cf9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! + ## 9.32.0 ### Important Changes From e8eb291938480012f63163ef41769a3abee800e5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 27 Jun 2025 09:17:12 +0200 Subject: [PATCH 11/11] meta(changelog): Update changelog for 9.33.0 --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24882564cf9e..983de7416ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.33.0 + +### Important Changes + +- **feat: Add opt-in `vercelAiIntegration` to cloudflare & vercel-edge ([#16732](https://github.com/getsentry/sentry-javascript/pull/16732))** + +The `vercelAiIntegration` is now available as opt-in for the Cloudflare and the Next.js SDK for Vercel Edge. +To use it, add the integration in `Sentry.init` + +```js +Sentry.init({ + tracesSampleRate: 1.0, + integrations: [Sentry.vercelAIIntegration()], +}); +``` + +And enable telemetry for Vercel AI calls + +```js +const result = await generateText({ + model: openai('gpt-4o'), + experimental_telemetry: { + isEnabled: true, + }, +}); +``` + +- **feat(node): Add postgresjs instrumentation ([#16665](https://github.com/getsentry/sentry-javascript/pull/16665))** + +The Node.js SDK now includes instrumentation for [Postgres.js](https://www.npmjs.com/package/postgres). + +- **feat(node): Use diagnostics channel for Fastify v5 error handling ([#16715](https://github.com/getsentry/sentry-javascript/pull/16715))** + +If you're on Fastify v5, you no longer need to call `setupFastifyErrorHandler`. It is done automatically by the node SDK. Older versions still rely on calling `setupFastifyErrorHandler`. + +### Other Changes + +- feat(cloudflare): Allow interop with OpenTelemetry emitted spans ([#16714](https://github.com/getsentry/sentry-javascript/pull/16714)) +- feat(cloudflare): Flush after `waitUntil` ([#16681](https://github.com/getsentry/sentry-javascript/pull/16681)) +- fix(nextjs): Remove `ai` from default server external packages ([#16736](https://github.com/getsentry/sentry-javascript/pull/16736)) + Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! ## 9.32.0