diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 7446ed57aabc..5f0479a16749 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -32,11 +32,17 @@ body: options: - '@sentry/browser' - '@sentry/node' + - '@sentry/node - express' + - '@sentry/node - fastify' + - '@sentry/node - koa' + - '@sentry/node - hapi' + - '@sentry/node - connect' - '@sentry/angular' - '@sentry/astro' - '@sentry/aws-serverless' - '@sentry/bun' - '@sentry/cloudflare' + - '@sentry/cloudflare - hono' - '@sentry/deno' - '@sentry/ember' - '@sentry/gatsby' @@ -45,6 +51,7 @@ body: - '@sentry/nextjs' - '@sentry/nuxt' - '@sentry/react' + - '@sentry/react-router' - '@sentry/remix' - '@sentry/solid' - '@sentry/solidstart' diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index 39aaeaafdcc7..bcec195ffa5e 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -30,76 +30,100 @@ jobs: map: | { "@sentry.angular": { - "label": "Package: angular" + "label": "Angular" }, "@sentry.astro": { - "label": "Package: astro" + "label": "Astro" }, "@sentry.aws-serverless": { - "label": "Package: aws-serverless" + "label": "AWS Lambda" }, "@sentry.browser": { - "label": "Package: browser" + "label": "Browser" }, "@sentry.bun": { - "label": "Package: bun" + "label": "Bun" + }, + "@sentry.cloudflare - hono": { + "label": "Hono" }, "@sentry.cloudflare": { - "label": "Package: cloudflare" + "label": "Cloudflare Workers" }, "@sentry.deno": { - "label": "Package: deno" + "label": "Deno" }, "@sentry.ember": { - "label": "Package: ember" + "label": "Ember" }, "@sentry.gatsby": { - "label": "Package: gatbsy" + "label": "Gatbsy" }, "@sentry.google-cloud-serverless": { - "label": "Package: google-cloud-serverless" + "label": "Google Cloud Functions" }, "@sentry.nestjs": { - "label": "Package: nestjs" + "label": "Nest.js" }, "@sentry.nextjs": { - "label": "Package: nextjs" + "label": "Next.js" + }, + "@sentry.node - express": { + "label": "Express" + }, + "@sentry.node - fastify": { + "label": "Fastify" + }, + "@sentry.node - koa": { + "label": "Koa" + }, + "@sentry.node - hapi": { + "label": "Hapi + }, + "@sentry.node - connect": { + "label": "Connect }, "@sentry.node": { - "label": "Package: node" + "label": "Node.js" }, "@sentry.nuxt": { - "label": "Package: nuxt" + "label": "Nuxt" + }, + "@sentry.react-router": { + "label": "React Router Framework " }, "@sentry.react": { - "label": "Package: react" + "label": "React" }, "@sentry.remix": { - "label": "Package: remix" + "label": "Remix" }, "@sentry.solid": { - "label": "Package: solid" + "label": "Solid" }, - "@sentry.solid": { - "label": "Package: solidstart" + "@sentry.solidstart": { + "label": "SolidStart" }, "@sentry.sveltekit": { - "label": "Package: sveltekit" + "label": "SvelteKit" }, "@sentry.svelte": { - "label": "Package: svelte" + "label": "Svelte" }, "@sentry.vue": { - "label": "Package: vue" + "label": "Vue" + }, + "@sentry.tanstackstart-react": { + "label": "Tanstack Start React" }, "@sentry.wasm": { - "label": "Package: wasm" + "label": "WASM" }, "Sentry.Browser.Loader": { - "label": "Package-Meta: Loader" + "label": "Browser\nLoader Script" }, "Sentry.Browser.CDN.bundle": { - "label": "Package-Meta: CDN" + "label": "Browser\nCDN Bundle" } } export_to: output diff --git a/.size-limit.js b/.size-limit.js index ca26288b07b3..d66ece2b690d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -40,7 +40,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '38 KB', + limit: '39 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', diff --git a/CHANGELOG.md b/CHANGELOG.md index a90046b67055..65e5e08bc7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,65 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.13.0 + +### Important Changes + +- **feat(node): Add support for winston logger ([#15983](https://github.com/getsentry/sentry-javascript/pull/15983))** + + Sentry is adding support for [structured logging](https://github.com/getsentry/sentry-javascript/discussions/15916). In this release we've added support for sending logs to Sentry via the [winston](https://github.com/winstonjs/winston) logger to the Sentry Node SDK (and SDKs that use the Node SDK under the hood like `@sentry/nestjs`). The Logging APIs in the Sentry SDK are still experimental and subject to change. + + ```js + const winston = require('winston'); + const Transport = require('winston-transport'); + + const transport = Sentry.createSentryWinstonTransport(Transport); + + const logger = winston.createLogger({ + transports: [transport], + }); + ``` + +- **feat(core): Add `wrapMcpServerWithSentry` to instrument MCP servers from `@modelcontextprotocol/sdk` ([#16032](https://github.com/getsentry/sentry-javascript/pull/16032))** + + The Sentry SDK now supports instrumenting MCP servers from the `@modelcontextprotocol/sdk` package. Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. + + ```js + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + + // Create an MCP server + const server = new McpServer({ + name: 'Demo', + version: '1.0.0', + }); + + // Use the instrumented server in your application + const instrumentedServer = Sentry.wrapMcpServerWithSentry(server); + ``` + +- **feat(core): Move console integration into core and add to cloudflare/vercel-edge ([#16024](https://github.com/getsentry/sentry-javascript/pull/16024))** + + Console instrumentation has been added to `@sentry/cloudflare` and `@sentry/nextjs` Edge Runtime and is enabled by default. Now calls to the console object will be captured as breadcrumbs for those SDKs. + +- **feat(bun): Support new `Bun.serve` APIs ([#16035](https://github.com/getsentry/sentry-javascript/pull/16035))** + + Bun `1.2.6` and above have a new `Bun.serve` API, which the Bun SDK now supports. The SDK instruments the new routes object that can be used to define routes for the server. + + Thanks to @Jarred-Sumner for helping us get this supported! + +### Other Changes + +- feat(browser): Warn on duplicate `browserTracingIntegration` ([#16042](https://github.com/getsentry/sentry-javascript/pull/16042)) +- feat(core): Allow delayed sending with offline transport ([#15937](https://github.com/getsentry/sentry-javascript/pull/15937)) +- feat(deps): Bump @sentry/webpack-plugin from 3.2.4 to 3.3.1 ([#16057](https://github.com/getsentry/sentry-javascript/pull/16057)) +- feat(vue): Apply stateTransformer to attachments in Pinia Plugin ([#16034](https://github.com/getsentry/sentry-javascript/pull/16034)) +- fix(core): Run `beforeSendLog` after we process log ([#16019](https://github.com/getsentry/sentry-javascript/pull/16019)) +- fix(nextjs): Don't show turbopack warning for newer Next.js canaries ([#16065](https://github.com/getsentry/sentry-javascript/pull/16065)) +- fix(nextjs): Include patch version 0 for min supported 15.3.0 ([#16026](https://github.com/getsentry/sentry-javascript/pull/16026)) +- fix(node): Ensure late init works with all integrations ([#16016](https://github.com/getsentry/sentry-javascript/pull/16016)) +- fix(react-router): Pass `unstable_sentryVitePluginOptions` to cli instance ([#16033](https://github.com/getsentry/sentry-javascript/pull/16033)) +- fix(serverless-aws): Overwrite root span name with GraphQL if set ([#16010](https://github.com/getsentry/sentry-javascript/pull/16010)) + ## 9.12.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/init.js new file mode 100644 index 000000000000..6d4dd43801b8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.browserTracingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/test.ts new file mode 100644 index 000000000000..f7f3c50ee052 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/test.ts @@ -0,0 +1,22 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('warns if multiple integrations are used', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const msgs: string[] = []; + + page.on('console', msg => { + msgs.push(msg.text()); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + expect(msgs).toEqual(['Multiple browserTracingIntegration instances are not supported.']); +}); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d43f37b2d3b2..eb087616f7fd 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -27,7 +27,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.552.0", "@hapi/hapi": "^21.3.10", - "@nestjs/common": "10.4.6", + "@nestjs/common": "11.0.16", "@nestjs/core": "10.4.6", "@nestjs/platform-express": "10.4.6", "@sentry/aws-serverless": "9.12.0", @@ -37,7 +37,7 @@ "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", "ai": "^4.0.6", - "amqplib": "^0.10.4", + "amqplib": "^0.10.7", "apollo-server": "^3.11.1", "axios": "^1.7.7", "body-parser": "^1.20.3", @@ -67,6 +67,7 @@ "reflect-metadata": "0.2.1", "rxjs": "^7.8.1", "tedious": "^18.6.1", + "winston": "^3.17.0", "yargs": "^16.2.0" }, "devDependencies": { diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/apollo-server.js b/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/apollo-server.js new file mode 100644 index 000000000000..6561adaf67ce --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/apollo-server.js @@ -0,0 +1,35 @@ +const { ApolloServer, gql } = require('apollo-server'); +const Sentry = require('@sentry/aws-serverless'); + +module.exports = () => { + return Sentry.startSpan({ name: 'Test Server Start' }, () => { + return new ApolloServer({ + typeDefs: gql` + type Query { + hello: String + world: String + } + type Mutation { + login(email: String): String + } + `, + resolvers: { + Query: { + hello: () => { + return 'Hello!'; + }, + world: () => { + return 'World!'; + }, + }, + Mutation: { + login: async (_, { email }) => { + return `${email}--token`; + }, + }, + }, + introspection: false, + debug: false, + }); + }); +}; diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/scenario.js b/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/scenario.js new file mode 100644 index 000000000000..4023421921b5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/scenario.js @@ -0,0 +1,27 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/aws-serverless'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })], + transport: loggingTransport, +}); + +async function run() { + const apolloServer = require('./apollo-server')(); + + await Sentry.startSpan({ name: 'Test Transaction' }, async span => { + // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation + await apolloServer.executeOperation({ + query: 'query GetHello {hello}', + }); + + setTimeout(() => { + span.end(); + apolloServer.stop(); + }, 500); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/test.ts new file mode 100644 index 000000000000..84098edb46ae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/test.ts @@ -0,0 +1,28 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +const EXPECTED_TRANSCATION = { + transaction: 'Test Transaction (query GetHello)', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'query GetHello', + origin: 'auto.graphql.otel.graphql', + status: 'ok', + }), + ]), +}; + +describe('graphqlIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should use GraphQL operation name for root span if useOperationNameForRootSpan is set', async () => { + await createRunner(__dirname, 'scenario.js') + .ignore('event') + .expect({ transaction: { transaction: 'Test Server Start (query IntrospectionQuery)' } }) + .expect({ transaction: EXPECTED_TRANSCATION }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts index c9289efbde8e..2abe2932ece2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts @@ -3,13 +3,13 @@ import { createRunner } from '../../../utils/runner'; // Graphql Instrumentation emits some spans by default on server start const EXPECTED_START_SERVER_TRANSACTION = { - transaction: 'Test Server Start', + transaction: 'Test Server Start (query IntrospectionQuery)', }; describe('GraphQL/Apollo Tests', () => { test('should instrument GraphQL queries used from Apollo Server.', async () => { const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', + transaction: 'Test Transaction (query)', spans: expect.arrayContaining([ expect.objectContaining({ data: { @@ -33,7 +33,7 @@ describe('GraphQL/Apollo Tests', () => { test('should instrument GraphQL mutations used from Apollo Server.', async () => { const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', + transaction: 'Test Transaction (mutation Mutation)', spans: expect.arrayContaining([ expect.objectContaining({ data: { diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts index 4aa7616cc73c..b77dcd34777b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts @@ -1,9 +1,9 @@ import { createRunner } from '../../../../utils/runner'; -import { describe, test, expect } from 'vitest' +import { describe, test, expect } from 'vitest'; // Graphql Instrumentation emits some spans by default on server start const EXPECTED_START_SERVER_TRANSACTION = { - transaction: 'Test Server Start', + transaction: 'Test Server Start (query IntrospectionQuery)', }; describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { @@ -61,7 +61,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => { test('useOperationNameForRootSpan ignores an invalid root span', async () => { const EXPECTED_TRANSACTION = { - transaction: 'test span name', + transaction: 'test span name (query GetHello)', spans: expect.arrayContaining([ expect.objectContaining({ data: { diff --git a/dev-packages/node-integration-tests/suites/winston/subject.ts b/dev-packages/node-integration-tests/suites/winston/subject.ts new file mode 100644 index 000000000000..aff667aa64ca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/winston/subject.ts @@ -0,0 +1,73 @@ +import * as Sentry from '@sentry/node'; +import winston from 'winston'; +import Transport from 'winston-transport'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + _experiments: { + enableLogs: true, + }, + transport: loggingTransport, +}); + +async function run(): Promise { + // Create a custom transport that extends winston-transport + const SentryWinstonTransport = Sentry.createSentryWinstonTransport(Transport); + + // Create logger with default levels + const logger = winston.createLogger({ + transports: [new SentryWinstonTransport()], + }); + + // Test basic logging + logger.info('Test info message'); + logger.error('Test error message'); + + // If custom levels are requested + if (process.env.CUSTOM_LEVELS === 'true') { + const customLevels = { + levels: { + error: 0, + warn: 1, + info: 2, + http: 3, + verbose: 4, + debug: 5, + silly: 6, + }, + colors: { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + verbose: 'cyan', + debug: 'blue', + silly: 'grey', + }, + }; + + const customLogger = winston.createLogger({ + levels: customLevels.levels, + transports: [new SentryWinstonTransport()], + }); + + customLogger.info('Test info message'); + customLogger.error('Test error message'); + } + + // If metadata is requested + if (process.env.WITH_METADATA === 'true') { + logger.info('Test message with metadata', { + foo: 'bar', + number: 42, + }); + } + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +void run(); diff --git a/dev-packages/node-integration-tests/suites/winston/test.ts b/dev-packages/node-integration-tests/suites/winston/test.ts new file mode 100644 index 000000000000..60eeb7242154 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/winston/test.ts @@ -0,0 +1,307 @@ +import { afterAll, describe, test, expect } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +describe('winston integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should capture winston logs with default levels', async () => { + const runner = createRunner(__dirname, 'subject.ts') + .expect({ + otel_log: { + severityText: 'info', + body: { + stringValue: 'Test info message', + }, + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.logging.winston', + }, + }, + { + key: 'sentry.release', + value: { + stringValue: '1.0.0', + }, + }, + { + key: 'sentry.environment', + value: { + stringValue: 'test', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.node', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + { + key: 'server.address', + value: { + stringValue: expect.any(String), + }, + }, + ], + }, + }) + .expect({ + otel_log: { + severityText: 'error', + body: { + stringValue: 'Test error message', + }, + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.logging.winston', + }, + }, + { + key: 'sentry.release', + value: { + stringValue: '1.0.0', + }, + }, + { + key: 'sentry.environment', + value: { + stringValue: 'test', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.node', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + { + key: 'server.address', + value: { + stringValue: expect.any(String), + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); + + test('should capture winston logs with custom levels', async () => { + const runner = createRunner(__dirname, 'subject.ts') + .withEnv({ CUSTOM_LEVELS: 'true' }) + .expect({ + otel_log: { + severityText: 'info', + body: { + stringValue: 'Test info message', + }, + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.logging.winston', + }, + }, + { + key: 'sentry.release', + value: { + stringValue: '1.0.0', + }, + }, + { + key: 'sentry.environment', + value: { + stringValue: 'test', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.node', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + { + key: 'server.address', + value: { + stringValue: expect.any(String), + }, + }, + ], + }, + }) + .expect({ + otel_log: { + severityText: 'error', + body: { + stringValue: 'Test error message', + }, + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.logging.winston', + }, + }, + { + key: 'sentry.release', + value: { + stringValue: '1.0.0', + }, + }, + { + key: 'sentry.environment', + value: { + stringValue: 'test', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.node', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + { + key: 'server.address', + value: { + stringValue: expect.any(String), + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); + + test('should capture winston logs with metadata', async () => { + const runner = createRunner(__dirname, 'subject.ts') + .withEnv({ WITH_METADATA: 'true' }) + .expect({ + otel_log: { + severityText: 'info', + body: { + stringValue: 'Test info message', + }, + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.logging.winston', + }, + }, + { + key: 'sentry.release', + value: { + stringValue: '1.0.0', + }, + }, + { + key: 'sentry.environment', + value: { + stringValue: 'test', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.node', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + { + key: 'server.address', + value: { + stringValue: expect.any(String), + }, + }, + ], + }, + }) + .expect({ + otel_log: { + severityText: 'error', + body: { + stringValue: 'Test error message', + }, + attributes: [ + { + key: 'sentry.origin', + value: { + stringValue: 'auto.logging.winston', + }, + }, + { + key: 'sentry.release', + value: { + stringValue: '1.0.0', + }, + }, + { + key: 'sentry.environment', + value: { + stringValue: 'test', + }, + }, + { + key: 'sentry.sdk.name', + value: { + stringValue: 'sentry.javascript.node', + }, + }, + { + key: 'sentry.sdk.version', + value: { + stringValue: expect.any(String), + }, + }, + { + key: 'server.address', + value: { + stringValue: expect.any(String), + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index d89503eb9dfb..78bf958ce243 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -85,6 +85,7 @@ export { postgresIntegration, prismaIntegration, childProcessIntegration, + createSentryWinstonTransport, redisIntegration, requestDataIntegration, rewriteFramesIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 59465831a734..7dd6bcb597ca 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -100,6 +100,7 @@ export { postgresIntegration, prismaIntegration, childProcessIntegration, + createSentryWinstonTransport, hapiIntegration, setupHapiErrorHandler, spotlightIntegration, diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 61776daed18c..2ba148d8b165 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -15,22 +15,19 @@ interface AwsLambdaOptions { disableAwsContextPropagation?: boolean; } -export const instrumentAwsLambda = generateInstrumentOnce( +export const instrumentAwsLambda = generateInstrumentOnce( 'AwsLambda', - (_options: AwsLambdaOptions = {}) => { - const options = { + AwsLambdaInstrumentation, + (options: AwsLambdaOptions) => { + return { disableAwsContextPropagation: true, - ..._options, - }; - - return new AwsLambdaInstrumentation({ ...options, eventContextExtractor, requestHook(span) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda'); }, - }); + }; }, ); diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index bec6fbff019e..1abb3beacc50 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -74,6 +74,7 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => return { name: INTEGRATION_NAME, setup(client) { + // TODO(v10): Remove this functionality and use `consoleIntegration` from @sentry/core instead. if (_options.console) { addConsoleInstrumentationHandler(_getConsoleBreadcrumbHandler(client)); } diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index fab45cd1ed4f..5851edfec823 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -10,6 +10,7 @@ import { startTrackingWebVitals, } from '@sentry-internal/browser-utils'; import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core'; +import { consoleSandbox } from '@sentry/core'; import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, @@ -217,6 +218,8 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { ...defaultRequestInstrumentationOptions, }; +let _hasBeenInitialized = false; + /** * The Browser Tracing integration automatically instruments browser pageload/navigation * actions as transactions, and captures requests, metrics and errors as spans. @@ -227,6 +230,15 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * We explicitly export the proper type here, as this has to be extended in some cases. */ export const browserTracingIntegration = ((_options: Partial = {}) => { + if (_hasBeenInitialized) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('Multiple browserTracingIntegration instances are not supported.'); + }); + } + + _hasBeenInitialized = true; + /** * This is just a small wrapper that makes `document` optional. * We want to be extra-safe and always check that this exists, to ensure weird environments do not blow up. diff --git a/packages/bun/package.json b/packages/bun/package.json index 5283164b287d..f55b9d8637f9 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -44,7 +44,7 @@ "@sentry/opentelemetry": "9.12.0" }, "devDependencies": { - "bun-types": "latest" + "bun-types": "^1.2.9" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index a1c26d5a2819..c8d11b4d101d 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -134,6 +134,7 @@ export { vercelAIIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; export { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 1f1974839455..89a86d827ea0 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -1,17 +1,17 @@ +import type { ServeOptions } from 'bun'; import type { IntegrationFn, RequestEventData, SpanAttributes } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, - continueTrace, - defineIntegration, - extractQueryParamsFromUrl, - getSanitizedUrlString, - parseUrl, + isURLObjectRelative, setHttpStatus, + defineIntegration, + continueTrace, startSpan, withIsolationScope, + parseStringToURLObject, } from '@sentry/core'; const INTEGRATION_NAME = 'BunServer'; @@ -28,6 +28,8 @@ const _bunServerIntegration = (() => { /** * Instruments `Bun.serve` to automatically create transactions and capture errors. * + * Does not support instrumenting static routes. + * * Enabled by default in the Bun SDK. * * ```js @@ -40,10 +42,18 @@ const _bunServerIntegration = (() => { */ export const bunServerIntegration = defineIntegration(_bunServerIntegration); +let hasPatchedBunServe = false; + /** * Instruments Bun.serve by patching it's options. + * + * Only exported for tests. */ export function instrumentBunServe(): void { + if (hasPatchedBunServe) { + return; + } + Bun.serve = new Proxy(Bun.serve, { apply(serveTarget, serveThisArg, serveArgs: Parameters) { instrumentBunServeOptions(serveArgs[0]); @@ -53,7 +63,7 @@ export function instrumentBunServe(): void { // We can't use a Proxy for this as Bun does `instanceof` checks internally that fail if we // wrap the Server instance. const originalReload: typeof server.reload = server.reload.bind(server); - server.reload = (serveOptions: Parameters[0]) => { + server.reload = (serveOptions: ServeOptions) => { instrumentBunServeOptions(serveOptions); return originalReload(serveOptions); }; @@ -61,81 +71,223 @@ export function instrumentBunServe(): void { return server; }, }); + + hasPatchedBunServe = true; } /** - * Instruments Bun.serve `fetch` option to automatically create spans and capture errors. + * Instruments Bun.serve options. + * + * @param serveOptions - The options for the Bun.serve function. */ function instrumentBunServeOptions(serveOptions: Parameters[0]): void { + // First handle fetch + instrumentBunServeOptionFetch(serveOptions); + // then handle routes + instrumentBunServeOptionRoutes(serveOptions); +} + +/** + * Instruments the `fetch` option of Bun.serve. + * + * @param serveOptions - The options for the Bun.serve function. + */ +function instrumentBunServeOptionFetch(serveOptions: Parameters[0]): void { + if (typeof serveOptions.fetch !== 'function') { + return; + } + serveOptions.fetch = new Proxy(serveOptions.fetch, { apply(fetchTarget, fetchThisArg, fetchArgs: Parameters) { - return withIsolationScope(isolationScope => { - const request = fetchArgs[0]; - const upperCaseMethod = request.method.toUpperCase(); - if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { - return fetchTarget.apply(fetchThisArg, fetchArgs); - } + return wrapRequestHandler(fetchTarget, fetchThisArg, fetchArgs); + }, + }); +} - const parsedUrl = parseUrl(request.url); - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve', - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - }; - if (parsedUrl.search) { - attributes['http.query'] = parsedUrl.search; - } +/** + * Instruments the `routes` option of Bun.serve. + * + * @param serveOptions - The options for the Bun.serve function. + */ +function instrumentBunServeOptionRoutes(serveOptions: Parameters[0]): void { + if (!serveOptions.routes) { + return; + } - const url = getSanitizedUrlString(parsedUrl); - - isolationScope.setSDKProcessingMetadata({ - normalizedRequest: { - url, - method: request.method, - headers: request.headers.toJSON(), - query_string: extractQueryParamsFromUrl(url), - } satisfies RequestEventData, - }); - - return continueTrace( - { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, - () => { - return startSpan( - { - attributes, - op: 'http.server', - name: `${request.method} ${parsedUrl.path || '/'}`, - }, - async span => { - try { - const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType< - typeof serveOptions.fetch - >); - if (response?.status) { - setHttpStatus(span, response.status); - isolationScope.setContext('response', { - headers: response.headers.toJSON(), - status_code: response.status, - }); - } - return response; - } catch (e) { - captureException(e, { - mechanism: { - type: 'bun', - handled: false, - data: { - function: 'serve', - }, - }, - }); - throw e; - } + if (typeof serveOptions.routes !== 'object') { + return; + } + + Object.keys(serveOptions.routes).forEach(route => { + const routeHandler = serveOptions.routes[route]; + + // Handle route handlers that are an object + if (typeof routeHandler === 'function') { + serveOptions.routes[route] = new Proxy(routeHandler, { + apply: (routeHandlerTarget, routeHandlerThisArg, routeHandlerArgs: Parameters) => { + return wrapRequestHandler(routeHandlerTarget, routeHandlerThisArg, routeHandlerArgs, route); + }, + }); + } + + // Static routes are not instrumented + if (routeHandler instanceof Response) { + return; + } + + // Handle the route handlers that are an object. This means they define a route handler for each method. + if (typeof routeHandler === 'object') { + Object.entries(routeHandler).forEach(([routeHandlerObjectHandlerKey, routeHandlerObjectHandler]) => { + if (typeof routeHandlerObjectHandler === 'function') { + (serveOptions.routes[route] as Record)[routeHandlerObjectHandlerKey] = new Proxy( + routeHandlerObjectHandler, + { + apply: ( + routeHandlerObjectHandlerTarget, + routeHandlerObjectHandlerThisArg, + routeHandlerObjectHandlerArgs: Parameters, + ) => { + return wrapRequestHandler( + routeHandlerObjectHandlerTarget, + routeHandlerObjectHandlerThisArg, + routeHandlerObjectHandlerArgs, + route, + ); }, - ); - }, - ); + }, + ); + } }); - }, + } }); } + +type RouteHandler = Extract< + NonNullable[0]['routes']>[string], + // eslint-disable-next-line @typescript-eslint/ban-types + Function +>; + +function wrapRequestHandler( + target: T, + thisArg: unknown, + args: Parameters, + route?: string, +): ReturnType { + return withIsolationScope(isolationScope => { + const request = args[0]; + const upperCaseMethod = request.method.toUpperCase(); + if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') { + return target.apply(thisArg, args); + } + + const parsedUrl = parseStringToURLObject(request.url); + const attributes = getSpanAttributesFromParsedUrl(parsedUrl, request); + + let routeName = parsedUrl?.pathname || '/'; + if (request.params) { + Object.keys(request.params).forEach(key => { + attributes[`url.path.parameter.${key}`] = (request.params as Record)[key]; + }); + + // If a route has parameters, it's a parameterized route + if (route) { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + attributes['url.template'] = route; + routeName = route; + } + } + + // Handle wildcard routes + if (route?.endsWith('/*')) { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + attributes['url.template'] = route; + routeName = route; + } + + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: { + url: request.url, + method: request.method, + headers: request.headers.toJSON(), + query_string: parsedUrl?.search, + } satisfies RequestEventData, + }); + + return continueTrace( + { + sentryTrace: request.headers.get('sentry-trace') ?? '', + baggage: request.headers.get('baggage'), + }, + () => + startSpan( + { + attributes, + op: 'http.server', + name: `${request.method} ${routeName}`, + }, + async span => { + try { + const response = (await target.apply(thisArg, args)) as Response | undefined; + if (response?.status) { + setHttpStatus(span, response.status); + isolationScope.setContext('response', { + headers: response.headers.toJSON(), + status_code: response.status, + }); + } + return response; + } catch (e) { + captureException(e, { + mechanism: { + type: 'bun', + handled: false, + data: { + function: 'serve', + }, + }, + }); + throw e; + } + }, + ), + ); + }); +} + +function getSpanAttributesFromParsedUrl( + parsedUrl: ReturnType, + request: Request, +): SpanAttributes { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve', + [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }; + + if (parsedUrl) { + if (parsedUrl.search) { + attributes['url.query'] = parsedUrl.search; + } + if (parsedUrl.hash) { + attributes['url.fragment'] = parsedUrl.hash; + } + if (parsedUrl.pathname) { + attributes['url.path'] = parsedUrl.pathname; + } + if (!isURLObjectRelative(parsedUrl)) { + attributes['url.full'] = parsedUrl.href; + if (parsedUrl.port) { + attributes['url.port'] = parsedUrl.port; + } + if (parsedUrl.protocol) { + attributes['url.scheme'] = parsedUrl.protocol; + } + if (parsedUrl.hostname) { + attributes['url.domain'] = parsedUrl.hostname; + } + } + } + + return attributes; +} diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 66a66476f78d..29e8241917ba 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,26 +1,24 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; -import type { Span } from '@sentry/core'; -import { getDynamicSamplingContextFromSpan, spanIsSampled, spanToJSON } from '@sentry/core'; +import { afterEach, beforeEach, beforeAll, describe, expect, test, spyOn } from 'bun:test'; +import * as SentryCore from '@sentry/core'; -import { init } from '../../src'; -import type { NodeClient } from '../../src'; import { instrumentBunServe } from '../../src/integrations/bunserver'; -import { getDefaultBunClientOptions } from '../helpers'; describe('Bun Serve Integration', () => { - let client: NodeClient | undefined; - // Fun fact: Bun = 2 21 14 :) - let port: number = 22114; + const continueTraceSpy = spyOn(SentryCore, 'continueTrace'); + const startSpanSpy = spyOn(SentryCore, 'startSpan'); beforeAll(() => { instrumentBunServe(); }); beforeEach(() => { - const options = getDefaultBunClientOptions({ tracesSampleRate: 1 }); - client = init(options); + startSpanSpy.mockClear(); + continueTraceSpy.mockClear(); }); + // Fun fact: Bun = 2 21 14 :) + let port: number = 22114; + afterEach(() => { // Don't reuse the port; Bun server stops lazily so tests may accidentally hit a server still closing from a // previous test @@ -28,12 +26,6 @@ describe('Bun Serve Integration', () => { }); test('generates a transaction around a request', async () => { - let generatedSpan: Span | undefined; - - client?.on('spanEnd', span => { - generatedSpan = span; - }); - const server = Bun.serve({ async fetch(_req) { return new Response('Bun!'); @@ -41,34 +33,30 @@ describe('Bun Serve Integration', () => { port, }); await fetch(`http://localhost:${port}/users?id=123`); - server.stop(); - - if (!generatedSpan) { - throw 'No span was generated in the test'; - } - - const spanJson = spanToJSON(generatedSpan); - expect(spanJson.status).toBe('ok'); - expect(spanJson.op).toEqual('http.server'); - expect(spanJson.description).toEqual('GET /users'); - expect(spanJson.data).toEqual({ - 'http.query': '?id=123', - 'http.request.method': 'GET', - 'http.response.status_code': 200, - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.bun.serve', - 'sentry.sample_rate': 1, - 'sentry.source': 'url', - }); + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + attributes: { + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'GET', + 'sentry.source': 'url', + 'url.query': '?id=123', + 'url.path': '/users', + 'url.full': `http://localhost:${port}/users?id=123`, + 'url.port': port.toString(), + 'url.scheme': 'http:', + 'url.domain': 'localhost', + }, + op: 'http.server', + name: 'GET /users', + }, + expect.any(Function), + ); }); test('generates a post transaction', async () => { - let generatedSpan: Span | undefined; - - client?.on('spanEnd', span => { - generatedSpan = span; - }); - const server = Bun.serve({ async fetch(_req) { return new Response('Bun!'); @@ -80,16 +68,26 @@ describe('Bun Serve Integration', () => { method: 'POST', }); - server.stop(); - - if (!generatedSpan) { - throw 'No span was generated in the test'; - } - - expect(spanToJSON(generatedSpan).status).toBe('ok'); - expect(spanToJSON(generatedSpan).data?.['http.response.status_code']).toEqual(200); - expect(spanToJSON(generatedSpan).op).toEqual('http.server'); - expect(spanToJSON(generatedSpan).description).toEqual('POST /'); + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + attributes: { + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'POST', + 'sentry.source': 'url', + 'url.path': '/', + 'url.full': `http://localhost:${port}/`, + 'url.port': port.toString(), + 'url.scheme': 'http:', + 'url.domain': 'localhost', + }, + op: 'http.server', + name: 'POST /', + }, + expect.any(Function), + ); }); test('continues a trace', async () => { @@ -98,13 +96,7 @@ describe('Bun Serve Integration', () => { const PARENT_SAMPLED = '1'; const SENTRY_TRACE_HEADER = `${TRACE_ID}-${PARENT_SPAN_ID}-${PARENT_SAMPLED}`; - const SENTRY_BAGGAGE_HEADER = 'sentry-version=1.0,sentry-sample_rand=0.42,sentry-environment=production'; - - let generatedSpan: Span | undefined; - - client?.on('spanEnd', span => { - generatedSpan = span; - }); + const SENTRY_BAGGAGE_HEADER = 'sentry-sample_rand=0.42,sentry-environment=production'; const server = Bun.serve({ async fetch(_req) { @@ -113,35 +105,31 @@ describe('Bun Serve Integration', () => { port, }); + // Make request with trace headers await fetch(`http://localhost:${port}/`, { - headers: { 'sentry-trace': SENTRY_TRACE_HEADER, baggage: SENTRY_BAGGAGE_HEADER }, + headers: { + 'sentry-trace': SENTRY_TRACE_HEADER, + baggage: SENTRY_BAGGAGE_HEADER, + }, }); - server.stop(); + await server.stop(); - if (!generatedSpan) { - throw 'No span was generated in the test'; - } - - expect(generatedSpan.spanContext().traceId).toBe(TRACE_ID); - expect(spanToJSON(generatedSpan).parent_span_id).toBe(PARENT_SPAN_ID); - expect(spanIsSampled(generatedSpan)).toBe(true); - expect(generatedSpan.isRecording()).toBe(false); + // Verify continueTrace was called with the correct headers + expect(continueTraceSpy).toHaveBeenCalledTimes(1); + expect(continueTraceSpy).toHaveBeenCalledWith( + { + sentryTrace: SENTRY_TRACE_HEADER, + baggage: SENTRY_BAGGAGE_HEADER, + }, + expect.any(Function), + ); - expect(getDynamicSamplingContextFromSpan(generatedSpan)).toStrictEqual({ - version: '1.0', - sample_rand: '0.42', - environment: 'production', - }); + // Verify a span was created + expect(startSpanSpy).toHaveBeenCalledTimes(1); }); - test('does not create transactions for OPTIONS or HEAD requests', async () => { - let generatedSpan: Span | undefined; - - client?.on('spanEnd', span => { - generatedSpan = span; - }); - + test('skips span creation for OPTIONS and HEAD requests', async () => { const server = Bun.serve({ async fetch(_req) { return new Response('Bun!'); @@ -149,42 +137,265 @@ describe('Bun Serve Integration', () => { port, }); - await fetch(`http://localhost:${port}/`, { + // Make OPTIONS request + const optionsResponse = await fetch(`http://localhost:${port}/`, { method: 'OPTIONS', }); + expect(await optionsResponse.text()).toBe('Bun!'); - await fetch(`http://localhost:${port}/`, { + // Make HEAD request + const headResponse = await fetch(`http://localhost:${port}/`, { method: 'HEAD', }); + expect(await headResponse.text()).toBe(''); - server.stop(); + // Verify no spans were created + expect(startSpanSpy).not.toHaveBeenCalled(); - expect(generatedSpan).toBeUndefined(); + // Make a GET request to verify spans are still created for other methods + const getResponse = await fetch(`http://localhost:${port}/`); + expect(await getResponse.text()).toBe('Bun!'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + + await server.stop(); }); - test('intruments the server again if it is reloaded', async () => { - let serverWasInstrumented = false; - client?.on('spanEnd', () => { - serverWasInstrumented = true; + test('handles route parameters correctly', async () => { + const server = Bun.serve({ + routes: { + '/users/:id': req => { + return new Response(`User ${req.params.id}`); + }, + }, + port, }); + // Make request to parameterized route + const response = await fetch(`http://localhost:${port}/users/123`); + expect(await response.text()).toBe('User 123'); + + // Verify span was created with correct attributes + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'GET', + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'url.path.parameter.id': '123', + 'url.path': '/users/123', + 'url.full': `http://localhost:${port}/users/123`, + 'url.port': port.toString(), + 'url.scheme': 'http:', + 'url.domain': 'localhost', + }), + op: 'http.server', + name: 'GET /users/:id', + }), + expect.any(Function), + ); + + await server.stop(); + }); + + test('handles wildcard routes correctly', async () => { + const server = Bun.serve({ + routes: { + '/api/*': req => { + return new Response(`API route: ${req.url}`); + }, + }, + port, + }); + + // Make request to wildcard route + const response = await fetch(`http://localhost:${port}/api/users/123`); + expect(await response.text()).toBe(`API route: http://localhost:${port}/api/users/123`); + + // Verify span was created with correct attributes + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'GET', + 'sentry.source': 'route', + 'url.template': '/api/*', + 'url.path': '/api/users/123', + 'url.full': `http://localhost:${port}/api/users/123`, + 'url.port': port.toString(), + 'url.scheme': 'http:', + 'url.domain': 'localhost', + }), + op: 'http.server', + name: 'GET /api/*', + }), + expect.any(Function), + ); + + await server.stop(); + }); + + test('reapplies instrumentation after server reload', async () => { const server = Bun.serve({ async fetch(_req) { - return new Response('Bun!'); + return new Response('Initial handler'); }, port, }); + // Verify initial handler works + const initialResponse = await fetch(`http://localhost:${port}/`); + expect(await initialResponse.text()).toBe('Initial handler'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + startSpanSpy.mockClear(); + + // Reload server with new handler server.reload({ async fetch(_req) { - return new Response('Reloaded Bun!'); + return new Response('Reloaded handler'); }, }); - await fetch(`http://localhost:${port}/`); + // Verify new handler works and is instrumented + const reloadedResponse = await fetch(`http://localhost:${port}/`); + expect(await reloadedResponse.text()).toBe('Reloaded handler'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + + await server.stop(); + }); + + describe('per-HTTP method routes', () => { + test('handles GET method correctly', async () => { + const server = Bun.serve({ + routes: { + '/api/posts': { + GET: () => new Response('List posts'), + }, + }, + port, + }); + + const response = await fetch(`http://localhost:${port}/api/posts`); + expect(await response.text()).toBe('List posts'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'GET', + 'sentry.source': 'route', + 'url.path': '/api/posts', + }), + op: 'http.server', + name: 'GET /api/posts', + }), + expect.any(Function), + ); + + await server.stop(); + }); + + test('handles POST method correctly', async () => { + const server = Bun.serve({ + routes: { + '/api/posts': { + POST: async req => { + const body = (await req.json()) as Record; + return Response.json({ created: true, ...body }); + }, + }, + }, + port, + }); + + const response = await fetch(`http://localhost:${port}/api/posts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'New Post' }), + }); + expect(await response.json()).toEqual({ created: true, title: 'New Post' }); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'POST', + 'sentry.source': 'route', + 'url.path': '/api/posts', + }), + op: 'http.server', + name: 'POST /api/posts', + }), + expect.any(Function), + ); + + await server.stop(); + }); - server.stop(); + test('handles PUT method correctly', async () => { + const server = Bun.serve({ + routes: { + '/api/posts': { + PUT: () => new Response('Update post'), + }, + }, + port, + }); + + const response = await fetch(`http://localhost:${port}/api/posts`, { + method: 'PUT', + }); + expect(await response.text()).toBe('Update post'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'PUT', + 'sentry.source': 'route', + 'url.path': '/api/posts', + }), + op: 'http.server', + name: 'PUT /api/posts', + }), + expect.any(Function), + ); + + await server.stop(); + }); - expect(serverWasInstrumented).toBeTrue(); + test('handles DELETE method correctly', async () => { + const server = Bun.serve({ + routes: { + '/api/posts': { + DELETE: () => new Response('Delete post'), + }, + }, + port, + }); + + const response = await fetch(`http://localhost:${port}/api/posts`, { + method: 'DELETE', + }); + expect(await response.text()).toBe('Delete post'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.bun.serve', + 'http.request.method': 'DELETE', + 'sentry.source': 'route', + 'url.path': '/api/posts', + }), + op: 'http.server', + name: 'DELETE /api/posts', + }), + expect.any(Function), + ); + + await server.stop(); + }); }); }); diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 05fd40fb4c96..faad474cc801 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -76,6 +76,7 @@ export { captureConsoleIntegration, moduleMetadataIntegration, zodErrorsIntegration, + consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 9891994e8de1..96e5fcc643a9 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -8,6 +8,7 @@ import { linkedErrorsIntegration, requestDataIntegration, stackParserFromStackParserOptions, + consoleIntegration, } from '@sentry/core'; import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; @@ -27,6 +28,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ linkedErrorsIntegration(), fetchIntegration(), requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }), + consoleIntegration(), ]; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c9a7fdde82e..71a8b03acacb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -108,9 +108,12 @@ export { extraErrorDataIntegration } from './integrations/extraerrordata'; export { rewriteFramesIntegration } from './integrations/rewriteframes'; export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; +export { consoleIntegration } from './integrations/console'; + export { profiler } from './profiling'; export { instrumentFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; +export { wrapMcpServerWithSentry } from './mcp-server'; export { captureFeedback } from './feedback'; export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from './logs/exports'; diff --git a/packages/core/src/integrations/console.ts b/packages/core/src/integrations/console.ts new file mode 100644 index 000000000000..3cd0bff04a1e --- /dev/null +++ b/packages/core/src/integrations/console.ts @@ -0,0 +1,95 @@ +import { addBreadcrumb } from '../breadcrumbs'; +import { getClient } from '../currentScopes'; +import { defineIntegration } from '../integration'; +import type { ConsoleLevel } from '../types-hoist'; +import { + CONSOLE_LEVELS, + GLOBAL_OBJ, + addConsoleInstrumentationHandler, + safeJoin, + severityLevelFromString, +} from '../utils-hoist'; + +interface ConsoleIntegrationOptions { + levels: ConsoleLevel[]; +} + +type GlobalObjectWithUtil = typeof GLOBAL_OBJ & { + util: { + format: (...args: unknown[]) => string; + }; +}; + +const INTEGRATION_NAME = 'Console'; + +/** + * Captures calls to the `console` API as breadcrumbs in Sentry. + * + * By default the integration instruments `console.debug`, `console.info`, `console.warn`, `console.error`, + * `console.log`, `console.trace`, and `console.assert`. You can use the `levels` option to customize which + * levels are captured. + * + * @example + * + * ```js + * Sentry.init({ + * integrations: [Sentry.consoleIntegration({ levels: ['error', 'warn'] })], + * }); + * ``` + */ +export const consoleIntegration = defineIntegration((options: Partial = {}) => { + const levels = new Set(options.levels || CONSOLE_LEVELS); + + return { + name: INTEGRATION_NAME, + setup(client) { + addConsoleInstrumentationHandler(({ args, level }) => { + if (getClient() !== client || !levels.has(level)) { + return; + } + + addConsoleBreadcrumb(level, args); + }); + }, + }; +}); + +/** + * Capture a console breadcrumb. + * + * Exported just for tests. + */ +export function addConsoleBreadcrumb(level: ConsoleLevel, args: unknown[]): void { + const breadcrumb = { + category: 'console', + data: { + arguments: args, + logger: 'console', + }, + level: severityLevelFromString(level), + message: formatConsoleArgs(args), + }; + + if (level === 'assert') { + if (args[0] === false) { + const assertionArgs = args.slice(1); + breadcrumb.message = + assertionArgs.length > 0 ? `Assertion failed: ${formatConsoleArgs(assertionArgs)}` : 'Assertion failed'; + breadcrumb.data.arguments = assertionArgs; + } else { + // Don't capture a breadcrumb for passed assertions + return; + } + } + + addBreadcrumb(breadcrumb, { + input: args, + level, + }); +} + +function formatConsoleArgs(values: unknown[]): string { + return 'util' in GLOBAL_OBJ && typeof (GLOBAL_OBJ as GlobalObjectWithUtil).util.format === 'function' + ? (GLOBAL_OBJ as GlobalObjectWithUtil).util.format(...values) + : safeJoin(values, ' '); +} diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index 8b5f0c76bc17..4864f3b32b8e 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -79,57 +79,60 @@ export function _INTERNAL_captureLog( return; } - client.emit('beforeCaptureLog', beforeLog); - - const log = beforeSendLog ? beforeSendLog(beforeLog) : beforeLog; - if (!log) { - client.recordDroppedEvent('before_send', 'log_item', 1); - DEBUG_BUILD && logger.warn('beforeSendLog returned null, log will not be captured.'); - return; - } - const [, traceContext] = _getTraceInfoFromScope(client, scope); - const { level, message, attributes, severityNumber } = log; - - const logAttributes = { - ...attributes, + const processedLogAttributes = { + ...beforeLog.attributes, }; if (release) { - logAttributes['sentry.release'] = release; + processedLogAttributes['sentry.release'] = release; } if (environment) { - logAttributes['sentry.environment'] = environment; + processedLogAttributes['sentry.environment'] = environment; } const { sdk } = client.getSdkMetadata() ?? {}; if (sdk) { - logAttributes['sentry.sdk.name'] = sdk.name; - logAttributes['sentry.sdk.version'] = sdk.version; + processedLogAttributes['sentry.sdk.name'] = sdk.name; + processedLogAttributes['sentry.sdk.version'] = sdk.version; } - if (isParameterizedString(message)) { - const { __sentry_template_string__, __sentry_template_values__ = [] } = message; - logAttributes['sentry.message.template'] = __sentry_template_string__; + const beforeLogMessage = beforeLog.message; + if (isParameterizedString(beforeLogMessage)) { + const { __sentry_template_string__, __sentry_template_values__ = [] } = beforeLogMessage; + processedLogAttributes['sentry.message.template'] = __sentry_template_string__; __sentry_template_values__.forEach((param, index) => { - logAttributes[`sentry.message.parameter.${index}`] = param; + processedLogAttributes[`sentry.message.parameter.${index}`] = param; }); } const span = _getSpanForScope(scope); if (span) { // Add the parent span ID to the log attributes for trace context - logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId; + processedLogAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId; } + const processedLog = { ...beforeLog, attributes: processedLogAttributes }; + + client.emit('beforeCaptureLog', processedLog); + + const log = beforeSendLog ? beforeSendLog(processedLog) : processedLog; + if (!log) { + client.recordDroppedEvent('before_send', 'log_item', 1); + DEBUG_BUILD && logger.warn('beforeSendLog returned null, log will not be captured.'); + return; + } + + const { level, message, attributes = {}, severityNumber } = log; + const serializedLog: SerializedOtelLog = { severityText: level, body: { stringValue: message, }, - attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)), + attributes: Object.entries(attributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)), timeUnixNano: `${new Date().getTime().toString()}000000`, traceId: traceContext?.trace_id, severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], diff --git a/packages/core/src/mcp-server.ts b/packages/core/src/mcp-server.ts new file mode 100644 index 000000000000..85e9428853e2 --- /dev/null +++ b/packages/core/src/mcp-server.ts @@ -0,0 +1,129 @@ +import { DEBUG_BUILD } from './debug-build'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from './semanticAttributes'; +import { startSpan } from './tracing'; +import { logger } from './utils-hoist'; + +interface MCPServerInstance { + // The first arg is always a name, the last arg should always be a callback function (ie a handler). + // TODO: We could also make use of the resource uri argument somehow. + resource: (name: string, ...args: unknown[]) => void; + // The first arg is always a name, the last arg should always be a callback function (ie a handler). + tool: (name: string, ...args: unknown[]) => void; + // The first arg is always a name, the last arg should always be a callback function (ie a handler). + prompt: (name: string, ...args: unknown[]) => void; +} + +const wrappedMcpServerInstances = new WeakSet(); + +/** + * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. + * + * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. + */ +// We are exposing this API for non-node runtimes that cannot rely on auto-instrumentation. +export function wrapMcpServerWithSentry(mcpServerInstance: S): S { + if (wrappedMcpServerInstances.has(mcpServerInstance)) { + return mcpServerInstance; + } + + if (!isMcpServerInstance(mcpServerInstance)) { + DEBUG_BUILD && logger.warn('Did not patch MCP server. Interface is incompatible.'); + return mcpServerInstance; + } + + mcpServerInstance.resource = new Proxy(mcpServerInstance.resource, { + apply(target, thisArg, argArray) { + const resourceName: unknown = argArray[0]; + const resourceHandler: unknown = argArray[argArray.length - 1]; + + if (typeof resourceName !== 'string' || typeof resourceHandler !== 'function') { + return target.apply(thisArg, argArray); + } + + return startSpan( + { + name: `mcp-server/resource:${resourceName}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'mcp_server.resource': resourceName, + }, + }, + () => target.apply(thisArg, argArray), + ); + }, + }); + + mcpServerInstance.tool = new Proxy(mcpServerInstance.tool, { + apply(target, thisArg, argArray) { + const toolName: unknown = argArray[0]; + const toolHandler: unknown = argArray[argArray.length - 1]; + + if (typeof toolName !== 'string' || typeof toolHandler !== 'function') { + return target.apply(thisArg, argArray); + } + + return startSpan( + { + name: `mcp-server/tool:${toolName}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'mcp_server.tool': toolName, + }, + }, + () => target.apply(thisArg, argArray), + ); + }, + }); + + mcpServerInstance.prompt = new Proxy(mcpServerInstance.prompt, { + apply(target, thisArg, argArray) { + const promptName: unknown = argArray[0]; + const promptHandler: unknown = argArray[argArray.length - 1]; + + if (typeof promptName !== 'string' || typeof promptHandler !== 'function') { + return target.apply(thisArg, argArray); + } + + return startSpan( + { + name: `mcp-server/resource:${promptName}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'mcp_server.prompt': promptName, + }, + }, + () => target.apply(thisArg, argArray), + ); + }, + }); + + wrappedMcpServerInstances.add(mcpServerInstance); + + return mcpServerInstance as S; +} + +function isMcpServerInstance(mcpServerInstance: unknown): mcpServerInstance is MCPServerInstance { + return ( + typeof mcpServerInstance === 'object' && + mcpServerInstance !== null && + 'resource' in mcpServerInstance && + typeof mcpServerInstance.resource === 'function' && + 'tool' in mcpServerInstance && + typeof mcpServerInstance.tool === 'function' && + 'prompt' in mcpServerInstance && + typeof mcpServerInstance.prompt === 'function' + ); +} diff --git a/packages/core/src/transports/offline.ts b/packages/core/src/transports/offline.ts index 0b99baba1e4b..34f8c438529d 100644 --- a/packages/core/src/transports/offline.ts +++ b/packages/core/src/transports/offline.ts @@ -38,8 +38,19 @@ export interface OfflineTransportOptions extends InternalBaseTransportOptions { * @param envelope The envelope that failed to send. * @param error The error that occurred. * @param retryDelay The current retry delay in milliseconds. + * @returns Whether the envelope should be stored. */ shouldStore?: (envelope: Envelope, error: Error, retryDelay: number) => boolean | Promise; + + /** + * Should an attempt be made to send the envelope to Sentry. + * + * If this function is supplied and returns false, `shouldStore` will be called to determine if the envelope should be stored. + * + * @param envelope The envelope that will be sent. + * @returns Whether we should attempt to send the envelope + */ + shouldSend?: (envelope: Envelope) => boolean | Promise; } type Timer = number | { unref?: () => void }; @@ -128,6 +139,10 @@ export function makeOfflineTransport( } try { + if (options.shouldSend && (await options.shouldSend(envelope)) === false) { + throw new Error('Envelope not sent because `shouldSend` callback returned false'); + } + const result = await transport.send(envelope); let delay = MIN_DELAY; diff --git a/packages/core/test/lib/integrations/console.test.ts b/packages/core/test/lib/integrations/console.test.ts new file mode 100644 index 000000000000..e760df8c5446 --- /dev/null +++ b/packages/core/test/lib/integrations/console.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { addConsoleBreadcrumb } from '../../../src/integrations/console'; +import { addBreadcrumb } from '../../../src/breadcrumbs'; + +vi.mock('../../../src/breadcrumbs', () => ({ + addBreadcrumb: vi.fn(), +})); + +describe('addConsoleBreadcrumb', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a breadcrumb with correct properties for basic console log', () => { + const level = 'log'; + const args = ['test message', 123]; + + addConsoleBreadcrumb(level, args); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'console', + data: { + arguments: args, + logger: 'console', + }, + level: 'log', + message: 'test message 123', + }), + { + input: args, + level, + }, + ); + }); + + it.each(['debug', 'info', 'warn', 'error'] as const)('handles %s level correctly', level => { + addConsoleBreadcrumb(level, ['test']); + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: expect.any(String), + }), + expect.any(Object), + ); + }); + + it('skips breadcrumb for passed assertions', () => { + addConsoleBreadcrumb('assert', [true, 'should not be captured']); + expect(addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('creates breadcrumb for failed assertions', () => { + const args = [false, 'assertion failed', 'details']; + + addConsoleBreadcrumb('assert', args); + + expect(addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Assertion failed'), + data: { + arguments: args.slice(1), + logger: 'console', + }, + }), + { + input: args, + level: 'assert', + }, + ); + }); +}); diff --git a/packages/core/test/lib/logs/exports.test.ts b/packages/core/test/lib/logs/exports.test.ts index c672373df947..e71df274ec06 100644 --- a/packages/core/test/lib/logs/exports.test.ts +++ b/packages/core/test/lib/logs/exports.test.ts @@ -343,6 +343,7 @@ describe('_INTERNAL_captureLog', () => { const log: Log = { level: 'info', message: 'test message', + attributes: {}, }; _INTERNAL_captureLog(log, client, undefined); diff --git a/packages/core/test/lib/mcp-server.test.ts b/packages/core/test/lib/mcp-server.test.ts new file mode 100644 index 000000000000..70904409e06d --- /dev/null +++ b/packages/core/test/lib/mcp-server.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapMcpServerWithSentry } from '../../src/mcp-server'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '../../src/semanticAttributes'; +import * as tracingModule from '../../src/tracing'; + +vi.mock('../../src/tracing'); + +describe('wrapMcpServerWithSentry', () => { + beforeEach(() => { + vi.clearAllMocks(); + // @ts-expect-error mocking span is annoying + vi.mocked(tracingModule.startSpan).mockImplementation((_, cb) => cb()); + }); + + it('should wrap valid MCP server instance methods with Sentry spans', () => { + // Create a mock MCP server instance + const mockResource = vi.fn(); + const mockTool = vi.fn(); + const mockPrompt = vi.fn(); + + const mockMcpServer = { + resource: mockResource, + tool: mockTool, + prompt: mockPrompt, + }; + + // Wrap the MCP server + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + // Verify it returns the same instance (modified) + expect(wrappedMcpServer).toBe(mockMcpServer); + + // Original methods should be wrapped + expect(wrappedMcpServer.resource).not.toBe(mockResource); + expect(wrappedMcpServer.tool).not.toBe(mockTool); + expect(wrappedMcpServer.prompt).not.toBe(mockPrompt); + }); + + it('should return the input unchanged if it is not a valid MCP server instance', () => { + const invalidMcpServer = { + // Missing required methods + resource: () => {}, + tool: () => {}, + // No prompt method + }; + + const result = wrapMcpServerWithSentry(invalidMcpServer); + expect(result).toBe(invalidMcpServer); + + // Methods should not be wrapped + expect(result.resource).toBe(invalidMcpServer.resource); + expect(result.tool).toBe(invalidMcpServer.tool); + + // No calls to startSpan + expect(tracingModule.startSpan).not.toHaveBeenCalled(); + }); + + it('should not wrap the same instance twice', () => { + const mockMcpServer = { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + }; + + // First wrap + const wrappedOnce = wrapMcpServerWithSentry(mockMcpServer); + + // Store references to wrapped methods + const wrappedResource = wrappedOnce.resource; + const wrappedTool = wrappedOnce.tool; + const wrappedPrompt = wrappedOnce.prompt; + + // Second wrap + const wrappedTwice = wrapMcpServerWithSentry(wrappedOnce); + + // Should be the same instance with the same wrapped methods + expect(wrappedTwice).toBe(wrappedOnce); + expect(wrappedTwice.resource).toBe(wrappedResource); + expect(wrappedTwice.tool).toBe(wrappedTool); + expect(wrappedTwice.prompt).toBe(wrappedPrompt); + }); + + describe('resource method wrapping', () => { + it('should create a span with proper attributes when resource is called', () => { + const mockResourceHandler = vi.fn(); + const resourceName = 'test-resource'; + + const mockMcpServer = { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + }; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + wrappedMcpServer.resource(resourceName, {}, mockResourceHandler); + + expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); + expect(tracingModule.startSpan).toHaveBeenCalledWith( + { + name: `mcp-server/resource:${resourceName}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'mcp_server.resource': resourceName, + }, + }, + expect.any(Function), + ); + + // Verify the original method was called with all arguments + expect(mockMcpServer.resource).toHaveBeenCalledWith(resourceName, {}, mockResourceHandler); + }); + + it('should call the original resource method directly if name or handler is not valid', () => { + const mockMcpServer = { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + }; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + // Call without string name + wrappedMcpServer.resource({} as any, 'handler'); + + // Call without function handler + wrappedMcpServer.resource('name', 'not-a-function'); + + // Original method should be called directly without creating spans + expect(mockMcpServer.resource).toHaveBeenCalledTimes(2); + expect(tracingModule.startSpan).not.toHaveBeenCalled(); + }); + }); + + describe('tool method wrapping', () => { + it('should create a span with proper attributes when tool is called', () => { + const mockToolHandler = vi.fn(); + const toolName = 'test-tool'; + + const mockMcpServer = { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + }; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + wrappedMcpServer.tool(toolName, {}, mockToolHandler); + + expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); + expect(tracingModule.startSpan).toHaveBeenCalledWith( + { + name: `mcp-server/tool:${toolName}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'mcp_server.tool': toolName, + }, + }, + expect.any(Function), + ); + + // Verify the original method was called with all arguments + expect(mockMcpServer.tool).toHaveBeenCalledWith(toolName, {}, mockToolHandler); + }); + + it('should call the original tool method directly if name or handler is not valid', () => { + const mockMcpServer = { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + }; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + // Call without string name + wrappedMcpServer.tool({} as any, 'handler'); + + // Original method should be called directly without creating spans + expect(mockMcpServer.tool).toHaveBeenCalledTimes(1); + expect(tracingModule.startSpan).not.toHaveBeenCalled(); + }); + }); + + describe('prompt method wrapping', () => { + it('should create a span with proper attributes when prompt is called', () => { + const mockPromptHandler = vi.fn(); + const promptName = 'test-prompt'; + + const mockMcpServer = { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + }; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + wrappedMcpServer.prompt(promptName, {}, mockPromptHandler); + + expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); + expect(tracingModule.startSpan).toHaveBeenCalledWith( + { + name: `mcp-server/resource:${promptName}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'mcp_server.prompt': promptName, + }, + }, + expect.any(Function), + ); + + // Verify the original method was called with all arguments + expect(mockMcpServer.prompt).toHaveBeenCalledWith(promptName, {}, mockPromptHandler); + }); + + it('should call the original prompt method directly if name or handler is not valid', () => { + const mockMcpServer = { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + }; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + // Call without function handler + wrappedMcpServer.prompt('name', 'not-a-function'); + + // Original method should be called directly without creating spans + expect(mockMcpServer.prompt).toHaveBeenCalledTimes(1); + expect(tracingModule.startSpan).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index 0dfc550fcd38..cf3b414c0d0e 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -353,6 +353,27 @@ describe('makeOfflineTransport', () => { expect(getCalls()).toEqual([]); }); + it('shouldSend can stop envelopes from being sent', async () => { + const { getCalls, store } = createTestStore(); + const { getSendCount, baseTransport } = createTestTransport(new Error()); + let queuedCount = 0; + const transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + shouldSend: () => false, + shouldStore: () => { + queuedCount += 1; + return true; + }, + }); + const result = transport.send(ERROR_ENVELOPE); + + await expect(result).resolves.toEqual({}); + expect(queuedCount).toEqual(1); + expect(getSendCount()).toEqual(0); + expect(getCalls()).toEqual(['push']); + }); + it('should not store client report envelopes on send failure', async () => { const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport(new Error()); diff --git a/packages/deno/src/integrations/breadcrumbs.ts b/packages/deno/src/integrations/breadcrumbs.ts index 4d83b7972b21..47a04b08fc93 100644 --- a/packages/deno/src/integrations/breadcrumbs.ts +++ b/packages/deno/src/integrations/breadcrumbs.ts @@ -42,6 +42,7 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => return { name: INTEGRATION_NAME, setup(client) { + // TODO(v10): Remove this functionality and use `consoleIntegration` from @sentry/core instead. if (_options.console) { addConsoleInstrumentationHandler(_getConsoleBreadcrumbHandler(client)); } diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index dee849ca8dee..64ad9689875d 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -47,7 +47,7 @@ "dependencies": { "@sentry/core": "9.12.0", "@sentry/react": "9.12.0", - "@sentry/webpack-plugin": "3.2.4" + "@sentry/webpack-plugin": "3.3.1" }, "peerDependencies": { "gatsby": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 54ae30fb5c8c..5e6b81e9c68b 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -112,6 +112,7 @@ export { profiler, amqplibIntegration, childProcessIntegration, + createSentryWinstonTransport, vercelAIIntegration, logger, consoleLoggingIntegration, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 6f7f76ab6e23..2bdfd00b38de 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -85,7 +85,7 @@ "@sentry/opentelemetry": "9.12.0", "@sentry/react": "9.12.0", "@sentry/vercel-edge": "9.12.0", - "@sentry/webpack-plugin": "3.2.4", + "@sentry/webpack-plugin": "3.3.1", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "4.35.0", diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 0ecb2caee9b9..7dd86b600c15 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -178,7 +178,8 @@ function getFinalConfigObject( patch !== undefined && (major > 15 || (major === 15 && minor > 3) || - (major === 15 && minor === 3 && patch > 0 && prerelease === undefined)); + (major === 15 && minor === 3 && patch === 0 && prerelease === undefined) || + (major === 15 && minor === 3 && patch > 0)); const isSupportedCanary = major !== undefined && minor !== undefined && diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 31e383040f70..8d999343a1ae 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -2,7 +2,6 @@ export { httpIntegration } from './integrations/http'; export { nativeNodeFetchIntegration } from './integrations/node-fetch'; export { fsIntegration } from './integrations/fs'; -export { consoleIntegration } from './integrations/console'; export { nodeContextIntegration } from './integrations/context'; export { contextLinesIntegration } from './integrations/contextlines'; export { localVariablesIntegration } from './integrations/local-variables'; @@ -34,6 +33,7 @@ export { dataloaderIntegration } from './integrations/tracing/dataloader'; export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { childProcessIntegration } from './integrations/childProcess'; +export { createSentryWinstonTransport } from './integrations/winston'; export { SentryContextManager } from './otel/contextManager'; export { generateInstrumentOnce } from './otel/instrument'; @@ -131,6 +131,7 @@ export { zodErrorsIntegration, profiler, consoleLoggingIntegration, + consoleIntegration, } from '@sentry/core'; export type { @@ -152,6 +153,6 @@ export type { Span, } from '@sentry/core'; -import * as logger from './log'; +import * as logger from './logs/exports'; export { logger }; diff --git a/packages/node/src/integrations/console.ts b/packages/node/src/integrations/console.ts deleted file mode 100644 index d1bb0463551e..000000000000 --- a/packages/node/src/integrations/console.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as util from 'node:util'; -import { - addBreadcrumb, - addConsoleInstrumentationHandler, - defineIntegration, - getClient, - severityLevelFromString, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Console'; - -/** - * Capture console logs as breadcrumbs. - */ -export const consoleIntegration = defineIntegration(() => { - return { - name: INTEGRATION_NAME, - setup(client) { - addConsoleInstrumentationHandler(({ args, level }) => { - if (getClient() !== client) { - return; - } - - addBreadcrumb( - { - category: 'console', - level: severityLevelFromString(level), - message: util.format.apply(undefined, args), - }, - { - input: [...args], - level, - }, - ); - }); - }, - }; -}); diff --git a/packages/node/src/integrations/node-fetch/index.ts b/packages/node/src/integrations/node-fetch/index.ts index dc0df9b5ef57..cfcc93f1881e 100644 --- a/packages/node/src/integrations/node-fetch/index.ts +++ b/packages/node/src/integrations/node-fetch/index.ts @@ -5,7 +5,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration, getClient } from ' import { generateInstrumentOnce } from '../../otel/instrument'; import type { NodeClient } from '../../sdk/client'; import type { NodeClientOptions } from '../../types'; -import type { SentryNodeFetchInstrumentationOptions } from './SentryNodeFetchInstrumentation'; import { SentryNodeFetchInstrumentation } from './SentryNodeFetchInstrumentation'; const INTEGRATION_NAME = 'NodeFetch'; @@ -33,14 +32,19 @@ interface NodeFetchOptions { ignoreOutgoingRequests?: (url: string) => boolean; } -const instrumentOtelNodeFetch = generateInstrumentOnce(INTEGRATION_NAME, config => { - return new UndiciInstrumentation(config); -}); +const instrumentOtelNodeFetch = generateInstrumentOnce( + INTEGRATION_NAME, + UndiciInstrumentation, + (options: NodeFetchOptions) => { + return getConfigWithDefaults(options); + }, +); -const instrumentSentryNodeFetch = generateInstrumentOnce( +const instrumentSentryNodeFetch = generateInstrumentOnce( `${INTEGRATION_NAME}.sentry`, - config => { - return new SentryNodeFetchInstrumentation(config); + SentryNodeFetchInstrumentation, + (options: NodeFetchOptions) => { + return options; }, ); @@ -52,8 +56,7 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { // This is the "regular" OTEL instrumentation that emits spans if (instrumentSpans) { - const instrumentationConfig = getConfigWithDefaults(options); - instrumentOtelNodeFetch(instrumentationConfig); + instrumentOtelNodeFetch(options); } // This is the Sentry-specific instrumentation that creates breadcrumbs & propagates traces diff --git a/packages/node/src/integrations/tracing/graphql.ts b/packages/node/src/integrations/tracing/graphql.ts index 945327064df2..ac9fc0e87c63 100644 --- a/packages/node/src/integrations/tracing/graphql.ts +++ b/packages/node/src/integrations/tracing/graphql.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemet import { generateInstrumentOnce } from '../../otel/instrument'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; +import type { AttributeValue } from '@opentelemetry/api'; interface GraphqlOptions { /** @@ -37,12 +38,13 @@ interface GraphqlOptions { const INTEGRATION_NAME = 'Graphql'; -export const instrumentGraphql = generateInstrumentOnce( +export const instrumentGraphql = generateInstrumentOnce( INTEGRATION_NAME, - (_options: GraphqlOptions = {}) => { + GraphQLInstrumentation, + (_options: GraphqlOptions) => { const options = getOptionsWithDefaults(_options); - return new GraphQLInstrumentation({ + return { ...options, responseHook(span) { addOriginToSpan(span, 'auto.graphql.otel.graphql'); @@ -71,9 +73,19 @@ export const instrumentGraphql = generateInstrumentOnce( } else { rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, newOperation); } + + if (!spanToJSON(rootSpan).data['original-description']) { + rootSpan.setAttribute('original-description', spanToJSON(rootSpan).description); + } + // Important for e.g. @sentry/aws-serverless because this would otherwise overwrite the name again + rootSpan.updateName( + `${spanToJSON(rootSpan).data['original-description']} (${getGraphqlOperationNamesFromAttribute( + existingOperations, + )})`, + ); } }, - }); + }; }, ); @@ -114,3 +126,20 @@ function getOptionsWithDefaults(options?: GraphqlOptions): GraphqlOptions { ...options, }; } + +// copy from packages/opentelemetry/utils +function getGraphqlOperationNamesFromAttribute(attr: AttributeValue): string { + if (Array.isArray(attr)) { + const sorted = attr.slice().sort(); + + // Up to 5 items, we just add all of them + if (sorted.length <= 5) { + return sorted.join(', '); + } else { + // Else, we add the first 5 and the diff of other operations + return `${sorted.slice(0, 5).join(', ')}, +${sorted.length - 5}`; + } + } + + return `${attr}`; +} diff --git a/packages/node/src/integrations/winston.ts b/packages/node/src/integrations/winston.ts new file mode 100644 index 000000000000..74af701d7144 --- /dev/null +++ b/packages/node/src/integrations/winston.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { LogSeverityLevel } from '@sentry/core'; +import { captureLog } from '../logs/capture'; + +const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; + +// See: https://github.com/winstonjs/triple-beam +const LEVEL_SYMBOL = Symbol.for('level'); +const MESSAGE_SYMBOL = Symbol.for('message'); +const SPLAT_SYMBOL = Symbol.for('splat'); + +/** + * Options for the Sentry Winston transport. + */ +interface WinstonTransportOptions { + /** + * Use this option to filter which levels should be captured. By default, all levels are captured. + * + * @example + * ```ts + * const transport = Sentry.createSentryWinstonTransport(Transport, { + * // Only capture error and warn logs + * levels: ['error', 'warn'], + * }); + * ``` + */ + levels?: Array; +} + +/** + * Creates a new Sentry Winston transport that fowards logs to Sentry. Requires `_experiments.enableLogs` to be enabled. + * + * Supports Winston 3.x.x. + * + * @param TransportClass - The Winston transport class to extend. + * @returns The extended transport class. + * + * @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. + * + * @example + * ```ts + * const winston = require('winston'); + * const Transport = require('winston-transport'); + * + * const transport = Sentry.createSentryWinstonTransport(Transport); + * + * const logger = winston.createLogger({ + * transports: [transport], + * }); + * ``` + */ +export function createSentryWinstonTransport( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TransportClass: new (options?: any) => TransportStreamInstance, + sentryWinstonOptions?: WinstonTransportOptions, +): typeof TransportClass { + // @ts-ignore - We know this is safe because SentryWinstonTransport extends TransportClass + class SentryWinstonTransport extends TransportClass { + private _levels: Set; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public constructor(options?: any) { + super(options); + this._levels = new Set(sentryWinstonOptions?.levels ?? DEFAULT_CAPTURED_LEVELS); + } + + /** + * Forwards a winston log to the Sentry SDK. + */ + public log(info: unknown, callback: () => void): void { + try { + setImmediate(() => { + // @ts-ignore - We know this is safe because SentryWinstonTransport extends TransportClass + this.emit('logged', info); + }); + + if (!isObject(info)) { + return; + } + + const levelFromSymbol = info[LEVEL_SYMBOL]; + + // See: https://github.com/winstonjs/winston?tab=readme-ov-file#streams-objectmode-and-info-objects + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { level, message, timestamp, ...attributes } = info; + // Remove all symbols from the remaining attributes + attributes[LEVEL_SYMBOL] = undefined; + attributes[MESSAGE_SYMBOL] = undefined; + attributes[SPLAT_SYMBOL] = undefined; + + const logSeverityLevel = WINSTON_LEVEL_TO_LOG_SEVERITY_LEVEL_MAP[levelFromSymbol as string] ?? 'info'; + if (this._levels.has(logSeverityLevel)) { + captureLog(logSeverityLevel, message as string, { + ...attributes, + 'sentry.origin': 'auto.logging.winston', + }); + } + } catch { + // do nothing + } + + if (callback) { + callback(); + } + } + } + + return SentryWinstonTransport as typeof TransportClass; +} + +function isObject(anything: unknown): anything is Record { + return typeof anything === 'object' && anything != null; +} + +// npm +// { +// error: 0, +// warn: 1, +// info: 2, +// http: 3, +// verbose: 4, +// debug: 5, +// silly: 6 +// } +// +// syslog +// { +// emerg: 0, +// alert: 1, +// crit: 2, +// error: 3, +// warning: 4, +// notice: 5, +// info: 6, +// debug: 7, +// } +const WINSTON_LEVEL_TO_LOG_SEVERITY_LEVEL_MAP: Record = { + // npm + silly: 'trace', + // npm and syslog + debug: 'debug', + // npm + verbose: 'debug', + // npm + http: 'debug', + // npm and syslog + info: 'info', + // syslog + notice: 'info', + // npm + warn: 'warn', + // syslog + warning: 'warn', + // npm and syslog + error: 'error', + // syslog + emerg: 'fatal', + // syslog + alert: 'fatal', + // syslog + crit: 'fatal', +}; diff --git a/packages/node/src/logs/capture.ts b/packages/node/src/logs/capture.ts new file mode 100644 index 000000000000..d4fdd11e99fb --- /dev/null +++ b/packages/node/src/logs/capture.ts @@ -0,0 +1,30 @@ +import { format } from 'node:util'; + +import type { LogSeverityLevel, Log, ParameterizedString } from '@sentry/core'; +import { _INTERNAL_captureLog } from '@sentry/core'; + +export type CaptureLogArgs = + | [message: ParameterizedString, attributes?: Log['attributes']] + | [messageTemplate: string, messageParams: Array, attributes?: Log['attributes']]; + +/** + * Capture a log with the given level. + * + * @param level - The level of the log. + * @param message - The message to log. + * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. + */ +export function captureLog(level: LogSeverityLevel, ...args: CaptureLogArgs): void { + const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributes] = args; + if (Array.isArray(paramsOrAttributes)) { + const attributes = { ...maybeAttributes }; + attributes['sentry.message.template'] = messageOrMessageTemplate; + paramsOrAttributes.forEach((param, index) => { + attributes[`sentry.message.parameter.${index}`] = param; + }); + const message = format(messageOrMessageTemplate, ...paramsOrAttributes); + _INTERNAL_captureLog({ level, message, attributes }); + } else { + _INTERNAL_captureLog({ level, message: messageOrMessageTemplate, attributes: paramsOrAttributes }); + } +} diff --git a/packages/node/src/log.ts b/packages/node/src/logs/exports.ts similarity index 75% rename from packages/node/src/log.ts rename to packages/node/src/logs/exports.ts index e66d8a24fd17..7c9299dc2660 100644 --- a/packages/node/src/log.ts +++ b/packages/node/src/logs/exports.ts @@ -1,33 +1,4 @@ -import { format } from 'node:util'; - -import type { LogSeverityLevel, Log, ParameterizedString } from '@sentry/core'; -import { _INTERNAL_captureLog } from '@sentry/core'; - -type CaptureLogArgs = - | [message: ParameterizedString, attributes?: Log['attributes']] - | [messageTemplate: string, messageParams: Array, attributes?: Log['attributes']]; - -/** - * Capture a log with the given level. - * - * @param level - The level of the log. - * @param message - The message to log. - * @param attributes - Arbitrary structured data that stores information about the log - e.g., userId: 100. - */ -function captureLog(level: LogSeverityLevel, ...args: CaptureLogArgs): void { - const [messageOrMessageTemplate, paramsOrAttributes, maybeAttributes] = args; - if (Array.isArray(paramsOrAttributes)) { - const attributes = { ...maybeAttributes }; - attributes['sentry.message.template'] = messageOrMessageTemplate; - paramsOrAttributes.forEach((param, index) => { - attributes[`sentry.message.parameter.${index}`] = param; - }); - const message = format(messageOrMessageTemplate, ...paramsOrAttributes); - _INTERNAL_captureLog({ level, message, attributes }); - } else { - _INTERNAL_captureLog({ level, message: messageOrMessageTemplate, attributes: paramsOrAttributes }); - } -} +import { captureLog, type CaptureLogArgs } from './capture'; /** * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. diff --git a/packages/node/src/otel/instrument.ts b/packages/node/src/otel/instrument.ts index 6f8b10db2ba7..c5e94991140a 100644 --- a/packages/node/src/otel/instrument.ts +++ b/packages/node/src/otel/instrument.ts @@ -3,16 +3,47 @@ import { type Instrumentation, registerInstrumentations } from '@opentelemetry/i /** Exported only for tests. */ export const INSTRUMENTED: Record = {}; -/** - * Instrument an OpenTelemetry instrumentation once. - * This will skip running instrumentation again if it was already instrumented. - */ +export function generateInstrumentOnce< + Options, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + InstrumentationClass extends new (...args: any[]) => Instrumentation, +>( + name: string, + instrumentationClass: InstrumentationClass, + optionsCallback: (options: Options) => ConstructorParameters[0], +): ((options: Options) => InstanceType) & { id: string }; export function generateInstrumentOnce< Options = unknown, InstrumentationInstance extends Instrumentation = Instrumentation, >( name: string, creator: (options?: Options) => InstrumentationInstance, +): ((options?: Options) => InstrumentationInstance) & { id: string }; +/** + * Instrument an OpenTelemetry instrumentation once. + * This will skip running instrumentation again if it was already instrumented. + */ +export function generateInstrumentOnce( + name: string, + creatorOrClass: (new (...args: any[]) => Instrumentation) | ((options?: Options) => Instrumentation), + optionsCallback?: (options: Options) => unknown, +): ((options: Options) => Instrumentation) & { id: string } { + if (optionsCallback) { + return _generateInstrumentOnceWithOptions( + name, + creatorOrClass as new (...args: unknown[]) => Instrumentation, + optionsCallback, + ); + } + + return _generateInstrumentOnce(name, creatorOrClass as (options?: Options) => Instrumentation); +} + +// The plain version without handling of options +// Should not be used with custom options that are mutated in the creator! +function _generateInstrumentOnce( + name: string, + creator: (options?: Options) => InstrumentationInstance, ): ((options?: Options) => InstrumentationInstance) & { id: string } { return Object.assign( (options?: Options) => { @@ -38,6 +69,40 @@ export function generateInstrumentOnce< ); } +// This version handles options properly +function _generateInstrumentOnceWithOptions< + Options, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + InstrumentationClass extends new (...args: any[]) => Instrumentation, +>( + name: string, + instrumentationClass: InstrumentationClass, + optionsCallback: (options: Options) => ConstructorParameters[0], +): ((options: Options) => InstanceType) & { id: string } { + return Object.assign( + (_options: Options) => { + const options = optionsCallback(_options); + + const instrumented = INSTRUMENTED[name] as InstanceType | undefined; + if (instrumented) { + // Ensure we update options + instrumented.setConfig(options); + return instrumented; + } + + const instrumentation = new instrumentationClass(options) as InstanceType; + INSTRUMENTED[name] = instrumentation; + + registerInstrumentations({ + instrumentations: [instrumentation], + }); + + return instrumentation; + }, + { id: name }, + ); +} + /** * Ensure a given callback is called when the instrumentation is actually wrapping something. * This can be used to ensure some logic is only called when the instrumentation is actually active. diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 21088a253fe3..7df3696c3d58 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -11,6 +11,7 @@ import { propagationContextFromHeaders, requestDataIntegration, stackParserFromStackParserOptions, + consoleIntegration, } from '@sentry/core'; import { enhanceDscWithOpenTelemetryRootSpanName, @@ -20,7 +21,6 @@ import { } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../debug-build'; import { childProcessIntegration } from '../integrations/childProcess'; -import { consoleIntegration } from '../integrations/console'; import { nodeContextIntegration } from '../integrations/context'; import { contextLinesIntegration } from '../integrations/contextlines'; import { httpIntegration } from '../integrations/http'; diff --git a/packages/node/test/integration/console.test.ts b/packages/node/test/integration/console.test.ts deleted file mode 100644 index 691ccd4397ee..000000000000 --- a/packages/node/test/integration/console.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { resetInstrumentationHandlers } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getClient } from '../../src'; -import type { NodeClient } from '../../src'; -import { consoleIntegration } from '../../src/integrations/console'; - -const addBreadcrumbSpy = vi.spyOn(SentryCore, 'addBreadcrumb'); - -vi.spyOn(console, 'log').mockImplementation(() => { - // noop so that we don't spam the logs -}); - -afterEach(() => { - vi.clearAllMocks(); - resetInstrumentationHandlers(); -}); - -describe('Console integration', () => { - it('should add a breadcrumb on console.log', () => { - consoleIntegration().setup?.(getClient() as NodeClient); - - // eslint-disable-next-line no-console - console.log('test'); - - expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); - expect(addBreadcrumbSpy).toHaveBeenCalledWith( - { - category: 'console', - level: 'log', - message: 'test', - }, - { - input: ['test'], - level: 'log', - }, - ); - }); -}); diff --git a/packages/node/test/log.test.ts b/packages/node/test/logs/exports.test.ts similarity index 98% rename from packages/node/test/log.test.ts rename to packages/node/test/logs/exports.test.ts index 6ad6678d12f1..7a7a67a1b777 100644 --- a/packages/node/test/log.test.ts +++ b/packages/node/test/logs/exports.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as sentryCore from '@sentry/core'; -import * as nodeLogger from '../src/log'; +import * as nodeLogger from '../../src/logs/exports'; // Mock the core functions vi.mock('@sentry/core', async () => { diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index ce45f57a8db3..d34b5394945e 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -28,12 +28,14 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo release, sourceMapsUploadOptions = { enabled: true }, debug = false, + unstable_sentryVitePluginOptions, } = getSentryConfig(viteConfig); const cliInstance = new SentryCli(null, { authToken, org, project, + ...unstable_sentryVitePluginOptions, }); // check if release should be created if (release?.name) { diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index e183a4b1c14a..ace8695ec209 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -234,4 +234,39 @@ describe('sentryOnBuildEnd', () => { expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Deleting asset after upload:')); consoleSpy.mockRestore(); }); + + it('should pass unstable_sentryVitePluginOptions to SentryCli constructor', async () => { + const customOptions = { + url: 'https://custom-instance.ejemplo.es', + headers: { + 'X-Custom-Header': 'test-value', + }, + timeout: 30000, + }; + + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + unstable_sentryVitePluginOptions: customOptions, + }, + }, + }; + + await sentryOnBuildEnd(config); + + // Verify SentryCli was constructed with the correct options + expect(SentryCli).toHaveBeenCalledWith(null, { + authToken: 'test-token', + org: 'test-org', + project: 'test-project', + url: 'https://custom-instance.ejemplo.es', + headers: { + 'X-Custom-Header': 'test-value', + }, + timeout: 30000, + }); + }); }); diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 6c5319349294..69daf708dd31 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -114,6 +114,7 @@ export { zodErrorsIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; // Keeping the `*` exports for backwards compatibility and types diff --git a/packages/remix/src/server/integrations/opentelemetry.ts b/packages/remix/src/server/integrations/opentelemetry.ts index 7ba99421c82f..dac05ed89d33 100644 --- a/packages/remix/src/server/integrations/opentelemetry.ts +++ b/packages/remix/src/server/integrations/opentelemetry.ts @@ -7,22 +7,24 @@ import type { RemixOptions } from '../../utils/remixOptions'; const INTEGRATION_NAME = 'Remix'; -const instrumentRemix = generateInstrumentOnce( - INTEGRATION_NAME, - (_options?: RemixOptions) => - new RemixInstrumentation({ - actionFormDataAttributes: _options?.sendDefaultPii ? _options?.captureActionFormDataKeys : undefined, - }), -); +interface RemixInstrumentationOptions { + actionFormDataAttributes?: Record; +} + +const instrumentRemix = generateInstrumentOnce(INTEGRATION_NAME, (options?: RemixInstrumentationOptions) => { + return new RemixInstrumentation(options); +}); const _remixIntegration = (() => { return { name: 'Remix', setupOnce() { const client = getClient(); - const options = client?.getOptions(); + const options = client?.getOptions() as RemixOptions | undefined; - instrumentRemix(options); + instrumentRemix({ + actionFormDataAttributes: options?.sendDefaultPii ? options?.captureActionFormDataKeys : undefined, + }); }, setup(client: Client) { diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index da00b43a4fde..1753b6252517 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -117,6 +117,7 @@ export { zodErrorsIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index f50420fd2937..ce2c3c476b56 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -119,6 +119,7 @@ export { zodErrorsIntegration, logger, consoleLoggingIntegration, + createSentryWinstonTransport, } from '@sentry/node'; // We can still leave this for the carrier init and type exports diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index eb6429c441fa..64ae281481d1 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -76,6 +76,7 @@ export { captureConsoleIntegration, moduleMetadataIntegration, zodErrorsIntegration, + consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 8c3939d26cba..1a09b16496a9 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -22,6 +22,7 @@ import { nodeStackLineParser, requestDataIntegration, stackParserFromStackParserOptions, + consoleIntegration, } from '@sentry/core'; import { SentryPropagator, @@ -57,6 +58,7 @@ export function getDefaultIntegrations(options: Options): Integration[] { functionToStringIntegration(), linkedErrorsIntegration(), winterCGFetchIntegration(), + consoleIntegration(), ...(options.sendDefaultPii ? [requestDataIntegration()] : []), ]; } diff --git a/packages/vue/src/pinia.ts b/packages/vue/src/pinia.ts index c7448deaeed1..9d576a461cef 100644 --- a/packages/vue/src/pinia.ts +++ b/packages/vue/src/pinia.ts @@ -13,31 +13,42 @@ type PiniaPlugin = (context: { }) => void; type SentryPiniaPluginOptions = { - attachPiniaState?: boolean; - addBreadcrumbs?: boolean; - actionTransformer?: (action: string) => any; - stateTransformer?: (state: Record) => any; + attachPiniaState: boolean; + addBreadcrumbs: boolean; + actionTransformer: (action: string) => any; + stateTransformer: (state: Record) => any; }; -export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => PiniaPlugin = ( - options: SentryPiniaPluginOptions = { - attachPiniaState: true, - addBreadcrumbs: true, - actionTransformer: action => action, - stateTransformer: state => state, - }, -) => { - const plugin: PiniaPlugin = ({ store, pinia }) => { - const getAllStoreStates = (): Record => { - const states: Record = {}; +const DEFAULT_PINIA_PLUGIN_OPTIONS: SentryPiniaPluginOptions = { + attachPiniaState: true, + addBreadcrumbs: true, + actionTransformer: action => action, + stateTransformer: state => state, +}; - Object.keys(pinia.state.value).forEach(storeId => { - states[storeId] = pinia.state.value[storeId]; - }); +const getAllStoreStates = ( + pinia: { state: Ref> }, + stateTransformer?: SentryPiniaPluginOptions['stateTransformer'], +): Record => { + const states: Record = {}; + + try { + Object.keys(pinia.state.value).forEach(storeId => { + states[storeId] = pinia.state.value[storeId]; + }); + + return stateTransformer ? stateTransformer(states) : states; + } catch { + return states; + } +}; - return states; - }; +export const createSentryPiniaPlugin: ( + userOptions?: Partial, +) => PiniaPlugin = userOptions => { + const options: SentryPiniaPluginOptions = { ...DEFAULT_PINIA_PLUGIN_OPTIONS, ...userOptions }; + const plugin: PiniaPlugin = ({ store, pinia }) => { options.attachPiniaState !== false && getGlobalScope().addEventProcessor((event, hint) => { try { @@ -55,7 +66,7 @@ export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => Pi ...(hint.attachments || []), { filename, - data: JSON.stringify(getAllStoreStates()), + data: JSON.stringify(getAllStoreStates(pinia, options.stateTransformer)), }, ]; } @@ -68,9 +79,7 @@ export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => Pi store.$onAction(context => { context.after(() => { - const transformedActionName = options.actionTransformer - ? options.actionTransformer(context.name) - : context.name; + const transformedActionName = options.actionTransformer(context.name); if ( typeof transformedActionName !== 'undefined' && @@ -85,16 +94,15 @@ export const createSentryPiniaPlugin: (options?: SentryPiniaPluginOptions) => Pi } /* Set latest state of all stores to scope */ - const allStates = getAllStoreStates(); - const transformedState = options.stateTransformer ? options.stateTransformer(allStates) : allStates; + const allStates = getAllStoreStates(pinia, options.stateTransformer); const scope = getCurrentScope(); const currentState = scope.getScopeData().contexts.state; - if (typeof transformedState !== 'undefined' && transformedState !== null) { + if (typeof allStates !== 'undefined' && allStates !== null) { const client = getClient(); const options = client?.getOptions(); const normalizationDepth = options?.normalizeDepth || 3; // default state normalization depth to 3 - const piniaStateContext = { type: 'pinia', value: transformedState }; + const piniaStateContext = { type: 'pinia', value: allStates }; const newState = { ...(currentState || {}), diff --git a/yarn.lock b/yarn.lock index 346dae607107..b9636e97c211 100644 --- a/yarn.lock +++ b/yarn.lock @@ -75,15 +75,6 @@ resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== -"@acuminous/bitsyntax@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@acuminous/bitsyntax/-/bitsyntax-0.1.2.tgz#e0b31b9ee7ad1e4dd840c34864327c33d9f1f653" - integrity sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ== - dependencies: - buffer-more-ints "~1.0.0" - debug "^4.3.4" - safe-buffer "~5.1.2" - "@adobe/css-tools@^4.0.1", "@adobe/css-tools@^4.4.0": version "4.4.2" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.2.tgz#c836b1bd81e6d62cd6cdf3ee4948bcdce8ea79c8" @@ -4613,14 +4604,14 @@ dependencies: sparse-bitfield "^3.0.3" -"@nestjs/common@10.4.6": - version "10.4.6" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.6.tgz#952e8fd0ceafeffcc4eaf47effd67fb395844ae0" - integrity sha512-KkezkZvU9poWaNq4L+lNvx+386hpOxPJkfXBBeSMrcqBOx8kVr36TGN2uYkF4Ta4zNu1KbCjmZbc0rhHSg296g== +"@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.7.0" + tslib "2.8.1" "@nestjs/common@^10.0.0": version "10.4.15" @@ -6464,6 +6455,11 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.2.4.tgz#c0877df6e5ce227bf51754bf27da2fa5227af847" integrity sha512-yBzRn3GEUSv1RPtE4xB4LnuH74ZxtdoRJ5cmQ9i6mzlmGDxlrnKuvem5++AolZTE9oJqAD3Tx2rd1PqmpWnLoA== +"@sentry/babel-plugin-component-annotate@3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.3.1.tgz#baecd89396cbb4659565a4e8efe7f0a71b19262a" + integrity sha512-5GOxGT7lZN+I8A7Vp0rWY+726FDKEw8HnFiebe51rQrMbfGfCu2Aw9uSM0nT9OG6xhV6WvGccIcCszTPs4fUZQ== + "@sentry/bundler-plugin-core@2.22.6": version "2.22.6" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-2.22.6.tgz#a1ea1fd43700a3ece9e7db016997e79a2782b87d" @@ -6492,6 +6488,20 @@ magic-string "0.30.8" unplugin "1.0.1" +"@sentry/bundler-plugin-core@3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.3.1.tgz#67c5017dc8a70f629c14e88420c6ede4e51c2047" + integrity sha512-Dd6xaWb293j9otEJ1yJqG2Ra6zB49OPzMNdIkdP8wdY+S9UFQE5PyKTyredmPY7hqCc005OrUQZolIIo9Zl13A== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "3.3.1" + "@sentry/cli" "2.42.2" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^9.3.2" + magic-string "0.30.8" + unplugin "1.0.1" + "@sentry/cli-darwin@2.42.2": version "2.42.2" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.42.2.tgz#a32a4f226e717122b37d9969e8d4d0e14779f720" @@ -6630,12 +6640,12 @@ "@sentry/bundler-plugin-core" "3.2.4" unplugin "1.0.1" -"@sentry/webpack-plugin@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-3.2.4.tgz#f9016aa30be87d196aaa35cd9fdf9d5f5f0d0c91" - integrity sha512-LCuNu5LXPSCq2BNke1zvEW8CXL4SPBsCjYexAx51PZ6Lp87VxWcCxGqXhr37MGpYwY10A1r31/XOe69iXHJjGA== +"@sentry/webpack-plugin@3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-3.3.1.tgz#b257e1cb5f939b68f5050e9c4ea040d7366a55de" + integrity sha512-AFRnGNUnlIvq3M+ADdfWb+DIXWKK6yYEkVPAyOppkjO+cL/19gjXMdvAwv+CMFts28YCFKF8Kr3pamUiCmwodA== dependencies: - "@sentry/bundler-plugin-core" "3.2.4" + "@sentry/bundler-plugin-core" "3.3.1" unplugin "1.0.1" uuid "^9.0.0" @@ -8256,10 +8266,10 @@ dependencies: "@types/webidl-conversions" "*" -"@types/ws@^8.5.1": - version "8.5.10" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" - integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== +"@types/ws@*", "@types/ws@^8.5.1": + version "8.18.1" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== dependencies: "@types/node" "*" @@ -9385,14 +9395,12 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= -amqplib@^0.10.4: - version "0.10.4" - resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.4.tgz#4058c775830c908267dc198969015e0e8d280e70" - integrity sha512-DMZ4eCEjAVdX1II2TfIUpJhfKAuoCeDIo/YyETbfAqehHTXxxs7WOOd+N1Xxr4cKhx12y23zk8/os98FxlZHrw== +amqplib@^0.10.7: + version "0.10.7" + resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.7.tgz#d28586805169bedb03a2efe6e09a3e43148eaa0f" + integrity sha512-7xPSYKSX2kj/bT6iHZ3MlctzxdCW1Ds9xyN0EmuRi2DZxHztwwoG1YkZrgmLyuPNjfxlRiMdWJPQscmoa3Vgdg== dependencies: - "@acuminous/bitsyntax" "^0.1.2" buffer-more-ints "~1.0.0" - readable-stream "1.x >=1.1.9" url-parse "~1.5.10" ansi-align@^3.0.0, ansi-align@^3.0.1: @@ -11216,10 +11224,13 @@ builtins@^5.0.0, builtins@^5.0.1: dependencies: semver "^7.0.0" -bun-types@latest: - version "1.0.1" - resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.0.1.tgz#8bcb10ae3a1548a39f0932fdb365f4b3a649efba" - integrity sha512-7NrXqhMIaNKmWn2dSWEQ50znMZqrN/5Z0NBMXvQTRu/+Y1CvoXRznFy0pnqLe024CeZgVdXoEpARNO1JZLAPGw== +bun-types@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.2.9.tgz#e0208ba62f534eb64284c1f347f73bde7105c0f0" + integrity sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw== + dependencies: + "@types/node" "*" + "@types/ws" "*" bundle-name@^3.0.0: version "3.0.0" @@ -24623,16 +24634,6 @@ read@^2.0.0: dependencies: mute-stream "~1.0.0" -"readable-stream@1.x >=1.1.9": - version "1.1.14" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" - integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - "readable-stream@2 || 3", readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0, readable-stream@^3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" @@ -25563,7 +25564,7 @@ sade@^1.7.3, sade@^1.8.1: dependencies: mri "^1.1.0" -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1, safe-buffer@~5.1.2: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -27017,7 +27018,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" @@ -29524,7 +29524,7 @@ wildcard@^2.0.0: resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== -winston-transport@^4.7.0: +winston-transport@^4.7.0, winston-transport@^4.9.0: version "4.9.0" resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.9.0.tgz#3bba345de10297654ea6f33519424560003b3bf9" integrity sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A== @@ -29550,6 +29550,23 @@ winston@3.13.0: triple-beam "^1.3.0" winston-transport "^4.7.0" +winston@^3.17.0: + version "3.17.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.17.0.tgz#74b8665ce9b4ea7b29d0922cfccf852a08a11423" + integrity sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.2" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.7.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.9.0" + word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f"