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) + }) + }) + }) +})