diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a8397098d2..983de7416ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ - "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 ### Important Changes 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); + }, +); 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/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/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", 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/cloudflare/package.json b/packages/cloudflare/package.json index e3363708c70d..b9edb74a4231 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -49,7 +49,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.32.0" + "@sentry/core": "9.32.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..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); } } @@ -36,6 +55,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; } /** @@ -43,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 { + 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/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/request.ts b/packages/cloudflare/src/request.ts index f1905609fb94..d403501f3ed4 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -35,7 +35,9 @@ export function wrapRequestHandler( // see: https://github.com/getsentry/sentry-javascript/issues/13217 const context = wrapperOptions.context as ExecutionContext | undefined; - const client = init(options); + const waitUntil = context?.waitUntil?.bind?.(context); + + const client = init({ ...options, ctx: context }); isolationScope.setClient(client); const urlObject = parseStringToURLObject(request.url); @@ -65,7 +67,7 @@ export function wrapRequestHandler( captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); throw e; } finally { - context?.waitUntil(flush(2000)); + waitUntil?.(flush(2000)); } } @@ -89,7 +91,7 @@ export function wrapRequestHandler( captureException(e, { mechanism: { handled: false, type: 'cloudflare' } }); throw e; } finally { - context?.waitUntil(flush(2000)); + waitUntil?.(flush(2000)); } }, ); diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 90ef3c0bedf9..58adf8f4e145 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -12,7 +12,9 @@ import { } from '@sentry/core'; import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; +import { makeFlushLock } from './flush'; import { fetchIntegration } from './integrations/fetch'; +import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; @@ -43,12 +45,27 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { options.defaultIntegrations = getDefaultIntegrations(options); } + const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined; + delete options.ctx; + const clientOptions: CloudflareClientOptions = { ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), integrations: getIntegrationsToSetup(options), transport: options.transport || makeCloudflareTransport, + flushLock, }; + /** + * 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/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/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts new file mode 100644 index 000000000000..40d33741658e --- /dev/null +++ b/packages/cloudflare/test/durableobject.test.ts @@ -0,0 +1,55 @@ +import type { ExecutionContext } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { instrumentDurableObjectWithSentry } from '../src/durableobject'; +import { isInstrumented } from '../src/instrument'; + +describe('durable object', () => { + 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/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/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 = {}; 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(); +} 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/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/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/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', 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/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/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(); }, { 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/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/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/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/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/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); diff --git a/yarn.lock b/yarn.lock index f696182ab447..ac3970cfb153 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" @@ -24196,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" @@ -24560,16 +24534,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 +29449,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"