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/tracing/fastify/fastify-otel/.eslintrc.js b/packages/node/src/integrations/tracing/fastify/fastify-otel/.eslintrc.js new file mode 100644 index 000000000000..9b2b2d51af09 --- /dev/null +++ b/packages/node/src/integrations/tracing/fastify/fastify-otel/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + env: { + node: true, + }, + parserOptions: { + sourceType: 'module', + ecmaVersion: 2020, + }, +}; diff --git a/packages/node/src/integrations/tracing/fastify/fastify-otel/index.d.ts b/packages/node/src/integrations/tracing/fastify/fastify-otel/index.d.ts new file mode 100644 index 000000000000..48b5297e074c --- /dev/null +++ b/packages/node/src/integrations/tracing/fastify/fastify-otel/index.d.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/// + +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/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==