diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfcb504f813..c65c71599258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.21.0 + +- docs: Fix v7 migration link ([#14629](https://github.com/getsentry/sentry-javascript/pull/14629)) +- feat(node): Vendor in `@fastify/otel` ([#16328](https://github.com/getsentry/sentry-javascript/pull/16328)) +- fix(nestjs): Handle multiple `OnEvent` decorators ([#16306](https://github.com/getsentry/sentry-javascript/pull/16306)) +- fix(node): Avoid creating breadcrumbs for suppressed requests ([#16285](https://github.com/getsentry/sentry-javascript/pull/16285)) +- fix(remix): Add missing `client` exports to `server` and `cloudflare` entries ([#16341](https://github.com/getsentry/sentry-javascript/pull/16341)) + +Work in this release was contributed by @phthhieu. Thank you for your contribution! + ## 9.20.0 ### Important changes diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts index cb5ddebcc3ae..5c4c92ac5f7d 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts @@ -11,4 +11,11 @@ export class EventsController { return { message: 'Events emitted' }; } + + @Get('emit-multiple') + async emitMultipleEvents() { + await this.eventsService.emitMultipleEvents(); + + return { message: 'Events emitted' }; + } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts index 4a9f36ddaf5c..ad119106ef08 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts @@ -11,4 +11,11 @@ export class EventsService { return { message: 'Events emitted' }; } + + async emitMultipleEvents() { + this.eventEmitter.emit('multiple.first', { data: 'test-first' }); + this.eventEmitter.emit('multiple.second', { data: 'test-second' }); + + return { message: 'Events emitted' }; + } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts index c1a3237f1f0c..26d934ba384c 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import * as Sentry from '@sentry/nestjs'; @Injectable() export class TestEventListener { @@ -13,4 +14,11 @@ export class TestEventListener { await new Promise(resolve => setTimeout(resolve, 100)); throw new Error('Test error from event handler'); } + + @OnEvent('multiple.first') + @OnEvent('multiple.second') + async handleMultipleEvents(payload: any): Promise { + Sentry.setTag(payload.data, true); + await new Promise(resolve => setTimeout(resolve, 100)); + } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts index ed4a36303efa..62781e32e37c 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -40,3 +40,27 @@ test('Event emitter', async () => { status: 'ok', }); }); + +test('Multiple OnEvent decorators', async () => { + const firstTxPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { + return transactionEvent.transaction === 'event multiple.first|multiple.second'; + }); + const secondTxPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { + return transactionEvent.transaction === 'event multiple.first|multiple.second'; + }); + const rootPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { + return transactionEvent.transaction === 'GET /events/emit-multiple'; + }); + + const eventsUrl = `http://localhost:3050/events/emit-multiple`; + await fetch(eventsUrl); + + const firstTx = await firstTxPromise; + const secondTx = await secondTxPromise; + const rootTx = await rootPromise; + + expect(firstTx).toBeDefined(); + expect(secondTx).toBeDefined(); + // assert that the correct payloads were added + expect(rootTx.tags).toMatchObject({ 'test-first': true, 'test-second': true }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs index 21694ba54e9d..779240ec755f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/scenario.mjs @@ -8,6 +8,8 @@ async function run() { await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); + await Sentry.suppressTracing(() => fetch(`${process.env.SERVER_URL}/api/v4`).then(res => res.text())); + Sentry.captureException(new Error('foo')); } diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts index cab9c61a1b65..d3315ae86ece 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -4,7 +4,7 @@ import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('outgoing fetch requests create breadcrumbs xxx', async () => { + test('outgoing fetch requests create breadcrumbs', async () => { const [SERVER_URL, closeTestServer] = await createTestServer().start(); await createRunner() diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs index 2ee57c8651e0..318b76858881 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.mjs @@ -9,6 +9,8 @@ async function run() { await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); + await Sentry.suppressTracing(() => makeHttpRequest(`${process.env.SERVER_URL}/api/v4`)); + Sentry.captureException(new Error('foo')); } diff --git a/docs/changelog/v7.md b/docs/changelog/v7.md index cef925871efa..8cc8c4db8b17 100644 --- a/docs/changelog/v7.md +++ b/docs/changelog/v7.md @@ -3714,7 +3714,7 @@ requires changes to certain configuration options or custom clients/integrations a version of [self-hosted Sentry](https://develop.sentry.dev/self-hosted/) (aka onpremise) older than `20.6.0` then you will need to [upgrade](https://develop.sentry.dev/self-hosted/releases/).** -For detailed overview of all the changes, please see our [v7 migration guide](./MIGRATION.md#upgrading-from-6x-to-7x). +For detailed overview of all the changes, please see our [v7 migration guide](/docs/migration/v6-to-v7.md). ### Breaking Changes diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 8745e34106f4..b454f29556c2 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -84,7 +84,8 @@ export { } from './stack-parsers'; export { eventFromException, eventFromMessage, exceptionFromError } from './eventbuilder'; export { createUserFeedbackEnvelope } from './userfeedback'; -export { getDefaultIntegrations, forceLoad, init, onLoad, showReportDialog } from './sdk'; +export { getDefaultIntegrations, forceLoad, init, onLoad } from './sdk'; +export { showReportDialog } from './report-dialog'; export { breadcrumbsIntegration } from './integrations/breadcrumbs'; export { globalHandlersIntegration } from './integrations/globalhandlers'; diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts new file mode 100644 index 000000000000..99b9cbd7733b --- /dev/null +++ b/packages/browser/src/report-dialog.ts @@ -0,0 +1,64 @@ +import type { ReportDialogOptions } from '@sentry/core'; +import { getClient, getCurrentScope, getReportDialogEndpoint, lastEventId, logger } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import { WINDOW } from './helpers'; + +/** + * Present the user with a report dialog. + * + * @param options Everything is optional, we try to fetch all info need from the current scope. + */ +export function showReportDialog(options: ReportDialogOptions = {}): void { + const optionalDocument = WINDOW.document as Document | undefined; + const injectionPoint = optionalDocument?.head || optionalDocument?.body; + + // doesn't work without a document (React Native) + if (!injectionPoint) { + DEBUG_BUILD && logger.error('[showReportDialog] Global document not defined'); + return; + } + + const scope = getCurrentScope(); + const client = getClient(); + const dsn = client?.getDsn(); + + if (!dsn) { + DEBUG_BUILD && logger.error('[showReportDialog] DSN not configured'); + return; + } + + const mergedOptions = { + ...options, + user: { + ...scope.getUser(), + ...options.user, + }, + eventId: options.eventId || lastEventId(), + }; + + const script = WINDOW.document.createElement('script'); + script.async = true; + script.crossOrigin = 'anonymous'; + script.src = getReportDialogEndpoint(dsn, mergedOptions); + + const { onLoad, onClose } = mergedOptions; + + if (onLoad) { + script.onload = onLoad; + } + + if (onClose) { + const reportDialogClosedMessageHandler = (event: MessageEvent): void => { + if (event.data === '__sentry_reportdialog_closed__') { + try { + onClose(); + } finally { + WINDOW.removeEventListener('message', reportDialogClosedMessageHandler); + } + } + }; + WINDOW.addEventListener('message', reportDialogClosedMessageHandler); + } + + injectionPoint.appendChild(script); +} diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 1c7e6fbe95ad..f3c8be4b3f40 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -1,15 +1,12 @@ -import type { Client, Integration, Options, ReportDialogOptions } from '@sentry/core'; +import type { Client, Integration, Options } from '@sentry/core'; import { consoleSandbox, dedupeIntegration, functionToStringIntegration, - getCurrentScope, getIntegrationsToSetup, getLocationHref, - getReportDialogEndpoint, inboundFiltersIntegration, initAndBind, - lastEventId, logger, stackParserFromStackParserOptions, supportsFetch, @@ -201,72 +198,6 @@ export function init(browserOptions: BrowserOptions = {}): Client | undefined { return initAndBind(BrowserClient, clientOptions); } -/** - * Present the user with a report dialog. - * - * @param options Everything is optional, we try to fetch all info need from the global scope. - */ -export function showReportDialog(options: ReportDialogOptions = {}): void { - // doesn't work without a document (React Native) - if (!WINDOW.document) { - DEBUG_BUILD && logger.error('Global document not defined in showReportDialog call'); - return; - } - - const scope = getCurrentScope(); - const client = scope.getClient(); - const dsn = client?.getDsn(); - - if (!dsn) { - DEBUG_BUILD && logger.error('DSN not configured for showReportDialog call'); - return; - } - - if (scope) { - options.user = { - ...scope.getUser(), - ...options.user, - }; - } - - if (!options.eventId) { - const eventId = lastEventId(); - if (eventId) { - options.eventId = eventId; - } - } - - const script = WINDOW.document.createElement('script'); - script.async = true; - script.crossOrigin = 'anonymous'; - script.src = getReportDialogEndpoint(dsn, options); - - if (options.onLoad) { - script.onload = options.onLoad; - } - - const { onClose } = options; - if (onClose) { - const reportDialogClosedMessageHandler = (event: MessageEvent): void => { - if (event.data === '__sentry_reportdialog_closed__') { - try { - onClose(); - } finally { - WINDOW.removeEventListener('message', reportDialogClosedMessageHandler); - } - } - }; - WINDOW.addEventListener('message', reportDialogClosedMessageHandler); - } - - const injectionPoint = WINDOW.document.head || WINDOW.document.body; - if (injectionPoint) { - injectionPoint.appendChild(script); - } else { - DEBUG_BUILD && logger.error('Not injecting report dialog. No injection point found in HTML'); - } -} - /** * This function is here to be API compatible with the loader. * @hidden diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index b3686272d12e..0b71fcc01383 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -71,6 +71,9 @@ describe('browserTracingIntegration', () => { getIsolationScope().clear(); getCurrentScope().setClient(undefined); document.head.innerHTML = ''; + + // We want to suppress the "Multiple browserTracingIntegration instances are not supported." warnings + vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index 05e319e8d774..968c24a469e4 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -58,31 +58,46 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { private _createWrapOnEvent() { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function wrapOnEvent(original: any) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function wrappedOnEvent(event: any, options?: any) { - const eventName = Array.isArray(event) - ? event.join(',') - : typeof event === 'string' || typeof event === 'symbol' - ? event.toString() - : ''; - + return function wrappedOnEvent(event: unknown, options?: unknown) { // Get the original decorator result const decoratorResult = original(event, options); // Return a new decorator function that wraps the handler - return function (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) { - if (!descriptor.value || typeof descriptor.value !== 'function' || target.__SENTRY_INTERNAL__) { + return (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + if ( + !descriptor.value || + typeof descriptor.value !== 'function' || + target.__SENTRY_INTERNAL__ || + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + descriptor.value.__SENTRY_INSTRUMENTED__ + ) { return decoratorResult(target, propertyKey, descriptor); } - // Get the original handler const originalHandler = descriptor.value; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const handlerName = originalHandler.name || propertyKey; + let eventName = typeof event === 'string' ? event : String(event); + + // Instrument the actual handler + descriptor.value = async function (...args: unknown[]) { + // When multiple @OnEvent decorators are used on a single method, we need to get all event names + // from the reflector metadata as there is no information during execution which event triggered it + if (Reflect.getMetadataKeys(descriptor.value).includes('EVENT_LISTENER_METADATA')) { + const eventData = Reflect.getMetadata('EVENT_LISTENER_METADATA', descriptor.value); + if (Array.isArray(eventData)) { + eventName = eventData + .map((data: unknown) => { + if (data && typeof data === 'object' && 'event' in data && data.event) { + return data.event; + } + return ''; + }) + .reverse() // decorators are evaluated bottom to top + .join('|'); + } + } - // Instrument the handler - // eslint-disable-next-line @typescript-eslint/no-explicit-any - descriptor.value = async function (...args: any[]) { return startSpan(getEventSpanOptions(eventName), async () => { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -96,6 +111,9 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { }); }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + descriptor.value.__SENTRY_INSTRUMENTED__ = true; + // Preserve the original function name Object.defineProperty(descriptor.value, 'name', { value: handlerName, diff --git a/packages/nestjs/test/integrations/nest.test.ts b/packages/nestjs/test/integrations/nest.test.ts index 58b004232449..2eecfbc6b240 100644 --- a/packages/nestjs/test/integrations/nest.test.ts +++ b/packages/nestjs/test/integrations/nest.test.ts @@ -1,3 +1,4 @@ +import 'reflect-metadata'; import * as core from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isPatched } from '../../src/integrations/helpers'; diff --git a/packages/node/package.json b/packages/node/package.json index a2da87ce2400..e029554d52f1 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -65,7 +65,6 @@ "access": "public" }, "dependencies": { - "@fastify/otel": "https://codeload.github.com/getsentry/fastify-otel/tar.gz/ae3088d65e286bdc94ac5d722573537d6a6671bb", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", @@ -98,7 +97,8 @@ "@prisma/instrumentation": "6.7.0", "@sentry/core": "9.20.0", "@sentry/opentelemetry": "9.20.0", - "import-in-the-middle": "^1.13.1" + "import-in-the-middle": "^1.13.1", + "minimatch": "^9.0.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 8eb13bc144cf..03c1260a2e5a 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -5,7 +5,7 @@ import type * as http 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 { isTracingSuppressed, VERSION } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core'; @@ -132,11 +132,13 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024; */ export class SentryHttpInstrumentation extends InstrumentationBase { private _propagationDecisionMap: LRUMap; + private _ignoreOutgoingRequestsMap: WeakMap; public constructor(config: SentryHttpInstrumentationOptions = {}) { super(INSTRUMENTATION_NAME, VERSION, config); this._propagationDecisionMap = new LRUMap(100); + this._ignoreOutgoingRequestsMap = new WeakMap(); } /** @inheritdoc */ @@ -165,6 +167,37 @@ export class SentryHttpInstrumentation extends InstrumentationBase(moduleExports: T): T => { + if (hasRegisteredHandlers) { + return moduleExports; + } + + hasRegisteredHandlers = true; + + subscribe('http.server.request.start', onHttpServerRequestStart); + subscribe('http.client.response.finish', onHttpClientResponseFinish); + + // When an error happens, we still want to have a breadcrumb + // In this case, `http.client.response.finish` is not triggered + subscribe('http.client.request.error', onHttpClientRequestError); + + // NOTE: This channel only exist since Node 22 + // Before that, outgoing requests are not patched + // and trace headers are not propagated, sadly. + if (this.getConfig().propagateTraceInOutgoingRequests) { + subscribe('http.client.request.created', onHttpClientRequestCreated); + } + + return moduleExports; + }; + + const unwrap = (): void => { + unsubscribe('http.server.request.start', onHttpServerRequestStart); + unsubscribe('http.client.response.finish', onHttpClientResponseFinish); + unsubscribe('http.client.request.error', onHttpClientRequestError); + unsubscribe('http.client.request.created', onHttpClientRequestCreated); + }; + /** * You may be wondering why we register these diagnostics-channel listeners * in such a convoluted way (as InstrumentationNodeModuleDefinition...)˝, @@ -174,64 +207,8 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - if (hasRegisteredHandlers) { - return moduleExports; - } - - hasRegisteredHandlers = true; - - subscribe('http.server.request.start', onHttpServerRequestStart); - subscribe('http.client.response.finish', onHttpClientResponseFinish); - - // When an error happens, we still want to have a breadcrumb - // In this case, `http.client.response.finish` is not triggered - subscribe('http.client.request.error', onHttpClientRequestError); - - // NOTE: This channel only exist since Node 23 - // Before that, outgoing requests are not patched - // and trace headers are not propagated, sadly. - if (this.getConfig().propagateTraceInOutgoingRequests) { - subscribe('http.client.request.created', onHttpClientRequestCreated); - } - - return moduleExports; - }, - () => { - unsubscribe('http.server.request.start', onHttpServerRequestStart); - unsubscribe('http.client.response.finish', onHttpClientResponseFinish); - unsubscribe('http.client.request.error', onHttpClientRequestError); - unsubscribe('http.client.request.created', onHttpClientRequestCreated); - }, - ), - new InstrumentationNodeModuleDefinition( - 'https', - ['*'], - (moduleExports: Https): Https => { - if (hasRegisteredHandlers) { - return moduleExports; - } - - hasRegisteredHandlers = true; - - subscribe('http.server.request.start', onHttpServerRequestStart); - subscribe('http.client.response.finish', onHttpClientResponseFinish); - - // When an error happens, we still want to have a breadcrumb - // In this case, `http.client.response.finish` is not triggered - subscribe('http.client.request.error', onHttpClientRequestError); - - return moduleExports; - }, - () => { - unsubscribe('http.server.request.start', onHttpServerRequestStart); - unsubscribe('http.client.response.finish', onHttpClientResponseFinish); - unsubscribe('http.client.request.error', onHttpClientRequestError); - }, - ), + new InstrumentationNodeModuleDefinition('http', ['*'], wrap, unwrap), + new InstrumentationNodeModuleDefinition('https', ['*'], wrap, unwrap), ]; } @@ -244,13 +221,12 @@ export class SentryHttpInstrumentation extends InstrumentationBase; private _propagationDecisionMap: LRUMap; + private _ignoreOutgoingRequestsMap: WeakMap; public constructor(config: SentryNodeFetchInstrumentationOptions = {}) { super('@sentry/instrumentation-node-fetch', VERSION, config); this._channelSubs = []; this._propagationDecisionMap = new LRUMap(100); + this._ignoreOutgoingRequestsMap = new WeakMap(); } /** No need to instrument files/modules. */ @@ -118,15 +121,17 @@ export class SentryNodeFetchInstrumentation extends InstrumentationBase + +import type { InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { InstrumentationBase } from '@opentelemetry/instrumentation'; +import type { FastifyPluginCallback } from 'fastify'; +import type { FastifyOtelInstrumentationOpts, FastifyOtelOptions, FastifyOtelRequestContext } from './types'; + +declare module 'fastify' { + interface FastifyRequest { + opentelemetry(): FastifyOtelRequestContext; + } +} + +declare class FastifyOtelInstrumentation< + Config extends FastifyOtelInstrumentationOpts = FastifyOtelInstrumentationOpts, +> extends InstrumentationBase { + servername: string; + constructor(config?: FastifyOtelInstrumentationOpts); + init(): InstrumentationNodeModuleDefinition[]; + plugin(): FastifyPluginCallback; +} + +declare namespace exported { + export type { FastifyOtelInstrumentationOpts }; + export { FastifyOtelInstrumentation }; +} + +export = exported; diff --git a/packages/node/src/integrations/tracing/fastify/fastify-otel/index.js b/packages/node/src/integrations/tracing/fastify/fastify-otel/index.js new file mode 100644 index 000000000000..d4f0638cb30a --- /dev/null +++ b/packages/node/src/integrations/tracing/fastify/fastify-otel/index.js @@ -0,0 +1,492 @@ +/* +Vendored in and modified from @fastify/otel version 0.8.0 +https://github.com/fastify/otel/releases/tag/v0.8.0 + +Tried not to modify the original code too much keeping it as a JavaScript CJS module to make it easier to update when required + +Modifications include: +- Removed reading of package.json to get the version and package name + +MIT License + +Copyright (c) 2024 Fastify + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable max-lines */ +/* eslint-disable no-param-reassign */ +import dc from 'node:diagnostics_channel'; +import { context, diag, propagation, SpanStatusCode, trace } from '@opentelemetry/api'; +import { getRPCMetadata, RPCType } from '@opentelemetry/core'; +import { InstrumentationBase } from '@opentelemetry/instrumentation'; +import { + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_HTTP_ROUTE, + ATTR_SERVICE_NAME, +} from '@opentelemetry/semantic-conventions'; + +// SENTRY VENDOR NOTE +// Instead of using the package.json file, we hard code the package name and version here. +const PACKAGE_NAME = '@fastify/otel'; +const PACKAGE_VERSION = '0.8.0'; + +// Constants +const SUPPORTED_VERSIONS = '>=4.0.0 <6'; +const FASTIFY_HOOKS = [ + 'onRequest', + 'preParsing', + 'preValidation', + 'preHandler', + 'preSerialization', + 'onSend', + 'onResponse', + 'onError', +]; +const ATTRIBUTE_NAMES = { + HOOK_NAME: 'hook.name', + FASTIFY_TYPE: 'fastify.type', + HOOK_CALLBACK_NAME: 'hook.callback.name', + ROOT: 'fastify.root', +}; +const HOOK_TYPES = { + ROUTE: 'route-hook', + INSTANCE: 'hook', + HANDLER: 'request-handler', +}; +const ANONYMOUS_FUNCTION_NAME = 'anonymous'; + +// Symbols +const kInstrumentation = Symbol('fastify otel instance'); +const kRequestSpan = Symbol('fastify otel request spans'); +const kRequestContext = Symbol('fastify otel request context'); +const kAddHookOriginal = Symbol('fastify otel addhook original'); +const kSetNotFoundOriginal = Symbol('fastify otel setnotfound original'); +const kIgnorePaths = Symbol('fastify otel ignore path'); + +export class FastifyOtelInstrumentation extends InstrumentationBase { + constructor(config) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + this.servername = config?.servername ?? process.env.OTEL_SERVICE_NAME ?? 'fastify'; + this[kIgnorePaths] = null; + this._logger = diag.createComponentLogger({ namespace: PACKAGE_NAME }); + + if (config?.ignorePaths != null || process.env.OTEL_FASTIFY_IGNORE_PATHS != null) { + const ignorePaths = config?.ignorePaths ?? process.env.OTEL_FASTIFY_IGNORE_PATHS; + + if ((typeof ignorePaths !== 'string' || ignorePaths.length === 0) && typeof ignorePaths !== 'function') { + throw new TypeError('ignorePaths must be a string or a function'); + } + + let globMatcher = null; + + this[kIgnorePaths] = routeOptions => { + if (typeof ignorePaths === 'function') { + return ignorePaths(routeOptions); + } else { + // Using minimatch to match the path until path.matchesGlob is out of experimental + // path.matchesGlob uses minimatch internally + if (globMatcher == null) { + globMatcher = require('minimatch').minimatch; + } + + return globMatcher(routeOptions.url, ignorePaths); + } + }; + } + } + + enable() { + if (this._handleInitialization === undefined && this.getConfig().registerOnInitialization) { + const FastifyInstrumentationPlugin = this.plugin(); + this._handleInitialization = message => { + message.fastify.register(FastifyInstrumentationPlugin); + }; + dc.subscribe('fastify.initialization', this._handleInitialization); + } + return super.enable(); + } + + disable() { + if (this._handleInitialization) { + dc.unsubscribe('fastify.initialization', this._handleInitialization); + this._handleInitialization = undefined; + } + return super.disable(); + } + + // We do not do patching in this instrumentation + init() { + return []; + } + + plugin() { + const instrumentation = this; + + FastifyInstrumentationPlugin[Symbol.for('skip-override')] = true; + FastifyInstrumentationPlugin[Symbol.for('fastify.display-name')] = '@fastify/otel'; + FastifyInstrumentationPlugin[Symbol.for('plugin-meta')] = { + fastify: SUPPORTED_VERSIONS, + name: '@fastify/otel', + }; + + return FastifyInstrumentationPlugin; + + function FastifyInstrumentationPlugin(instance, opts, done) { + instance.decorate(kInstrumentation, instrumentation); + // addHook and notfoundHandler are essentially inherited from the prototype + // what is important is to bound it to the right instance + instance.decorate(kAddHookOriginal, instance.addHook); + instance.decorate(kSetNotFoundOriginal, instance.setNotFoundHandler); + instance.decorateRequest('opentelemetry', function openetelemetry() { + const ctx = this[kRequestContext]; + const span = this[kRequestSpan]; + return { + span, + tracer: instrumentation.tracer, + context: ctx, + inject: (carrier, setter) => { + return propagation.inject(ctx, carrier, setter); + }, + extract: (carrier, getter) => { + return propagation.extract(ctx, carrier, getter); + }, + }; + }); + instance.decorateRequest(kRequestSpan, null); + instance.decorateRequest(kRequestContext, null); + + instance.addHook('onRoute', function (routeOptions) { + if (instrumentation[kIgnorePaths]?.(routeOptions) === true) { + instrumentation._logger.debug( + `Ignoring route instrumentation ${routeOptions.method} ${routeOptions.url} because it matches the ignore path`, + ); + return; + } + + for (const hook of FASTIFY_HOOKS) { + if (routeOptions[hook] != null) { + const handlerLike = routeOptions[hook]; + + if (typeof handlerLike === 'function') { + routeOptions[hook] = handlerWrapper(handlerLike, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - route -> ${hook}`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.ROUTE, + [ATTR_HTTP_ROUTE]: routeOptions.url, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + handlerLike.name?.length > 0 ? handlerLike.name : ANONYMOUS_FUNCTION_NAME /* c8 ignore next */, + }); + } else if (Array.isArray(handlerLike)) { + const wrappedHandlers = []; + + for (const handler of handlerLike) { + wrappedHandlers.push( + handlerWrapper(handler, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - route -> ${hook}`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.ROUTE, + [ATTR_HTTP_ROUTE]: routeOptions.url, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + handler.name?.length > 0 ? handler.name : ANONYMOUS_FUNCTION_NAME, + }), + ); + } + + routeOptions[hook] = wrappedHandlers; + } + } + } + + // We always want to add the onSend hook to the route to be executed last + if (routeOptions.onSend != null) { + routeOptions.onSend = Array.isArray(routeOptions.onSend) + ? [...routeOptions.onSend, onSendHook] + : [routeOptions.onSend, onSendHook]; + } else { + routeOptions.onSend = onSendHook; + } + + // We always want to add the onError hook to the route to be executed last + if (routeOptions.onError != null) { + routeOptions.onError = Array.isArray(routeOptions.onError) + ? [...routeOptions.onError, onErrorHook] + : [routeOptions.onError, onErrorHook]; + } else { + routeOptions.onError = onErrorHook; + } + + routeOptions.handler = handlerWrapper(routeOptions.handler, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - route-handler`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.HANDLER, + [ATTR_HTTP_ROUTE]: routeOptions.url, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + routeOptions.handler.name.length > 0 ? routeOptions.handler.name : ANONYMOUS_FUNCTION_NAME, + }); + }); + + instance.addHook('onRequest', function (request, _reply, hookDone) { + if (this[kInstrumentation].isEnabled() === false) { + return hookDone(); + } else if ( + this[kInstrumentation][kIgnorePaths]?.({ + url: request.url, + method: request.method, + }) === true + ) { + this[kInstrumentation]._logger.debug( + `Ignoring request ${request.method} ${request.url} because it matches the ignore path`, + ); + return hookDone(); + } + + let ctx = context.active(); + + if (trace.getSpan(ctx) == null) { + ctx = propagation.extract(ctx, request.headers); + } + + const rpcMetadata = getRPCMetadata(ctx); + + if (request.routeOptions.url != null && rpcMetadata?.type === RPCType.HTTP) { + rpcMetadata.route = request.routeOptions.url; + } + + /** @type {import('@opentelemetry/api').Span} */ + const span = this[kInstrumentation].tracer.startSpan( + 'request', + { + attributes: { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.ROOT]: '@fastify/otel', + [ATTR_HTTP_ROUTE]: request.url, + [ATTR_HTTP_REQUEST_METHOD]: request.method, + }, + }, + ctx, + ); + + request[kRequestContext] = trace.setSpan(ctx, span); + request[kRequestSpan] = span; + + context.with(request[kRequestContext], () => { + hookDone(); + }); + }); + + // onResponse is the last hook to be executed, only added for 404 handlers + instance.addHook('onResponse', function (request, reply, hookDone) { + const span = request[kRequestSpan]; + + if (span != null) { + span.setStatus({ + code: SpanStatusCode.OK, + message: 'OK', + }); + span.setAttributes({ + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 404, + }); + span.end(); + } + + request[kRequestSpan] = null; + + hookDone(); + }); + + instance.addHook = addHookPatched; + instance.setNotFoundHandler = setNotFoundHandlerPatched; + + done(); + + function onSendHook(request, reply, payload, hookDone) { + /** @type {import('@opentelemetry/api').Span} */ + const span = request[kRequestSpan]; + + if (span != null) { + if (reply.statusCode < 500) { + span.setStatus({ + code: SpanStatusCode.OK, + message: 'OK', + }); + } + + span.setAttributes({ + [ATTR_HTTP_RESPONSE_STATUS_CODE]: reply.statusCode, + }); + span.end(); + } + + request[kRequestSpan] = null; + + hookDone(null, payload); + } + + function onErrorHook(request, reply, error, hookDone) { + /** @type {Span} */ + const span = request[kRequestSpan]; + + if (span != null) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.recordException(error); + } + + hookDone(); + } + + function addHookPatched(name, hook) { + const addHookOriginal = this[kAddHookOriginal]; + + if (FASTIFY_HOOKS.includes(name)) { + return addHookOriginal.call( + this, + name, + handlerWrapper(hook, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - ${name}`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.INSTANCE, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + hook.name?.length > 0 ? hook.name : ANONYMOUS_FUNCTION_NAME /* c8 ignore next */, + }), + ); + } else { + return addHookOriginal.call(this, name, hook); + } + } + + function setNotFoundHandlerPatched(hooks, handler) { + const setNotFoundHandlerOriginal = this[kSetNotFoundOriginal]; + if (typeof hooks === 'function') { + handler = handlerWrapper(hooks, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - not-found-handler`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.INSTANCE, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + hooks.name?.length > 0 ? hooks.name : ANONYMOUS_FUNCTION_NAME /* c8 ignore next */, + }); + setNotFoundHandlerOriginal.call(this, handler); + } else { + if (hooks.preValidation != null) { + hooks.preValidation = handlerWrapper(hooks.preValidation, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - not-found-handler - preValidation`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.INSTANCE, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + hooks.preValidation.name?.length > 0 + ? hooks.preValidation.name + : ANONYMOUS_FUNCTION_NAME /* c8 ignore next */, + }); + } + + if (hooks.preHandler != null) { + hooks.preHandler = handlerWrapper(hooks.preHandler, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - not-found-handler - preHandler`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.INSTANCE, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + hooks.preHandler.name?.length > 0 + ? hooks.preHandler.name + : ANONYMOUS_FUNCTION_NAME /* c8 ignore next */, + }); + } + + handler = handlerWrapper(handler, { + [ATTR_SERVICE_NAME]: instance[kInstrumentation].servername, + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - not-found-handler`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.INSTANCE, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + handler.name?.length > 0 ? handler.name : ANONYMOUS_FUNCTION_NAME /* c8 ignore next */, + }); + setNotFoundHandlerOriginal.call(this, hooks, handler); + } + } + + function handlerWrapper(handler, spanAttributes = {}) { + return function handlerWrapped(...args) { + /** @type {FastifyOtelInstrumentation} */ + const instrumentation = this[kInstrumentation]; + const [request] = args; + + if (instrumentation.isEnabled() === false) { + return handler.call(this, ...args); + } + + const ctx = request[kRequestContext] ?? context.active(); + const span = instrumentation.tracer.startSpan( + `handler - ${ + handler.name?.length > 0 + ? handler.name + : this.pluginName /* c8 ignore next */ ?? ANONYMOUS_FUNCTION_NAME /* c8 ignore next */ + }`, + { + attributes: spanAttributes, + }, + ctx, + ); + + return context.with( + trace.setSpan(ctx, span), + function () { + try { + const res = handler.call(this, ...args); + + if (typeof res?.then === 'function') { + return res.then( + result => { + span.end(); + return result; + }, + error => { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.recordException(error); + span.end(); + return Promise.reject(error); + }, + ); + } + + span.end(); + return res; + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.recordException(error); + span.end(); + throw error; + } + }, + this, + ); + }; + } + } + } +} diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts index 49140f46edda..13805e18d575 100644 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ b/packages/node/src/integrations/tracing/fastify/index.ts @@ -1,5 +1,4 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; -import { FastifyOtelInstrumentation } from '@fastify/otel'; import type { Instrumentation, InstrumentationConfig } from '@opentelemetry/instrumentation'; import type { IntegrationFn, Span } from '@sentry/core'; import { @@ -14,6 +13,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../../debug-build'; import { generateInstrumentOnce } from '../../../otel/instrument'; +import { FastifyOtelInstrumentation } from './fastify-otel/index'; import type { FastifyInstance, FastifyReply, FastifyRequest } from './types'; import { FastifyInstrumentationV3 } from './v3/instrumentation'; diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 8ef997dc9e1c..f503adaf0711 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -8,6 +8,7 @@ export * from '@sentry/react'; export { captureRemixErrorBoundaryError } from '../client/errors'; export { withSentry } from '../client/performance'; +export { ErrorBoundary, browserTracingIntegration } from '../client'; export { makeWrappedCreateRequestHandler, sentryHandleError }; /** diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 9d9fd47efd47..4ba64d609317 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -1,4 +1,4 @@ export * from './server'; -export { captureRemixErrorBoundaryError, withSentry } from './client'; +export { captureRemixErrorBoundaryError, withSentry, ErrorBoundary, browserTracingIntegration } from './client'; export type { SentryMetaArgs } from './utils/types'; diff --git a/yarn.lock b/yarn.lock index 78c0f0fb30a3..b9faa5f80dab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3912,15 +3912,6 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== -"@fastify/otel@https://codeload.github.com/getsentry/fastify-otel/tar.gz/ae3088d65e286bdc94ac5d722573537d6a6671bb": - version "0.8.0" - resolved "https://codeload.github.com/getsentry/fastify-otel/tar.gz/ae3088d65e286bdc94ac5d722573537d6a6671bb#1632d3df7ebf8cd86996a50e9e42721aea05b39c" - dependencies: - "@opentelemetry/core" "^1.30.1" - "@opentelemetry/instrumentation" "^0.57.2" - "@opentelemetry/semantic-conventions" "^1.28.0" - minimatch "^9" - "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -20856,7 +20847,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9, minimatch@^9.0.0, minimatch@^9.0.4: +minimatch@^9.0.0, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==