diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a0e7df9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Set default behavior to automatically convert line endings
+* text=auto eol=lf
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..dfa7fa6
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ open-pull-requests-limit: 10
+
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 0000000..d51ce63
--- /dev/null
+++ b/.github/stale.yml
@@ -0,0 +1,21 @@
+# Number of days of inactivity before an issue becomes stale
+daysUntilStale: 15
+# Number of days of inactivity before a stale issue is closed
+daysUntilClose: 7
+# Issues with these labels will never be considered stale
+exemptLabels:
+ - "discussion"
+ - "feature request"
+ - "bug"
+ - "help wanted"
+ - "plugin suggestion"
+ - "good first issue"
+# Label to use when marking an issue as stale
+staleLabel: stale
+# Comment to post when marking an issue as stale. Set to `false` to disable
+markComment: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed if no further activity occurs. Thank you
+ for your contributions.
+# Comment to post when closing a stale issue. Set to `false` to disable
+closeComment: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8ef7a89
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,23 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - master
+ - next
+ - 'v*'
+ paths-ignore:
+ - 'docs/**'
+ - '*.md'
+ pull_request:
+ paths-ignore:
+ - 'docs/**'
+ - '*.md'
+
+jobs:
+ test:
+ uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5
+ with:
+ lint: true
+ license-check: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2b6aed4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,152 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+.cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# Vim swap files
+*.swp
+
+# macOS files
+.DS_Store
+
+# Clinic
+.clinic
+
+# lock files
+bun.lockb
+package-lock.json
+pnpm-lock.yaml
+yarn.lock
+
+# editor files
+.vscode
+.idea
+
+#tap files
+.tap/
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..43c97e7
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..226c5a9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+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.
diff --git a/README.md b/README.md
index 79d8e21..b049bc0 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,74 @@
-# otel
-OpenTelemetry instrumentation library.
+# @fastify/otel
+
+OpenTelemetry auto-instrumentation library.
+
+## Install
+
+```sh
+npm i @fastify/otel
+```
+
+## Usage
+
+`@fastify/otel` works as a metric creator as well as application performance monitor for your Fastify application.
+
+It must be configured before defining routes and other plugins in order to cover the most of your Fastify server.
+
+- It automatically wraps the main request handler
+- Instruments all route hooks (defined at instance and route definition level)
+ - `onRequest`
+ - `preParsing`
+ - `preValidation`
+ - `preHandler`
+ - `preSerialization`
+ - `onSend`
+ - `onResponse`
+ - `onError`
+- Instruments automatically custom 404 Not Found handler
+
+Example:
+
+```js
+// ... in your OTEL setup
+const FastifyInstrumentation = require('@fastify/otel');
+
+const fastifyInstrumentation = new FastifyInstrumentation();
+fastifyInstrumentation.setTraceProvider(provider)
+
+module.exports = { fastifyInstrumentation }
+
+// ... in your Fastify definition
+const { fastifyInstrumentation } = require('./otel.js');
+const Fastify = require('fastify');
+
+const app = fastify();
+// It is necessary to await for its register as it requires to be able
+// to intercept all route definitions
+await app.register(fastifyInstrumentation.plugin());
+
+// automatically all your routes will be instrumented
+app.get('/', () => 'hello world')
+// as well as your instance level hooks.
+app.addHook('onError', () => /* do something */)
+
+// you can also scope your instrumentation to only be enabled on a sub context
+// of your application
+app.register((instance, opts, done) => {
+ instance.register(fastifyInstrumentation.plugin());
+ // If only enabled in your encapsulated context
+ // the parent context won't be instrumented
+ app.get('/', () => 'hello world')
+
+}, { prefix: '/nested' })
+```
+
+> **Notes**:
+>
+> - This instrumentation requires `@opentelemetry/http-instrumentation` to be able to propagate the traces all the way back to upstream
+> - The HTTP instrumentation might cover all your routes although `@fastify/otel` just covers a subset of your application
+
+For more information about OpenTelemetry, please refer to the [OpenTelemetry JavaScript](https://opentelemetry.io/docs/languages/js/) documentation.
+
+## License
+
+Licensed under [MIT](./LICENSE).
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..89fd678
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,6 @@
+'use strict'
+
+module.exports = require('neostandard')({
+ ignores: require('neostandard').resolveIgnoresFromGitignore(),
+ ts: true
+})
diff --git a/index.d.ts b/index.d.ts
new file mode 100644
index 0000000..72f56bb
--- /dev/null
+++ b/index.d.ts
@@ -0,0 +1,19 @@
+///
+
+import { InstrumentationBase, InstrumentationConfig, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'
+import { FastifyInstance } from 'fastify'
+
+export interface FastifyOtelOptions {}
+export interface FastifyOtelInstrumentationOpts extends InstrumentationConfig {
+ servername?: string
+}
+
+declare class FastifyOtelInstrumentation extends InstrumentationBase {
+ static FastifyInstrumentation: FastifyOtelInstrumentation
+ constructor (config?: FastifyOtelInstrumentationOpts)
+ init (): InstrumentationNodeModuleDefinition[]
+ plugin (): (instance: FastifyInstance, opts: FastifyOtelOptions, done: (err?: Error) => void) => void
+}
+
+export default FastifyOtelInstrumentation
+export { FastifyOtelInstrumentation }
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..9f7cb65
--- /dev/null
+++ b/index.js
@@ -0,0 +1,381 @@
+'use strict'
+const { context, trace, SpanStatusCode } = 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 fp = require('fastify-plugin')
+
+const {
+ version: PACKAGE_VERSION,
+ name: PACKAGE_NAME
+} = require('./package.json')
+
+// Constants
+const SUPPORTED_VERSIONS = '>=5.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 instrumentation instance')
+const kRequestSpan = Symbol('fastify instrumentation request spans')
+const kRequestContext = Symbol('fastify instrumentation request context')
+
+class FastifyOtelInstrumentation extends InstrumentationBase {
+ static FastifyOtelInstrumentation = FastifyOtelInstrumentation
+ static default = FastifyOtelInstrumentation
+ servername = ''
+
+ constructor (config) {
+ super(PACKAGE_NAME, PACKAGE_VERSION, config)
+ this.servername = config?.servername ?? 'fastify'
+ }
+
+ // We do not do patching in this instrumentation
+ init () {
+ return []
+ }
+
+ plugin () {
+ const instrumentation = this
+
+ return fp(FastifyInstrumentationPlugin, {
+ fastify: SUPPORTED_VERSIONS,
+ name: `@fastify/otel@${PACKAGE_VERSION}`
+ })
+
+ function FastifyInstrumentationPlugin (instance, opts, done) {
+ const addHookOriginal = instance.addHook.bind(instance)
+ const setNotFoundHandlerOriginal =
+ instance.setNotFoundHandler.bind(instance)
+
+ instance.decorate(kInstrumentation, instrumentation)
+ instance.decorateRequest(kRequestSpan, null)
+ instance.decorateRequest(kRequestContext, null)
+
+ instance.addHook('onRoute', function (routeOptions) {
+ 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() === true) {
+ const rpcMetadata = getRPCMetadata(context.active())
+
+ if (
+ request.routeOptions.url != null &&
+ rpcMetadata?.type === RPCType.HTTP
+ ) {
+ rpcMetadata.route = request.routeOptions.url
+ }
+
+ /** @type {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
+ }
+ })
+
+ request[kRequestContext] = trace.setSpan(context.active(), span)
+ request[kRequestSpan] = span
+ }
+
+ 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.bind(instance)
+ instance.setNotFoundHandler = setNotFoundHandlerPatched.bind(instance)
+
+ done()
+
+ function onSendHook (request, reply, payload, hookDone) {
+ /** @type {import('@opentelemetry/api').Span} */
+ const span = request[kRequestSpan]
+
+ if (span != null) {
+ 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) {
+ if (FASTIFY_HOOKS.includes(name)) {
+ addHookOriginal(
+ 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 {
+ addHookOriginal(name, hook)
+ }
+ }
+
+ function setNotFoundHandlerPatched (hooks, handler) {
+ 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(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]:
+ hooks.name?.length > 0
+ ? hooks.name
+ : ANONYMOUS_FUNCTION_NAME /* c8 ignore next */
+ })
+ setNotFoundHandlerOriginal(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]
+ const span = instrumentation.tracer.startSpan(
+ `handler - ${
+ handler.name?.length > 0
+ ? handler.name
+ : this.pluginName ??
+ 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
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d814c9b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,61 @@
+{
+ "name": "@fastify/otel",
+ "version": "0.0.0",
+ "description": "Official Fastify OpenTelemetry Instrumentation",
+ "main": "index.js",
+ "type": "commonjs",
+ "types": "index.d.ts",
+ "scripts": {
+ "lint": "eslint",
+ "lint:fix": "eslint --fix",
+ "test": "npm run test:unit && npm run test:typescript",
+ "test:unit": "c8 --100 node --test",
+ "test:coverage": "c8 node --test && c8 report --reporter=html",
+ "test:typescript": "tsd"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/fastify/otel.git"
+ },
+ "keywords": [
+ "plugin",
+ "helper",
+ "fastify",
+ "instrumentation",
+ "otel",
+ "opentelemetry"
+ ],
+ "author": "Carlos Fuentes - @metcoder95 (https://metcoder.dev)",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/fastify/otel/issues"
+ },
+ "homepage": "https://github.com/fastify/otel#readme",
+ "devDependencies": {
+ "@fastify/pre-commit": "^2.1.0",
+ "@fastify/type-provider-typebox": "^5.0.0-pre.fv5.1",
+ "@opentelemetry/context-async-hooks": "^1.29.0",
+ "@opentelemetry/contrib-test-utils": "^0.44.0",
+ "@opentelemetry/instrumentation-http": "^0.56.0",
+ "@opentelemetry/sdk-trace-base": "^1.29.0",
+ "@opentelemetry/sdk-trace-node": "^1.29.0",
+ "@types/node": "^22.0.0",
+ "c8": "^10.1.2",
+ "eslint": "^9.16.0",
+ "fastify": "^5.1.0",
+ "neostandard": "^0.11.9",
+ "tsd": "^0.31.0"
+ },
+ "dependencies": {
+ "@opentelemetry/core": "^1.29.0",
+ "@opentelemetry/instrumentation": "^0.56.0",
+ "@opentelemetry/semantic-conventions": "^1.28.0",
+ "fastify-plugin": "^5.0.1"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0"
+ },
+ "tsd": {
+ "directory": "test"
+ }
+}
diff --git a/test/index.test-d.ts b/test/index.test-d.ts
new file mode 100644
index 0000000..0414bdd
--- /dev/null
+++ b/test/index.test-d.ts
@@ -0,0 +1,16 @@
+import { expectAssignable } from 'tsd'
+import { InstrumentationBase, InstrumentationConfig } from '@opentelemetry/instrumentation'
+import { fastify as Fastify } from 'fastify'
+
+import { FastifyOtelInstrumentation, FastifyOtelInstrumentationOpts } from '..'
+
+expectAssignable(new FastifyOtelInstrumentation())
+expectAssignable({ servername: 'server', enabled: true } as FastifyOtelInstrumentationOpts)
+expectAssignable({} as FastifyOtelInstrumentationOpts)
+
+const app = Fastify()
+app.register(new FastifyOtelInstrumentation().plugin())
+app.register((nested, _opts, done) => {
+ nested.register(new FastifyOtelInstrumentation().plugin())
+ done()
+})
diff --git a/test/index.test.js b/test/index.test.js
new file mode 100644
index 0000000..e9b7a5f
--- /dev/null
+++ b/test/index.test.js
@@ -0,0 +1,1175 @@
+const {
+ test,
+ describe,
+ before,
+ after,
+ afterEach,
+ beforeEach
+} = require('node:test')
+
+const { InstrumentationBase } = require('@opentelemetry/instrumentation')
+const {
+ AsyncHooksContextManager
+} = require('@opentelemetry/context-async-hooks')
+const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node')
+const {
+ InMemorySpanExporter,
+ SimpleSpanProcessor
+} = require('@opentelemetry/sdk-trace-base')
+const { context, SpanStatusCode } = require('@opentelemetry/api')
+
+const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http')
+
+const Fastify = require('fastify')
+
+const FastifyInstrumentation = require('..')
+
+describe('Interface', () => {
+ test('should exports support', t => {
+ t.assert.equal(FastifyInstrumentation.name, 'FastifyOtelInstrumentation')
+ t.assert.equal(
+ FastifyInstrumentation.default.name,
+ 'FastifyOtelInstrumentation'
+ )
+ t.assert.equal(
+ FastifyInstrumentation.FastifyOtelInstrumentation.name,
+ 'FastifyOtelInstrumentation'
+ )
+ t.assert.strictEqual(
+ Object.getPrototypeOf(FastifyInstrumentation),
+ InstrumentationBase
+ )
+ })
+
+ test('FastifyInstrumentation#plugin should return a valid Fastify Plugin', async t => {
+ const app = Fastify()
+ const instrumentation = new FastifyInstrumentation()
+ const plugin = instrumentation.plugin()
+
+ t.assert.equal(typeof plugin, 'function')
+ t.assert.equal(plugin.length, 3)
+
+ app.register(plugin)
+
+ await app.ready()
+ })
+})
+
+describe('FastifyInstrumentation', () => {
+ const httpInstrumentation = new HttpInstrumentation()
+ const instrumentation = new FastifyInstrumentation()
+ const contextManager = new AsyncHooksContextManager()
+ const memoryExporter = new InMemorySpanExporter()
+ const provider = new NodeTracerProvider()
+ const spanProcessor = new SimpleSpanProcessor(memoryExporter)
+
+ provider.addSpanProcessor(spanProcessor)
+ context.setGlobalContextManager(contextManager)
+ httpInstrumentation.setTracerProvider(provider)
+ instrumentation.setTracerProvider(provider)
+
+ describe('Instrumentation#disabled', () => {
+ test('should not create spans if disabled', async t => {
+ before(() => {
+ contextManager.enable()
+ })
+
+ after(() => {
+ contextManager.disable()
+ spanProcessor.forceFlush()
+ memoryExporter.reset()
+ instrumentation.disable()
+ httpInstrumentation.disable()
+ })
+
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get('/', async (request, reply) => 'hello world')
+
+ instrumentation.disable()
+
+ t.plan(3)
+
+ const response = await app.inject({
+ method: 'GET',
+ url: '/'
+ })
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .find(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ t.assert.ok(spans == null)
+ t.assert.equal(response.statusCode, 200)
+ t.assert.equal(response.body, 'hello world')
+ })
+ })
+
+ describe('Instrumentation#enabled', () => {
+ beforeEach(() => {
+ instrumentation.enable()
+ httpInstrumentation.enable()
+ contextManager.enable()
+ })
+
+ afterEach(() => {
+ contextManager.disable()
+ instrumentation.disable()
+ httpInstrumentation.disable()
+ spanProcessor.forceFlush()
+ memoryExporter.reset()
+ })
+
+ test('should create anonymous span (simple case)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get('/', async (request, reply) => 'hello world')
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [end, start] = spans
+
+ t.plan(5)
+ t.assert.equal(spans.length, 2)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'http.request.method': 'GET',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'anonymous'
+ })
+ t.assert.equal(response.status, 200)
+ t.assert.equal(await response.text(), 'hello world')
+ })
+
+ test('should create named span (simple case)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get('/', async function helloworld () {
+ return 'hello world'
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [end, start] = spans
+
+ t.plan(6)
+ t.assert.equal(spans.length, 2)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'http.request.method': 'GET',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'helloworld'
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 200)
+ t.assert.equal(await response.text(), 'hello world')
+ })
+
+ test('should create span for different hooks', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get(
+ '/',
+ {
+ preHandler: function preHandler (request, reply, done) {
+ done()
+ },
+ onRequest: [
+ function onRequest1 (request, reply, done) {
+ done()
+ },
+ function (request, reply, done) {
+ done()
+ }
+ ]
+ },
+ async function helloworld () {
+ return 'hello world'
+ }
+ )
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [preHandler, onReq2, onReq1, end, start] = spans
+
+ t.plan(10)
+ t.assert.equal(spans.length, 5)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'http.request.method': 'GET',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'http.request.method': 'GET',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(onReq1.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'onRequest1',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route -> onRequest',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ })
+ t.assert.deepStrictEqual(onReq2.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'anonymous',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route -> onRequest',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ })
+ t.assert.deepStrictEqual(preHandler.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'preHandler',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route -> preHandler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'hook.callback.name': 'helloworld',
+ 'service.name': 'fastify',
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 200)
+ t.assert.equal(await response.text(), 'hello world')
+ })
+
+ test('should create span for different hooks (patched)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get(
+ '/',
+ {
+ onSend: function onSend (request, reply, payload, done) {
+ done(null, payload)
+ }
+ },
+ async function helloworld () {
+ return 'hello world'
+ }
+ )
+
+ app.addHook('preValidation', function (request, reply, done) {
+ done()
+ })
+
+ // Should not be patched
+ app.addHook('onReady', function (done) {
+ done()
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [preValidation, end, start, onReq1] = spans
+
+ t.plan(9)
+ t.assert.equal(spans.length, 4)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(onReq1.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'onSend',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route -> onSend',
+ 'service.name': 'fastify',
+ 'http.route': '/'
+ })
+ t.assert.deepStrictEqual(preValidation.attributes, {
+ 'fastify.type': 'hook',
+ 'hook.callback.name': 'anonymous',
+ 'service.name': 'fastify',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - preValidation'
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'helloworld'
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 200)
+ t.assert.equal(await response.text(), 'hello world')
+ })
+
+ test('should create span for different hooks (error scenario)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get('/', async function helloworld () {
+ return 'hello world'
+ })
+
+ app.addHook('preHandler', function (request, reply, done) {
+ throw new Error('error')
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [preHandler, start] = spans
+
+ t.plan(6)
+ t.assert.equal(spans.length, 2)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 500
+ })
+ t.assert.deepStrictEqual(preHandler.attributes, {
+ 'fastify.type': 'hook',
+ 'hook.callback.name': 'anonymous',
+ 'service.name': 'fastify',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - preHandler'
+ })
+ t.assert.equal(preHandler.status.code, SpanStatusCode.ERROR)
+ t.assert.equal(preHandler.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 500)
+ })
+
+ test('should create named span (404)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get('/', async function helloworld () {
+ return 'hello world'
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`,
+ { method: 'POST' }
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [start] = spans
+
+ t.plan(3)
+ t.assert.equal(response.status, 404)
+ t.assert.equal(spans.length, 1)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'POST',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 404
+ })
+ })
+
+ test('should create named span (404 - customized)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.setNotFoundHandler(async function notFoundHandler (request, reply) {
+ reply.code(404).send('not found')
+ })
+
+ app.get('/', async function helloworld () {
+ return 'hello world'
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`,
+ { method: 'POST' }
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [start, fof] = spans
+
+ t.plan(4)
+ t.assert.equal(response.status, 404)
+ t.assert.equal(spans.length, 2)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'POST',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 404
+ })
+ t.assert.deepStrictEqual(fof.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - not-found-handler',
+ 'fastify.type': 'hook',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'notFoundHandler'
+ })
+ })
+
+ test('should create named span (404 - customized with hooks)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.setNotFoundHandler(
+ {
+ preHandler: function preHandler (request, reply, done) {
+ done()
+ },
+ preValidation: function preValidation (request, reply, done) {
+ done()
+ }
+ },
+ async function notFoundHandler (request, reply) {
+ reply.code(404).send('not found')
+ }
+ )
+
+ app.get(
+ '/',
+ {
+ schema: {
+ headers: {
+ type: 'object',
+ properties: {
+ 'x-foo': { type: 'string' }
+ }
+ }
+ }
+ },
+ async function helloworld () {
+ return 'hello world'
+ }
+ )
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`,
+ { method: 'POST' }
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [preHandler, preValidation, start, fof] = spans
+
+ t.plan(9)
+ t.assert.equal(response.status, 404)
+ t.assert.equal(spans.length, 4)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'POST',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 404
+ })
+ t.assert.deepStrictEqual(preHandler.attributes, {
+ 'hook.name':
+ 'fastify -> @fastify/otel@0.0.0 - not-found-handler - preHandler',
+ 'fastify.type': 'hook',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'preHandler'
+ })
+ t.assert.deepStrictEqual(preValidation.attributes, {
+ 'hook.name':
+ 'fastify -> @fastify/otel@0.0.0 - not-found-handler - preValidation',
+ 'fastify.type': 'hook',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'preValidation'
+ })
+ t.assert.deepStrictEqual(fof.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - not-found-handler',
+ 'fastify.type': 'hook',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'anonymous'
+ })
+ t.assert.equal(fof.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(preValidation.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(preHandler.parentSpanId, start.spanContext().spanId)
+ })
+
+ test('should create named span (404 - customized with hooks)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.setNotFoundHandler(
+ {
+ preHandler: function preHandler (request, reply, done) {
+ done()
+ },
+ preValidation: function preValidation (request, reply, done) {
+ done()
+ }
+ },
+ async function notFoundHandler (request, reply) {
+ reply.code(404).send('not found')
+ }
+ )
+
+ app.get(
+ '/',
+ {
+ schema: {
+ headers: {
+ type: 'object',
+ properties: {
+ 'x-foo': { type: 'string' }
+ }
+ }
+ }
+ },
+ async function helloworld () {
+ return 'hello world'
+ }
+ )
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`,
+ { method: 'POST' }
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [preHandler, preValidation, start, fof] = spans
+
+ t.plan(9)
+ t.assert.equal(response.status, 404)
+ t.assert.equal(spans.length, 4)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'POST',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 404
+ })
+ t.assert.deepStrictEqual(preHandler.attributes, {
+ 'hook.name':
+ 'fastify -> @fastify/otel@0.0.0 - not-found-handler - preHandler',
+ 'fastify.type': 'hook',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'preHandler'
+ })
+ t.assert.deepStrictEqual(preValidation.attributes, {
+ 'hook.name':
+ 'fastify -> @fastify/otel@0.0.0 - not-found-handler - preValidation',
+ 'fastify.type': 'hook',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'preValidation'
+ })
+ t.assert.deepStrictEqual(fof.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - not-found-handler',
+ 'fastify.type': 'hook',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'anonymous'
+ })
+ t.assert.equal(fof.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(preValidation.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(preHandler.parentSpanId, start.spanContext().spanId)
+ })
+
+ test('should end spans upon error', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get(
+ '/',
+ {
+ errorHandler: function errorHandler (error, request, reply) {
+ throw error
+ }
+ },
+ async function helloworld () {
+ throw new Error('error')
+ }
+ )
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [end, start] = spans
+
+ t.plan(6)
+ t.assert.equal(spans.length, 2)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 500
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'helloworld'
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 500)
+ t.assert.deepStrictEqual(await response.json(), {
+ statusCode: 500,
+ error: 'Internal Server Error',
+ message: 'error'
+ })
+ })
+
+ test('should end spans upon error (with hook)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get(
+ '/',
+ {
+ onError: function decorated (_request, _reply, _error, done) {
+ done()
+ },
+ errorHandler: function errorHandler (error, request, reply) {
+ throw error
+ }
+ },
+ async function helloworld () {
+ throw new Error('error')
+ }
+ )
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [end, start, error] = spans
+
+ t.plan(7)
+ t.assert.equal(spans.length, 3)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 500
+ })
+ t.assert.deepStrictEqual(error.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'decorated',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route -> onError',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'helloworld'
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 500)
+ t.assert.deepStrictEqual(await response.json(), {
+ statusCode: 500,
+ error: 'Internal Server Error',
+ message: 'error'
+ })
+ })
+
+ test('should end spans upon error (with hook [array])', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ app.get(
+ '/',
+ {
+ onError: [
+ function decorated (_request, _reply, _error, done) {
+ done()
+ },
+ function decorated2 (_request, _reply, _error, done) {
+ done()
+ }
+ ],
+ errorHandler: function errorHandler (error, request, reply) {
+ throw error
+ }
+ },
+ async function helloworld () {
+ throw new Error('error')
+ }
+ )
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [end, start, error2, error] = spans
+
+ t.plan(8)
+ t.assert.equal(spans.length, 4)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 500
+ })
+ t.assert.deepStrictEqual(error.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'decorated',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route -> onError',
+ 'service.name': 'fastify',
+ 'http.route': '/'
+ })
+ t.assert.deepStrictEqual(error2.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'decorated2',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route -> onError',
+ 'service.name': 'fastify',
+ 'http.route': '/'
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'helloworld'
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 500)
+ t.assert.deepStrictEqual(await response.json(), {
+ statusCode: 500,
+ error: 'Internal Server Error',
+ message: 'error'
+ })
+ })
+ })
+
+ describe('Encapulated Context', () => {
+ describe('Instrumentation#disabled', () => {
+ test('should not create spans if disabled', async t => {
+ before(() => {
+ contextManager.enable()
+ })
+
+ after(() => {
+ contextManager.disable()
+ spanProcessor.forceFlush()
+ memoryExporter.reset()
+ instrumentation.disable()
+ httpInstrumentation.disable()
+ })
+
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ await app.register(function plugin (instance, _opts, done) {
+ instance.get('/', async (request, reply) => 'hello world')
+ done()
+ })
+
+ instrumentation.disable()
+
+ t.plan(3)
+
+ const response = await app.inject({
+ method: 'GET',
+ url: '/'
+ })
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .find(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ t.assert.ok(spans == null)
+ t.assert.equal(response.statusCode, 200)
+ t.assert.equal(response.body, 'hello world')
+ })
+ })
+
+ describe('Instrumentation#enabled', () => {
+ beforeEach(() => {
+ instrumentation.enable()
+ httpInstrumentation.enable()
+ contextManager.enable()
+ })
+
+ afterEach(() => {
+ contextManager.disable()
+ instrumentation.disable()
+ httpInstrumentation.disable()
+ spanProcessor.forceFlush()
+ memoryExporter.reset()
+ })
+
+ test('should create anonymous span (simple case)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ await app.register(function plugin (instance, _opts, done) {
+ instance.get('/', async (request, reply) => 'hello world')
+ done()
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [end, start] = spans
+
+ t.plan(5)
+ t.assert.equal(spans.length, 2)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'plugin - route-handler',
+ 'fastify.type': 'request-handler',
+ 'service.name': 'fastify',
+ 'http.route': '/',
+ 'hook.callback.name': 'anonymous'
+ })
+ t.assert.equal(response.status, 200)
+ t.assert.equal(await response.text(), 'hello world')
+ })
+
+ test('should create named span (simple case)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(async function nested (instance, _opts) {
+ await instance.register(plugin)
+
+ instance.get('/', async function helloworld () {
+ return 'hello world'
+ })
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [end, start] = spans
+
+ t.plan(6)
+ t.assert.equal(spans.length, 2)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'nested -> @fastify/otel@0.0.0 - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'helloworld'
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 200)
+ t.assert.equal(await response.text(), 'hello world')
+ })
+
+ test('should create span for different hooks (patched)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(plugin)
+
+ await app.register(function nested (instance, _opts, done) {
+ instance.get(
+ '/',
+ {
+ onSend: function onSend (request, reply, payload, done) {
+ done(null, payload)
+ }
+ },
+ async function helloworld () {
+ return 'hello world'
+ }
+ )
+
+ instance.addHook('preValidation', function (request, reply, done) {
+ done()
+ })
+
+ // Should not be patched
+ instance.addHook('onReady', function (done) {
+ done()
+ })
+
+ done()
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [preValidation, end, start, onReq1] = spans
+
+ t.plan(9)
+ t.assert.equal(spans.length, 4)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 200
+ })
+ t.assert.deepStrictEqual(onReq1.attributes, {
+ 'fastify.type': 'route-hook',
+ 'hook.callback.name': 'onSend',
+ 'hook.name': 'nested - route -> onSend',
+ 'service.name': 'fastify',
+ 'http.route': '/'
+ })
+ t.assert.deepStrictEqual(preValidation.attributes, {
+ 'fastify.type': 'hook',
+ 'hook.callback.name': 'anonymous',
+ 'service.name': 'fastify',
+ 'hook.name': 'fastify -> @fastify/otel@0.0.0 - preValidation'
+ })
+ t.assert.deepStrictEqual(end.attributes, {
+ 'hook.name': 'nested - route-handler',
+ 'fastify.type': 'request-handler',
+ 'http.route': '/',
+ 'service.name': 'fastify',
+ 'hook.callback.name': 'helloworld'
+ })
+ t.assert.equal(end.parentSpanId, start.spanContext().spanId)
+ t.assert.equal(response.status, 200)
+ t.assert.equal(await response.text(), 'hello world')
+ })
+
+ test('should respect context (error scenario)', async t => {
+ const app = Fastify()
+ const plugin = instrumentation.plugin()
+
+ await app.register(async function nested (instance, _opts) {
+ await instance.register(plugin)
+ instance.get('/', async function helloworld () {
+ return 'hello world'
+ })
+ })
+
+ // If registered under encapsulated context, hooks should be registered
+ // under the encapsulated context
+ app.addHook('preHandler', function (request, reply, done) {
+ throw new Error('error')
+ })
+
+ await app.listen()
+
+ after(() => app.close())
+
+ const response = await fetch(
+ `http://localhost:${app.server.address().port}/`
+ )
+
+ const spans = memoryExporter
+ .getFinishedSpans()
+ .filter(span => span.instrumentationLibrary.name === '@fastify/otel')
+
+ const [start] = spans
+
+ t.plan(3)
+ t.assert.equal(spans.length, 1)
+ t.assert.deepStrictEqual(start.attributes, {
+ 'fastify.root': '@fastify/otel',
+ 'http.route': '/',
+ 'http.request.method': 'GET',
+ 'service.name': 'fastify',
+ 'http.response.status_code': 500
+ })
+ t.assert.equal(response.status, 500)
+ })
+ })
+ })
+})