diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 64709dd1f999..8acac6fd2709 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -51,7 +51,6 @@ body: - '@sentry/nestjs' - '@sentry/nextjs' - '@sentry/nuxt' - - '@sentry/pino-transport' - '@sentry/react' - '@sentry/react-router' - '@sentry/remix' diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1100ba7ad0..1ae0eba65cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.18.0 + +### Important Changes + +- **feat(node): `pino` integration ([#17584](https://github.com/getsentry/sentry-javascript/pull/17584))** + + This release adds a new `pino` integration for Node.js, enabling Sentry to capture logs from the Pino logging library. + +- **feat: Remove @sentry/pino-transport package ([#17851](https://github.com/getsentry/sentry-javascript/pull/17851))** + + The `@sentry/pino-transport` package has been removed. Please use the new `pino` integration in `@sentry/node` instead. + +- **feat(node-core): Extend onnhandledrejection with ignore errors option ([#17736](https://github.com/getsentry/sentry-javascript/pull/17736))** + + Added support for selectively suppressing specific errors with configurable logging control in onnhandledrejection integration. + +### Other Changes + +- feat(core): Rename vercelai.schema to gen_ai.request.schema ([#17850](https://github.com/getsentry/sentry-javascript/pull/17850)) +- feat(core): Support stream responses and tool calls for Google GenAI ([#17664](https://github.com/getsentry/sentry-javascript/pull/17664)) +- feat(nextjs): Attach headers using client hook ([#17831](https://github.com/getsentry/sentry-javascript/pull/17831)) +- fix(core): Keep all property values in baggage header ([#17847](https://github.com/getsentry/sentry-javascript/pull/17847)) +- fix(nestjs): Add support for Symbol as event name ([#17785](https://github.com/getsentry/sentry-javascript/pull/17785)) +- fix(nuxt): include `sentry.client.config.ts` in nuxt app types ([#17830](https://github.com/getsentry/sentry-javascript/pull/17830)) +- fix(react-router): Fix type for `OriginalHandleRequest` with middleware ([#17870](https://github.com/getsentry/sentry-javascript/pull/17870)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#17866](https://github.com/getsentry/sentry-javascript/pull/17866)) +- chore(deps): Bump @sentry/cli from 2.53.0 to 2.56.0 ([#17819](https://github.com/getsentry/sentry-javascript/pull/17819)) +- chore(deps): Bump axios in browser integration tests ([#17839](https://github.com/getsentry/sentry-javascript/pull/17839)) +- chore(deps): Bump nestjs in integration tests ([#17840](https://github.com/getsentry/sentry-javascript/pull/17840)) + +
+ +Work in this release was contributed by @stefanvanderwolf. Thank you for your contribution! + ## 10.17.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 969f4bbb1e46..2ac5163bc855 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -45,7 +45,7 @@ "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.17.0", "@supabase/supabase-js": "2.49.3", - "axios": "1.8.2", + "axios": "^1.12.2", "babel-loader": "^8.2.2", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/subject.js new file mode 100644 index 000000000000..76fafc9df148 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/subject.js @@ -0,0 +1,9 @@ +fetchButton.addEventListener('click', () => { + // W3C spec example: property values can contain = signs + // See: https://www.w3.org/TR/baggage/#example + fetch('http://sentry-test-site.example/fetch-test', { + headers: { + baggage: 'key1=value1;property1;property2,key2=value2,key3=value3; propertyKey=propertyValue', + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/template.html b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/template.html new file mode 100644 index 000000000000..404eee952355 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/template.html @@ -0,0 +1,11 @@ + + + + + + Fetch Baggage Property Values Test + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/test.ts new file mode 100644 index 000000000000..c191304ec8e0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-baggage-property-values/test.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test'; +import { TRACEPARENT_REGEXP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest( + 'preserves baggage property values with equal signs in fetch requests', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/fetch-test'); + + await page.goto(url); + await page.click('#fetchButton'); + + const request = await requestPromise; + + const requestHeaders = request.headers(); + + expect(requestHeaders).toMatchObject({ + 'sentry-trace': expect.stringMatching(TRACEPARENT_REGEXP), + }); + + const baggageHeader = requestHeaders.baggage; + expect(baggageHeader).toBeDefined(); + + const baggageItems = baggageHeader.split(',').map(item => decodeURIComponent(item.trim())); + + // Verify property values with = signs are preserved + expect(baggageItems).toContainEqual(expect.stringContaining('key1=value1;property1;property2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key2=value2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key3=value3; propertyKey=propertyValue')); + + // Verify Sentry baggage is also present + expect(baggageHeader).toMatch(/sentry-/); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/subject.js new file mode 100644 index 000000000000..839cdf137fd7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/subject.js @@ -0,0 +1,8 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test-site.example/1'); +// W3C spec example: property values can contain = signs +// See: https://www.w3.org/TR/baggage/#example +xhr.setRequestHeader('baggage', 'key1=value1;property1;property2,key2=value2,key3=value3; propertyKey=propertyValue'); + +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/test.ts new file mode 100644 index 000000000000..f2ac4edb4a67 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/xhr-baggage-property-values/test.ts @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; +import { TRACEPARENT_REGEXP } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('preserves baggage property values with equal signs in XHR requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const requestPromise = page.waitForRequest('http://sentry-test-site.example/1'); + + await page.goto(url); + + const request = await requestPromise; + + const requestHeaders = request.headers(); + + expect(requestHeaders).toMatchObject({ + 'sentry-trace': expect.stringMatching(TRACEPARENT_REGEXP), + }); + + const baggageHeader = requestHeaders.baggage; + expect(baggageHeader).toBeDefined(); + const baggageItems = baggageHeader.split(',').map(item => decodeURIComponent(item.trim())); + + // Verify property values with = signs are preserved + expect(baggageItems).toContainEqual(expect.stringContaining('key1=value1;property1;property2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key2=value2')); + expect(baggageItems).toContainEqual(expect.stringContaining('key3=value3; propertyKey=propertyValue')); + + // Verify Sentry baggage is also present + expect(baggageHeader).toMatch(/sentry-/); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/edge/route.ts new file mode 100644 index 000000000000..7cd1fc7e332c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/edge/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Edge Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/node/route.ts new file mode 100644 index 000000000000..5bc418f077aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/route-handler/[xoxo]/node/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Node Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts index 3cad4a546508..c9a9fc44bdba 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts @@ -24,11 +24,6 @@ test('App router transactions should be attached to the pageload request span', }); test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { - test.skip( - process.env.TEST_ENV === 'prod-turbopack' || process.env.TEST_ENV === 'dev-turbopack', - 'Incoming fetch request headers are not added as span attributes when Turbopack is enabled (addHeadersAsAttributes)', - ); - const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { return transactionEvent?.transaction === 'GET /pageload-tracing'; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts new file mode 100644 index 000000000000..f9dedccb4923 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/route-handler.test.ts @@ -0,0 +1,40 @@ +import test, { expect } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for node route handlers', async ({ request }) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + console.log(transactionEvent?.transaction); + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; + }); + + const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + + // This is flaking on dev mode + if (process.env.TEST_ENV !== 'development' && process.env.TEST_ENV !== 'dev-turbopack') { + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); + } +}); + +test('Should create a transaction for edge route handlers', async ({ request }) => { + // This test only works for webpack builds on non-async param extraction + // todo: check if we can set request headers for edge on sdkProcessingMetadata + test.skip(); + const routehandlerTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/edge'; + }); + + const response = await request.get('/route-handler/123/edge', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Edge Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 596109c0a596..17c6f714c499 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -53,6 +53,7 @@ const DEPENDENTS: Dependent[] = [ 'NODE_VERSION', 'childProcessIntegration', 'systemErrorIntegration', + 'pinoIntegration', ], }, { diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index 1d565dbecda2..0773603b033e 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -122,12 +122,6 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! - '@sentry/pino-transport': - access: $all - publish: $all - unpublish: $all - # proxy: npmjs # Don't proxy for E2E tests! - '@sentry/profiling-node': access: $all publish: $all diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index eb6161cb9a80..5cec961c736a 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -23,9 +23,9 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@nestjs/common": "11.0.16", - "@nestjs/core": "10.4.6", - "@nestjs/platform-express": "10.4.6", + "@nestjs/common": "^11", + "@nestjs/core": "^11", + "@nestjs/platform-express": "^11", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index dbf8d9001ed4..e7e811b40d14 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -28,9 +28,9 @@ "@google/genai": "^1.20.0", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", - "@nestjs/common": "11.1.3", - "@nestjs/core": "11.1.3", - "@nestjs/platform-express": "11.1.3", + "@nestjs/common": "^11", + "@nestjs/core": "^11", + "@nestjs/platform-express": "^11", "@prisma/client": "6.15.0", "@sentry/aws-serverless": "10.17.0", "@sentry/core": "10.17.0", @@ -66,6 +66,8 @@ "node-schedule": "^2.1.1", "openai": "5.18.1", "pg": "8.16.0", + "pino": "9.9.4", + "pino-next": "npm:pino@^9.12.0", "postgres": "^3.4.7", "prisma": "6.15.0", "proxy": "^2.1.1", diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/server.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/server.ts new file mode 100644 index 000000000000..da278ce61688 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/server.ts @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + environment: 'prod', + // disable requests to /express + tracePropagationTargets: [/^(?!.*express).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, +}); + +import cors from 'cors'; +import express from 'express'; +import http from 'http'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express-property-values', (req, res) => { + const incomingBaggage = req.headers.baggage; + + // Forward the incoming baggage (which contains property values) to the outgoing request + // This tests that property values with = signs are preserved during parsing and re-serialization + const headers = http.get({ hostname: 'somewhere.not.sentry', headers: { baggage: incomingBaggage } }).getHeaders(); + + // Responding with the headers outgoing request headers back to the assertions. + res.send({ test_data: headers }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/test.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/test.ts new file mode 100644 index 000000000000..23848d36a3df --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-property-values/test.ts @@ -0,0 +1,28 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import type { TestAPIResponse } from './server'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should preserve baggage property values with equal signs (W3C spec compliance)', async () => { + const runner = createRunner(__dirname, 'server.ts').start(); + + // W3C spec example: https://www.w3.org/TR/baggage/#example + const response = await runner.makeRequest('get', '/test/express-property-values', { + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'key1=value1;property1;property2,key2=value2,key3=value3; propertyKey=propertyValue', + }, + }); + + expect(response).toBeDefined(); + + // The baggage should be parsed and re-serialized, preserving property values with = signs + const baggageItems = response?.test_data.baggage?.split(',').map(item => decodeURIComponent(item.trim())); + + expect(baggageItems).toContain('key1=value1;property1;property2'); + expect(baggageItems).toContain('key2=value2'); + expect(baggageItems).toContain('key3=value3; propertyKey=propertyValue'); +}); diff --git a/dev-packages/node-integration-tests/suites/pino/instrument.mjs b/dev-packages/node-integration-tests/suites/pino/instrument.mjs new file mode 100644 index 000000000000..2c09097de1f4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: '1.0', + enableLogs: true, + integrations: [Sentry.pinoIntegration({ error: { levels: ['error', 'fatal'] } })], +}); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs new file mode 100644 index 000000000000..11fc038fea3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino-next'; + +const logger = pino({}); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs new file mode 100644 index 000000000000..3ff6c0b5e08d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({}); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts new file mode 100644 index 000000000000..15a9397ebb27 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -0,0 +1,172 @@ +import { join } from 'path'; +import { expect, test } from 'vitest'; +import { conditionalTest } from '../../utils'; +import { createRunner } from '../../utils/runner'; + +conditionalTest({ min: 20 })('Pino integration', () => { + test('has different trace ids for logs from different spans', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .ignore('event') + .expect({ + log: log => { + const traceId1 = log.items?.[0]?.trace_id; + const traceId2 = log.items?.[1]?.trace_id; + expect(traceId1).not.toBe(traceId2); + }, + }) + .start() + .completed(); + }); + + test('captures event and logs', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'oh no', + mechanism: { + type: 'pino', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: '?', + in_app: true, + module: 'scenario', + context_line: " logger.error(new Error('oh no'));", + }), + ]), + }, + }, + ], + }, + }, + }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 30, type: 'integer' }, + user: { value: 'user-id', type: 'string' }, + something: { + type: 'string', + value: '{"more":3,"complex":"nope"}', + }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 50, type: 'integer' }, + err: { value: '{}', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); + + test('captures with Pino integrated channel', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario-next.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'oh no', + mechanism: { + type: 'pino', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: '?', + in_app: true, + module: 'scenario-next', + context_line: " logger.error(new Error('oh no'));", + }), + ]), + }, + }, + ], + }, + }, + }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 30, type: 'integer' }, + user: { value: 'user-id', type: 'string' }, + something: { + type: 'string', + value: '{"more":3,"complex":"nope"}', + }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.pino.level': { value: 50, type: 'integer' }, + err: { value: '{}', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js new file mode 100644 index 000000000000..7ff548624e5f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-custom-name.js @@ -0,0 +1,27 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.onUnhandledRejectionIntegration({ + // Use default mode: 'warn' - integration is active but should ignore CustomIgnoredError + ignore: [{ name: 'CustomIgnoredError' }], + }), + ], +}); + +// Create a custom error that should be ignored +class CustomIgnoredError extends Error { + constructor(message) { + super(message); + this.name = 'CustomIgnoredError'; + } +} + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +// This should be ignored by the custom ignore matcher and not produce a warning +Promise.reject(new CustomIgnoredError('This error should be ignored')); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js new file mode 100644 index 000000000000..623aa8eaa8f7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/ignore-default.js @@ -0,0 +1,22 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + // Use default mode: 'warn' - integration is active but should ignore AI_NoOutputGeneratedError +}); + +// Create an error with the name that should be ignored by default +class AI_NoOutputGeneratedError extends Error { + constructor(message) { + super(message); + this.name = 'AI_NoOutputGeneratedError'; + } +} + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +// This should be ignored by default and not produce a warning +Promise.reject(new AI_NoOutputGeneratedError('Stream aborted')); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts index d3c8b4d599ff..cd0627664ea3 100644 --- a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -178,4 +178,32 @@ test rejection`); expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); }); + + test('should not warn when AI_NoOutputGeneratedError is rejected (default ignore)', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'ignore-default.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); // No warning should be shown + done(); + }); + })); + + test('should not warn when custom ignored error by name is rejected', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'ignore-custom-name.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); // No warning should be shown + done(); + }); + })); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-streaming.mjs new file mode 100644 index 000000000000..be5c75638694 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-streaming.mjs @@ -0,0 +1,237 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json()); + + // Streaming endpoint for models.generateContentStream and chat.sendMessageStream + app.post('/v1beta/models/:model\\:streamGenerateContent', (req, res) => { + const model = req.params.model; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + // Set headers for streaming response + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Transfer-Encoding', 'chunked'); + + // Create a mock stream + const mockStream = createMockStream(model); + + // Send chunks + const sendChunk = async () => { + const { value, done } = await mockStream.next(); + if (done) { + res.end(); + return; + } + + res.write(`data: ${JSON.stringify(value)}\n\n`); + setTimeout(sendChunk, 10); // Small delay between chunks + }; + + sendChunk(); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +// Helper function to create mock stream +async function* createMockStream(model) { + if (model === 'blocked-model') { + // First chunk: Contains promptFeedback with blockReason + yield { + promptFeedback: { + blockReason: 'SAFETY', + blockReasonMessage: 'The prompt was blocked due to safety concerns', + }, + responseId: 'mock-blocked-response-streaming-id', + modelVersion: 'gemini-1.5-pro', + }; + + // Note: In a real blocked scenario, there would typically be no more chunks + // But we'll add one more to test that processing stops after the error + yield { + candidates: [ + { + content: { + parts: [{ text: 'This should not be processed' }], + role: 'model', + }, + index: 0, + }, + ], + }; + return; + } + + // First chunk: Start of response with initial text + yield { + candidates: [ + { + content: { + parts: [{ text: 'Hello! ' }], + role: 'model', + }, + index: 0, + }, + ], + responseId: 'mock-response-streaming-id', + modelVersion: 'gemini-1.5-pro', + }; + + // Second chunk: More text content + yield { + candidates: [ + { + content: { + parts: [{ text: 'This is a streaming ' }], + role: 'model', + }, + index: 0, + }, + ], + }; + + // Third chunk: Final text content + yield { + candidates: [ + { + content: { + parts: [{ text: 'response from Google GenAI!' }], + role: 'model', + }, + index: 0, + }, + ], + }; + + // Final chunk: End with finish reason and usage metadata + yield { + candidates: [ + { + content: { + parts: [{ text: '' }], // Empty text in final chunk + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 12, + totalTokenCount: 22, + }, + }; +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Test 1: models.generateContentStream (streaming) + const streamResponse = await client.models.generateContentStream({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'Tell me about streaming' }], + }, + ], + }); + + // Consume the stream + for await (const _ of streamResponse) { + void _; + } + + // Test 2: chat.sendMessageStream (streaming) + const streamingChat = client.chats.create({ + model: 'gemini-1.5-pro', + config: { + temperature: 0.8, + topP: 0.9, + maxOutputTokens: 150, + }, + }); + + const chatStreamResponse = await streamingChat.sendMessageStream({ + message: 'Tell me a streaming joke', + }); + + // Consume the chat stream + for await (const _ of chatStreamResponse) { + void _; + } + + // Test 3: Blocked content streaming (should trigger error handling) + try { + const blockedStreamResponse = await client.models.generateContentStream({ + model: 'blocked-model', + config: { + temperature: 0.7, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'This should be blocked' }], + }, + ], + }); + + // Consume the blocked stream + for await (const _ of blockedStreamResponse) { + void _; + } + } catch { + // Expected: The stream should be processed, but the span should be marked with error status + // The error handling happens in the streaming instrumentation, not as a thrown error + } + + // Test 4: Error handling for streaming + try { + const errorStreamResponse = await client.models.generateContentStream({ + model: 'error-model', + config: { + temperature: 0.7, + }, + contents: [ + { + role: 'user', + parts: [{ text: 'This will fail' }], + }, + ], + }); + + // Consume the error stream + for await (const _ of errorStreamResponse) { + void _; + } + } catch { + // Expected error + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-tools.mjs new file mode 100644 index 000000000000..97984f2eb1ed --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-tools.mjs @@ -0,0 +1,307 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json()); + + // Non-streaming endpoint for models.generateContent + app.post('/v1beta/models/:model\\:generateContent', (req, res) => { + const { tools } = req.body; + + // Check if tools are provided to return function call response + if (tools && tools.length > 0) { + const response = { + candidates: [ + { + content: { + parts: [ + { + text: 'I need to check the light status first.', + }, + { + functionCall: { + id: 'call_light_control_1', + name: 'controlLight', + args: { + brightness: 0.3, + colorTemperature: 'warm', + }, + }, + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 15, + candidatesTokenCount: 8, + totalTokenCount: 23, + }, + }; + + // Add functionCalls getter, this should exist in the response object + Object.defineProperty(response, 'functionCalls', { + get: function () { + return [ + { + id: 'call_light_control_1', + name: 'controlLight', + args: { + brightness: 0.3, + colorTemperature: 'warm', + }, + }, + ]; + }, + }); + + res.send(response); + return; + } + + // Regular response without tools + res.send({ + candidates: [ + { + content: { + parts: [ + { + text: 'Mock response from Google GenAI without tools!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + }); + }); + + // Streaming endpoint for models.generateContentStream + // And chat.sendMessageStream + app.post('/v1beta/models/:model\\:streamGenerateContent', (req, res) => { + const { tools } = req.body; + + // Set headers for streaming response + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Transfer-Encoding', 'chunked'); + + // Create a mock stream + const mockStream = createMockToolsStream({ tools }); + + // Send chunks + const sendChunk = async () => { + // Testing .next() works as expected + const { value, done } = await mockStream.next(); + if (done) { + res.end(); + return; + } + + res.write(`data: ${JSON.stringify(value)}\n\n`); + setTimeout(sendChunk, 10); // Small delay between chunks + }; + + sendChunk(); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +// Helper function to create mock stream +async function* createMockToolsStream({ tools }) { + // Check if tools are provided to return function call response + if (tools && tools.length > 0) { + // First chunk: Text response + yield { + candidates: [ + { + content: { + parts: [{ text: 'Let me control the lights for you.' }], + role: 'model', + }, + index: 0, + }, + ], + responseId: 'mock-response-tools-id', + modelVersion: 'gemini-2.0-flash-001', + }; + + // Second chunk: Function call + yield { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + id: 'call_light_stream_1', + name: 'controlLight', + args: { + brightness: 0.5, + colorTemperature: 'cool', + }, + }, + }, + ], + role: 'model', + }, + index: 0, + }, + ], + }; + + // Final chunk: End with finish reason and usage metadata + yield { + candidates: [ + { + content: { + parts: [{ text: ' Done!' }], // Additional text in final chunk + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 12, + candidatesTokenCount: 10, + totalTokenCount: 22, + }, + }; + return; + } + + // Regular stream without tools + yield { + candidates: [ + { + content: { + parts: [{ text: 'Mock streaming response' }], + role: 'model', + }, + index: 0, + }, + ], + responseId: 'mock-response-tools-id', + modelVersion: 'gemini-2.0-flash-001', + }; + + // Final chunk + yield { + candidates: [ + { + content: { + parts: [{ text: ' from Google GenAI!' }], + role: 'model', + }, + finishReason: 'STOP', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 12, + totalTokenCount: 22, + }, + }; +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Test 1: Non-streaming with tools + await client.models.generateContent({ + model: 'gemini-2.0-flash-001', + contents: 'Dim the lights so the room feels cozy and warm.', + config: { + tools: [ + { + functionDeclarations: [ + { + name: 'controlLight', + parametersJsonSchema: { + type: 'object', + properties: { + brightness: { + type: 'number', + }, + colorTemperature: { + type: 'string', + }, + }, + required: ['brightness', 'colorTemperature'], + }, + }, + ], + }, + ], + }, + }); + + // Test 2: Streaming with tools + const stream = await client.models.generateContentStream({ + model: 'gemini-2.0-flash-001', + contents: 'Turn on the lights with medium brightness.', + config: { + tools: [ + { + functionDeclarations: [ + { + name: 'controlLight', + parametersJsonSchema: { + type: 'object', + properties: { + brightness: { + type: 'number', + }, + colorTemperature: { + type: 'string', + }, + }, + required: ['brightness', 'colorTemperature'], + }, + }, + ], + }, + ], + }, + }); + + // Consume the stream to trigger instrumentation + for await (const _ of stream) { + void _; + } + + // Test 3: Without tools for comparison + await client.models.generateContent({ + model: 'gemini-2.0-flash-001', + contents: 'Tell me about the weather.', + }); + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs index ddb9e16b8254..91c75886e410 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario.mjs @@ -54,6 +54,7 @@ async function run() { }); // Test 1: chats.create and sendMessage flow + // This should generate two spans: one for chats.create and one for sendMessage const chat = client.chats.create({ model: 'gemini-1.5-pro', config: { diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 9aa5523c61d7..92d669c7e10f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -169,6 +169,7 @@ describe('Google GenAI integration', () => { 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true }), + description: expect.not.stringContaining('stream-response'), // Non-streaming span }), ]), }; @@ -202,4 +203,287 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + const EXPECTED_TRANSACTION_TOOLS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Non-streaming with tools + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-2.0-flash-001', + 'gen_ai.request.available_tools': expect.any(String), // Should include tools + 'gen_ai.request.messages': expect.any(String), // Should include contents + 'gen_ai.response.text': expect.any(String), // Should include response text + 'gen_ai.response.tool_calls': expect.any(String), // Should include tool calls + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.usage.total_tokens': 23, + }), + description: 'models gemini-2.0-flash-001', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Streaming with tools + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-2.0-flash-001', + 'gen_ai.request.available_tools': expect.any(String), // Should include tools + 'gen_ai.request.messages': expect.any(String), // Should include contents + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': expect.any(String), // Should include response text + 'gen_ai.response.tool_calls': expect.any(String), // Should include tool calls + 'gen_ai.response.id': 'mock-response-tools-id', + 'gen_ai.response.model': 'gemini-2.0-flash-001', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'models gemini-2.0-flash-001 stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Without tools for comparison + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-2.0-flash-001', + 'gen_ai.request.messages': expect.any(String), // Should include contents + 'gen_ai.response.text': expect.any(String), // Should include response text + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + }), + description: 'models gemini-2.0-flash-001', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('creates google genai related spans with tool calls', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_TOOLS }).start().completed(); + }); + }); + + const EXPECTED_TRANSACTION_STREAMING = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - models.generateContentStream (streaming) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + 'gen_ai.response.finish_reasons': '["STOP"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'models gemini-1.5-flash stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - chat.create + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 150, + }), + description: 'chat gemini-1.5-pro create', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Third span - chat.sendMessageStream (streaming) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + }), + description: 'chat gemini-1.5-pro stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Fourth span - blocked content streaming + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + }), + description: 'models blocked-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'unknown_error', + }), + // Fifth span - error handling for streaming + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + }), + description: 'models error-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_STREAMING_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - models.generateContentStream (streaming) with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + 'gen_ai.response.finish_reasons': '["STOP"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'models gemini-1.5-flash stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - chat.create + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.request.max_tokens': 150, + }), + description: 'chat gemini-1.5-pro create', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Third span - chat.sendMessageStream (streaming) with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + 'gen_ai.request.messages': expect.any(String), // Should include message when recordInputs: true + 'gen_ai.response.streaming': true, + 'gen_ai.response.id': 'mock-response-streaming-id', + 'gen_ai.response.model': 'gemini-1.5-pro', + 'gen_ai.response.finish_reasons': '["STOP"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 22, + }), + description: 'chat gemini-1.5-pro stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Fourth span - blocked content stream with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'blocked-model', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + 'gen_ai.response.streaming': true, + }), + description: 'models blocked-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'unknown_error', + }), + // Fifth span - error handling for streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages': expect.any(String), // Should include contents when recordInputs: true + }), + description: 'models error-model stream-response', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates google genai streaming spans with sendDefaultPii: false', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_STREAMING }).start().completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates google genai streaming spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_STREAMING_PII_TRUE }) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs new file mode 100644 index 000000000000..64d0d3ba0ec7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-generate-object.mjs @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node'; +import { generateObject } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Test generateObject with schema + await generateObject({ + model: new MockLanguageModelV1({ + defaultObjectGenerationMode: 'json', + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 15, completionTokens: 25 }, + text: '{ "name": "John Doe", "age": 30 }', + }), + }), + schema: z.object({ + name: z.string().describe('The name of the person'), + age: z.number().describe('The age of the person'), + }), + schemaName: 'Person', + schemaDescription: 'A person with name and age', + prompt: 'Generate a person object', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test-generate-object.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test-generate-object.ts new file mode 100644 index 000000000000..1e87b63535ac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test-generate-object.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Vercel AI integration - generateObject', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION = { + transaction: 'main', + spans: expect.arrayContaining([ + // generateObject span + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateObject', + 'vercel.ai.pipeline.name': 'generateObject', + 'vercel.ai.streaming': false, + 'vercel.ai.settings.mode': 'json', + 'vercel.ai.settings.output': 'object', + 'gen_ai.request.schema': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateObject', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateObject', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // generateObject.doGenerate span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_object', + 'operation.name': 'ai.generateObject.doGenerate', + 'vercel.ai.operationId': 'ai.generateObject.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.pipeline.name': 'generateObject.doGenerate', + 'vercel.ai.streaming': false, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + }), + description: 'generate_object mock-model-id', + op: 'gen_ai.generate_object', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-generate-object.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures generateObject spans with schema attributes', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }); +}); diff --git a/package.json b/package.json index e74379564683..edbd645b3c97 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "packages/node-native", "packages/nuxt", "packages/opentelemetry", - "packages/pino-transport", "packages/profiling-node", "packages/react", "packages/react-router", diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 790810e93797..f70d6e0a3573 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -94,6 +94,7 @@ export { onUnhandledRejectionIntegration, openAIIntegration, parameterize, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index f7e72ec908ae..5a608a925edb 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -106,6 +106,7 @@ export { mysql2Integration, redisIntegration, tediousIntegration, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/core/src/utils/baggage.ts b/packages/core/src/utils/baggage.ts index b483207ba8f2..e94bb3d896e6 100644 --- a/packages/core/src/utils/baggage.ts +++ b/packages/core/src/utils/baggage.ts @@ -113,8 +113,15 @@ export function parseBaggageHeader( function baggageHeaderToObject(baggageHeader: string): Record { return baggageHeader .split(',') - .map(baggageEntry => - baggageEntry.split('=').map(keyOrValue => { + .map(baggageEntry => { + const eqIdx = baggageEntry.indexOf('='); + if (eqIdx === -1) { + // Likely an invalid entry + return []; + } + const key = baggageEntry.slice(0, eqIdx); + const value = baggageEntry.slice(eqIdx + 1); + return [key, value].map(keyOrValue => { try { return decodeURIComponent(keyOrValue.trim()); } catch { @@ -122,8 +129,8 @@ function baggageHeaderToObject(baggageHeader: string): Record { // This will then be skipped in the next step return; } - }), - ) + }); + }) .reduce>((acc, [key, value]) => { if (key && value) { acc[key] = value; diff --git a/packages/core/src/utils/google-genai/constants.ts b/packages/core/src/utils/google-genai/constants.ts index 8617460482c6..b06e46e18755 100644 --- a/packages/core/src/utils/google-genai/constants.ts +++ b/packages/core/src/utils/google-genai/constants.ts @@ -2,7 +2,15 @@ export const GOOGLE_GENAI_INTEGRATION_NAME = 'Google_GenAI'; // https://ai.google.dev/api/rest/v1/models/generateContent // https://ai.google.dev/api/rest/v1/chats/sendMessage -export const GOOGLE_GENAI_INSTRUMENTED_METHODS = ['models.generateContent', 'chats.create', 'sendMessage'] as const; +// https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream +// https://googleapis.github.io/js-genai/release_docs/classes/chats.Chat.html#sendmessagestream +export const GOOGLE_GENAI_INSTRUMENTED_METHODS = [ + 'models.generateContent', + 'models.generateContentStream', + 'chats.create', + 'sendMessage', + 'sendMessageStream', +] as const; // Constants for internal use export const GOOGLE_GENAI_SYSTEM_NAME = 'google_genai'; diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 58d7e2e6b5e6..20e6e2a53606 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -1,10 +1,12 @@ import { getClient } from '../../currentScopes'; import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; -import { startSpan } from '../../tracing/trace'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, @@ -14,6 +16,7 @@ import { GEN_AI_REQUEST_TOP_K_ATTRIBUTE, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, @@ -22,6 +25,7 @@ import { import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; +import { instrumentStream } from './streaming'; import type { Candidate, ContentPart, @@ -29,7 +33,7 @@ import type { GoogleGenAIOptions, GoogleGenAIResponse, } from './types'; -import { shouldInstrument } from './utils'; +import { isStreamingMethod, shouldInstrument } from './utils'; /** * Extract model from parameters or chat context object @@ -91,8 +95,8 @@ function extractConfigAttributes(config: Record): Record, context?: unknown, ): Record { const attributes: Record = { @@ -101,14 +105,21 @@ function extractRequestAttributes( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', }; - if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { - const params = args[0] as Record; - + if (params) { attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = extractModel(params, context); // Extract generation config parameters if ('config' in params && typeof params.config === 'object' && params.config) { - Object.assign(attributes, extractConfigAttributes(params.config as Record)); + const config = params.config as Record; + Object.assign(attributes, extractConfigAttributes(config)); + + // Extract available tools from config + if ('tools' in config && Array.isArray(config.tools)) { + const functionDeclarations = config.tools.map( + (tool: { functionDeclarations: unknown[] }) => tool.functionDeclarations, + ); + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(functionDeclarations); + } } } else { attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = extractModel({}, context); @@ -186,6 +197,16 @@ function addResponseAttributes(span: Span, response: GoogleGenAIResponse, record }); } } + + // Add tool calls if recordOutputs is enabled + if (recordOutputs && response.functionCalls) { + const functionCalls = response.functionCalls; + if (Array.isArray(functionCalls) && functionCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), + }); + } + } } /** @@ -201,43 +222,75 @@ function instrumentMethod( ): (...args: T) => R | Promise { const isSyncCreate = methodPath === CHATS_CREATE_METHOD; - const run = (...args: T): R | Promise => { - const requestAttributes = extractRequestAttributes(args, methodPath, context); - const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); - - // Single span for both sync and async operations - return startSpan( - { - name: isSyncCreate ? `${operationName} ${model} create` : `${operationName} ${model}`, - op: getSpanOperation(methodPath), - attributes: requestAttributes, - }, - (span: Span) => { - if (options.recordInputs && args[0] && typeof args[0] === 'object') { - addPrivateRequestAttributes(span, args[0] as Record); - } - - return handleCallbackErrors( - () => originalMethod.apply(context, args), - error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.google_genai', data: { function: methodPath } }, - }); + return new Proxy(originalMethod, { + apply(target, _, args: T): R | Promise { + const params = args[0] as Record | undefined; + const requestAttributes = extractRequestAttributes(methodPath, params, context); + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const operationName = getFinalOperationName(methodPath); + + // Check if this is a streaming method + if (isStreamingMethod(methodPath)) { + // Use startSpanManual for streaming methods to control span lifecycle + return startSpanManual( + { + name: `${operationName} ${model} stream-response`, + op: getSpanOperation(methodPath), + attributes: requestAttributes, }, - () => {}, - result => { - // Only add response attributes for content-producing methods, not for chats.create - if (!isSyncCreate) { - addResponseAttributes(span, result, options.recordOutputs); + async (span: Span) => { + try { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + const stream = await target.apply(context, args); + return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.google_genai', + data: { function: methodPath }, + }, + }); + span.end(); + throw error; } }, ); - }, - ); - }; - - return run; + } + // Single span for both sync and async operations + return startSpan( + { + name: isSyncCreate ? `${operationName} ${model} create` : `${operationName} ${model}`, + op: getSpanOperation(methodPath), + attributes: requestAttributes, + }, + (span: Span) => { + if (options.recordInputs && params) { + addPrivateRequestAttributes(span, params); + } + + return handleCallbackErrors( + () => target.apply(context, args), + error => { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.google_genai', data: { function: methodPath } }, + }); + }, + () => {}, + result => { + // Only add response attributes for content-producing methods, not for chats.create + if (!isSyncCreate) { + addResponseAttributes(span, result, options.recordOutputs); + } + }, + ); + }, + ); + }, + }) as (...args: T) => R | Promise; } /** diff --git a/packages/core/src/utils/google-genai/streaming.ts b/packages/core/src/utils/google-genai/streaming.ts new file mode 100644 index 000000000000..b9462e8c90dd --- /dev/null +++ b/packages/core/src/utils/google-genai/streaming.ts @@ -0,0 +1,163 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import type { GoogleGenAIResponse } from './types'; + +/** + * State object used to accumulate information from a stream of Google GenAI events. + */ +interface StreamingState { + /** Collected response text fragments (for output recording). */ + responseTexts: string[]; + /** Reasons for finishing the response, as reported by the API. */ + finishReasons: string[]; + /** The response ID. */ + responseId?: string; + /** The model name. */ + responseModel?: string; + /** Number of prompt/input tokens used. */ + promptTokens?: number; + /** Number of completion/output tokens used. */ + completionTokens?: number; + /** Number of total tokens used. */ + totalTokens?: number; + /** Accumulated tool calls (finalized) */ + toolCalls: Array>; +} + +/** + * Checks if a response chunk contains an error + * @param chunk - The response chunk to check + * @param span - The span to update if error is found + * @returns Whether an error occurred + */ +function isErrorChunk(chunk: GoogleGenAIResponse, span: Span): boolean { + const feedback = chunk?.promptFeedback; + if (feedback?.blockReason) { + const message = feedback.blockReasonMessage ?? feedback.blockReason; + span.setStatus({ code: SPAN_STATUS_ERROR, message: `Content blocked: ${message}` }); + captureException(`Content blocked: ${message}`, { + mechanism: { handled: false, type: 'auto.ai.google_genai' }, + }); + return true; + } + return false; +} + +/** + * Processes response metadata from a chunk + * @param chunk - The response chunk to process + * @param state - The state of the streaming process + */ +function handleResponseMetadata(chunk: GoogleGenAIResponse, state: StreamingState): void { + if (typeof chunk.responseId === 'string') state.responseId = chunk.responseId; + if (typeof chunk.modelVersion === 'string') state.responseModel = chunk.modelVersion; + + const usage = chunk.usageMetadata; + if (usage) { + if (typeof usage.promptTokenCount === 'number') state.promptTokens = usage.promptTokenCount; + if (typeof usage.candidatesTokenCount === 'number') state.completionTokens = usage.candidatesTokenCount; + if (typeof usage.totalTokenCount === 'number') state.totalTokens = usage.totalTokenCount; + } +} + +/** + * Processes candidate content from a response chunk + * @param chunk - The response chunk to process + * @param state - The state of the streaming process + * @param recordOutputs - Whether to record outputs + */ +function handleCandidateContent(chunk: GoogleGenAIResponse, state: StreamingState, recordOutputs: boolean): void { + if (Array.isArray(chunk.functionCalls)) { + state.toolCalls.push(...chunk.functionCalls); + } + + for (const candidate of chunk.candidates ?? []) { + if (candidate?.finishReason && !state.finishReasons.includes(candidate.finishReason)) { + state.finishReasons.push(candidate.finishReason); + } + + for (const part of candidate?.content?.parts ?? []) { + if (recordOutputs && part.text) state.responseTexts.push(part.text); + if (part.functionCall) { + state.toolCalls.push({ + type: 'function', + id: part.functionCall.id, + name: part.functionCall.name, + arguments: part.functionCall.args, + }); + } + } + } +} + +/** + * Processes a single chunk from the Google GenAI stream + * @param chunk - The chunk to process + * @param state - The state of the streaming process + * @param recordOutputs - Whether to record outputs + * @param span - The span to update + */ +function processChunk(chunk: GoogleGenAIResponse, state: StreamingState, recordOutputs: boolean, span: Span): void { + if (!chunk || isErrorChunk(chunk, span)) return; + handleResponseMetadata(chunk, state); + handleCandidateContent(chunk, state, recordOutputs); +} + +/** + * Instruments an async iterable stream of Google GenAI response chunks, updates the span with + * streaming attributes and (optionally) the aggregated output text, and yields + * each chunk from the input stream unchanged. + */ +export async function* instrumentStream( + stream: AsyncIterable, + span: Span, + recordOutputs: boolean, +): AsyncGenerator { + const state: StreamingState = { + responseTexts: [], + finishReasons: [], + toolCalls: [], + }; + + try { + for await (const chunk of stream) { + processChunk(chunk, state, recordOutputs, span); + yield chunk; + } + } finally { + const attrs: Record = { + [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, + }; + + if (state.responseId) attrs[GEN_AI_RESPONSE_ID_ATTRIBUTE] = state.responseId; + if (state.responseModel) attrs[GEN_AI_RESPONSE_MODEL_ATTRIBUTE] = state.responseModel; + if (state.promptTokens !== undefined) attrs[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] = state.promptTokens; + if (state.completionTokens !== undefined) attrs[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] = state.completionTokens; + if (state.totalTokens !== undefined) attrs[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE] = state.totalTokens; + + if (state.finishReasons.length) { + attrs[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE] = JSON.stringify(state.finishReasons); + } + if (recordOutputs && state.responseTexts.length) { + attrs[GEN_AI_RESPONSE_TEXT_ATTRIBUTE] = state.responseTexts.join(''); + } + if (recordOutputs && state.toolCalls.length) { + attrs[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] = JSON.stringify(state.toolCalls); + } + + span.setAttributes(attrs); + span.end(); + } +} diff --git a/packages/core/src/utils/google-genai/utils.ts b/packages/core/src/utils/google-genai/utils.ts index c7a18477c7dd..a394ed64a1bb 100644 --- a/packages/core/src/utils/google-genai/utils.ts +++ b/packages/core/src/utils/google-genai/utils.ts @@ -14,3 +14,14 @@ export function shouldInstrument(methodPath: string): methodPath is GoogleGenAII const methodName = methodPath.split('.').pop(); return GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodName as GoogleGenAIIstrumentedMethod); } + +/** + * Check if a method is a streaming method + */ +export function isStreamingMethod(methodPath: string): boolean { + return ( + methodPath.includes('Stream') || + methodPath.endsWith('generateContentStream') || + methodPath.endsWith('sendMessageStream') + ); +} diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 912dcaee3bc4..9b1cc2bc8aae 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -17,6 +17,7 @@ import { AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE, AI_RESPONSE_TEXT_ATTRIBUTE, AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + AI_SCHEMA_ATTRIBUTE, AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, AI_TOOL_CALL_ARGS_ATTRIBUTE, AI_TOOL_CALL_ID_ATTRIBUTE, @@ -125,6 +126,8 @@ function processEndedVercelAiSpan(span: SpanJSON): void { renameAttributeKey(attributes, AI_TOOL_CALL_ARGS_ATTRIBUTE, 'gen_ai.tool.input'); renameAttributeKey(attributes, AI_TOOL_CALL_RESULT_ATTRIBUTE, 'gen_ai.tool.output'); + renameAttributeKey(attributes, AI_SCHEMA_ATTRIBUTE, 'gen_ai.request.schema'); + addProviderMetadataToAttributes(attributes); // Change attributes namespaced with `ai.X` to `vercel.ai.X` diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index e2f1ad5fc2b2..2eb7f39f3a24 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -48,6 +48,8 @@ export type InternalGlobal = { */ _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; + _sentryInjectLoaderHookRegister?: () => void; + _sentryInjectLoaderHookRegistered?: boolean; } & Carrier; /** Get's the global object for the current JavaScript runtime */ diff --git a/packages/core/test/lib/utils/baggage.test.ts b/packages/core/test/lib/utils/baggage.test.ts index 4816a3fbf079..f3717a524bf8 100644 --- a/packages/core/test/lib/utils/baggage.test.ts +++ b/packages/core/test/lib/utils/baggage.test.ts @@ -71,4 +71,16 @@ describe('parseBaggageHeader', () => { const actual = parseBaggageHeader(input); expect(actual).toStrictEqual(expectedOutput); }); + + test('should preserve property values with equal signs', () => { + // see https://www.w3.org/TR/baggage/#example + const baggageHeader = 'key1=value1;property1;property2, key2 = value2, key3=value3; propertyKey=propertyValue'; + const result = parseBaggageHeader(baggageHeader); + + expect(result).toStrictEqual({ + key1: 'value1;property1;property2', + key2: 'value2', + key3: 'value3; propertyKey=propertyValue', + }); + }); }); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index bab9dc3a1cbb..8f1d236f7877 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -106,6 +106,7 @@ export { mysql2Integration, redisIntegration, tediousIntegration, + pinoIntegration, postgresIntegration, postgresJsIntegration, prismaIntegration, diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index f8076087fd5d..92c90c3719de 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -74,10 +74,18 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { return decoratorResult(target, propertyKey, descriptor); } + function eventNameFromEvent(event: unknown): string { + if (typeof event === 'string') { + return event; + } else if (Array.isArray(event)) { + return event.map(eventNameFromEvent).join(','); + } else return String(event); + } + const originalHandler = descriptor.value; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const handlerName = originalHandler.name || propertyKey; - let eventName = typeof event === 'string' ? event : String(event); + let eventName = eventNameFromEvent(event); // Instrument the actual handler descriptor.value = async function (...args: unknown[]) { @@ -93,7 +101,7 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { eventName = eventData .map((data: unknown) => { if (data && typeof data === 'object' && 'event' in data && data.event) { - return data.event; + return eventNameFromEvent(data.event); } return ''; }) diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 69fb022441dd..2d1d73b4657a 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -75,17 +75,72 @@ describe('Nest', () => { await descriptor.value(); - expect(core.startSpan).toHaveBeenCalled(); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event test.event', + }), + expect.any(Function), + ); expect(originalHandler).toHaveBeenCalled(); }); - it('should wrap array event handlers', async () => { + it('should wrap symbol event handlers', async () => { + const decorated = wrappedOnEvent(Symbol('test.event')); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event Symbol(test.event)', + }), + expect.any(Function), + ); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should wrap string array event handlers', async () => { const decorated = wrappedOnEvent(['test.event1', 'test.event2']); decorated(mockTarget, 'testMethod', descriptor); await descriptor.value(); - expect(core.startSpan).toHaveBeenCalled(); + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event test.event1,test.event2', + }), + expect.any(Function), + ); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should wrap symbol array event handlers', async () => { + const decorated = wrappedOnEvent([Symbol('test.event1'), Symbol('test.event2')]); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event Symbol(test.event1),Symbol(test.event2)', + }), + expect.any(Function), + ); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should wrap mixed type array event handlers', async () => { + const decorated = wrappedOnEvent([Symbol('test.event1'), 'test.event2', Symbol('test.event3')]); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'event Symbol(test.event1),test.event2,Symbol(test.event3)', + }), + expect.any(Function), + ); expect(originalHandler).toHaveBeenCalled(); }); diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index 4cf8fde751fb..8f02df798f84 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -15,7 +15,6 @@ import { } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { AugmentedNextApiResponse, NextApiHandler } from '../types'; -import { addHeadersAsAttributes } from '../utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../utils/responseEnd'; import { dropNextjsRootContext, escapeNextjsTracing } from '../utils/tracingUtils'; @@ -88,7 +87,6 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', - ...addHeadersAsAttributes(normalizedRequest.headers || {}), }, }, async span => { diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 323d4d1f2e3b..c22910df43bf 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -22,7 +22,6 @@ import { import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; -import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; @@ -64,11 +63,6 @@ export function wrapGenerationFunctionWithSentry a const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - addHeadersAsAttributes(headers, rootSpan); - } - let data: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index ab05fbd5e944..07694d659e57 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -13,7 +13,6 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from '../edge/types'; @@ -60,7 +59,6 @@ export function wrapMiddlewareWithSentry( let spanName: string; let spanSource: TransactionSource; - let headerAttributes: Record = {}; if (req instanceof Request) { isolationScope.setSDKProcessingMetadata({ @@ -68,8 +66,6 @@ export function wrapMiddlewareWithSentry( }); spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; spanSource = 'url'; - - headerAttributes = addHeadersAsAttributes(req.headers); } else { spanName = 'middleware'; spanSource = 'component'; @@ -88,7 +84,6 @@ export function wrapMiddlewareWithSentry( const rootSpan = getRootSpan(activeSpan); if (rootSpan) { setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); - rootSpan.setAttributes(headerAttributes); } } @@ -99,7 +94,6 @@ export function wrapMiddlewareWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrap_middleware', - ...headerAttributes, }, }, () => { diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 54858a9bdae2..068ab7960ae4 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -19,7 +19,6 @@ import { } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; -import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope } from './utils/tracingUtils'; @@ -40,10 +39,6 @@ export function wrapRouteHandlerWithSentry any>( const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - if (rootSpan && process.env.NEXT_RUNTIME !== 'edge') { - addHeadersAsAttributes(headers, rootSpan); - } - let edgeRuntimeIsolationScopeOverride: Scope | undefined; if (rootSpan && process.env.NEXT_RUNTIME === 'edge') { const isolationScope = commonObjectToIsolationScope(headers); @@ -55,7 +50,6 @@ export function wrapRouteHandlerWithSentry any>( rootSpan.updateName(`${method} ${parameterizedRoute}`); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server'); - addHeadersAsAttributes(headers, rootSpan); } return withIsolationScope( diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index d25225a149f9..1f522dbf212f 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -24,7 +24,6 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/ import type { ServerComponentContext } from '../common/types'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; -import { addHeadersAsAttributes } from './utils/addHeadersAsAttributes'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; @@ -62,11 +61,6 @@ export function wrapServerComponentWithSentry any> const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - addHeadersAsAttributes(context.headers, rootSpan); - } - let params: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 7982667f0c3f..6469e3c6a2c8 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,6 +1,7 @@ import { applySdkMetadata, getGlobalScope, + getIsolationScope, getRootSpan, GLOBAL_OBJ, registerSpanErrorInstrumentation, @@ -13,6 +14,7 @@ import { } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -59,6 +61,8 @@ export function init(options: VercelEdgeOptions = {}): void { client?.on('spanStart', span => { const spanAttributes = spanToJSON(span).data; + const rootSpan = getRootSpan(span); + const isRootSpan = span === rootSpan; // Mark all spans generated by Next.js as 'auto' if (spanAttributes?.['next.span_type'] !== undefined) { @@ -70,6 +74,12 @@ export function init(options: VercelEdgeOptions = {}): void { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); } + + if (isRootSpan) { + // todo: check if we can set request headers for edge on sdkProcessingMetadata + const headers = getIsolationScope().getScopeData().sdkProcessingMetadata?.normalizedRequest?.headers; + addHeadersAsAttributes(headers, rootSpan); + } }); // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 735caf8d7788..9d3d4e4427fa 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -39,7 +39,6 @@ export function wrapApiHandlerWithSentry( normalizedRequest: winterCGRequestToRequestData(req), }); currentScope.setTransactionName(`${req.method} ${parameterizedRoute}`); - headerAttributes = addHeadersAsAttributes(req.headers); } else { currentScope.setTransactionName(`handler (${parameterizedRoute})`); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 5866f014ec69..5ce23e6a9460 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -36,6 +36,7 @@ import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, } from '../common/span-attributes-with-logic-attached'; +import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -163,13 +164,13 @@ export function init(options: NodeOptions): NodeClient | undefined { client?.on('spanStart', span => { const spanAttributes = spanToJSON(span).data; + const rootSpan = getRootSpan(span); + const isRootSpan = span === rootSpan; // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. if (typeof spanAttributes?.['next.route'] === 'string') { - const rootSpan = getRootSpan(span); const rootSpanAttributes = spanToJSON(rootSpan).data; - // Only hoist the http.route attribute if the transaction doesn't already have it if ( // eslint-disable-next-line deprecation/deprecation @@ -190,8 +191,13 @@ export function init(options: NodeOptions): NodeClient | undefined { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); } + if (isRootSpan) { + const headers = getIsolationScope().getScopeData().sdkProcessingMetadata?.normalizedRequest?.headers; + addHeadersAsAttributes(headers, rootSpan); + } + // We want to fork the isolation scope for incoming requests - if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && span === getRootSpan(span)) { + if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && isRootSpan) { const scopes = getCapturedScopesOnSpan(span); const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 4e83faeb767d..ed90625bac44 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -68,9 +68,11 @@ "dependencies": { "@sentry/core": "10.17.0", "@sentry/opentelemetry": "10.17.0", + "@apm-js-collab/tracing-hooks": "^0.3.1", "import-in-the-middle": "^1.14.2" }, "devDependencies": { + "@apm-js-collab/code-transformer": "^0.8.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.1.0", "@opentelemetry/core": "^2.1.0", diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index e6cf209d23f6..0f976bd23436 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -27,6 +27,7 @@ export { spotlightIntegration } from './integrations/spotlight'; export { systemErrorIntegration } from './integrations/systemError'; export { childProcessIntegration } from './integrations/childProcess'; export { createSentryWinstonTransport } from './integrations/winston'; +export { pinoIntegration } from './integrations/pino'; export { SentryContextManager } from './otel/contextManager'; export { setupOpenTelemetryLogger } from './otel/logger'; diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts index dbddb2a4c396..42a17e2e6c7e 100644 --- a/packages/node-core/src/integrations/onunhandledrejection.ts +++ b/packages/node-core/src/integrations/onunhandledrejection.ts @@ -1,24 +1,41 @@ import type { Client, IntegrationFn, SeverityLevel, Span } from '@sentry/core'; -import { captureException, consoleSandbox, defineIntegration, getClient, withActiveSpan } from '@sentry/core'; +import { + captureException, + consoleSandbox, + defineIntegration, + getClient, + isMatchingPattern, + withActiveSpan, +} from '@sentry/core'; import { logAndExitProcess } from '../utils/errorhandling'; type UnhandledRejectionMode = 'none' | 'warn' | 'strict'; +type IgnoreMatcher = { name?: string | RegExp; message?: string | RegExp }; + interface OnUnhandledRejectionOptions { /** * Option deciding what to do after capturing unhandledRejection, * that mimicks behavior of node's --unhandled-rejection flag. */ mode: UnhandledRejectionMode; + /** Rejection Errors to ignore (don't capture or warn). */ + ignore?: IgnoreMatcher[]; } const INTEGRATION_NAME = 'OnUnhandledRejection'; +const DEFAULT_IGNORES: IgnoreMatcher[] = [ + { + name: 'AI_NoOutputGeneratedError', // When stream aborts in Vercel AI SDK, Vercel flush() fails with an error + }, +]; + const _onUnhandledRejectionIntegration = ((options: Partial = {}) => { - const opts = { - mode: 'warn', - ...options, - } satisfies OnUnhandledRejectionOptions; + const opts: OnUnhandledRejectionOptions = { + mode: options.mode ?? 'warn', + ignore: [...DEFAULT_IGNORES, ...(options.ignore ?? [])], + }; return { name: INTEGRATION_NAME, @@ -28,27 +45,54 @@ const _onUnhandledRejectionIntegration = ((options: Partial; + const name = typeof errorLike.name === 'string' ? errorLike.name : ''; + const message = typeof errorLike.message === 'string' ? errorLike.message : String(reason); + + return { name, message }; +} + +/** Check if a matcher matches the reason */ +function isMatchingReason(matcher: IgnoreMatcher, errorInfo: ReturnType): boolean { + // name/message matcher + const nameMatches = matcher.name === undefined || isMatchingPattern(errorInfo.name, matcher.name, true); + + const messageMatches = matcher.message === undefined || isMatchingPattern(errorInfo.message, matcher.message); + + return nameMatches && messageMatches; +} + +/** Match helper */ +function matchesIgnore(list: IgnoreMatcher[], reason: unknown): boolean { + const errorInfo = extractErrorInfo(reason); + return list.some(matcher => isMatchingReason(matcher, errorInfo)); +} + +/** Core handler */ export function makeUnhandledPromiseHandler( client: Client, options: OnUnhandledRejectionOptions, ): (reason: unknown, promise: unknown) => void { return function sendUnhandledPromise(reason: unknown, promise: unknown): void { + // Only handle for the active client if (getClient() !== client) { return; } + // Skip if configured to ignore + if (matchesIgnore(options.ignore ?? [], reason)) { + return; + } + const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error'; // this can be set in places where we cannot reliably get access to the active span/error diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts new file mode 100644 index 000000000000..af3f41735c4a --- /dev/null +++ b/packages/node-core/src/integrations/pino.ts @@ -0,0 +1,149 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { IntegrationFn, LogSeverityLevel } from '@sentry/core'; +import { + _INTERNAL_captureLog, + addExceptionMechanism, + captureException, + captureMessage, + defineIntegration, + severityLevelFromString, + withScope, +} from '@sentry/core'; +import { addInstrumentationConfig } from '../sdk/injectLoader'; + +type LevelMapping = { + // Fortunately pino uses the same levels as Sentry + labels: { [level: number]: LogSeverityLevel }; +}; + +type Pino = { + levels: LevelMapping; +}; + +type MergeObject = { + [key: string]: unknown; + err?: Error; +}; + +type PinoHookArgs = [MergeObject, string, number]; + +type PinoOptions = { + error: { + /** + * Levels that trigger capturing of events. + * + * @default [] + */ + levels: LogSeverityLevel[]; + /** + * By default, Sentry will mark captured errors as handled. + * Set this to `false` if you want to mark them as unhandled instead. + * + * @default true + */ + handled: boolean; + }; + log: { + /** + * Levels that trigger capturing of logs. Logs are only captured if + * `enableLogs` is enabled. + * + * @default ["trace", "debug", "info", "warn", "error", "fatal"] + */ + levels: LogSeverityLevel[]; + }; +}; + +const DEFAULT_OPTIONS: PinoOptions = { + error: { levels: [], handled: true }, + log: { levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] }, +}; + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? Partial : T[P]; +}; + +/** + * Integration for Pino logging library. + * Captures Pino logs as Sentry logs and optionally captures some log levels as events. + * + * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 + */ +export const pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { + const options: PinoOptions = { + error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, + log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, + }; + + return { + name: 'Pino', + setup: client => { + const enableLogs = !!client.getOptions().enableLogs; + + addInstrumentationConfig({ + channelName: 'pino-log', + // From Pino v9.10.0 a tracing channel is available directly from Pino: + // https://github.com/pinojs/pino/pull/2281 + module: { name: 'pino', versionRange: '>=8.0.0 < 9.10.0', filePath: 'lib/tools.js' }, + functionQuery: { + functionName: 'asJson', + kind: 'Sync', + }, + }); + + const injectedChannel = tracingChannel('orchestrion:pino:pino-log'); + const integratedChannel = tracingChannel('pino_asJson'); + + function onPinoStart(self: Pino, args: PinoHookArgs): void { + const [obj, message, levelNumber] = args; + const level = self?.levels?.labels?.[levelNumber] || 'info'; + + const attributes = { + ...obj, + 'sentry.origin': 'auto.logging.pino', + 'sentry.pino.level': levelNumber, + }; + + if (enableLogs && options.log.levels.includes(level)) { + _INTERNAL_captureLog({ level, message, attributes }); + } + + if (options.error.levels.includes(level)) { + const captureContext = { + level: severityLevelFromString(level), + }; + + withScope(scope => { + scope.addEventProcessor(event => { + event.logger = 'pino'; + + addExceptionMechanism(event, { + handled: options.error.handled, + type: 'pino', + }); + + return event; + }); + + if (obj.err) { + captureException(obj.err, captureContext); + return; + } + + captureMessage(message, captureContext); + }); + } + } + + injectedChannel.start.subscribe(data => { + const { self, arguments: args } = data as { self: Pino; arguments: PinoHookArgs }; + onPinoStart(self, args); + }); + + integratedChannel.start.subscribe(data => { + const { instance, arguments: args } = data as { instance: Pino; arguments: PinoHookArgs }; + onPinoStart(instance, args); + }); + }, + }; +}) satisfies IntegrationFn; diff --git a/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts new file mode 100644 index 000000000000..c4ae4897678d --- /dev/null +++ b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts @@ -0,0 +1,11 @@ +declare module '@apm-js-collab/tracing-hooks' { + import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + + type PatchConfig = { instrumentations: InstrumentationConfig[] }; + + /** Hooks require */ + export default class ModulePatch { + public constructor(config: PatchConfig): ModulePatch; + public patch(): void; + } +} diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index c4a16d76a1d0..d53f5d4faefb 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -7,6 +7,7 @@ import { functionToStringIntegration, getCurrentScope, getIntegrationsToSetup, + GLOBAL_OBJ, hasSpansEnabled, inboundFiltersIntegration, linkedErrorsIntegration, @@ -131,6 +132,8 @@ function _init( client.init(); + GLOBAL_OBJ._sentryInjectLoaderHookRegister?.(); + debug.log(`SDK initialized from ${isCjs() ? 'CommonJS' : 'ESM'}`); client.startClientReportTracking(); diff --git a/packages/node-core/src/sdk/injectLoader.ts b/packages/node-core/src/sdk/injectLoader.ts new file mode 100644 index 000000000000..667996ebbe53 --- /dev/null +++ b/packages/node-core/src/sdk/injectLoader.ts @@ -0,0 +1,46 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { debug, GLOBAL_OBJ } from '@sentry/core'; +import * as moduleModule from 'module'; +import { supportsEsmLoaderHooks } from '../utils/detection'; + +let instrumentationConfigs: InstrumentationConfig[] | undefined; + +/** + * Add an instrumentation config to be used by the injection loader. + */ +export function addInstrumentationConfig(config: InstrumentationConfig): void { + if (!supportsEsmLoaderHooks()) { + return; + } + + if (!instrumentationConfigs) { + instrumentationConfigs = []; + } + + instrumentationConfigs.push(config); + + GLOBAL_OBJ._sentryInjectLoaderHookRegister = () => { + if (GLOBAL_OBJ._sentryInjectLoaderHookRegistered) { + return; + } + + GLOBAL_OBJ._sentryInjectLoaderHookRegistered = true; + + const instrumentations = instrumentationConfigs || []; + + // Patch require to support CJS modules + const requirePatch = new ModulePatch({ instrumentations }); + requirePatch.patch(); + + // Add ESM loader to support ESM modules + try { + // @ts-expect-error register is available in these versions + moduleModule.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations }, + }); + } catch (error) { + debug.warn("Failed to register '@apm-js-collab/tracing-hooks' hook", error); + } + }; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 4808f22b472b..db378e55f6ca 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -171,6 +171,7 @@ export { disableAnrDetectionForCallback, spotlightIntegration, childProcessIntegration, + pinoIntegration, createSentryWinstonTransport, SentryContextManager, systemErrorIntegration, diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 7e9445a154a7..1e806e4dc2eb 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -72,6 +72,18 @@ export default defineNuxtModule({ mode: 'client', order: 1, }); + + // Add the sentry config file to the include array + nuxt.hook('prepare:types', options => { + if (!options.tsConfig.include) { + options.tsConfig.include = []; + } + + // Add type references for useRuntimeConfig in root files for nuxt v4 + // Should be relative to `root/.nuxt` + const relativePath = path.relative(nuxt.options.buildDir, clientConfigFile); + options.tsConfig.include.push(relativePath); + }); } const serverConfigFile = findDefaultSdkInitFile('server', nuxt); diff --git a/packages/pino-transport/.eslintrc.js b/packages/pino-transport/.eslintrc.js deleted file mode 100644 index 01c6be4c7080..000000000000 --- a/packages/pino-transport/.eslintrc.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - env: { - node: true, - }, - extends: ['../../.eslintrc.js'], - overrides: [ - { - files: ['src/**/*.ts'], - rules: {}, - }, - ], -}; diff --git a/packages/pino-transport/LICENSE b/packages/pino-transport/LICENSE deleted file mode 100644 index 5251db3eaaca..000000000000 --- a/packages/pino-transport/LICENSE +++ /dev/null @@ -1,16 +0,0 @@ -MIT License - -Copyright (c) 2025 Functional Software, Inc. dba Sentry - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the -Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/pino-transport/README.md b/packages/pino-transport/README.md deleted file mode 100644 index fdc077aa2b01..000000000000 --- a/packages/pino-transport/README.md +++ /dev/null @@ -1,266 +0,0 @@ -

- - Sentry - -

- -# Official Sentry Pino Transport - -[![npm version](https://img.shields.io/npm/v/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) -[![npm dm](https://img.shields.io/npm/dm/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) -[![npm dt](https://img.shields.io/npm/dt/@sentry/solid.svg)](https://www.npmjs.com/package/@sentry/solid) - -**WARNING**: This transport is in a **pre-release alpha**. The API is unstable and may change at any time. - -A Pino transport for sending logs to Sentry using the Sentry JavaScript SDK. - -This transport forwards Pino logs to Sentry, allowing you to view and analyze your application logs alongside your errors and performance data in Sentry. - -## Installation - -```bash -npm install @sentry/pino-transport pino -# or -yarn add @sentry/pino-transport pino -# or -pnpm add @sentry/pino-transport pino -``` - -## Requirements - -- Node.js 18+ -- Pino v8 or v9 -- `@sentry/node` SDK with `enableLogs: true` - -## Setup - -First, make sure Sentry is initialized with logging enabled: - -```javascript -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: 'YOUR_DSN', - enableLogs: true, -}); -``` - -Then create a Pino logger with the Sentry transport: - -```javascript -import pino from 'pino'; - -const logger = pino({ - transport: { - target: '@sentry/pino-transport', - options: { - // Optional: filter which log levels to send to Sentry - levels: ['error', 'fatal'], // defaults to all levels - }, - }, -}); - -// Now your logs will be sent to Sentry -logger.info('This is an info message'); -logger.error('This is an error message'); -``` - -## Configuration Options - -The transport accepts the following options: - -### `logLevels` - -**Type:** `Array<'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'>` - -**Default:** `['trace', 'debug', 'info', 'warn', 'error', 'fatal']` (all log levels) - -Use this option to filter which log severity levels should be sent to Sentry. - -```javascript -const transport = pino.transport({ - target: '@sentry/pino-transport', - options: { - logLevels: ['warn', 'error', 'fatal'], // Only send warnings and above - }, -}); -``` - -## Log Level Mapping - -Pino log levels are automatically mapped to Sentry log severity levels: - -| Pino Level | Pino Numeric | Sentry Level | -| ---------- | ------------ | ------------ | -| trace | 10 | trace | -| debug | 20 | debug | -| info | 30 | info | -| warn | 40 | warn | -| error | 50 | error | -| fatal | 60 | fatal | - -### Custom Levels Support - -Custom numeric levels are mapped to Sentry levels using ranges, so levels like `11`, `23`, or `42` will map correctly: - -- `0-19` → `trace` -- `20-29` → `debug` -- `30-39` → `info` -- `40-49` → `warn` -- `50-59` → `error` -- `60+` → `fatal` - -```javascript -import pino from 'pino'; - -const logger = pino({ - customLevels: { - critical: 55, // Maps to 'fatal' (55+ range) - notice: 35, // Maps to 'warn' (35-44 range) - verbose: 11, // Maps to 'trace' (0-14 range) - }, - transport: { - target: '@sentry/pino-transport', - }, -}); - -logger.critical('Critical issue occurred'); // → Sent as 'fatal' to Sentry -logger.notice('Important notice'); // → Sent as 'warn' to Sentry -logger.verbose('Detailed information'); // → Sent as 'trace' to Sentry -``` - -#### Custom Level Attributes - -When using custom string levels, the original level name is preserved as `sentry.pino.level` attribute for better traceability: - -```javascript -// Log entry in Sentry will include: -// { -// level: 'warn', // Mapped Sentry level -// message: 'Audit event', -// attributes: { -// 'sentry.pino.level': 'audit', // Original custom level name -// 'sentry.origin': 'auto.logging.pino', -// // ... other log attributes -// } -// } -``` - -### Custom Message Key - -The transport respects Pino's `messageKey` configuration: - -```javascript -const logger = pino({ - messageKey: 'message', // Use 'message' instead of default 'msg' - transport: { - target: '@sentry/pino-transport', - }, -}); - -logger.info({ message: 'Hello world' }); // Works correctly with custom messageKey -``` - -### Nested Key Support - -The transport automatically supports Pino's `nestedKey` configuration, which is used to avoid property conflicts by nesting logged objects under a specific key. When `nestedKey` is configured, the transport flattens these nested properties using dot notation for better searchability in Sentry. - -```javascript -const logger = pino({ - nestedKey: 'payload', // Nest logged objects under 'payload' key - transport: { - target: '@sentry/pino-transport', - }, -}); - -const conflictingObject = { - level: 'hi', // Conflicts with Pino's level - time: 'never', // Conflicts with Pino's time - foo: 'bar', - userId: 123, -}; - -logger.info(conflictingObject); - -// Without nestedKey, this would cause property conflicts -// With nestedKey, Pino creates: { level: 30, time: 1234567890, payload: conflictingObject } -// The transport flattens it to: -// { -// level: 'info', -// message: undefined, -// attributes: { -// 'payload.level': 'hi', // Flattened nested properties -// 'payload.time': 'never', -// 'payload.foo': 'bar', -// 'payload.userId': 123, -// 'sentry.origin': 'auto.logging.pino', -// } -// } -``` - -This flattening ensures that no property conflicts occur between logged objects and Pino's internal properties. - -## Usage Examples - -### Basic Logging - -```javascript -import pino from 'pino'; - -const logger = pino({ - transport: { - target: '@sentry/pino-transport', - }, -}); - -logger.trace('Starting application'); -logger.debug('Debug information', { userId: 123 }); -logger.info('User logged in', { userId: 123, username: 'john_doe' }); -logger.warn('Deprecated API used', { endpoint: '/old-api' }); -logger.error('Database connection failed', { error: 'Connection timeout' }); -logger.fatal('Application crashed', { reason: 'Out of memory' }); -``` - -### Multiple Transports - -```javascript -import pino from 'pino'; - -const logger = pino({ - transport: { - targets: [ - { - target: 'pino-pretty', - options: { colorize: true }, - level: 'debug', - }, - { - target: '@sentry/pino-transport', - options: { - logLevels: ['warn', 'error', 'fatal'], - }, - level: 'warn', - }, - ], - }, -}); -``` - -## Troubleshooting - -### Logs not appearing in Sentry - -1. Ensure `enableLogs: true` is set in your Sentry configuration. -2. Check that your DSN is correct and the SDK is properly initialized. -3. Verify the log level is included in the `levels` configuration. -4. Check your Sentry organization stats page to see if logs are being received by Sentry. - -## Related Documentation - -- [Sentry Logs Documentation](https://docs.sentry.io/platforms/javascript/guides/node/logs/) -- [Pino Documentation](https://getpino.io/) -- [Pino Transports](https://getpino.io/#/docs/transports) - -## License - -MIT diff --git a/packages/pino-transport/package.json b/packages/pino-transport/package.json deleted file mode 100644 index 13c83d19b6ee..000000000000 --- a/packages/pino-transport/package.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "@sentry/pino-transport", - "version": "10.17.0", - "description": "Pino transport for Sentry SDK", - "repository": "git://github.com/getsentry/sentry-javascript.git", - "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/pino-transport", - "author": "Sentry", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "files": [ - "/build" - ], - "main": "build/cjs/index.js", - "module": "build/esm/index.js", - "types": "build/types/index.d.ts", - "exports": { - "./package.json": "./package.json", - ".": { - "import": { - "types": "./build/types/index.d.ts", - "default": "./build/esm/index.js" - }, - "require": { - "types": "./build/types/index.d.ts", - "default": "./build/cjs/index.js" - } - } - }, - "typesVersions": { - "<5.0": { - "build/types/index.d.ts": [ - "build/types-ts3.8/index.d.ts" - ] - } - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@sentry/core": "10.17.0", - "@sentry/node": "10.17.0", - "pino-abstract-transport": "^2.0.0" - }, - "peerDependencies": { - "pino": "^8.0.0 || ^9.0.0" - }, - "devDependencies": { - "@types/node": "^18.19.1", - "pino": "^9.0.0" - }, - "scripts": { - "build": "run-p build:transpile build:types", - "build:dev": "yarn build", - "build:transpile": "rollup -c rollup.npm.config.mjs", - "build:types": "run-s build:types:core build:types:downlevel", - "build:types:core": "tsc -p tsconfig.types.json", - "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", - "build:watch": "run-p build:transpile:watch build:types:watch", - "build:dev:watch": "yarn build:watch", - "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", - "build:types:watch": "tsc -p tsconfig.types.json --watch", - "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.ts", - "clean": "rimraf build coverage sentry-pino-transport-*.tgz", - "fix": "eslint . --format stylish --fix", - "lint": "eslint . --format stylish", - "lint:es-compatibility": "es-check es2022 ./build/cjs/*.js && es-check es2022 ./build/esm/*.js --module", - "test": "yarn test:unit", - "test:unit": "vitest run", - "test:watch": "vitest --watch", - "yalc:publish": "yalc publish --push --sig" - }, - "volta": { - "extends": "../../package.json" - }, - "sideEffects": false -} diff --git a/packages/pino-transport/rollup.npm.config.mjs b/packages/pino-transport/rollup.npm.config.mjs deleted file mode 100644 index 84a06f2fb64a..000000000000 --- a/packages/pino-transport/rollup.npm.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; - -export default makeNPMConfigVariants(makeBaseNPMConfig()); diff --git a/packages/pino-transport/src/debug-build.ts b/packages/pino-transport/src/debug-build.ts deleted file mode 100644 index 60aa50940582..000000000000 --- a/packages/pino-transport/src/debug-build.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const __DEBUG_BUILD__: boolean; - -/** - * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. - * - * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. - */ -export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/pino-transport/src/index.ts b/packages/pino-transport/src/index.ts deleted file mode 100644 index 986c7e892fc2..000000000000 --- a/packages/pino-transport/src/index.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { LogSeverityLevel } from '@sentry/core'; -import { _INTERNAL_captureLog, debug, isPrimitive, normalize } from '@sentry/core'; -import type buildType from 'pino-abstract-transport'; -import * as pinoAbstractTransport from 'pino-abstract-transport'; -import { DEBUG_BUILD } from './debug-build'; - -// Handle both CommonJS and ES module exports -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -const build = (pinoAbstractTransport as any).default || pinoAbstractTransport; - -/** - * The default log levels that will be captured by the Sentry Pino transport. - */ -const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; - -/** - * Options for the Sentry Pino transport. - */ -export interface SentryPinoTransportOptions { - /** - * Use this option to filter which levels should be captured as logs. - * By default, all levels are captured as logs. - * - * @example - * ```ts - * const logger = pino({ - * transport: { - * target: '@sentry/pino-transport', - * options: { - * logLevels: ['error', 'warn'], // Only capture error and warn logs - * }, - * }, - * }); - * ``` - */ - logLevels?: Array; -} - -/** - * Pino source configuration passed to the transport. - * This interface represents the configuration options that Pino provides to transports. - */ -interface PinoSourceConfig { - /** - * Custom levels configuration from Pino. - * Contains the mapping of custom level names to numeric values. - * - * @default undefined - * @example { values: { critical: 55, notice: 35 } } - */ - levels?: unknown; - - /** - * The property name used for the log message. - * Pino allows customizing which property contains the main log message. - * - * @default 'msg' - * @example 'message' when configured with messageKey: 'message' - * @see https://getpino.io/#/docs/api?id=messagekey-string - */ - messageKey?: string; - - /** - * The property name used for error objects. - * Pino allows customizing which property contains error information. - * - * @default 'err' - * @example 'error' when configured with errorKey: 'error' - * @see https://getpino.io/#/docs/api?id=errorkey-string - */ - errorKey?: string; - - /** - * The property name used to nest logged objects to avoid conflicts. - * When set, Pino nests all logged objects under this key to prevent - * conflicts with Pino's internal properties (level, time, pid, etc.). - * The transport flattens these nested properties using dot notation. - * - * @default undefined (no nesting) - * @example 'payload' - objects logged will be nested under { payload: {...} } - * @see https://getpino.io/#/docs/api?id=nestedkey-string - */ - nestedKey?: string; -} - -/** - * Creates a new Sentry Pino transport that forwards logs to Sentry. Requires the `enableLogs` option to be enabled. - * - * Supports Pino v8 and v9. - * - * @param options - Options for the transport. - * @returns A Pino transport that forwards logs to Sentry. - * - * @experimental This method will experience breaking changes. This is not yet part of - * the stable Sentry SDK API and can be changed or removed without warning. - */ -export function createSentryPinoTransport(options?: SentryPinoTransportOptions): ReturnType { - DEBUG_BUILD && debug.log('Initializing Sentry Pino transport'); - const capturedLogLevels = new Set(options?.logLevels ?? DEFAULT_CAPTURED_LEVELS); - - return build( - async function (source: AsyncIterable & PinoSourceConfig) { - for await (const log of source) { - try { - if (!isObject(log)) { - continue; - } - - // Use Pino's messageKey if available, fallback to 'msg' - const messageKey = source.messageKey || 'msg'; - const message = log[messageKey]; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [messageKey]: _, level, time, ...attributes } = log; - - // Handle nestedKey flattening if configured - if (source.nestedKey && attributes[source.nestedKey] && isObject(attributes[source.nestedKey])) { - const nestedObject = attributes[source.nestedKey] as Record; - // Remove the nested object and flatten its properties - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete attributes[source.nestedKey]; - - // Flatten nested properties with dot notation - for (const [key, value] of Object.entries(nestedObject)) { - attributes[`${source.nestedKey}.${key}`] = value; - } - } - - const logSeverityLevel = mapPinoLevelToSentryLevel(log.level, source.levels); - - if (capturedLogLevels.has(logSeverityLevel)) { - const logAttributes: Record = { - ...attributes, - 'sentry.origin': 'auto.logging.pino', - }; - - // Attach custom level as an attribute if it's a string (custom level) - if (typeof log.level === 'string') { - logAttributes['sentry.pino.level'] = log.level; - } - - _INTERNAL_captureLog({ - level: logSeverityLevel, - message: formatMessage(message), - attributes: logAttributes, - }); - } - } catch { - // Silently ignore errors to prevent breaking the logging pipeline - } - } - }, - { - expectPinoConfig: true, - }, - ); -} - -function formatMessage(message: unknown): string { - if (message === undefined) { - return ''; - } - - if (isPrimitive(message)) { - return String(message); - } - return JSON.stringify(normalize(message)); -} - -/** - * Maps a Pino log level (numeric or custom string) to a Sentry log severity level. - * - * Handles both standard and custom levels, including when `useOnlyCustomLevels` is enabled. - * Uses range-based mapping for numeric levels to handle custom values (e.g., 11 -> trace). - */ -function mapPinoLevelToSentryLevel(level: unknown, levelsConfig?: unknown): LogSeverityLevel { - // Handle numeric levels - if (typeof level === 'number') { - return mapNumericLevelToSentryLevel(level); - } - - // Handle custom string levels - if ( - typeof level === 'string' && - isObject(levelsConfig) && - 'values' in levelsConfig && - isObject(levelsConfig.values) - ) { - // Map custom string levels to numeric then to Sentry levels - const numericLevel = levelsConfig.values[level]; - if (typeof numericLevel === 'number') { - return mapNumericLevelToSentryLevel(numericLevel); - } - } - - // Default fallback - return 'info'; -} - -/** - * Maps a numeric level to the closest Sentry severity level using range-based mapping. - * Handles both standard Pino levels and custom numeric levels. - * - * - `0-19` -> `trace` - * - `20-29` -> `debug` - * - `30-39` -> `info` - * - `40-49` -> `warn` - * - `50-59` -> `error` - * - `60+` -> `fatal` - * - * @see https://github.com/pinojs/pino/blob/116b1b17935630b97222fbfd1c053d199d18ca4b/lib/constants.js#L6-L13 - */ -function mapNumericLevelToSentryLevel(numericLevel: number): LogSeverityLevel { - // 0-19 -> trace - if (numericLevel < 20) { - return 'trace'; - } - // 20-29 -> debug - if (numericLevel < 30) { - return 'debug'; - } - // 30-39 -> info - if (numericLevel < 40) { - return 'info'; - } - // 40-49 -> warn - if (numericLevel < 50) { - return 'warn'; - } - // 50-59 -> error - if (numericLevel < 60) { - return 'error'; - } - // 60+ -> fatal - return 'fatal'; -} - -/** - * Type guard to check if a value is an object. - */ -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value != null; -} - -export default createSentryPinoTransport; diff --git a/packages/pino-transport/test/index.test.ts b/packages/pino-transport/test/index.test.ts deleted file mode 100644 index a93d56f340cd..000000000000 --- a/packages/pino-transport/test/index.test.ts +++ /dev/null @@ -1,708 +0,0 @@ -import { _INTERNAL_captureLog } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createSentryPinoTransport } from '../src'; - -// Mock the _INTERNAL_captureLog function -vi.mock('@sentry/core', async actual => { - const actualModule = (await actual()) as any; - return { - ...actualModule, - _INTERNAL_captureLog: vi.fn(), - }; -}); - -const mockCaptureLog = vi.mocked(_INTERNAL_captureLog); - -describe('createSentryPinoTransport', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should be defined', () => { - expect(createSentryPinoTransport).toBeDefined(); - }); - - it('should create a transport that forwards logs to Sentry', async () => { - const transport = await createSentryPinoTransport(); - expect(transport).toBeDefined(); - expect(typeof transport.write).toBe('function'); - }); - - it('should capture logs with correct level mapping', async () => { - const transport = await createSentryPinoTransport(); - - // Simulate a Pino log entry - const testLog = { - level: 30, // info level in Pino - msg: 'Test message', - time: Date.now(), - hostname: 'test-host', - pid: 12345, - }; - - // Write the log to the transport - transport.write(`${JSON.stringify(testLog)}\n`); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message', - attributes: expect.objectContaining({ - hostname: 'test-host', - pid: 12345, - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should map all Pino log levels correctly', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [ - { pinoLevel: 10, expectedSentryLevel: 'trace' }, - { pinoLevel: 20, expectedSentryLevel: 'debug' }, - { pinoLevel: 30, expectedSentryLevel: 'info' }, - { pinoLevel: 40, expectedSentryLevel: 'warn' }, - { pinoLevel: 50, expectedSentryLevel: 'error' }, - { pinoLevel: 60, expectedSentryLevel: 'fatal' }, - ]; - - for (const { pinoLevel, expectedSentryLevel } of testCases) { - const testLog = { - level: pinoLevel, - msg: `Test ${expectedSentryLevel} message`, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - // Give it a moment to process all logs - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(6); - - testCases.forEach(({ expectedSentryLevel }, index) => { - expect(mockCaptureLog).toHaveBeenNthCalledWith(index + 1, { - level: expectedSentryLevel, - message: `Test ${expectedSentryLevel} message`, - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - }); - - it('should respect level filtering', async () => { - const transport = await createSentryPinoTransport({ - logLevels: ['error', 'fatal'], - }); - - const testLogs = [ - { level: 30, msg: 'Info message' }, // Should be filtered out - { level: 50, msg: 'Error message' }, // Should be captured - { level: 60, msg: 'Fatal message' }, // Should be captured - ]; - - for (const testLog of testLogs) { - transport.write(`${JSON.stringify(testLog)}\n`); - } - - // Give it a moment to process all logs - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(2); - expect(mockCaptureLog).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - level: 'error', - message: 'Error message', - }), - ); - expect(mockCaptureLog).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - level: 'fatal', - message: 'Fatal message', - }), - ); - }); - - it('should handle unknown levels gracefully', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 999, // Unknown level - msg: 'Unknown level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'fatal', // 999 maps to fatal (55+ range) - message: 'Unknown level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle non-numeric levels gracefully', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 'invalid', // Non-numeric level - msg: 'Invalid level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Default fallback - message: 'Invalid level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - 'sentry.pino.level': 'invalid', - }), - }); - }); - - it('should handle malformed JSON gracefully', async () => { - const transport = await createSentryPinoTransport(); - - // Write invalid JSON - transport.write('{ invalid json \n'); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - // Should not crash and should not call captureLog - expect(mockCaptureLog).not.toHaveBeenCalled(); - }); - - it('should handle non-object logs gracefully', async () => { - const transport = await createSentryPinoTransport(); - - // Write a string instead of an object - transport.write('"just a string"\n'); - - // Give it a moment to process - await new Promise(resolve => setTimeout(resolve, 10)); - - // pino-abstract-transport parses JSON, so this actually becomes an object - // The transport should handle it gracefully by logging it - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Default fallback since no level provided - message: '', // Empty string for undefined message - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle string levels gracefully when no custom levels config is available', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 'custom', // String level without custom levels config - msg: 'Custom string level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Should fallback to info for unknown string levels - message: 'Custom string level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - 'sentry.pino.level': 'custom', - }), - }); - }); - - it('should attach custom level name as attribute for string levels', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 'critical', // Custom string level - msg: 'Critical level message', - time: Date.now(), - userId: 123, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', // Mapped level - message: 'Critical level message', - attributes: expect.objectContaining({ - userId: 123, - 'sentry.origin': 'auto.logging.pino', - 'sentry.pino.level': 'critical', // Original custom level name preserved - }), - }); - }); - - it('should not attach custom level attribute for numeric levels', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, // Standard numeric level - msg: 'Standard level message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Standard level message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - // Should NOT have 'sentry.pino.level' for numeric levels - }), - }); - - // Explicitly check that the custom level attribute is not present - const capturedCall = mockCaptureLog.mock.calls[0][0]; - expect(capturedCall.attributes).not.toHaveProperty('sentry.pino.level'); - }); - - it('should handle custom numeric levels with range-based mapping', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [ - { level: 11, expectedSentryLevel: 'trace' }, // 11 is in trace range (0-14) - { level: 23, expectedSentryLevel: 'debug' }, // 23 is in debug range (15-24) - { level: 33, expectedSentryLevel: 'info' }, // 33 is in info range (25-34) - { level: 42, expectedSentryLevel: 'warn' }, // 42 is in warn range (35-44) - { level: 52, expectedSentryLevel: 'error' }, // 52 is in error range (45-54) - { level: 75, expectedSentryLevel: 'fatal' }, // 75 is in fatal range (55+) - ]; - - for (const { level } of testCases) { - const testLog = { - level, - msg: `Custom numeric level ${level}`, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(6); - - testCases.forEach(({ level, expectedSentryLevel }, index) => { - expect(mockCaptureLog).toHaveBeenNthCalledWith(index + 1, { - level: expectedSentryLevel, - message: `Custom numeric level ${level}`, - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - }); - - it('should handle nested keys', async () => { - const transport = await createSentryPinoTransport(); - - // Test with logs that include a nested object structure as Pino would create - // when nestedKey is configured (we'll test by manually checking the flattening logic) - const testLog = { - level: 30, - msg: 'Test message with nested payload', - time: Date.now(), - payload: { - level: 'hi', // Conflicting with Pino's level - time: 'never', // Conflicting with Pino's time - foo: 'bar', - userId: 123, - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Without nestedKey configuration, the nested object should remain as-is - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message with nested payload', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - payload: { - level: 'hi', - time: 'never', - foo: 'bar', - userId: 123, - }, // Should remain nested without nestedKey config - }), - }); - }); - - it('should handle logs without conflicting nested objects', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 40, - msg: 'Warning with simple nested data', - time: Date.now(), - data: { - errorCode: 'E001', - module: 'auth', - details: 'Invalid credentials', - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'warn', - message: 'Warning with simple nested data', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - data: { - errorCode: 'E001', - module: 'auth', - details: 'Invalid credentials', - }, // Should remain as nested object - }), - }); - }); - - it('should handle logs with multiple nested objects', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'Test message with multiple nested objects', - time: Date.now(), - user: { - id: 123, - name: 'John Doe', - }, - request: { - method: 'POST', - url: '/api/users', - headers: { - 'content-type': 'application/json', - }, - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message with multiple nested objects', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - user: { - id: 123, - name: 'John Doe', - }, - request: { - method: 'POST', - url: '/api/users', - headers: { - 'content-type': 'application/json', - }, - }, - }), - }); - }); - - it('should handle null nested objects', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'Test message with null values', - time: Date.now(), - data: null, - user: undefined, - config: { - setting: null, - }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Test message with null values', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - data: null, - config: { - setting: null, - }, - }), - }); - }); - - it('should work normally with mixed data types', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'Mixed data types log', - time: Date.now(), - stringValue: 'test', - numberValue: 42, - booleanValue: true, - arrayValue: [1, 2, 3], - objectValue: { nested: 'value' }, - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'Mixed data types log', - attributes: expect.objectContaining({ - stringValue: 'test', - numberValue: 42, - booleanValue: true, - arrayValue: [1, 2, 3], - objectValue: { nested: 'value' }, - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle string messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 'This is a string message', - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: 'This is a string message', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle number messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: 42, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '42', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle boolean messages', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [{ msg: true }, { msg: false }]; - - for (const { msg } of testCases) { - const testLog = { - level: 30, - msg, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(2); - expect(mockCaptureLog).toHaveBeenNthCalledWith(1, { - level: 'info', - message: 'true', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - expect(mockCaptureLog).toHaveBeenNthCalledWith(2, { - level: 'info', - message: 'false', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle null and undefined messages', async () => { - const transport = await createSentryPinoTransport(); - - const testCases = [{ msg: null }, { msg: undefined }]; - - for (const { msg } of testCases) { - const testLog = { - level: 30, - msg, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledTimes(2); - expect(mockCaptureLog).toHaveBeenNthCalledWith(1, { - level: 'info', - message: 'null', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - expect(mockCaptureLog).toHaveBeenNthCalledWith(2, { - level: 'info', - message: '', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle object messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: { key: 'value', nested: { prop: 123 } }, - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '{"key":"value","nested":{"prop":123}}', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle array messages', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - msg: [1, 'two', { three: 3 }], - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '[1,"two",{"three":3}]', - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle circular object messages gracefully', async () => { - const transport = await createSentryPinoTransport(); - - // Create a test log with a circular object as the message - // We can't use JSON.stringify directly, so we'll simulate what happens - const testLog = { - level: 30, - msg: { name: 'test', circular: true }, // Simplified object that represents circular data - time: Date.now(), - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '{"name":"test","circular":true}', // The object should be serialized normally - attributes: expect.objectContaining({ - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); - - it('should handle missing message gracefully', async () => { - const transport = await createSentryPinoTransport(); - - const testLog = { - level: 30, - // No msg property - time: Date.now(), - someOtherData: 'value', - }; - - transport.write(`${JSON.stringify(testLog)}\n`); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockCaptureLog).toHaveBeenCalledWith({ - level: 'info', - message: '', // Empty string for undefined message - attributes: expect.objectContaining({ - someOtherData: 'value', - 'sentry.origin': 'auto.logging.pino', - }), - }); - }); -}); diff --git a/packages/pino-transport/tsconfig.json b/packages/pino-transport/tsconfig.json deleted file mode 100644 index 64d6f3a1b9e0..000000000000 --- a/packages/pino-transport/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.json", - - "include": ["src/**/*"], - - "compilerOptions": { - "lib": ["es2020"], - "module": "Node16" - } -} diff --git a/packages/pino-transport/tsconfig.test.json b/packages/pino-transport/tsconfig.test.json deleted file mode 100644 index 4c24dbbea96e..000000000000 --- a/packages/pino-transport/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["test/**/*", "src/**/*", "vite.config.ts"], - "compilerOptions": { - "types": ["vitest/globals", "node"] - } -} diff --git a/packages/pino-transport/tsconfig.types.json b/packages/pino-transport/tsconfig.types.json deleted file mode 100644 index f35cdd6b5d81..000000000000 --- a/packages/pino-transport/tsconfig.types.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "build/types", - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": true, - "stripInternal": true - } -} diff --git a/packages/pino-transport/vite.config.ts b/packages/pino-transport/vite.config.ts deleted file mode 100644 index ff64487a9265..000000000000 --- a/packages/pino-transport/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import baseConfig from '../../vite/vite.config'; - -export default defineConfig({ - ...baseConfig, - test: { - ...baseConfig.test, - environment: 'node', - }, -}); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 1b41ba966a1b..328551df5fd1 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -50,7 +50,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/browser": "10.17.0", - "@sentry/cli": "^2.53.0", + "@sentry/cli": "^2.56.0", "@sentry/core": "10.17.0", "@sentry/node": "10.17.0", "@sentry/react": "10.17.0", diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx index eaf13eb16779..75080b827165 100644 --- a/packages/react-router/src/server/createSentryHandleRequest.tsx +++ b/packages/react-router/src/server/createSentryHandleRequest.tsx @@ -53,6 +53,22 @@ export interface SentryHandleRequestOptions { botRegex?: RegExp; } +type HandleRequestWithoutMiddleware = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, +) => Promise; + +type HandleRequestWithMiddleware = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: RouterContextProvider, +) => Promise; + /** * A complete Sentry-instrumented handleRequest implementation that handles both * route parametrization and trace meta tag injection. @@ -62,13 +78,7 @@ export interface SentryHandleRequestOptions { */ export function createSentryHandleRequest( options: SentryHandleRequestOptions, -): ( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - routerContext: EntryContext, - loadContext: AppLoadContext | RouterContextProvider, -) => Promise { +): HandleRequestWithoutMiddleware & HandleRequestWithMiddleware { const { streamTimeout = 10000, renderToPipeableStream, @@ -135,5 +145,6 @@ export function createSentryHandleRequest( }; // Wrap the handle request function for request parametrization - return wrapSentryHandleRequest(handleRequest); + return wrapSentryHandleRequest(handleRequest as HandleRequestWithoutMiddleware) as HandleRequestWithoutMiddleware & + HandleRequestWithMiddleware; } diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 5651ad208a9d..308b5c07a32b 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -10,12 +10,20 @@ import { } from '@sentry/core'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; -type OriginalHandleRequest = ( +type OriginalHandleRequestWithoutMiddleware = ( request: Request, responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext | RouterContextProvider, + loadContext: AppLoadContext, +) => Promise; + +type OriginalHandleRequestWithMiddleware = ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: RouterContextProvider, ) => Promise; /** @@ -24,7 +32,27 @@ type OriginalHandleRequest = ( * @param originalHandle - The original handleRequest function to wrap * @returns A wrapped version of the handle request function with Sentry instrumentation */ -export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest { +export function wrapSentryHandleRequest( + originalHandle: OriginalHandleRequestWithoutMiddleware, +): OriginalHandleRequestWithoutMiddleware; +/** + * Wraps the original handleRequest function to add Sentry instrumentation. + * + * @param originalHandle - The original handleRequest function to wrap + * @returns A wrapped version of the handle request function with Sentry instrumentation + */ +export function wrapSentryHandleRequest( + originalHandle: OriginalHandleRequestWithMiddleware, +): OriginalHandleRequestWithMiddleware; +/** + * Wraps the original handleRequest function to add Sentry instrumentation. + * + * @param originalHandle - The original handleRequest function to wrap + * @returns A wrapped version of the handle request function with Sentry instrumentation + */ +export function wrapSentryHandleRequest( + originalHandle: OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware, +): OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware { return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, @@ -57,10 +85,39 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): } try { - return await originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); + // Type guard to call the correct overload based on loadContext type + if (isRouterContextProvider(loadContext)) { + // loadContext is RouterContextProvider + return await (originalHandle as OriginalHandleRequestWithMiddleware)( + request, + responseStatusCode, + responseHeaders, + routerContext, + loadContext, + ); + } else { + // loadContext is AppLoadContext + return await (originalHandle as OriginalHandleRequestWithoutMiddleware)( + request, + responseStatusCode, + responseHeaders, + routerContext, + loadContext, + ); + } } finally { await flushIfServerless(); } + + /** + * Helper type guard to determine if the context is a RouterContextProvider. + * + * @param ctx - The context to check + * @returns True if the context is a RouterContextProvider + */ + function isRouterContextProvider(ctx: AppLoadContext | RouterContextProvider): ctx is RouterContextProvider { + return typeof (ctx as RouterContextProvider)?.get === 'function'; + } }; } diff --git a/packages/remix/package.json b/packages/remix/package.json index 67c687ef0405..66c1b859565a 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -68,7 +68,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", - "@sentry/cli": "^2.53.0", + "@sentry/cli": "^2.56.0", "@sentry/core": "10.17.0", "@sentry/node": "10.17.0", "@sentry/react": "10.17.0", diff --git a/yarn.lock b/yarn.lock index 8c030924b033..4d084a8cf3c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,6 +335,20 @@ dependencies: json-schema-to-ts "^3.1.1" +"@apm-js-collab/code-transformer@^0.8.0", "@apm-js-collab/code-transformer@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz#a3160f16d1c4df9cb81303527287ad18d00994d1" + integrity sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA== + +"@apm-js-collab/tracing-hooks@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz#414d3a93c3a15d8be543a3fac561f7c602b6a588" + integrity sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw== + dependencies: + "@apm-js-collab/code-transformer" "^0.8.0" + debug "^4.4.1" + module-details-from-path "^1.0.4" + "@apollo/protobufjs@1.2.6": version "1.2.6" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.6.tgz#d601e65211e06ae1432bf5993a1a0105f2862f27" @@ -4958,26 +4972,6 @@ dependencies: sparse-bitfield "^3.0.3" -"@nestjs/common@11.0.16": - version "11.0.16" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.0.16.tgz#b6550ac2998e9991f24a99563a93475542885ba7" - integrity sha512-agvuQ8su4aZ+PVxAmY89odG1eR97HEQvxPmTMdDqyvDWzNerl7WQhUEd+j4/UyNWcF1or1UVcrtPj52x+eUSsA== - dependencies: - uid "2.0.2" - iterare "1.2.1" - tslib "2.8.1" - -"@nestjs/common@11.1.3": - version "11.1.3" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.3.tgz#d954644da5f4d1b601e48ee71a0d3e3405d81ea1" - integrity sha512-ogEK+GriWodIwCw6buQ1rpcH4Kx+G7YQ9EwuPySI3rS05pSdtQ++UhucjusSI9apNidv+QURBztJkRecwwJQXg== - dependencies: - uid "2.0.2" - file-type "21.0.0" - iterare "1.2.1" - load-esm "1.0.2" - tslib "2.8.1" - "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -4987,28 +4981,15 @@ iterare "1.2.1" tslib "2.8.1" -"@nestjs/core@10.4.6": - version "10.4.6" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.4.6.tgz#797b381f12bd62d2e425897058fa219da4c3689d" - integrity sha512-zXVPxCNRfO6gAy0yvEDjUxE/8gfZICJFpsl2lZAUH31bPb6m+tXuhUq2mVCTEltyMYQ+DYtRe+fEYM2v152N1g== - dependencies: - uid "2.0.2" - "@nuxtjs/opencollective" "0.3.2" - fast-safe-stringify "2.1.1" - iterare "1.2.1" - path-to-regexp "3.3.0" - tslib "2.7.0" - -"@nestjs/core@11.1.3": - version "11.1.3" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.3.tgz#42a9c6261ff70ef49afa809c526134cae22021e8" - integrity sha512-5lTni0TCh8x7bXETRD57pQFnKnEg1T6M+VLE7wAmyQRIecKQU+2inRGZD+A4v2DC1I04eA0WffP0GKLxjOKlzw== +"@nestjs/common@^11": + version "11.1.6" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.6.tgz#704ae26f09ccd135bf3e6f44b6ef4e3407ea3c54" + integrity sha512-krKwLLcFmeuKDqngG2N/RuZHCs2ycsKcxWIDgcm7i1lf3sQ0iG03ci+DsP/r3FcT/eJDFsIHnKtNta2LIi7PzQ== dependencies: uid "2.0.2" - "@nuxt/opencollective" "0.4.1" - fast-safe-stringify "2.1.1" + file-type "21.0.0" iterare "1.2.1" - path-to-regexp "8.2.0" + load-esm "1.0.2" tslib "2.8.1" "@nestjs/core@^10.0.0": @@ -5023,25 +5004,26 @@ path-to-regexp "3.3.0" tslib "2.8.1" -"@nestjs/platform-express@10.4.6": - version "10.4.6" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.4.6.tgz#6c39c522fa66036b4256714fea203fbeb49fc4de" - integrity sha512-HcyCpAKccAasrLSGRTGWv5BKRs0rwTIFOSsk6laNyqfqvgvYcJQAedarnm4jmaemtmSJ0PFI9PmtEZADd2ahCg== +"@nestjs/core@^11": + version "11.1.6" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.6.tgz#9d54882f121168b2fa2b07fa1db0858161a80626" + integrity sha512-siWX7UDgErisW18VTeJA+x+/tpNZrJewjTBsRPF3JVxuWRuAB1kRoiJcxHgln8Lb5UY9NdvklITR84DUEXD0Cg== dependencies: - body-parser "1.20.3" - cors "2.8.5" - express "4.21.1" - multer "1.4.4-lts.1" - tslib "2.7.0" + uid "2.0.2" + "@nuxt/opencollective" "0.4.1" + fast-safe-stringify "2.1.1" + iterare "1.2.1" + path-to-regexp "8.2.0" + tslib "2.8.1" -"@nestjs/platform-express@11.1.3": - version "11.1.3" - resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.3.tgz#bf470f2e270ca9daa930974476dd0d7d62879556" - integrity sha512-hEDNMlaPiBO72fxxX/CuRQL3MEhKRc/sIYGVoXjrnw6hTxZdezvvM6A95UaLsYknfmcZZa/CdG1SMBZOu9agHQ== +"@nestjs/platform-express@^11": + version "11.1.6" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-11.1.6.tgz#9b1dcf82a3b3fdd5761c918ad664aff83e4eacc7" + integrity sha512-HErwPmKnk+loTq8qzu1up+k7FC6Kqa8x6lJ4cDw77KnTxLzsCaPt+jBvOq6UfICmfqcqCCf3dKXg+aObQp+kIQ== dependencies: cors "2.8.5" express "5.1.0" - multer "2.0.1" + multer "2.0.2" path-to-regexp "8.2.0" tslib "2.8.1" @@ -7007,50 +6989,50 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.53.0.tgz#0584f5a4a376c9373f91ad5e1d9194278be2aed6" - integrity sha512-NNPfpILMwKgpHiyJubHHuauMKltkrgLQ5tvMdxNpxY60jBNdo5VJtpESp4XmXlnidzV4j1z61V4ozU6ttDgt5Q== - -"@sentry/cli-linux-arm64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.53.0.tgz#04a73b2592edf10d6e06957905becc98692605b1" - integrity sha512-xY/CZ1dVazsSCvTXzKpAgXaRqfljVfdrFaYZRUaRPf1ZJRGa3dcrivoOhSIeG/p5NdYtMvslMPY9Gm2MT0M83A== - -"@sentry/cli-linux-arm@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.53.0.tgz#caa1dceb23ee40e9d0c82a7c6156c3f010eebc0e" - integrity sha512-NdRzQ15Ht83qG0/Lyu11ciy/Hu/oXbbtJUgwzACc7bWvHQA8xEwTsehWexqn1529Kfc5EjuZ0Wmj3MHmp+jOWw== - -"@sentry/cli-linux-i686@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.53.0.tgz#989dc766b098e94c6751bad3efcd4ca0fe1a2565" - integrity sha512-0REmBibGAB4jtqt9S6JEsFF4QybzcXHPcHtJjgMi5T0ueh952uG9wLzjSxQErCsxTKF+fL8oG0Oz5yKBuCwCCQ== - -"@sentry/cli-linux-x64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.53.0.tgz#2a94361233ed24e4a32f08919011a591aea4cb6b" - integrity sha512-9UGJL+Vy5N/YL1EWPZ/dyXLkShlNaDNrzxx4G7mTS9ywjg+BIuemo6rnN7w43K1NOjObTVO6zY0FwumJ1pCyLg== - -"@sentry/cli-win32-arm64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.53.0.tgz#946609eabd318657521c4b3ef15a420cc00f1c60" - integrity sha512-G1kjOjrjMBY20rQcJV2GA8KQE74ufmROCDb2GXYRfjvb1fKAsm4Oh8N5+Tqi7xEHdjQoLPkE4CNW0aH68JSUDQ== - -"@sentry/cli-win32-i686@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.53.0.tgz#f51937d73cefad16b9d2e89acc4c9f178da36cc6" - integrity sha512-qbGTZUzesuUaPtY9rPXdNfwLqOZKXrJRC1zUFn52hdo6B+Dmv0m/AHwRVFHZP53Tg1NCa8bDei2K/uzRN0dUZw== - -"@sentry/cli-win32-x64@2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.53.0.tgz#d89cde8354b4eb8e89f2c11dc6a6fb5e7392e2ae" - integrity sha512-1TXYxYHtwgUq5KAJt3erRzzUtPqg7BlH9T7MdSPHjJatkrr/kwZqnVe2H6Arr/5NH891vOlIeSPHBdgJUAD69g== - -"@sentry/cli@^2.51.0", "@sentry/cli@^2.53.0": - version "2.53.0" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.53.0.tgz#fd5b65b9f6f06f0ed16345acf3ecf0720bd7bcf8" - integrity sha512-n2ZNb+5Z6AZKQSI0SusQ7ZzFL637mfw3Xh4C3PEyVSn9LiF683fX0TTq8OeGmNZQS4maYfS95IFD+XpydU0dEA== +"@sentry/cli-darwin@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.56.0.tgz#53fa7de2c26f6450d5454ba997c26c2471d112c8" + integrity sha512-CzXFWbv3GrjU0gFlUM9jt0fvJmyo5ktty4HGxRFfS/eMC6xW58Gg/sEeMVEkdvk5osKooX/YEgfLBdo4zvuWDA== + +"@sentry/cli-linux-arm64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.56.0.tgz#5041c8877416a607ddae87b948cbe6c9e86d7f54" + integrity sha512-91d5ZlC989j/t+TXor/glPyx6SnLFS/SlJ9fIrHIQohdGKyWWSFb4VKUan8Ok3GYu9SUzKTMByryIOoYEmeGVw== + +"@sentry/cli-linux-arm@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.56.0.tgz#c7875cf5f76e254ff1c0f49cf99d8c26b6ec4959" + integrity sha512-vQCCMhZLugPmr25XBoP94dpQsFa110qK5SBUVJcRpJKyzMZd+6ueeHNslq2mB0OF4BwL1qd/ZDIa4nxa1+0rjQ== + +"@sentry/cli-linux-i686@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.56.0.tgz#aeaff32f9f0d405e413373223e406d66b1d56176" + integrity sha512-MZzXuq1Q/TktN81DUs6XSBU752pG3XWSJdZR+NCStIg3l8s3O/Pwh6OcDHTYqgwsYJaGBpA0fP2Afl5XeSAUNg== + +"@sentry/cli-linux-x64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.56.0.tgz#3dd4ef83c2d710c3e6f5d078d05391fda2ce23ee" + integrity sha512-INOO2OQ90Y3UzYgHRdrHdKC/0es3YSHLv0iNNgQwllL0YZihSVNYSSrZqcPq8oSDllEy9Vt9oOm/7qEnUP2Kfw== + +"@sentry/cli-win32-arm64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.56.0.tgz#2113bcac721970ca4dbd04a6dab37dfb0ec147d2" + integrity sha512-eUvkVk9KK01q6/qyugQPh7dAxqFPbgOa62QAoSwo11WQFYc3NPgJLilFWLQo+nahHGYKh6PKuCJ5tcqnQq5Hkg== + +"@sentry/cli-win32-i686@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.56.0.tgz#bd8e646f4b5a98aa80bc9751a6e0db6514a935f5" + integrity sha512-mpCA8hKXuvT17bl1H/54KOa5i+02VBBHVlOiP3ltyBuQUqfvX/30Zl/86Spy+ikodovZWAHv5e5FpyXbY1/mPw== + +"@sentry/cli-win32-x64@2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.56.0.tgz#1acc7ca166ed531075a31b2bc1700294747da6b8" + integrity sha512-UV0pXNls+/ViAU/3XsHLLNEHCsRYaGEwJdY3HyGIufSlglxrX6BVApkV9ziGi4WAxcJWLjQdfcEs6V5B+wBy0A== + +"@sentry/cli@^2.51.0", "@sentry/cli@^2.56.0": + version "2.56.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.56.0.tgz#13dc043c78687b47285cc45db5bcfb65bbdb6dd9" + integrity sha512-br6+1nTPUV5EG1oaxLzxv31kREFKr49Y1+3jutfMUz9Nl8VyVP7o9YwakB/YWl+0Vi0NXg5vq7qsd/OOuV5j8w== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -7058,14 +7040,14 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.53.0" - "@sentry/cli-linux-arm" "2.53.0" - "@sentry/cli-linux-arm64" "2.53.0" - "@sentry/cli-linux-i686" "2.53.0" - "@sentry/cli-linux-x64" "2.53.0" - "@sentry/cli-win32-arm64" "2.53.0" - "@sentry/cli-win32-i686" "2.53.0" - "@sentry/cli-win32-x64" "2.53.0" + "@sentry/cli-darwin" "2.56.0" + "@sentry/cli-linux-arm" "2.56.0" + "@sentry/cli-linux-arm64" "2.56.0" + "@sentry/cli-linux-i686" "2.56.0" + "@sentry/cli-linux-x64" "2.56.0" + "@sentry/cli-win32-arm64" "2.56.0" + "@sentry/cli-win32-i686" "2.56.0" + "@sentry/cli-win32-x64" "2.56.0" "@sentry/rollup-plugin@^4.3.0": version "4.3.0" @@ -10956,13 +10938,13 @@ aws-ssl-profiles@^1.1.1: resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== -axios@1.8.2, axios@^1.0.0: - version "1.8.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979" - integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg== +axios@^1.0.0, axios@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" + integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== dependencies: follow-redirects "^1.15.6" - form-data "^4.0.0" + form-data "^4.0.4" proxy-from-env "^1.1.0" axobject-query@^3.2.1: @@ -12132,7 +12114,7 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" -busboy@1.6.0, busboy@^1.0.0, busboy@^1.6.0: +busboy@1.6.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -13052,16 +13034,6 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@^1.5.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - concat-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" @@ -16512,7 +16484,40 @@ expect-type@^1.2.1: resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f" integrity sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw== -express@4.21.1, express@^4.10.7, express@^4.17.1, express@^4.17.3, express@^4.18.1, express@^4.21.1: +express@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9" + integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.0" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +express@^4.10.7, express@^4.17.1, express@^4.17.3, express@^4.18.1, express@^4.21.1: version "4.21.1" resolved "https://registry.yarnpkg.com/express/-/express-4.21.1.tgz#9dae5dda832f16b4eec941a4e44aa89ec481b281" integrity sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ== @@ -16549,39 +16554,6 @@ express@4.21.1, express@^4.10.7, express@^4.17.1, express@^4.17.3, express@^4.18 utils-merge "1.0.1" vary "~1.1.2" -express@5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9" - integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA== - dependencies: - accepts "^2.0.0" - body-parser "^2.2.0" - content-disposition "^1.0.0" - content-type "^1.0.5" - cookie "^0.7.1" - cookie-signature "^1.2.1" - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - finalhandler "^2.1.0" - fresh "^2.0.0" - http-errors "^2.0.0" - merge-descriptors "^2.0.0" - mime-types "^3.0.0" - on-finished "^2.4.1" - once "^1.4.0" - parseurl "^1.3.3" - proxy-addr "^2.0.7" - qs "^6.14.0" - range-parser "^1.2.1" - router "^2.2.0" - send "^1.1.0" - serve-static "^2.2.0" - statuses "^2.0.1" - type-is "^2.0.1" - vary "^1.1.2" - exsolve@^1.0.4, exsolve@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" @@ -17213,7 +17185,7 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@^4.0.0: +form-data@^4.0.0, form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== @@ -22423,7 +22395,7 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@^0.5.5, mkdirp@^0.5.6: +mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.5, mkdirp@^0.5.6: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -22495,10 +22467,10 @@ module-definition@^6.0.1: ast-module-types "^6.0.1" node-source-walk "^7.0.1" -module-details-from-path@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b" - integrity sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A== +module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" + integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== module-lookup-amd@^8.0.5: version "8.0.5" @@ -22663,23 +22635,10 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -multer@1.4.4-lts.1: - version "1.4.4-lts.1" - resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.4-lts.1.tgz#24100f701a4611211cfae94ae16ea39bb314e04d" - integrity sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg== - dependencies: - append-field "^1.0.0" - busboy "^1.0.0" - concat-stream "^1.5.2" - mkdirp "^0.5.4" - object-assign "^4.1.1" - type-is "^1.6.4" - xtend "^4.0.0" - -multer@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/multer/-/multer-2.0.1.tgz#3ed335ed2b96240e3df9e23780c91cfcf5d29202" - integrity sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ== +multer@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/multer/-/multer-2.0.2.tgz#08a8aa8255865388c387aaf041426b0c87bf58dd" + integrity sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw== dependencies: append-field "^1.0.0" busboy "^1.6.0" @@ -24796,15 +24755,32 @@ pino-abstract-transport@^2.0.0: dependencies: split2 "^4.0.0" +"pino-next@npm:pino@^9.12.0": + version "9.12.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.12.0.tgz#976e549cc29e21e5dbf56b47910dc52817dc5a97" + integrity sha512-0Gd0OezGvqtqMwgYxpL7P0pSHHzTJ0Lx992h+mNlMtRVfNnqweWmf0JmRWk5gJzHalyd2mxTzKjhiNbGS2Ztfw== + dependencies: + atomic-sleep "^1.0.0" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^2.0.0" + pino-std-serializers "^7.0.0" + process-warning "^5.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + slow-redact "^0.3.0" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + pino-std-serializers@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== -pino@^9.0.0: - version "9.7.0" - resolved "https://registry.yarnpkg.com/pino/-/pino-9.7.0.tgz#ff7cd86eb3103ee620204dbd5ca6ffda8b53f645" - integrity sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg== +pino@9.9.4: + version "9.9.4" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.9.4.tgz#21ed2c27cc177f797e3249c99d340f0bcd6b248e" + integrity sha512-d1XorUQ7sSKqVcYdXuEYs2h1LKxejSorMEJ76XoZ0pPDf8VzJMe7GlPXpMBZeQ9gE4ZPIp5uGD+5Nw7scxiigg== dependencies: atomic-sleep "^1.0.0" fast-redact "^3.1.1" @@ -26329,7 +26305,7 @@ readable-stream@2.3.7: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@~2.3.6: +readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.3.5, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -27968,6 +27944,11 @@ sliced@1.0.1: resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" integrity sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA== +slow-redact@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/slow-redact/-/slow-redact-0.3.0.tgz#97b4d7bd04136404e529c1ab29f3cb50e903c746" + integrity sha512-cf723wn9JeRIYP9tdtd86GuqoR5937u64Io+CYjlm2i7jvu7g0H+Cp0l0ShAf/4ZL+ISUTVT+8Qzz7RZmp9FjA== + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" @@ -28556,7 +28537,7 @@ string-template@~0.2.1: string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: emoji-regex "^8.0.0" @@ -28666,7 +28647,7 @@ stringify-object@^3.2.1: strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" @@ -29691,11 +29672,6 @@ tslib@2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== -tslib@2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" - integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== - tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.7.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" @@ -29818,7 +29794,7 @@ type-fest@^4.18.2, type-fest@^4.39.1, type-fest@^4.6.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== -type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: +type-is@^1.6.18, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -31735,7 +31711,7 @@ wrangler@4.22.0: wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0"