@fastify/otel
Version:
Official Fastify OpenTelemetry Instrumentation
495 lines (431 loc) • 16.9 kB
JavaScript
'use strict'
const dc = require('node:diagnostics_channel')
const { context, trace, SpanStatusCode, propagation, diag } = require('@opentelemetry/api')
const { getRPCMetadata, RPCType } = require('@opentelemetry/core')
const {
ATTR_HTTP_ROUTE,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_HTTP_REQUEST_METHOD,
ATTR_SERVICE_NAME
} = require('@opentelemetry/semantic-conventions')
const { InstrumentationBase } = require('@opentelemetry/instrumentation')
const {
version: PACKAGE_VERSION,
name: PACKAGE_NAME
} = require('./package.json')
// 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')
class FastifyOtelInstrumentation extends InstrumentationBase {
servername = ''
logger = null
constructor (config) {
super(PACKAGE_NAME, PACKAGE_VERSION, config)
this.servername = config?.servername ?? process.env.OTEL_SERVICE_NAME ?? 'fastify'
this.logger = diag.createComponentLogger({ namespace: PACKAGE_NAME })
this[kIgnorePaths] = null
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 {
enabled: this.routeOptions.config?.otel !== false,
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
}
if (routeOptions.config?.otel === false) {
instrumentation.logger.debug(
`Ignoring route instrumentation ${routeOptions.method} ${routeOptions.url} because it is disabled`
)
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 ||
request.routeOptions.config?.otel === false
) {
return hookDone()
}
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
)
}
}
}
}
}
module.exports = FastifyOtelInstrumentation
module.exports.FastifyOtelInstrumentation = FastifyOtelInstrumentation