From 56aac537ea2627baec9025291b73f46b48670abf Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 9 Apr 2025 13:02:46 +0200 Subject: [PATCH 01/18] fix(node): Ensure late init works with all integrations (#16016) In cases where we tweak the options we pass to the instrumentation, our code did not handle this very well. This could lead to certain options missing when init is called again later, as we updated the config with incomplete/wrong options. To fix this properly, I added a new variant to `generateInstrumentOnce` which accepts a class and an options transformer, which ensures that the options are always correctly transformed, no matter if called the first or second time. (The code to make this overloaded `generateInstrumentOnce` function work with TS was pretty tricky, but I think it is good now - type inferral etc. works nicely now!) Fixes https://github.com/getsentry/sentry-javascript/issues/16004 --- .../src/integration/awslambda.ts | 13 ++-- .../node/src/integrations/node-fetch/index.ts | 21 +++--- .../node/src/integrations/tracing/graphql.ts | 9 ++- packages/node/src/otel/instrument.ts | 73 ++++++++++++++++++- .../src/server/integrations/opentelemetry.ts | 20 ++--- 5 files changed, 102 insertions(+), 34 deletions(-) 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/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..dbcbe20dcc40 100644 --- a/packages/node/src/integrations/tracing/graphql.ts +++ b/packages/node/src/integrations/tracing/graphql.ts @@ -37,12 +37,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'); @@ -73,7 +74,7 @@ export const instrumentGraphql = generateInstrumentOnce( } } }, - }); + }; }, ); 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/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) { From 8046e149546f85d93706ec7bf868e749a0c1e690 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 9 Apr 2025 18:04:34 +0200 Subject: [PATCH 02/18] fix(core): Run `beforeSendLog` after we process log (#16019) We should run `beforeSendLog` after we do processing to logs to add relevant attributes. This ensures that users can control or mutate things like `sentry.message.template` if they so wish. --- packages/core/src/logs/exports.ts | 49 +++++++++++---------- packages/core/test/lib/logs/exports.test.ts | 1 + 2 files changed, 27 insertions(+), 23 deletions(-) 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/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); From d007407c2e51d93d6d3933f9dea1e03ff3f4a4ab Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 10 Apr 2025 13:35:53 +0200 Subject: [PATCH 03/18] feat(core): Move console integration into core and add to cloudflare/vercel-edge (#16024) resolves https://github.com/getsentry/sentry-javascript/issues/15439 resolves https://github.com/getsentry/sentry-javascript/issues/4532 resolves https://linear.app/getsentry/issue/JSC-192 Supercedes https://github.com/getsentry/sentry-javascript/pull/16021 Our console instrumentation was always inconsistent, but adding breadcrumbs should not really be. This PR adds a unified console instrumentation to `@sentry/core`, and makes the Node SDK use that. We also add this to the `vercel-edge` and `cloudflare` SDKs. I also left todo comments in the deno and browser SDKs for us to unify into this single integration afterwards. --------- Co-authored-by: Francesco Gringl-Novy --- .../browser/src/integrations/breadcrumbs.ts | 1 + packages/cloudflare/src/index.ts | 1 + packages/cloudflare/src/sdk.ts | 2 + packages/core/src/index.ts | 2 + packages/core/src/integrations/console.ts | 95 +++++++++++++++++++ .../test/lib/integrations/console.test.ts | 71 ++++++++++++++ packages/deno/src/integrations/breadcrumbs.ts | 1 + packages/node/src/index.ts | 2 +- packages/node/src/integrations/console.ts | 38 -------- packages/node/src/sdk/index.ts | 2 +- .../node/test/integration/console.test.ts | 39 -------- packages/vercel-edge/src/index.ts | 1 + packages/vercel-edge/src/sdk.ts | 2 + 13 files changed, 178 insertions(+), 79 deletions(-) create mode 100644 packages/core/src/integrations/console.ts create mode 100644 packages/core/test/lib/integrations/console.test.ts delete mode 100644 packages/node/src/integrations/console.ts delete mode 100644 packages/node/test/integration/console.test.ts 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/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..2fcc73cdf392 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -108,6 +108,8 @@ 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'; 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/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/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/node/src/index.ts b/packages/node/src/index.ts index 31e383040f70..8467f3e3727d 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'; @@ -131,6 +130,7 @@ export { zodErrorsIntegration, profiler, consoleLoggingIntegration, + consoleIntegration, } from '@sentry/core'; export type { 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/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/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()] : []), ]; } From 4c5f2fbe8b70c13b0e9b3f218ee1b4a763197e43 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Apr 2025 14:06:41 +0200 Subject: [PATCH 04/18] feat(core): Allow delayed sending with offline transport (#15937) This PR adds a extra callback to the offline transport that allows delaying sending of envelopes. This callback has existed in the Electron offline transport for a couple of major versions. --- packages/core/src/transports/offline.ts | 15 +++++++++++++ .../core/test/lib/transports/offline.test.ts | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+) 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/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()); From 1bbe0d903834fd7914cbb6624dc373e48e74538b Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 11 Apr 2025 14:32:55 +0200 Subject: [PATCH 05/18] fix(nextjs): Include patch version 0 for min supported 15.3.0 (#16026) closes https://github.com/getsentry/sentry-javascript/issues/16025 --- packages/nextjs/src/config/withSentryConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 0ecb2caee9b9..e3092af747c6 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -178,7 +178,7 @@ 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)); const isSupportedCanary = major !== undefined && minor !== undefined && From be737e4a11319d7e0290ceaea6c63feb2b16a52e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 11 Apr 2025 14:58:35 +0200 Subject: [PATCH 06/18] feat(bun): Support new `Bun.serve` APIs (#16035) Supercedes https://github.com/getsentry/sentry-javascript/pull/15978 resolves https://github.com/getsentry/sentry-javascript/issues/15941 resolves https://github.com/getsentry/sentry-javascript/issues/15827 resolves https://github.com/getsentry/sentry-javascript/issues/15816 Bun recently updated their `Bun.serve` API with new functionality, which unfortunately broke our existing instrumentation. This is detailed with https://bun.sh/docs/api/http#bun-serve. Specifically, they added a new routing API that looks like so: ```ts Bun.serve({ // `routes` requires Bun v1.2.3+ routes: { // Dynamic routes "/users/:id": req => { return new Response(`Hello User ${req.params.id}!`); }, // Per-HTTP method handlers "/api/posts": { GET: () => new Response("List posts"), POST: async req => { const body = await req.json(); return Response.json({ created: true, ...body }); }, }, // Wildcard route for all routes that start with "/api/" and aren't otherwise matched "/api/*": Response.json({ message: "Not found" }, { status: 404 }), // Redirect from /blog/hello to /blog/hello/world "/blog/hello": Response.redirect("/blog/hello/world"), // Serve a file by buffering it in memory "/favicon.ico": new Response(await Bun.file("./favicon.ico").bytes(), { headers: { "Content-Type": "image/x-icon", }, }), }, // (optional) fallback for unmatched routes: // Required if Bun's version < 1.2.3 fetch(req) { return new Response("Not Found", { status: 404 }); }, }); ``` Because there are now dynamic routes and wildcard routes, we can actually generate `route` transaction source and send parameterized routes to Sentry. The `fetch` API is still supported. The only API we don't support is [static routes/responses](https://bun.sh/docs/api/http#static-responses). This is because these are optimized by Bun itself, and if we turn it into a function (which we need to do to time it), we'll lose out on the optimization. For now they aren't instrumented. --- packages/bun/package.json | 2 +- packages/bun/src/integrations/bunserver.ts | 294 +++++++++---- .../bun/test/integrations/bunserver.test.ts | 399 +++++++++++++----- yarn.lock | 20 +- 4 files changed, 540 insertions(+), 175 deletions(-) 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/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/yarn.lock b/yarn.lock index 346dae607107..9160308494b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8256,10 +8256,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" "*" @@ -11216,10 +11216,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" @@ -27017,7 +27020,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" From f9383c7830bc22cab6e4973be34e59934c72b4cb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Apr 2025 09:24:51 +0200 Subject: [PATCH 07/18] fix(react-router): Pass `unstable_sentryVitePluginOptions` to cli instance (#16033) closes https://github.com/getsentry/sentry-javascript/issues/15965 --- .../src/vite/buildEnd/handleOnBuildEnd.ts | 2 ++ .../vite/buildEnd/handleOnBuildEnd.test.ts | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) 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, + }); + }); }); From bd7205bf4b9e366dcae312ee76a65955caceb94c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 14 Apr 2025 12:38:08 +0200 Subject: [PATCH 08/18] feat(browser): Warn on duplicate `browserTracingIntegration` (#16042) Closes https://github.com/getsentry/sentry-javascript/issues/16040 this logs a warning if a user adds multiple instances of `browserTracingIntegration`. If this is done, this can lead to potentially weird things (e.g. we add multiple handlers etc). This is especially relevant for react, as there are multiple different integrations there that users may add. --- .size-limit.js | 2 +- .../multiple-integrations/init.js | 9 ++++++++ .../multiple-integrations/test.ts | 22 +++++++++++++++++++ .../src/tracing/browserTracingIntegration.ts | 12 ++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/multiple-integrations/test.ts 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/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/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. From 6eb7366d51da97dfeee0bf77f0e0020445c3388e Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 14 Apr 2025 16:19:04 +0200 Subject: [PATCH 09/18] feat(core): Add `wrapMcpServerWithSentry` to instrument MCP servers from `@modelcontextprotocol/sdk` (#16032) --- packages/core/src/index.ts | 1 + packages/core/src/mcp-server.ts | 129 ++++++++++++ packages/core/test/lib/mcp-server.test.ts | 242 ++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 packages/core/src/mcp-server.ts create mode 100644 packages/core/test/lib/mcp-server.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fcc73cdf392..71a8b03acacb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,7 @@ 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/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/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(); + }); + }); +}); From 458fa077818c282eead1b3736131dbf1c4a198d2 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:27:15 +0200 Subject: [PATCH 10/18] feat(vue): Apply stateTransformer to attachments in Pinia Plugin (#16034) Continuation of https://github.com/getsentry/sentry-javascript/pull/14474 As the store logic changed a bit, I changed the PR a bit. The `getAllStores` function can now receive the `stateTransformer` and apply it. Closes https://github.com/getsentry/sentry-javascript/issues/14441 --------- Co-authored-by: Olivier Savignac <1275666+sircharlo@users.noreply.github.com> --- packages/vue/src/pinia.ts | 64 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 28 deletions(-) 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 || {}), From cff47df967792b0a7906b8a16b94e75f3aebd7a9 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Apr 2025 11:51:43 -0400 Subject: [PATCH 11/18] feat(node): Add support for winston logger (#15983) resolves https://linear.app/getsentry/issue/JS-4 resolves https://github.com/getsentry/sentry-javascript/issues/15953 This PR adds support for winston, a popular logging library for Node.js. Specifically you can send logs via winston to Sentry. Usage: ```js const winston = require('winston'); const Transport = require('winston-transport'); const transport = Sentry.createSentryWinstonTransport(Transport); const logger = winston.createLogger({ transports: [transport], }); ``` `createSentryWinstonTransport` was used because we didn't want to add `winston` as a dependency to the nodejs sdk. --- .../node-integration-tests/package.json | 1 + .../suites/winston/subject.ts | 73 +++++ .../suites/winston/test.ts | 307 ++++++++++++++++++ packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 3 +- packages/node/src/integrations/winston.ts | 162 +++++++++ packages/node/src/logs/capture.ts | 30 ++ packages/node/src/{log.ts => logs/exports.ts} | 31 +- .../{log.test.ts => logs/exports.test.ts} | 2 +- packages/remix/src/server/index.ts | 1 + packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + yarn.lock | 19 +- 16 files changed, 602 insertions(+), 33 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/winston/subject.ts create mode 100644 dev-packages/node-integration-tests/suites/winston/test.ts create mode 100644 packages/node/src/integrations/winston.ts create mode 100644 packages/node/src/logs/capture.ts rename packages/node/src/{log.ts => logs/exports.ts} (75%) rename packages/node/test/{log.test.ts => logs/exports.test.ts} (98%) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d43f37b2d3b2..819493a3aea6 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -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/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/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/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/node/src/index.ts b/packages/node/src/index.ts index 8467f3e3727d..8d999343a1ae 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -33,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'; @@ -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/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/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/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/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/yarn.lock b/yarn.lock index 9160308494b2..e428498e05e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29526,7 +29526,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== @@ -29552,6 +29552,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" From 217ac8dcf6a79c6a97ec005eb2c1df725c7ea883 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:59:37 +0200 Subject: [PATCH 12/18] build(deps): Bump @nestjs/common from 10.4.6 to 11.0.16 in /dev-packages/node-integration-tests (#16059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@nestjs/common](https://github.com/nestjs/nest/tree/HEAD/packages/common) from 10.4.6 to 11.0.16.
Release notes

Sourced from @​nestjs/common's releases.

v11.0.16 (2025-04-11)

v11.0.15 (2025-04-10)

Bug fixes

Committers: 1

v11.0.14 (2025-04-09)

Bug fixes

  • platform-fastify
    • #14511 fix(fastify): adds the non-standard http methods to the instance (@​johaven)

Committers: 1

v11.0.13 (2025-04-03)

Bug fixes

  • platform-fastify
    • #14895 fix(fastify-adapter): global prefix exclusion path handling w/middleware (@​KyleLilly)
  • microservices
    • #14869 fix(microservices): do not re-create client connection once get client by service name (@​mingo023)

Dependencies

Committers: 2

v11.0.12 (2025-03-19)

Bug fixes

Enhancements

... (truncated)

Commits
  • b6edf9a chore(@​nestjs) publish v11.0.16 release
  • ab79c56 chore: minor tweaks
  • dcc177a Update packages/common/pipes/file/file-type.validator.ts
  • e019da8 refactor(common): move back file type validator options type
  • 4718a64 chore(@​nestjs) publish v11.0.15 release
  • b6078fd refactor(common): move file-type package to peer dependencies
  • f34ef9a refactor(common): refactor code to use simple eval
  • 3ff9024 fix(common): used eval import
  • 63d28bf refactor(common): removed async keyword
  • 5243fca fix(common): update file mime package and add param skip magic numbers
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@nestjs/common&package-manager=npm_and_yarn&previous-version=10.4.6&new-version=11.0.16)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/node-integration-tests/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 819493a3aea6..bc0dc914b34d 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", From d34e67e6d4a405f446b9e23c1d86db88f8315357 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:00:11 +0200 Subject: [PATCH 13/18] feat(deps): Bump @sentry/webpack-plugin from 3.2.4 to 3.3.1 (#16057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@sentry/webpack-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins) from 3.2.4 to 3.3.1.
Release notes

Sourced from @​sentry/webpack-plugin's releases.

3.3.1

  • fix(webpack5): All esm files must have .mjs postfix (#721)

3.3.0

  • feat(webpack): Add @sentry/webpack-plugin/webpack5 export for webpack 5.1+ and compatible environments (#715)
  • feat: Only do automatic commit association for Vercel production environments (#711)

3.3.0-alpha.1

Pre-release to test primitive APIs.

3.3.0-alpha.0

Pre-release to test primitive APIs.

Changelog

Sourced from @​sentry/webpack-plugin's changelog.

3.3.1

  • fix(webpack5): All esm files must have .mjs postfix (#721)

3.3.0

  • feat(webpack): Add @sentry/webpack-plugin/webpack5 export for webpack 5.1+ and compatible environments (#715)
  • feat: Only do automatic commit association for Vercel production environments (#711)
Commits
  • ecb826d release: 3.3.1
  • 6f232ac fix(webpack5): Ensure all esm files have .mjs postfix (#721)
  • abca2b0 Merge branch 'release/3.3.0'
  • 29d15bc release: 3.3.0
  • 8a4d4cb meta(changelog): Add missing feature entry for 3.3.0 release (#720)
  • 45cf007 meta(changelog): Add 3.3.0 release (#719)
  • f577a47 feat(webpack): Add sentry/webpack-plugin/webpack5 export for webpack 5.1+ a...
  • 95e5cca feat: Only do automatic commit association for Vercel production environments...
  • 109a52a Merge branch 'release/3.2.4'
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sentry/webpack-plugin&package-manager=npm_and_yarn&previous-version=3.2.4&new-version=3.3.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/gatsby/package.json | 2 +- packages/nextjs/package.json | 2 +- yarn.lock | 30 +++++++++++++++++++++++++----- 3 files changed, 27 insertions(+), 7 deletions(-) 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/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/yarn.lock b/yarn.lock index e428498e05e2..f20d8694c742 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6464,6 +6464,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 +6497,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 +6649,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" @@ -27020,6 +27039,7 @@ 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" From 36be635762849f6f4e47545d8be11fed4296c7f1 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 15 Apr 2025 12:50:26 +0200 Subject: [PATCH 14/18] fix(nextjs): Don't show turbopack warning for newer Next.js canaries (#16065) We don't want to log for `15.3.1-canary.3` Maybe ref https://github.com/getsentry/sentry-javascript/issues/16060 ??? --- packages/nextjs/src/config/withSentryConfig.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index e3092af747c6..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 && From 6a3e7c1c5ba06dbc1add88e4ebe140a1f2670e2b Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 16 Apr 2025 09:36:43 +0200 Subject: [PATCH 15/18] ci: Update label automation to new package label style (#16053) This updates the bug issue template labels as well as their mapping to labels to make this work nicer with linear. --------- Co-authored-by: Charly Gomez --- .github/ISSUE_TEMPLATE/bug.yml | 7 +++ .github/workflows/issue-package-label.yml | 74 +++++++++++++++-------- 2 files changed, 56 insertions(+), 25 deletions(-) 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 From f4cc3d21335d26912d6c666123946f63f267e1bf Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 16 Apr 2025 12:21:11 +0200 Subject: [PATCH 16/18] fix(serverless-aws): Overwrite root span name with GraphQL if set (#16010) --- .../apollo-server.js | 35 +++++++++++++++++++ .../useOperationNameForRootSpan/scenario.js | 27 ++++++++++++++ .../useOperationNameForRootSpan/test.ts | 28 +++++++++++++++ .../suites/tracing/apollo-graphql/test.ts | 6 ++-- .../useOperationNameForRootSpan/test.ts | 6 ++-- .../node/src/integrations/tracing/graphql.ts | 28 +++++++++++++++ 6 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/apollo-server.js create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/scenario.js create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/graphql/useOperationNameForRootSpan/test.ts 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/packages/node/src/integrations/tracing/graphql.ts b/packages/node/src/integrations/tracing/graphql.ts index dbcbe20dcc40..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 { /** @@ -72,6 +73,16 @@ 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, + )})`, + ); } }, }; @@ -115,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}`; +} From 55cb92ccfc1fb52b84a30822b6ab84d1886b954e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 16 Apr 2025 12:21:20 +0200 Subject: [PATCH 17/18] chore(dev-deps): Bump `amqlib` for node-integration-tests to 0.10.7 (#16074) See https://github.com/rabbitmq/rabbitmq-server/releases/tag/v4.1.0. the rabbitmq image v4.1.0 requires this new amqlib version to work. ## Quote of braking changes in rabbitmq 4.1.0 Before a client connection can negotiate a maximum frame size (frame_max), it must authenticate successfully. Before the authenticated phase, a special lower frame_max value is used. With this release, the value was increased from the original 4096 bytes to 8192 to accommodate larger [JWT tokens](https://www.rabbitmq.com/docs/oauth2). Clients that do override frame_max now must use values of 8192 bytes or greater. We recommend using the default server value of 131072: do not override the frame_max key in rabbitmq.conf and do not set it in the application code. [amqplib](https://github.com/amqp-node/amqplib/) is a popular client library that has been using a low frame_max default of 4096. Its users must [upgrade to a compatible version](https://github.com/amqp-node/amqplib/blob/main/CHANGELOG.md#v0107) (starting with 0.10.7) or explicitly use a higher frame_max. --- .../node-integration-tests/package.json | 2 +- yarn.lock | 42 +++++-------------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index bc0dc914b34d..eb087616f7fd 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index f20d8694c742..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" @@ -9404,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: @@ -24645,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" @@ -25585,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== @@ -27039,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" From b83950601bfd19d57b8863a15394a89ef34420c4 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 16 Apr 2025 08:12:00 -0400 Subject: [PATCH 18/18] meta(changelog): Update changelog for 9.13.0 --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) 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