From 2f16b2d4fdfc6d0976e1215610793cd7e21187a6 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 19 Mar 2025 09:39:28 +0100 Subject: [PATCH 1/9] fix(node): Always flush on Vercel before Lambda freeze (#15602) --- .../http/SentryHttpInstrumentation.ts | 41 +------ .../SentryHttpInstrumentationBeforeOtel.ts | 115 ++++++++++++++++++ packages/node/src/integrations/http/index.ts | 7 ++ packages/node/src/integrations/http/utils.ts | 39 ++++++ 4 files changed, 162 insertions(+), 40 deletions(-) create mode 100644 packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts create mode 100644 packages/node/src/integrations/http/utils.ts diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 4a268eaf31d5..8c7b729b8828 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -24,6 +24,7 @@ import { import { DEBUG_BUILD } from '../../debug-build'; import { getRequestUrl } from '../../utils/getRequestUrl'; import { getRequestInfo } from './vendor/getRequestInfo'; +import { stealthWrap } from './utils'; type Http = typeof http; type Https = typeof https; @@ -268,46 +269,6 @@ export class SentryHttpInstrumentation extends InstrumentationBase( - nodule: Nodule, - name: FieldName, - wrapper: (original: Nodule[FieldName]) => Nodule[FieldName], -): Nodule[FieldName] { - const original = nodule[name]; - const wrapped = wrapper(original); - - defineProperty(nodule, name, wrapped); - return wrapped; -} - -// Sets a property on an object, preserving its enumerability. -function defineProperty( - obj: Nodule, - name: FieldName, - value: Nodule[FieldName], -): void { - const enumerable = !!obj[name] && Object.prototype.propertyIsEnumerable.call(obj, name); - - Object.defineProperty(obj, name, { - configurable: true, - enumerable: enumerable, - writable: true, - value: value, - }); -} - /** Add a breadcrumb for outgoing requests. */ function addRequestBreadcrumb(request: http.ClientRequest, response: http.IncomingMessage): void { const data = getBreadcrumbData(request); diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts b/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts new file mode 100644 index 000000000000..335f23604e10 --- /dev/null +++ b/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts @@ -0,0 +1,115 @@ +import { VERSION } from '@opentelemetry/core'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { flush, logger, vercelWaitUntil } from '@sentry/core'; +import type * as http from 'node:http'; +import type * as https from 'node:https'; +import { DEBUG_BUILD } from '../../debug-build'; +import { stealthWrap } from './utils'; + +type Http = typeof http; +type Https = typeof https; + +/** + * A Sentry specific http instrumentation that is applied before the otel instrumentation. + */ +export class SentryHttpInstrumentationBeforeOtel extends InstrumentationBase { + public constructor() { + super('@sentry/instrumentation-http-before-otel', VERSION, {}); + } + + // eslint-disable-next-line jsdoc/require-jsdoc + public init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] { + return [this._getHttpsInstrumentation(), this._getHttpInstrumentation()]; + } + + /** Get the instrumentation for the http module. */ + private _getHttpInstrumentation(): InstrumentationNodeModuleDefinition { + return new InstrumentationNodeModuleDefinition('http', ['*'], (moduleExports: Http): Http => { + // Patch incoming requests + stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + + return moduleExports; + }); + } + + /** Get the instrumentation for the https module. */ + private _getHttpsInstrumentation(): InstrumentationNodeModuleDefinition { + return new InstrumentationNodeModuleDefinition('https', ['*'], (moduleExports: Https): Https => { + // Patch incoming requests + stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + + return moduleExports; + }); + } + + /** + * Patch the incoming request function for request isolation. + */ + private _getPatchIncomingRequestFunction(): ( + original: (event: string, ...args: unknown[]) => boolean, + ) => (this: unknown, event: string, ...args: unknown[]) => boolean { + return ( + original: (event: string, ...args: unknown[]) => boolean, + ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { + return function incomingRequest(this: unknown, event: string, ...args: unknown[]): boolean { + // Only traces request events + if (event !== 'request') { + return original.apply(this, [event, ...args]); + } + + const response = args[1] as http.OutgoingMessage; + + patchResponseToFlushOnServerlessPlatforms(response); + + return original.apply(this, [event, ...args]); + }; + }; + } +} + +function patchResponseToFlushOnServerlessPlatforms(res: http.OutgoingMessage): void { + // Freely extend this function with other platforms if necessary + if (process.env.VERCEL) { + let markOnEndDone = (): void => undefined; + const onEndDonePromise = new Promise(res => { + markOnEndDone = res; + }); + + res.on('close', () => { + markOnEndDone(); + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + res.end = new Proxy(res.end, { + apply(target, thisArg, argArray) { + vercelWaitUntil( + new Promise(finishWaitUntil => { + // Define a timeout that unblocks the lambda just to be safe so we're not indefinitely keeping it alive, exploding server bills + const timeout = setTimeout(() => { + finishWaitUntil(); + }, 2000); + + onEndDonePromise + .then(() => { + DEBUG_BUILD && logger.log('Flushing events before Vercel Lambda freeze'); + return flush(2000); + }) + .then( + () => { + clearTimeout(timeout); + finishWaitUntil(); + }, + e => { + clearTimeout(timeout); + DEBUG_BUILD && logger.log('Error while flushing events for Vercel:\n', e); + finishWaitUntil(); + }, + ); + }), + ); + + return target.apply(thisArg, argArray); + }, + }); + } +} diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index d48a36bf4bc0..6e7581b75c8d 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -11,6 +11,7 @@ import type { NodeClientOptions } from '../../types'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; import { getRequestUrl } from '../../utils/getRequestUrl'; import { SentryHttpInstrumentation } from './SentryHttpInstrumentation'; +import { SentryHttpInstrumentationBeforeOtel } from './SentryHttpInstrumentationBeforeOtel'; const INTEGRATION_NAME = 'Http'; @@ -97,6 +98,10 @@ interface HttpOptions { }; } +const instrumentSentryHttpBeforeOtel = generateInstrumentOnce(`${INTEGRATION_NAME}.sentry-before-otel`, () => { + return new SentryHttpInstrumentationBeforeOtel(); +}); + const instrumentSentryHttp = generateInstrumentOnce<{ breadcrumbs?: HttpOptions['breadcrumbs']; ignoreOutgoingRequests?: HttpOptions['ignoreOutgoingRequests']; @@ -143,6 +148,8 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => return { name: INTEGRATION_NAME, setupOnce() { + instrumentSentryHttpBeforeOtel(); + const instrumentSpans = _shouldInstrumentSpans(options, getClient()?.getOptions()); // This is the "regular" OTEL instrumentation that emits spans diff --git a/packages/node/src/integrations/http/utils.ts b/packages/node/src/integrations/http/utils.ts new file mode 100644 index 000000000000..ddb803c8fc58 --- /dev/null +++ b/packages/node/src/integrations/http/utils.ts @@ -0,0 +1,39 @@ +/** + * This is a minimal version of `wrap` from shimmer: + * https://github.com/othiym23/shimmer/blob/master/index.js + * + * In contrast to the original implementation, this version does not allow to unwrap, + * and does not make it clear that the method is wrapped. + * This is necessary because we want to wrap the http module with our own code, + * while still allowing to use the HttpInstrumentation from OTEL. + * + * Without this, if we'd just use `wrap` from shimmer, the OTEL instrumentation would remove our wrapping, + * because it only allows any module to be wrapped a single time. + */ +export function stealthWrap( + nodule: Nodule, + name: FieldName, + wrapper: (original: Nodule[FieldName]) => Nodule[FieldName], +): Nodule[FieldName] { + const original = nodule[name]; + const wrapped = wrapper(original); + + defineProperty(nodule, name, wrapped); + return wrapped; +} + +// Sets a property on an object, preserving its enumerability. +function defineProperty( + obj: Nodule, + name: FieldName, + value: Nodule[FieldName], +): void { + const enumerable = !!obj[name] && Object.prototype.propertyIsEnumerable.call(obj, name); + + Object.defineProperty(obj, name, { + configurable: true, + enumerable: enumerable, + writable: true, + value: value, + }); +} From e6fcb39b2a37290aead815c3bd5a413230f84e3d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 19 Mar 2025 17:45:48 +0900 Subject: [PATCH 2/9] feat(remix/cloudflare): Export `sentryHandleError` (#15726) Exposes `sentryHandleError` for `@sentry/remix/cloudflare`. Closes: #15620 --- packages/remix/src/cloudflare/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 4634baa1381e..5d15be8edee7 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -3,8 +3,12 @@ export * from '@sentry/react'; export { captureRemixErrorBoundaryError } from '../client/errors'; export { withSentry } from '../client/performance'; -import { instrumentBuild as instrumentRemixBuild, makeWrappedCreateRequestHandler } from '../server/instrumentServer'; -export { makeWrappedCreateRequestHandler }; +import { + instrumentBuild as instrumentRemixBuild, + makeWrappedCreateRequestHandler, + sentryHandleError, +} from '../server/instrumentServer'; +export { makeWrappedCreateRequestHandler, sentryHandleError }; /** * Instruments a Remix build to capture errors and performance data. From fa8e0431e957d9b55db6ca668f7a3175e6792822 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 19 Mar 2025 13:51:05 +0100 Subject: [PATCH 3/9] fix(node): Ensure incoming traces are propagated without HttpInstrumentation (#15732) Part of https://github.com/getsentry/sentry-javascript/pull/15730 Noticed that this is not really happening otherwise, so if we are not adding the `HttpInstrumentation` we now make sure to extract incoming traces ourselves! This will unlock us not adding that instrumentation when tracing is disabled. Also includes https://github.com/getsentry/sentry-javascript/pull/15731 as a side effect. --- .../http/SentryHttpInstrumentation.ts | 39 ++++++++++++++----- packages/node/src/integrations/http/index.ts | 27 +++++++------ 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 8c7b729b8828..3db9df73a196 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,8 +1,5 @@ /* eslint-disable max-lines */ -import type * as http from 'node:http'; -import type { IncomingMessage, RequestOptions } from 'node:http'; -import type * as https from 'node:https'; -import type { EventEmitter } from 'node:stream'; +import { context, propagation } from '@opentelemetry/api'; import { VERSION } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; @@ -12,6 +9,7 @@ import { generateSpanId, getBreadcrumbLogLevelFromHttpStatusCode, getClient, + getCurrentScope, getIsolationScope, getSanitizedUrlString, httpRequestToRequestData, @@ -19,17 +17,20 @@ import { parseUrl, stripUrlQueryAndFragment, withIsolationScope, - withScope, } from '@sentry/core'; +import type * as http from 'node:http'; +import type { IncomingMessage, RequestOptions } from 'node:http'; +import type * as https from 'node:https'; +import type { EventEmitter } from 'node:stream'; import { DEBUG_BUILD } from '../../debug-build'; import { getRequestUrl } from '../../utils/getRequestUrl'; -import { getRequestInfo } from './vendor/getRequestInfo'; import { stealthWrap } from './utils'; +import { getRequestInfo } from './vendor/getRequestInfo'; type Http = typeof http; type Https = typeof https; -type SentryHttpInstrumentationOptions = InstrumentationConfig & { +export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * Whether breadcrumbs should be recorded for requests. * @@ -37,6 +38,15 @@ type SentryHttpInstrumentationOptions = InstrumentationConfig & { */ breadcrumbs?: boolean; + /** + * Whether to extract the trace ID from the `sentry-trace` header for incoming requests. + * By default this is done by the HttpInstrumentation, but if that is not added (e.g. because tracing is disabled, ...) + * then this instrumentation can take over. + * + * @default `false` + */ + extractIncomingTraceFromHeader?: boolean; + /** * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. * For the scope of this instrumentation, this callback only controls breadcrumb creation. @@ -185,9 +195,18 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - return withScope(scope => { - // Set a new propagationSpanId for this request - scope.getPropagationContext().propagationSpanId = generateSpanId(); + // Set a new propagationSpanId for this request + // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope + // This way we can save an "unnecessary" `withScope()` invocation + getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); + + // If we don't want to extract the trace from the header, we can skip this + if (!instrumentation.getConfig().extractIncomingTraceFromHeader) { + return original.apply(this, [event, ...args]); + } + + const ctx = propagation.extract(context.active(), normalizedRequest.headers); + return context.with(ctx, () => { return original.apply(this, [event, ...args]); }); }); diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 6e7581b75c8d..0df3fc56b480 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -10,6 +10,7 @@ import type { HTTPModuleRequestIncomingMessage } from '../../transports/http-mod import type { NodeClientOptions } from '../../types'; import { addOriginToSpan } from '../../utils/addOriginToSpan'; import { getRequestUrl } from '../../utils/getRequestUrl'; +import type { SentryHttpInstrumentationOptions } from './SentryHttpInstrumentation'; import { SentryHttpInstrumentation } from './SentryHttpInstrumentation'; import { SentryHttpInstrumentationBeforeOtel } from './SentryHttpInstrumentationBeforeOtel'; @@ -102,19 +103,12 @@ const instrumentSentryHttpBeforeOtel = generateInstrumentOnce(`${INTEGRATION_NAM return new SentryHttpInstrumentationBeforeOtel(); }); -const instrumentSentryHttp = generateInstrumentOnce<{ - breadcrumbs?: HttpOptions['breadcrumbs']; - ignoreOutgoingRequests?: HttpOptions['ignoreOutgoingRequests']; - trackIncomingRequestsAsSessions?: HttpOptions['trackIncomingRequestsAsSessions']; - sessionFlushingDelayMS?: HttpOptions['sessionFlushingDelayMS']; -}>(`${INTEGRATION_NAME}.sentry`, options => { - return new SentryHttpInstrumentation({ - breadcrumbs: options?.breadcrumbs, - ignoreOutgoingRequests: options?.ignoreOutgoingRequests, - trackIncomingRequestsAsSessions: options?.trackIncomingRequestsAsSessions, - sessionFlushingDelayMS: options?.sessionFlushingDelayMS, - }); -}); +const instrumentSentryHttp = generateInstrumentOnce( + `${INTEGRATION_NAME}.sentry`, + options => { + return new SentryHttpInstrumentation(options); + }, +); export const instrumentOtelHttp = generateInstrumentOnce(INTEGRATION_NAME, config => { const instrumentation = new HttpInstrumentation(config); @@ -161,7 +155,12 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => // This is the Sentry-specific instrumentation that isolates requests & creates breadcrumbs // Note that this _has_ to be wrapped after the OTEL instrumentation, // otherwise the isolation will not work correctly - instrumentSentryHttp(options); + instrumentSentryHttp({ + ...options, + // If spans are not instrumented, it means the HttpInstrumentation has not been added + // In that case, we want to handle incoming trace extraction ourselves + extractIncomingTraceFromHeader: !instrumentSpans, + }); }, }; }); From d76a165eb604c3299b06ce9114a4f720380b28f0 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 19 Mar 2025 14:42:01 +0100 Subject: [PATCH 4/9] ref(node): Avoid unnecessary array parsing for incoming requests (#15736) Small optimization to avoid unnecessary work. ref: https://github.com/getsentry/sentry-javascript/issues/15725 --- .../integrations/http/SentryHttpInstrumentation.ts | 14 +++++++------- .../http/SentryHttpInstrumentationBeforeOtel.ts | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 3db9df73a196..aa1f0157f2cf 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -154,17 +154,17 @@ export class SentryHttpInstrumentation extends InstrumentationBase boolean, ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { - return function incomingRequest(this: unknown, event: string, ...args: unknown[]): boolean { + return function incomingRequest(this: unknown, ...args: [event: string, ...args: unknown[]]): boolean { // Only traces request events - if (event !== 'request') { - return original.apply(this, [event, ...args]); + if (args[0] !== 'request') { + return original.apply(this, args); } instrumentation._diag.debug('http instrumentation for incoming request'); const isolationScope = getIsolationScope().clone(); - const request = args[0] as http.IncomingMessage; - const response = args[1] as http.OutgoingMessage; + const request = args[1] as http.IncomingMessage; + const response = args[2] as http.OutgoingMessage; const normalizedRequest = httpRequestToRequestData(request); @@ -202,12 +202,12 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - return original.apply(this, [event, ...args]); + return original.apply(this, args); }); }); }; diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts b/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts index 335f23604e10..e98256211f84 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentationBeforeOtel.ts @@ -51,17 +51,17 @@ export class SentryHttpInstrumentationBeforeOtel extends InstrumentationBase { return ( original: (event: string, ...args: unknown[]) => boolean, ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { - return function incomingRequest(this: unknown, event: string, ...args: unknown[]): boolean { + return function incomingRequest(this: unknown, ...args: [event: string, ...args: unknown[]]): boolean { // Only traces request events - if (event !== 'request') { - return original.apply(this, [event, ...args]); + if (args[0] !== 'request') { + return original.apply(this, args); } const response = args[1] as http.OutgoingMessage; patchResponseToFlushOnServerlessPlatforms(response); - return original.apply(this, [event, ...args]); + return original.apply(this, args); }; }; } From aa1548feabc05aef752d17e92afadb7c780d5700 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 19 Mar 2025 14:49:02 +0100 Subject: [PATCH 5/9] fix(node): Use `fatal` level for unhandled rejections in `strict` mode (#15720) Closes https://github.com/getsentry/sentry-javascript/issues/13700 This also adds tests for unhandled rejections handling, which we did not really have before. --- .../mode-none.js | 13 ++ .../mode-strict.js | 14 ++ .../mode-warn-error.js | 12 ++ .../mode-warn-string.js | 12 ++ .../scenario-strict.ts | 12 ++ .../scenario-warn.ts | 11 ++ .../onUnhandledRejectionIntegration/test.ts | 127 ++++++++++++++++++ .../src/integrations/onunhandledrejection.ts | 20 ++- 8 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-none.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-strict.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-error.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-string.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-strict.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-warn.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-none.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-none.js new file mode 100644 index 000000000000..468b80a2df5e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-none.js @@ -0,0 +1,13 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.onUnhandledRejectionIntegration({ mode: 'none' })], +}); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject('test rejection'); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-strict.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-strict.js new file mode 100644 index 000000000000..76c18bbdc51e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-strict.js @@ -0,0 +1,14 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.onUnhandledRejectionIntegration({ mode: 'strict' })], +}); + +setTimeout(() => { + // should not be called + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject('test rejection'); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-error.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-error.js new file mode 100644 index 000000000000..57566677cd7a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-error.js @@ -0,0 +1,12 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject(new Error('test rejection')); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-string.js b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-string.js new file mode 100644 index 000000000000..5d45dbde5870 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/mode-warn-string.js @@ -0,0 +1,12 @@ +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); + +setTimeout(() => { + process.stdout.write("I'm alive!"); + process.exit(0); +}, 500); + +Promise.reject('test rejection'); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-strict.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-strict.ts new file mode 100644 index 000000000000..9da994967d58 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-strict.ts @@ -0,0 +1,12 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, + integrations: [Sentry.onUnhandledRejectionIntegration({ mode: 'strict' })], +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Promise.reject('test rejection'); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-warn.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-warn.ts new file mode 100644 index 000000000000..55b283af81ae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-warn.ts @@ -0,0 +1,11 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Promise.reject('test rejection'); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts new file mode 100644 index 000000000000..e2f2a2143438 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -0,0 +1,127 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses } from '../../../utils/runner'; +import { createRunner } from '../../../utils/runner'; + +describe('onUnhandledRejectionIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should show string-type promise rejection warnings by default', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'mode-warn-string.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr.trim()) + .toBe(`This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason: +test rejection`); + done(); + }); + })); + + test('should show error-type promise rejection warnings by default', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'mode-warn-error.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr) + .toContain(`This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason: +Error: test rejection + at Object.`); + done(); + }); + })); + + test('should not close process on unhandled rejection in strict mode', () => + new Promise(done => { + expect.assertions(4); + + const testScriptPath = path.resolve(__dirname, 'mode-strict.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).not.toBeNull(); + expect(err?.code).toBe(1); + expect(stdout).not.toBe("I'm alive!"); + expect(stderr) + .toContain(`This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason: +test rejection`); + done(); + }); + })); + + test('should not close process or warn on unhandled rejection in none mode', () => + new Promise(done => { + expect.assertions(3); + + const testScriptPath = path.resolve(__dirname, 'mode-none.js'); + + childProcess.execFile('node', [testScriptPath], { encoding: 'utf8' }, (err, stdout, stderr) => { + expect(err).toBeNull(); + expect(stdout).toBe("I'm alive!"); + expect(stderr).toBe(''); + done(); + }); + })); + + test('captures exceptions for unhandled rejections', async () => { + await createRunner(__dirname, 'scenario-warn.ts') + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'test rejection', + mechanism: { + type: 'onunhandledrejection', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + + test('captures exceptions for unhandled rejections in strict mode', async () => { + await createRunner(__dirname, 'scenario-strict.ts') + .expect({ + event: { + level: 'fatal', + exception: { + values: [ + { + type: 'Error', + value: 'test rejection', + mechanism: { + type: 'onunhandledrejection', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); diff --git a/packages/node/src/integrations/onunhandledrejection.ts b/packages/node/src/integrations/onunhandledrejection.ts index 4a7c7a1fe83d..8b41da189a0f 100644 --- a/packages/node/src/integrations/onunhandledrejection.ts +++ b/packages/node/src/integrations/onunhandledrejection.ts @@ -1,4 +1,4 @@ -import type { Client, IntegrationFn } from '@sentry/core'; +import type { Client, IntegrationFn, SeverityLevel } from '@sentry/core'; import { captureException, consoleSandbox, defineIntegration, getClient } from '@sentry/core'; import { logAndExitProcess } from '../utils/errorhandling'; @@ -15,12 +15,15 @@ interface OnUnhandledRejectionOptions { const INTEGRATION_NAME = 'OnUnhandledRejection'; const _onUnhandledRejectionIntegration = ((options: Partial = {}) => { - const mode = options.mode || 'warn'; + const opts = { + mode: 'warn', + ...options, + } satisfies OnUnhandledRejectionOptions; return { name: INTEGRATION_NAME, setup(client) { - global.process.on('unhandledRejection', makeUnhandledPromiseHandler(client, { mode })); + global.process.on('unhandledRejection', makeUnhandledPromiseHandler(client, opts)); }, }; }) satisfies IntegrationFn; @@ -46,10 +49,13 @@ export function makeUnhandledPromiseHandler( return; } + const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error'; + captureException(reason, { originalException: promise, captureContext: { extra: { unhandledPromiseRejection: true }, + level, }, mechanism: { handled: false, @@ -57,14 +63,14 @@ export function makeUnhandledPromiseHandler( }, }); - handleRejection(reason, options); + handleRejection(reason, options.mode); }; } /** * Handler for `mode` option */ -function handleRejection(reason: unknown, options: OnUnhandledRejectionOptions): void { +function handleRejection(reason: unknown, mode: UnhandledRejectionMode): void { // https://github.com/nodejs/node/blob/7cf6f9e964aa00772965391c23acda6d71972a9a/lib/internal/process/promises.js#L234-L240 const rejectionWarning = 'This error originated either by ' + @@ -73,12 +79,12 @@ function handleRejection(reason: unknown, options: OnUnhandledRejectionOptions): ' The promise rejected with the reason:'; /* eslint-disable no-console */ - if (options.mode === 'warn') { + if (mode === 'warn') { consoleSandbox(() => { console.warn(rejectionWarning); console.error(reason && typeof reason === 'object' && 'stack' in reason ? reason.stack : reason); }); - } else if (options.mode === 'strict') { + } else if (mode === 'strict') { consoleSandbox(() => { console.warn(rejectionWarning); }); From fc0af3892a2704326d2338c05d3f049176d9358d Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 19 Mar 2025 23:53:53 -0400 Subject: [PATCH 6/9] feat(core): Add `captureLog` method (#15717) ## Description `captureLog` takes in a `log` interface as well as a scope, and constructs a serialized log to send to Sentry. Instead of directly sending the serialized log, it adds it to a log buffer that is flushed based on some strategy. As a fallback, the buffer flushes itself when it gets over 100 items, which ensures we never get a payload thats too large. The browser and server runtime client are expected to add more advanced flushing strategies on top of the simple implementation exposed in `captureLog`. This will be done in future PRs. The serialized log that is constructed by `captureLog` send logs via the opentelemetry format. This is temporary, in the future we'll switch to the sentry logs protocol, but while that gets ready we are using the OTEL one. ## Next steps 1. Add flushing strategy for server-runtime-client 2. Implement top-level API methods as per develop spec 3. Add support for paramaterized strings 4. Add integration tests to validate envelope gets sent correctly --- .size-limit.js | 4 +- packages/browser/src/client.ts | 4 + packages/core/src/client.ts | 28 ++- packages/core/src/index.ts | 1 + packages/core/src/logs/constants.ts | 15 ++ packages/core/src/logs/envelope.ts | 51 ++++++ packages/core/src/logs/index.ts | 156 ++++++++++++++++ packages/core/src/server-runtime-client.ts | 33 +--- packages/core/src/types-hoist/index.ts | 8 +- packages/core/src/types-hoist/log.ts | 33 ++-- packages/core/src/types-hoist/options.ts | 5 + packages/core/test/lib/log/envelope.test.ts | 187 +++++++++++++++++++ packages/core/test/lib/log/index.test.ts | 190 ++++++++++++++++++++ 13 files changed, 670 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/logs/constants.ts create mode 100644 packages/core/src/logs/envelope.ts create mode 100644 packages/core/src/logs/index.ts create mode 100644 packages/core/test/lib/log/envelope.test.ts create mode 100644 packages/core/test/lib/log/index.test.ts diff --git a/.size-limit.js b/.size-limit.js index ffa69d850947..203e8cecb6ce 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -47,7 +47,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '75.2 KB', + limit: '75.5 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -79,7 +79,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '80 KB', + limit: '80.5 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 0e5b3fb6214c..bacf269871a4 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -15,6 +15,7 @@ import { addAutoIpAddressToUser, applySdkMetadata, getSDKSource, + _INTERNAL_flushLogsBuffer, } from '@sentry/core'; import { eventFromException, eventFromMessage } from './eventbuilder'; import { WINDOW } from './helpers'; @@ -85,6 +86,9 @@ export class BrowserClient extends Client { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { this._flushOutcomes(); + if (this._options._experiments?.enableLogs) { + _INTERNAL_flushLogsBuffer(this); + } } }); } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b3021ce087c9..af13ea2d691f 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -28,6 +28,7 @@ import type { SpanContextData, SpanJSON, StartSpanOptions, + TraceContext, TransactionEvent, Transport, TransportMakeRequestResponse, @@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration'; import { setupIntegration, setupIntegrations } from './integration'; import type { Scope } from './scope'; import { updateSession } from './session'; -import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; +import { + getDynamicSamplingContextFromScope, + getDynamicSamplingContextFromSpan, +} from './tracing/dynamicSamplingContext'; import { createClientReportEnvelope } from './utils-hoist/clientreport'; import { dsnToString, makeDsn } from './utils-hoist/dsn'; import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope'; @@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils'; import { merge } from './utils/merge'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; -import { showSpanDropWarning } from './utils/spanUtils'; +import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; +import { _getSpanForScope } from './utils/spanOnScope'; const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured."; const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release'; @@ -617,7 +622,7 @@ export abstract class Client { public on(hook: 'close', callback: () => void): () => void; /** - * Register a hook oin this client. + * Register a hook on this client. */ public on(hook: string, callback: unknown): () => void { const hooks = (this._hooks[hook] = this._hooks[hook] || []); @@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent { function isTransactionEvent(event: Event): event is TransactionEvent { return event.type === 'transaction'; } + +/** Extract trace information from scope */ +export function _getTraceInfoFromScope( + client: Client, + scope: Scope | undefined, +): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { + if (!scope) { + return [undefined, undefined]; + } + + const span = _getSpanForScope(scope); + const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); + const dynamicSamplingContext = span + ? getDynamicSamplingContextFromSpan(span) + : getDynamicSamplingContextFromScope(client, scope); + return [dynamicSamplingContext, traceContext]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35bfc35bc603..53b612fccc29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; export { captureFeedback } from './feedback'; export type { ReportDialogOptions } from './report-dialog'; +export { _INTERNAL_flushLogsBuffer } from './logs'; // TODO: Make this structure pretty again and don't do "export *" export * from './utils-hoist/index'; diff --git a/packages/core/src/logs/constants.ts b/packages/core/src/logs/constants.ts new file mode 100644 index 000000000000..d23a99ac95c7 --- /dev/null +++ b/packages/core/src/logs/constants.ts @@ -0,0 +1,15 @@ +import type { LogSeverityLevel } from '../types-hoist'; + +/** + * Maps a log severity level to a log severity number. + * + * @see LogSeverityLevel + */ +export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial> = { + trace: 1, + debug: 5, + info: 9, + warn: 13, + error: 17, + fatal: 21, +}; diff --git a/packages/core/src/logs/envelope.ts b/packages/core/src/logs/envelope.ts new file mode 100644 index 000000000000..1b0a58892546 --- /dev/null +++ b/packages/core/src/logs/envelope.ts @@ -0,0 +1,51 @@ +import { createEnvelope } from '../utils-hoist'; + +import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist'; +import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope'; +import { dsnToString } from '../utils-hoist'; + +/** + * Creates OTEL log envelope item for a serialized OTEL log. + * + * @param log - The serialized OTEL log to include in the envelope. + * @returns The created OTEL log envelope item. + */ +export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem { + return [ + { + type: 'otel_log', + }, + log, + ]; +} + +/** + * Creates an envelope for a list of logs. + * + * @param logs - The logs to include in the envelope. + * @param metadata - The metadata to include in the envelope. + * @param tunnel - The tunnel to include in the envelope. + * @param dsn - The DSN to include in the envelope. + * @returns The created envelope. + */ +export function createOtelLogEnvelope( + logs: Array, + metadata?: SdkMetadata, + tunnel?: string, + dsn?: DsnComponents, +): OtelLogEnvelope { + const headers: OtelLogEnvelope[0] = {}; + + if (metadata?.sdk) { + headers.sdk = { + name: metadata.sdk.name, + version: metadata.sdk.version, + }; + } + + if (!!tunnel && !!dsn) { + headers.dsn = dsnToString(dsn); + } + + return createEnvelope(headers, logs.map(createOtelLogEnvelopeItem)); +} diff --git a/packages/core/src/logs/index.ts b/packages/core/src/logs/index.ts new file mode 100644 index 000000000000..dcd9cd0abaf6 --- /dev/null +++ b/packages/core/src/logs/index.ts @@ -0,0 +1,156 @@ +import type { Client } from '../client'; +import { _getTraceInfoFromScope } from '../client'; +import { getClient, getCurrentScope } from '../currentScopes'; +import { DEBUG_BUILD } from '../debug-build'; +import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; +import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist'; +import type { Log } from '../types-hoist/log'; +import { logger } from '../utils-hoist'; +import { _getSpanForScope } from '../utils/spanOnScope'; +import { createOtelLogEnvelope } from './envelope'; + +const MAX_LOG_BUFFER_SIZE = 100; + +const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap>(); + +/** + * Converts a log attribute to a serialized log attribute. + * + * @param key - The key of the log attribute. + * @param value - The value of the log attribute. + * @returns The serialized log attribute. + */ +export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute { + switch (typeof value) { + case 'number': + return { + key, + value: { doubleValue: value }, + }; + case 'boolean': + return { + key, + value: { boolValue: value }, + }; + case 'string': + return { + key, + value: { stringValue: value }, + }; + default: { + let stringValue = ''; + try { + stringValue = JSON.stringify(value) ?? ''; + } catch (_) { + // Do nothing + } + return { + key, + value: { stringValue }, + }; + } + } +} + +/** + * Captures a log event and sends it to Sentry. + * + * @param log - The log event to capture. + * @param scope - A scope. Uses the current scope if not provided. + * @param client - A client. Uses the current client if not provided. + * + * @experimental This method will experience breaking changes. This is not yet part of + * the stable Sentry SDK API and can be changed or removed without warning. + */ +export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void { + if (!client) { + DEBUG_BUILD && logger.warn('No client available to capture log.'); + return; + } + + const { _experiments, release, environment } = client.getOptions(); + if (!_experiments?.enableLogs) { + DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.'); + return; + } + + const [, traceContext] = _getTraceInfoFromScope(client, scope); + + const { level, message, attributes, severityNumber } = log; + + const logAttributes = { + ...attributes, + }; + + if (release) { + logAttributes.release = release; + } + + if (environment) { + logAttributes.environment = environment; + } + + 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; + } + + const serializedLog: SerializedOtelLog = { + severityText: level, + body: { + stringValue: message, + }, + attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)), + timeUnixNano: `${new Date().getTime().toString()}000000`, + traceId: traceContext?.trace_id, + severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], + }; + + const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client); + if (logBuffer === undefined) { + CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]); + // Every time we initialize a new log buffer, we start a new interval to flush the buffer + return; + } + + logBuffer.push(serializedLog); + if (logBuffer.length > MAX_LOG_BUFFER_SIZE) { + _INTERNAL_flushLogsBuffer(client, logBuffer); + } +} + +/** + * Flushes the logs buffer to Sentry. + * + * @param client - A client. + * @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided. + */ +export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array): void { + const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? []; + if (logBuffer.length === 0) { + return; + } + + const clientOptions = client.getOptions(); + const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); + + // Clear the log buffer after envelopes have been constructed. + logBuffer.length = 0; + + // sendEnvelope should not throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + client.sendEnvelope(envelope); +} + +/** + * Returns the log buffer for a given client. + * + * Exported for testing purposes. + * + * @param client - The client to get the log buffer for. + * @returns The log buffer for the given client. + */ +export function _INTERNAL_getLogBuffer(client: Client): Array | undefined { + return CLIENT_TO_LOG_BUFFER_MAP.get(client); +} diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 8bb07b976d65..61db792b901e 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -2,32 +2,24 @@ import type { BaseTransportOptions, CheckIn, ClientOptions, - DynamicSamplingContext, Event, EventHint, MonitorConfig, ParameterizedString, SerializedCheckIn, SeverityLevel, - TraceContext, } from './types-hoist'; import { createCheckInEnvelope } from './checkin'; -import { Client } from './client'; -import { getIsolationScope, getTraceContextFromScope } from './currentScopes'; +import { Client, _getTraceInfoFromScope } from './client'; +import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { Scope } from './scope'; -import { - getDynamicSamplingContextFromScope, - getDynamicSamplingContextFromSpan, - registerSpanErrorInstrumentation, -} from './tracing'; +import { registerSpanErrorInstrumentation } from './tracing'; import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder'; import { logger } from './utils-hoist/logger'; import { uuid4 } from './utils-hoist/misc'; import { resolvedSyncPromise } from './utils-hoist/syncpromise'; -import { _getSpanForScope } from './utils/spanOnScope'; -import { spanToTraceContext } from './utils/spanUtils'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -136,7 +128,7 @@ export class ServerRuntimeClient< }; } - const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope); + const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope); if (traceContext) { serializedCheckIn.contexts = { trace: traceContext, @@ -186,23 +178,6 @@ export class ServerRuntimeClient< return super._prepareEvent(event, hint, currentScope, isolationScope); } - - /** Extract trace information from scope */ - protected _getTraceInfoFromScope( - scope: Scope | undefined, - ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { - if (!scope) { - return [undefined, undefined]; - } - - const span = _getSpanForScope(scope); - - const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); - const dynamicSamplingContext = span - ? getDynamicSamplingContextFromSpan(span) - : getDynamicSamplingContextFromScope(this, scope); - return [dynamicSamplingContext, traceContext]; - } } function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { diff --git a/packages/core/src/types-hoist/index.ts b/packages/core/src/types-hoist/index.ts index 742b7ffe2346..1ca827f225a5 100644 --- a/packages/core/src/types-hoist/index.ts +++ b/packages/core/src/types-hoist/index.ts @@ -110,7 +110,13 @@ export type { TraceFlag, } from './span'; export type { SpanStatus } from './spanStatus'; -export type { SerializedOtelLog, LogAttribute, LogSeverityLevel, LogAttributeValueType } from './log'; +export type { + Log, + LogSeverityLevel, + SerializedOtelLog, + SerializedLogAttribute, + SerializedLogAttributeValueType, +} from './log'; export type { TimedEvent } from './timedEvent'; export type { StackFrame } from './stackframe'; export type { Stacktrace, StackParser, StackLineParser, StackLineParserFn } from './stacktrace'; diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index b1588dc4efc4..a313b493306c 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -1,6 +1,6 @@ export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'critical'; -export type LogAttributeValueType = +export type SerializedLogAttributeValueType = | { stringValue: string; } @@ -16,12 +16,12 @@ export type LogAttributeValueType = doubleValue: number; }; -export type LogAttribute = { +export type SerializedLogAttribute = { key: string; - value: LogAttributeValueType; + value: SerializedLogAttributeValueType; }; -export interface SerializedOtelLog { +export interface Log { /** * The severity level of the log. * @@ -31,29 +31,42 @@ export interface SerializedOtelLog { * The log level changes how logs are filtered and displayed. * Critical level logs are emphasized more than trace level logs. */ - severityText?: LogSeverityLevel; + level: LogSeverityLevel; + + /** + * The message to be logged - for example, 'hello world' would become a log like '[INFO] hello world' + */ + message: string; + + /** + * Arbitrary structured data that stores information about the log - e.g., userId: 100. + */ + attributes?: Record>; /** * The severity number - generally higher severity are levels like 'error' and lower are levels like 'debug' */ severityNumber?: number; +} + +export interface SerializedOtelLog { + severityText?: Log['level']; /** * The trace ID for this log */ traceId?: string; - /** - * The message to be logged - for example, 'hello world' would become a log like '[INFO] hello world' - */ + severityNumber?: Log['severityNumber']; + body: { - stringValue: string; + stringValue: Log['message']; }; /** * Arbitrary structured data that stores information about the log - e.g., userId: 100. */ - attributes?: LogAttribute[]; + attributes?: SerializedLogAttribute[]; /** * This doesn't have to be explicitly specified most of the time. If you need to set it, the value diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 8e52b32eacf7..d0474b959fa9 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -182,7 +182,12 @@ export interface ClientOptions ({ + createEnvelope: vi.fn((_headers, items) => [_headers, items]), + dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`), +})); + +describe('createOtelLogEnvelopeItem', () => { + it('creates an envelope item with correct structure', () => { + const mockLog: SerializedOtelLog = { + severityText: 'error', + body: { + stringValue: 'Test error message', + }, + }; + + const result = createOtelLogEnvelopeItem(mockLog); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ type: 'otel_log' }); + expect(result[1]).toBe(mockLog); + }); +}); + +describe('createOtelLogEnvelope', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-01-01T12:00:00Z')); + + // Reset mocks + vi.mocked(utilsHoist.createEnvelope).mockClear(); + vi.mocked(utilsHoist.dsnToString).mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates an envelope with basic headers', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'Test log message' }, + }, + ]; + + const result = createOtelLogEnvelope(mockLogs); + + expect(result[0]).toEqual({}); + + // Verify createEnvelope was called with the right parameters + expect(utilsHoist.createEnvelope).toHaveBeenCalledWith({}, expect.any(Array)); + }); + + it('includes SDK info when metadata is provided', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'Test log message' }, + }, + ]; + + const metadata: SdkMetadata = { + sdk: { + name: 'sentry.javascript.node', + version: '7.0.0', + }, + }; + + const result = createOtelLogEnvelope(mockLogs, metadata); + + expect(result[0]).toEqual({ + sdk: { + name: 'sentry.javascript.node', + version: '7.0.0', + }, + }); + }); + + it('includes DSN when tunnel and DSN are provided', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'Test log message' }, + }, + ]; + + const dsn: DsnComponents = { + host: 'example.sentry.io', + path: '/', + projectId: '123', + port: '', + protocol: 'https', + publicKey: 'abc123', + }; + + const result = createOtelLogEnvelope(mockLogs, undefined, 'https://tunnel.example.com', dsn); + + expect(result[0]).toHaveProperty('dsn'); + expect(utilsHoist.dsnToString).toHaveBeenCalledWith(dsn); + }); + + it('maps each log to an envelope item', () => { + const mockLogs: SerializedOtelLog[] = [ + { + severityText: 'info', + body: { stringValue: 'First log message' }, + }, + { + severityText: 'error', + body: { stringValue: 'Second log message' }, + }, + ]; + + createOtelLogEnvelope(mockLogs); + + // Check that createEnvelope was called with an array of envelope items + expect(utilsHoist.createEnvelope).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.arrayContaining([{ type: 'otel_log' }, mockLogs[0]]), + expect.arrayContaining([{ type: 'otel_log' }, mockLogs[1]]), + ]), + ); + }); +}); + +describe('Trace context in logs', () => { + it('correctly sets parent_span_id in trace context', () => { + // Create a log with trace context + const mockParentSpanId = 'abcdef1234567890'; + const mockTraceId = '00112233445566778899aabbccddeeff'; + + const mockLog: SerializedOtelLog = { + severityText: 'info', + body: { stringValue: 'Test log with trace context' }, + traceId: mockTraceId, + attributes: [ + { + key: 'sentry.trace.parent_span_id', + value: { stringValue: mockParentSpanId }, + }, + { + key: 'some.other.attribute', + value: { stringValue: 'test value' }, + }, + ], + }; + + // Create an envelope item from this log + const envelopeItem = createOtelLogEnvelopeItem(mockLog); + + // Verify the parent_span_id is preserved in the envelope item + expect(envelopeItem[1]).toBe(mockLog); + expect(envelopeItem[1].traceId).toBe(mockTraceId); + expect(envelopeItem[1].attributes).toContainEqual({ + key: 'sentry.trace.parent_span_id', + value: { stringValue: mockParentSpanId }, + }); + + // Create an envelope with this log + createOtelLogEnvelope([mockLog]); + + // Verify the envelope preserves the trace information + expect(utilsHoist.createEnvelope).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.arrayContaining([ + { type: 'otel_log' }, + expect.objectContaining({ + traceId: mockTraceId, + attributes: expect.arrayContaining([ + { + key: 'sentry.trace.parent_span_id', + value: { stringValue: mockParentSpanId }, + }, + ]), + }), + ]), + ]), + ); + }); +}); diff --git a/packages/core/test/lib/log/index.test.ts b/packages/core/test/lib/log/index.test.ts new file mode 100644 index 000000000000..2d975abda4e1 --- /dev/null +++ b/packages/core/test/lib/log/index.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + _INTERNAL_flushLogsBuffer, + _INTERNAL_getLogBuffer, + captureLog, + logAttributeToSerializedLogAttribute, +} from '../../../src/logs'; +import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; +import * as loggerModule from '../../../src/utils-hoist/logger'; +import { Scope } from '../../../src'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +describe('logAttributeToSerializedLogAttribute', () => { + it('serializes number values', () => { + const result = logAttributeToSerializedLogAttribute('count', 42); + expect(result).toEqual({ + key: 'count', + value: { doubleValue: 42 }, + }); + }); + + it('serializes boolean values', () => { + const result = logAttributeToSerializedLogAttribute('enabled', true); + expect(result).toEqual({ + key: 'enabled', + value: { boolValue: true }, + }); + }); + + it('serializes string values', () => { + const result = logAttributeToSerializedLogAttribute('username', 'john_doe'); + expect(result).toEqual({ + key: 'username', + value: { stringValue: 'john_doe' }, + }); + }); + + it('serializes object values as JSON strings', () => { + const obj = { name: 'John', age: 30 }; + const result = logAttributeToSerializedLogAttribute('user', obj); + expect(result).toEqual({ + key: 'user', + value: { stringValue: JSON.stringify(obj) }, + }); + }); + + it('serializes array values as JSON strings', () => { + const array = [1, 2, 3, 'test']; + const result = logAttributeToSerializedLogAttribute('items', array); + expect(result).toEqual({ + key: 'items', + value: { stringValue: JSON.stringify(array) }, + }); + }); + + it('serializes undefined values as empty strings', () => { + const result = logAttributeToSerializedLogAttribute('missing', undefined); + expect(result).toEqual({ + key: 'missing', + value: { stringValue: '' }, + }); + }); + + it('serializes null values as JSON strings', () => { + const result = logAttributeToSerializedLogAttribute('empty', null); + expect(result).toEqual({ + key: 'empty', + value: { stringValue: 'null' }, + }); + }); +}); + +describe('captureLog', () => { + it('captures and sends logs', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + + captureLog({ level: 'info', message: 'test log message' }, undefined, client); + expect(_INTERNAL_getLogBuffer(client)).toHaveLength(1); + expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual( + expect.objectContaining({ + severityText: 'info', + body: { + stringValue: 'test log message', + }, + timeUnixNano: expect.any(String), + }), + ); + }); + + it('does not capture logs when enableLogs experiment is not enabled', () => { + const logWarnSpy = vi.spyOn(loggerModule.logger, 'warn').mockImplementation(() => undefined); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + + captureLog({ level: 'info', message: 'test log message' }, undefined, client); + + expect(logWarnSpy).toHaveBeenCalledWith('logging option not enabled, log will not be captured.'); + expect(_INTERNAL_getLogBuffer(client)).toBeUndefined(); + + logWarnSpy.mockRestore(); + }); + + it('includes trace context when available', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setPropagationContext({ + traceId: '3d9355f71e9c444b81161599adac6e29', + sampleRand: 1, + }); + + captureLog({ level: 'error', message: 'test log with trace' }, scope, client); + + expect(_INTERNAL_getLogBuffer(client)?.[0]).toEqual( + expect.objectContaining({ + traceId: '3d9355f71e9c444b81161599adac6e29', + }), + ); + }); + + it('includes release and environment in log attributes when available', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableLogs: true }, + release: '1.0.0', + environment: 'test', + }); + const client = new TestClient(options); + + captureLog({ level: 'info', message: 'test log with metadata' }, undefined, client); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'release', value: { stringValue: '1.0.0' } }), + expect.objectContaining({ key: 'environment', value: { stringValue: 'test' } }), + ]), + ); + }); + + it('includes custom attributes in log', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + + captureLog( + { + level: 'info', + message: 'test log with custom attributes', + attributes: { userId: '123', component: 'auth' }, + }, + undefined, + client, + ); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'userId', value: { stringValue: '123' } }), + expect.objectContaining({ key: 'component', value: { stringValue: 'auth' } }), + ]), + ); + }); + + it('flushes logs buffer when it reaches max size', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + + // Fill the buffer to max size (100 is the MAX_LOG_BUFFER_SIZE constant in client.ts) + for (let i = 0; i < 100; i++) { + captureLog({ level: 'info', message: `log message ${i}` }, undefined, client); + } + + expect(_INTERNAL_getLogBuffer(client)).toHaveLength(100); + + // Add one more to trigger flush + captureLog({ level: 'info', message: 'trigger flush' }, undefined, client); + + expect(_INTERNAL_getLogBuffer(client)).toEqual([]); + }); + + it('does not flush logs buffer when it is empty', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {}); + _INTERNAL_flushLogsBuffer(client); + expect(mockSendEnvelope).not.toHaveBeenCalled(); + }); +}); From 57e96545fd034bd53999b7c54636f6303110191b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 20 Mar 2025 09:42:08 +0100 Subject: [PATCH 7/9] chore(docs): Add note on alpha releases to docs (#15741) This just makes it a bit more disoverable for poor souls like me searching for "alpha" in this doc --------- Co-authored-by: Francesco Gringl-Novy --- docs/publishing-a-release.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/publishing-a-release.md b/docs/publishing-a-release.md index 290c6a3076c8..1ee26a12de77 100644 --- a/docs/publishing-a-release.md +++ b/docs/publishing-a-release.md @@ -20,9 +20,9 @@ _These steps are only relevant to Sentry employees when preparing and publishing [@getsentry/releases-approvers](https://github.com/orgs/getsentry/teams/release-approvers) to approve the release. a. Once the release is completed, a sync from `master` ->` develop` will be automatically triggered -## Publishing a release for previous majors +## Publishing a release for previous majors or prerelease (alpha, beta) versions -1. Run `yarn changelog` on a previous major branch (e.g. `v8`) and determine what version will be released (we use +1. Run `yarn changelog` on a previous major branch (e.g. `v8` or `9.7.0-alpha`) and determine what version will be released (we use [semver](https://semver.org)) 2. Create a branch, e.g. `changelog-8.45.1`, off a previous major branch (e.g. `v8`) 3. Update `CHANGELOG.md` to add an entry for the next release number and a list of changes since the @@ -32,9 +32,9 @@ _These steps are only relevant to Sentry employees when preparing and publishing (as the commits already exist on this branch). 6. Once the PR is merged, open the [Prepare Release workflow](https://github.com/getsentry/sentry-javascript/actions/workflows/release.yml) and fill in ![run-release-workflow.png](./assets/run-release-workflow.png) - 1. The major branch you want to release for, e.g. `v8` - 2. The version you want to release, e.g. `8.45.1` - 3. The major branch to merge into, e.g. `v8` + 1. The major branch you want to release for, e.g. `v8` or `9.7.0-alpha` + 2. The version you want to release, e.g. `8.45.1` `9.7.0-alpha.1` + 3. The major branch to merge into, e.g. `v8` `9.7.0-alpha` 7. Run the release workflow ## Updating the Changelog From 98c12a324635bddd555351cb7a5d230f616455f7 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 20 Mar 2025 12:16:54 +0100 Subject: [PATCH 8/9] fix(nuxt): Delete Nuxt server template injection (#15749) Follow-up for https://github.com/getsentry/sentry-javascript/pull/15710 The server config was still processed by Nuxt (not only Nitro) because the file was imported in a plugin template. This code can be deleted now, as the Sentry server config should only be processed on the Nitro-side. fixes https://github.com/getsentry/sentry-javascript/issues/15628 --- packages/nuxt/src/module.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index c239aeaf1432..275c558c6666 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -62,18 +62,6 @@ export default defineNuxtModule({ const serverConfigFile = findDefaultSdkInitFile('server'); if (serverConfigFile) { - if (moduleOptions.autoInjectServerSentry !== 'experimental_dynamic-import') { - addPluginTemplate({ - mode: 'server', - filename: 'sentry-server-config.mjs', - getContents: () => - // This won't actually import the server config in the build output (so no double init call). The import here is only needed for correctly resolving the Sentry release injection. - `import "${buildDirResolver.resolve(`/${serverConfigFile}`)}"; - import { defineNuxtPlugin } from "#imports"; - export default defineNuxtPlugin(() => {});`, - }); - } - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); } From 0614d95998f1dff31739088d596a9bb749f9d845 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 20 Mar 2025 13:27:55 +0100 Subject: [PATCH 9/9] meta(changelog): Update changelog for 9.7.0 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebe392a79f3c..0657b326b38c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.7.0 + +- feat(core): Add `captureLog` method ([#15717](https://github.com/getsentry/sentry-javascript/pull/15717)) +- feat(remix/cloudflare): Export `sentryHandleError` ([#15726](https://github.com/getsentry/sentry-javascript/pull/15726)) +- fix(node): Always flush on Vercel before Lambda freeze ([#15602](https://github.com/getsentry/sentry-javascript/pull/15602)) +- fix(node): Ensure incoming traces are propagated without HttpInstrumentation ([#15732](https://github.com/getsentry/sentry-javascript/pull/15732)) +- fix(node): Use `fatal` level for unhandled rejections in `strict` mode ([#15720](https://github.com/getsentry/sentry-javascript/pull/15720)) +- fix(nuxt): Delete Nuxt server template injection ([#15749](https://github.com/getsentry/sentry-javascript/pull/15749)) + ## 9.6.1 - feat(deps): bump @prisma/instrumentation from 6.4.1 to 6.5.0 ([#15714](https://github.com/getsentry/sentry-javascript/pull/15714))