From 8bd05537d1c1e5e05180c028b3d7fa7afc87f6a4 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Mon, 6 Jan 2025 09:48:48 +0100 Subject: [PATCH] feat: initial (#1) * feat: initial commit * chore: standardise settings * feat: initial implementation * feat: support more cases * chore: small helper * refactor: change attributes of parent span * feat: propagate context * feat: add support for not found handler * feat: better support for onError * fix: adjust implementation * test: extend for encapsulation * test: remove leftovers * refactor:naming * feat(types): add typescript * test: remove redundant checks * docs: add README * types; adjust typing * docs: adjust * refactor: small refactor * test: add ts --- .gitattributes | 2 + .github/dependabot.yml | 13 + .github/stale.yml | 21 + .github/workflows/ci.yml | 23 + .gitignore | 152 +++++ .npmrc | 1 + LICENSE | 21 + README.md | 76 ++- eslint.config.js | 6 + index.d.ts | 19 + index.js | 381 ++++++++++++ package.json | 61 ++ test/index.test-d.ts | 16 + test/index.test.js | 1175 ++++++++++++++++++++++++++++++++++++++ 14 files changed, 1965 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/stale.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 LICENSE create mode 100644 eslint.config.js create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 package.json create mode 100644 test/index.test-d.ts create mode 100644 test/index.test.js 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) + }) + }) + }) +})