From b006f7f4ce466611e437240508b9bc4deb622175 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:43:51 -0700 Subject: [PATCH 1/2] fix(winston-transport): Typescript issue with OpenTelemetryTransportV3 not assignable to TransportStream (#2079) Fixes: #2015 --- packages/winston-transport/src/OpenTelemetryTransportV3.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/winston-transport/src/OpenTelemetryTransportV3.ts b/packages/winston-transport/src/OpenTelemetryTransportV3.ts index 9ff40c4929..fe759676e9 100644 --- a/packages/winston-transport/src/OpenTelemetryTransportV3.ts +++ b/packages/winston-transport/src/OpenTelemetryTransportV3.ts @@ -15,14 +15,14 @@ */ import { Logger, logs } from '@opentelemetry/api-logs'; -import * as Transport from 'winston-transport'; +import TransportStream = require('winston-transport'); import { VERSION } from './version'; import { emitLogRecord } from './utils'; -export class OpenTelemetryTransportV3 extends Transport { +export class OpenTelemetryTransportV3 extends TransportStream { private _logger: Logger; - constructor(options?: Transport.TransportStreamOptions) { + constructor(options?: TransportStream.TransportStreamOptions) { super(options); this._logger = logs.getLogger('@opentelemetry/winston-transport', VERSION); } From fe18e2fbb2a6535cb72f314fdb1550a3a4160403 Mon Sep 17 00:00:00 2001 From: David Luna Date: Fri, 5 Apr 2024 00:21:52 +0200 Subject: [PATCH 2/2] feat(instr-undici): add instrumentation for `undici` versions `>=5 <7` and global `fetch` API (#1951) Closes: https://github.com/open-telemetry/opentelemetry-js/issues/4333 Co-authored-by: Jamie Danielson Co-authored-by: Trent Mick Co-authored-by: Marc Pichler --- .github/component_owners.yml | 3 + .release-please-manifest.json | 64 +- package-lock.json | 426 ++++++++++ .../node/instrumentation-undici/.eslintignore | 1 + .../node/instrumentation-undici/.eslintrc.js | 7 + plugins/node/instrumentation-undici/.tav.yml | 8 + plugins/node/instrumentation-undici/LICENSE | 201 +++++ plugins/node/instrumentation-undici/README.md | 100 +++ .../node/instrumentation-undici/package.json | 70 ++ .../src/enums/SemanticAttributes.ts | 168 ++++ .../node/instrumentation-undici/src/index.ts | 25 + .../src/internal-types.ts | 48 ++ .../node/instrumentation-undici/src/types.ts | 75 ++ .../node/instrumentation-undici/src/undici.ts | 483 ++++++++++++ .../instrumentation-undici/test/fetch.test.ts | 414 ++++++++++ .../test/metrics.test.ts | 194 +++++ .../test/undici.test.ts | 746 ++++++++++++++++++ .../test/utils/assertSpan.ts | 191 +++++ .../test/utils/mock-metrics-reader.ts | 47 ++ .../test/utils/mock-propagation.ts | 52 ++ .../test/utils/mock-server.ts | 74 ++ .../node/instrumentation-undici/tsconfig.json | 11 + release-please-config.json | 1 + 23 files changed, 3408 insertions(+), 1 deletion(-) create mode 100644 plugins/node/instrumentation-undici/.eslintignore create mode 100644 plugins/node/instrumentation-undici/.eslintrc.js create mode 100644 plugins/node/instrumentation-undici/.tav.yml create mode 100644 plugins/node/instrumentation-undici/LICENSE create mode 100644 plugins/node/instrumentation-undici/README.md create mode 100644 plugins/node/instrumentation-undici/package.json create mode 100644 plugins/node/instrumentation-undici/src/enums/SemanticAttributes.ts create mode 100644 plugins/node/instrumentation-undici/src/index.ts create mode 100644 plugins/node/instrumentation-undici/src/internal-types.ts create mode 100644 plugins/node/instrumentation-undici/src/types.ts create mode 100644 plugins/node/instrumentation-undici/src/undici.ts create mode 100644 plugins/node/instrumentation-undici/test/fetch.test.ts create mode 100644 plugins/node/instrumentation-undici/test/metrics.test.ts create mode 100644 plugins/node/instrumentation-undici/test/undici.test.ts create mode 100644 plugins/node/instrumentation-undici/test/utils/assertSpan.ts create mode 100644 plugins/node/instrumentation-undici/test/utils/mock-metrics-reader.ts create mode 100644 plugins/node/instrumentation-undici/test/utils/mock-propagation.ts create mode 100644 plugins/node/instrumentation-undici/test/utils/mock-server.ts create mode 100644 plugins/node/instrumentation-undici/tsconfig.json diff --git a/.github/component_owners.yml b/.github/component_owners.yml index be9a44a13b..fdc9c0fc10 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -123,6 +123,9 @@ components: - rauno56 plugins/node/opentelemetry-instrumentation-router: - rauno56 + plugins/node/opentelemetry-instrumentation-undici: + - david-luna + - trentm plugins/node/opentelemetry-instrumentation-winston: - seemk plugins/web/opentelemetry-instrumentation-document-load: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a58a58e654..da8e4144db 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1,63 @@ -{"detectors/node/opentelemetry-resource-detector-alibaba-cloud":"0.28.8","detectors/node/opentelemetry-resource-detector-aws":"1.4.1","detectors/node/opentelemetry-resource-detector-azure":"0.2.6","detectors/node/opentelemetry-resource-detector-container":"0.3.8","detectors/node/opentelemetry-resource-detector-gcp":"0.29.8","detectors/node/opentelemetry-resource-detector-github":"0.28.1","detectors/node/opentelemetry-resource-detector-instana":"0.8.0","metapackages/auto-instrumentations-node":"0.44.0","metapackages/auto-instrumentations-web":"0.38.0","packages/opentelemetry-host-metrics":"0.35.0","packages/opentelemetry-id-generator-aws-xray":"1.2.1","packages/opentelemetry-propagation-utils":"0.30.8","packages/opentelemetry-redis-common":"0.36.1","packages/opentelemetry-sql-common":"0.40.0","packages/opentelemetry-test-utils":"0.38.0","packages/winston-transport":"0.2.0","plugins/node/instrumentation-amqplib":"0.36.0","plugins/node/instrumentation-cucumber":"0.5.0","plugins/node/instrumentation-dataloader":"0.8.0","plugins/node/instrumentation-fs":"0.11.0","plugins/node/instrumentation-lru-memoizer":"0.36.0","plugins/node/instrumentation-mongoose":"0.37.0","plugins/node/instrumentation-runtime-node":"0.3.0","plugins/node/instrumentation-socket.io":"0.38.0","plugins/node/instrumentation-tedious":"0.9.0","plugins/node/opentelemetry-instrumentation-aws-lambda":"0.40.0","plugins/node/opentelemetry-instrumentation-aws-sdk":"0.40.0","plugins/node/opentelemetry-instrumentation-bunyan":"0.37.0","plugins/node/opentelemetry-instrumentation-cassandra":"0.37.0","plugins/node/opentelemetry-instrumentation-connect":"0.35.0","plugins/node/opentelemetry-instrumentation-dns":"0.35.0","plugins/node/opentelemetry-instrumentation-express":"0.37.0","plugins/node/opentelemetry-instrumentation-fastify":"0.35.0","plugins/node/opentelemetry-instrumentation-generic-pool":"0.35.0","plugins/node/opentelemetry-instrumentation-graphql":"0.39.0","plugins/node/opentelemetry-instrumentation-hapi":"0.36.0","plugins/node/opentelemetry-instrumentation-ioredis":"0.39.0","plugins/node/opentelemetry-instrumentation-knex":"0.35.0","plugins/node/opentelemetry-instrumentation-koa":"0.39.0","plugins/node/opentelemetry-instrumentation-memcached":"0.35.0","plugins/node/opentelemetry-instrumentation-mongodb":"0.42.0","plugins/node/opentelemetry-instrumentation-mysql":"0.37.0","plugins/node/opentelemetry-instrumentation-mysql2":"0.37.0","plugins/node/opentelemetry-instrumentation-nestjs-core":"0.36.0","plugins/node/opentelemetry-instrumentation-net":"0.35.0","plugins/node/opentelemetry-instrumentation-pg":"0.40.0","plugins/node/opentelemetry-instrumentation-pino":"0.37.0","plugins/node/opentelemetry-instrumentation-redis":"0.38.0","plugins/node/opentelemetry-instrumentation-redis-4":"0.38.0","plugins/node/opentelemetry-instrumentation-restify":"0.37.0","plugins/node/opentelemetry-instrumentation-router":"0.36.0","plugins/node/opentelemetry-instrumentation-winston":"0.36.0","plugins/web/opentelemetry-instrumentation-document-load":"0.37.0","plugins/web/opentelemetry-instrumentation-long-task":"0.37.0","plugins/web/opentelemetry-instrumentation-user-interaction":"0.37.0","plugins/web/opentelemetry-plugin-react-load":"0.30.0","propagators/opentelemetry-propagator-aws-xray":"1.3.1","propagators/opentelemetry-propagator-grpc-census-binary":"0.27.1","propagators/opentelemetry-propagator-instana":"0.3.1","propagators/opentelemetry-propagator-ot-trace":"0.27.1"} +{ + "detectors/node/opentelemetry-resource-detector-alibaba-cloud": "0.28.8", + "detectors/node/opentelemetry-resource-detector-aws": "1.4.1", + "detectors/node/opentelemetry-resource-detector-azure": "0.2.6", + "detectors/node/opentelemetry-resource-detector-container": "0.3.8", + "detectors/node/opentelemetry-resource-detector-gcp": "0.29.8", + "detectors/node/opentelemetry-resource-detector-github": "0.28.1", + "detectors/node/opentelemetry-resource-detector-instana": "0.8.0", + "metapackages/auto-instrumentations-node": "0.44.0", + "metapackages/auto-instrumentations-web": "0.38.0", + "packages/opentelemetry-host-metrics": "0.35.0", + "packages/opentelemetry-id-generator-aws-xray": "1.2.1", + "packages/opentelemetry-propagation-utils": "0.30.8", + "packages/opentelemetry-redis-common": "0.36.1", + "packages/opentelemetry-sql-common": "0.40.0", + "packages/opentelemetry-test-utils": "0.38.0", + "packages/winston-transport": "0.2.0", + "plugins/node/instrumentation-amqplib": "0.36.0", + "plugins/node/instrumentation-cucumber": "0.5.0", + "plugins/node/instrumentation-dataloader": "0.8.0", + "plugins/node/instrumentation-fs": "0.11.0", + "plugins/node/instrumentation-lru-memoizer": "0.36.0", + "plugins/node/instrumentation-mongoose": "0.37.0", + "plugins/node/instrumentation-runtime-node": "0.3.0", + "plugins/node/instrumentation-socket.io": "0.38.0", + "plugins/node/instrumentation-tedious": "0.9.0", + "plugins/node/instrumentation-undici": "0.1.0", + "plugins/node/opentelemetry-instrumentation-aws-lambda": "0.40.0", + "plugins/node/opentelemetry-instrumentation-aws-sdk": "0.40.0", + "plugins/node/opentelemetry-instrumentation-bunyan": "0.37.0", + "plugins/node/opentelemetry-instrumentation-cassandra": "0.37.0", + "plugins/node/opentelemetry-instrumentation-connect": "0.35.0", + "plugins/node/opentelemetry-instrumentation-dns": "0.35.0", + "plugins/node/opentelemetry-instrumentation-express": "0.37.0", + "plugins/node/opentelemetry-instrumentation-fastify": "0.35.0", + "plugins/node/opentelemetry-instrumentation-generic-pool": "0.35.0", + "plugins/node/opentelemetry-instrumentation-graphql": "0.39.0", + "plugins/node/opentelemetry-instrumentation-hapi": "0.36.0", + "plugins/node/opentelemetry-instrumentation-ioredis": "0.39.0", + "plugins/node/opentelemetry-instrumentation-knex": "0.35.0", + "plugins/node/opentelemetry-instrumentation-koa": "0.39.0", + "plugins/node/opentelemetry-instrumentation-memcached": "0.35.0", + "plugins/node/opentelemetry-instrumentation-mongodb": "0.42.0", + "plugins/node/opentelemetry-instrumentation-mysql": "0.37.0", + "plugins/node/opentelemetry-instrumentation-mysql2": "0.37.0", + "plugins/node/opentelemetry-instrumentation-nestjs-core": "0.36.0", + "plugins/node/opentelemetry-instrumentation-net": "0.35.0", + "plugins/node/opentelemetry-instrumentation-pg": "0.40.0", + "plugins/node/opentelemetry-instrumentation-pino": "0.37.0", + "plugins/node/opentelemetry-instrumentation-redis": "0.38.0", + "plugins/node/opentelemetry-instrumentation-redis-4": "0.38.0", + "plugins/node/opentelemetry-instrumentation-restify": "0.37.0", + "plugins/node/opentelemetry-instrumentation-router": "0.36.0", + "plugins/node/opentelemetry-instrumentation-winston": "0.36.0", + "plugins/web/opentelemetry-instrumentation-document-load": "0.37.0", + "plugins/web/opentelemetry-instrumentation-long-task": "0.37.0", + "plugins/web/opentelemetry-instrumentation-user-interaction": "0.37.0", + "plugins/web/opentelemetry-plugin-react-load": "0.30.0", + "propagators/opentelemetry-propagator-aws-xray": "1.3.1", + "propagators/opentelemetry-propagator-grpc-census-binary": "0.27.1", + "propagators/opentelemetry-propagator-instana": "0.3.1", + "propagators/opentelemetry-propagator-ot-trace": "0.27.1" +} diff --git a/package-lock.json b/package-lock.json index c30bea59b2..9a502dc61d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8582,6 +8582,10 @@ "resolved": "plugins/node/instrumentation-tedious", "link": true }, + "node_modules/@opentelemetry/instrumentation-undici": { + "resolved": "plugins/node/instrumentation-undici", + "link": true + }, "node_modules/@opentelemetry/instrumentation-user-interaction": { "resolved": "plugins/web/opentelemetry-instrumentation-user-interaction", "link": true @@ -14780,6 +14784,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "node_modules/cookies": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", @@ -19264,6 +19274,15 @@ "node": ">= 0.6.0" } }, + "node_modules/hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -32544,6 +32563,42 @@ "node": ">=4" } }, + "node_modules/superagent": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.9.tgz", + "integrity": "sha512-4C7Bh5pyHTvU33KpZgwrNKh/VQnvgtCSqPRfJAUdmrtSYePVzVg4E4OzsrbkhJj9O7SO6Bnv75K/F8XVZT8YHA==", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/formidable": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "dev": true, + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -34373,6 +34428,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/undici": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.10.1.tgz", + "integrity": "sha512-kSzmWrOx3XBKTgPm4Tal8Hyl3yf+hzlA00SAf4goxv8LZYafKmS6gJD/7Fe5HH/DMNiFTRXvkwhLo7mUn5fuQQ==", + "dev": true, + "engines": { + "node": ">=18.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -37312,6 +37376,169 @@ "@opentelemetry/api": "^1.3.0" } }, + "plugins/node/instrumentation-undici": { + "name": "@opentelemetry/instrumentation-undici", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1" + }, + "devDependencies": { + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/sdk-metrics": "^1.8.0", + "@opentelemetry/sdk-trace-base": "^1.8.0", + "@opentelemetry/sdk-trace-node": "^1.8.0", + "@types/mocha": "7.0.2", + "@types/node": "18.6.5", + "mocha": "7.2.0", + "nyc": "15.1.0", + "rimraf": "5.0.5", + "semver": "^7.6.0", + "superagent": "8.0.9", + "test-all-versions": "6.0.0", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "undici": "6.10.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "plugins/node/instrumentation-undici/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "plugins/node/instrumentation-undici/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "plugins/node/instrumentation-undici/node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "plugins/node/instrumentation-undici/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "plugins/node/instrumentation-undici/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "plugins/node/instrumentation-undici/node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "plugins/node/instrumentation-undici/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "plugins/node/instrumentation-undici/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "plugins/node/instrumentation-undici/node_modules/test-all-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-all-versions/-/test-all-versions-6.0.0.tgz", + "integrity": "sha512-/9wVTBRa7+arvItGinCYy/8+z7sHTsrs9cwEY/xAnzrkSEM7Tp2Cz49ewYZYuO1YYMLqxEaQp2g7Dnns7n7BGA==", + "dev": true, + "dependencies": { + "after-all-results": "^2.0.0", + "ansi-diff-stream": "^1.2.1", + "cli-spinners": "^2.9.2", + "deepmerge": "^4.3.1", + "import-fresh": "^3.3.0", + "is-ci": "^3.0.1", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimist": "^1.2.8", + "npm-package-versions": "^1.0.1", + "once": "^1.4.0", + "parse-env-string": "^1.0.1", + "resolve": "^1.22.8", + "semver": "^7.5.4", + "spawn-npm-install": "^1.2.0", + "which": "^2.0.2" + }, + "bin": { + "tav": "index.js" + }, + "engines": { + "node": ">=14" + } + }, "plugins/node/opentelemetry-instrumentation-aws-lambda": { "name": "@opentelemetry/instrumentation-aws-lambda", "version": "0.40.0", @@ -38571,6 +38798,39 @@ "@opentelemetry/api": "^1.3.0" } }, + "plugins/node/opentelemetry-instrumentation-undici": { + "name": "@opentelemetry/instrumentation-undici", + "version": "0.33.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1" + }, + "devDependencies": { + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/sdk-metrics": "^1.8.0", + "@opentelemetry/sdk-trace-base": "^1.8.0", + "@opentelemetry/sdk-trace-node": "^1.8.0", + "@types/mocha": "7.0.2", + "@types/node": "18.6.5", + "mocha": "7.2.0", + "nyc": "15.1.0", + "rimraf": "5.0.5", + "semver": "^7.6.0", + "superagent": "8.0.9", + "test-all-versions": "6.0.0", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "undici": "^6.7.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "plugins/node/opentelemetry-instrumentation-winston": { "name": "@opentelemetry/instrumentation-winston", "version": "0.36.0", @@ -47127,6 +47387,122 @@ "typescript": "4.4.4" } }, + "@opentelemetry/instrumentation-undici": { + "version": "file:plugins/node/instrumentation-undici", + "requires": { + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.49.1", + "@opentelemetry/sdk-metrics": "^1.8.0", + "@opentelemetry/sdk-trace-base": "^1.8.0", + "@opentelemetry/sdk-trace-node": "^1.8.0", + "@types/mocha": "7.0.2", + "@types/node": "18.6.5", + "mocha": "7.2.0", + "nyc": "15.1.0", + "rimraf": "5.0.5", + "semver": "^7.6.0", + "superagent": "8.0.9", + "test-all-versions": "6.0.0", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "undici": "6.10.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "requires": { + "ci-info": "^3.2.0" + } + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "test-all-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-all-versions/-/test-all-versions-6.0.0.tgz", + "integrity": "sha512-/9wVTBRa7+arvItGinCYy/8+z7sHTsrs9cwEY/xAnzrkSEM7Tp2Cz49ewYZYuO1YYMLqxEaQp2g7Dnns7n7BGA==", + "dev": true, + "requires": { + "after-all-results": "^2.0.0", + "ansi-diff-stream": "^1.2.1", + "cli-spinners": "^2.9.2", + "deepmerge": "^4.3.1", + "import-fresh": "^3.3.0", + "is-ci": "^3.0.1", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimist": "^1.2.8", + "npm-package-versions": "^1.0.1", + "once": "^1.4.0", + "parse-env-string": "^1.0.1", + "resolve": "^1.22.8", + "semver": "^7.5.4", + "spawn-npm-install": "^1.2.0", + "which": "^2.0.2" + } + } + } + }, "@opentelemetry/instrumentation-user-interaction": { "version": "file:plugins/web/opentelemetry-instrumentation-user-interaction", "requires": { @@ -52659,6 +53035,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "cookies": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", @@ -56185,6 +56567,12 @@ } } }, + "hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "dev": true + }, "highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -66725,6 +67113,38 @@ "through": "^2.3.4" } }, + "superagent": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.9.tgz", + "integrity": "sha512-4C7Bh5pyHTvU33KpZgwrNKh/VQnvgtCSqPRfJAUdmrtSYePVzVg4E4OzsrbkhJj9O7SO6Bnv75K/F8XVZT8YHA==", + "dev": true, + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "dependencies": { + "formidable": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "dev": true, + "requires": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" + } + } + } + }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -68080,6 +68500,12 @@ } } }, + "undici": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.10.1.tgz", + "integrity": "sha512-kSzmWrOx3XBKTgPm4Tal8Hyl3yf+hzlA00SAf4goxv8LZYafKmS6gJD/7Fe5HH/DMNiFTRXvkwhLo7mUn5fuQQ==", + "dev": true + }, "undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/plugins/node/instrumentation-undici/.eslintignore b/plugins/node/instrumentation-undici/.eslintignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/plugins/node/instrumentation-undici/.eslintignore @@ -0,0 +1 @@ +build diff --git a/plugins/node/instrumentation-undici/.eslintrc.js b/plugins/node/instrumentation-undici/.eslintrc.js new file mode 100644 index 0000000000..f756f4488b --- /dev/null +++ b/plugins/node/instrumentation-undici/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "env": { + "mocha": true, + "node": true + }, + ...require('../../../eslint.config.js') +} diff --git a/plugins/node/instrumentation-undici/.tav.yml b/plugins/node/instrumentation-undici/.tav.yml new file mode 100644 index 0000000000..9ef69e2462 --- /dev/null +++ b/plugins/node/instrumentation-undici/.tav.yml @@ -0,0 +1,8 @@ +undici: + jobs: + - versions: ">=5 <6" + node: '>=14' + commands: npm run test + - versions: ">=6 <7" + node: '>=18' + commands: npm run test diff --git a/plugins/node/instrumentation-undici/LICENSE b/plugins/node/instrumentation-undici/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/plugins/node/instrumentation-undici/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/node/instrumentation-undici/README.md b/plugins/node/instrumentation-undici/README.md new file mode 100644 index 0000000000..38b6fd6e9e --- /dev/null +++ b/plugins/node/instrumentation-undici/README.md @@ -0,0 +1,100 @@ +# OpenTelemetry Undici/fetch Instrumentation for Node.js + +[![NPM Published Version][npm-img]][npm-url] +[![Apache License][license-image]][license-image] + +**Note: This is an experimental package under active development. New releases may include breaking changes.** + +This module provides automatic instrumentation for [`undici`](https://undici.nodejs.org/) and Node.js global [`fetch`](https://nodejs.org/docs/latest/api/globals.html#fetch) API. +If you're looking the instrumentation for browser's `fetch` API it is located at [https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-fetch/](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-fetch/) + +## Installation + +```bash +npm install --save @opentelemetry/instrumentation-undici +``` + +## Usage + +OpenTelemetry Undici/fetch Instrumentation allows the user to automatically collect trace data and export them to their backend of choice, to give observability to distributed systems. + +To load a specific instrumentation (Undici in this case), specify it in the Node Tracer's configuration. + +```js +const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici'); +const { + ConsoleSpanExporter, + NodeTracerProvider, + SimpleSpanProcessor, +} = require('@opentelemetry/sdk-trace-node'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); + +const provider = new NodeTracerProvider(); + +provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); +provider.register(); + +registerInstrumentations({ + instrumentations: [new UndiciInstrumentation()], +}); + +``` + +### Undici/Fetch instrumentation Options + +Undici instrumentation has few options available to choose from. You can set the following: + +| Options | Type | Description | +| ------- | ---- | ----------- | +| [`ignoreRequestHook`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-undici/src/types.ts#63) | `IgnoreRequestFunction` | Undici instrumentation will not trace all incoming requests that matched with custom function. | +| [`requestHook`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-undici/src/types.ts#65) | `RequestHookFunction` | Function for adding custom attributes before request is handled. | +| [`startSpanHook`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-undici/src/types.ts#67) | `StartSpanHookFunction` | Function for adding custom attributes before a span is started. | +| [`requireParentforSpans`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-undici/src/types.ts#69) | `Boolean` | Require a parent span is present to create new span for outgoing requests. | +| [`headersToSpanAttributes`](https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-undici/src/types.ts#71) | `Object` | List of case insensitive HTTP headers to convert to span attributes. Headers will be converted to span attributes in the form of `http.{request\|response}.header.header-name` where the name is only lowercased, e.g. `http.response.header.content-length`| + +### Observations + +This instrumentation subscribes to certain [diagnostics_channel](https://nodejs.org/api/diagnostics_channel.html) to intercept the client requests +and generate traces and metrics. In particular tracing spans are started when [undici:request:create](https://undici.nodejs.org/#/docs/api/DiagnosticsChannel?id=undicirequestcreate) +channel receives a message and ended when [undici:request:trailers](https://undici.nodejs.org/#/docs/api/DiagnosticsChannel?id=undicirequesttrailers) channel receive a message. +This means the full response body has been received when the instrumentation ends the span. + +## Semantic Conventions + +This package uses Semantic Conventions [Version 1.24.0](https://github.com/open-telemetry/semantic-conventions/tree/v1.24.0/docs/http). As for now the Semantic Conventions +are bundled in this package but eventually will be imported from `@opentelemetry/semantic-conventions` package when it is updated to latest version. +Ref: [opentelemetry-js/issues/4235](https://github.com/open-telemetry/opentelemetry-js/issues/4235) + +Attributes collected: + +| Attribute | Short Description | Notes | +| ------------ | ---------------------------------- | ----------------- | +| `http.request.method` | HTTP request method. | Key: `HTTP_REQUEST_METHOD` | +| `http.request.method_original` | Original HTTP method sent by the client in the request line. | Key: `HTTP_REQUEST_METHOD_ORIGINAL` | +| `url.full` | Absolute URL describing a network resource according to [RFC3986](https://www.rfc-editor.org/rfc/rfc3986). | Key: `URL_FULL` | +| `url.path` | The [URI path](https://www.rfc-editor.org/rfc/rfc3986#section-3.3) component. | Key: `URL_PATH` | +| `url.query` | The [URI query](https://www.rfc-editor.org/rfc/rfc3986#section-3.4) component. | Key: `URL_QUERY` | +| `url.scheme` | HTTP request method. | Key: `URL_SCHEME` | +| `server.address` | Server domain name, IP address or Unix domain socket name. | Key: `HTTP_REQUEST_METHOD` | +| `server.port` | Server port number. | Key: `HTTP_REQUEST_METHOD` | +| `user_agent.original` | Value of the HTTP User-Agent header sent by the client. | Key: `USER_AGENT_ORIGINAL` | +| `network.peer.address` | Peer address of the network connection - IP address or Unix domain socket name. | Key: `NETWORK_PEER_ADDRESS` | +| `network.peer.port` | Peer port number of the network connection. | Key: `NETWORK_PEER_PORT` | +| `http.response.status_code` | [HTTP response status code](https://tools.ietf.org/html/rfc7231#section-6). | Key: `HTTP_RESPONSE_STATUS_CODE` | +| `error.type` | Describes a class of error the operation ended with. | Key: `ERROR_TYPE` | + +## Useful links + +- For more information on OpenTelemetry, visit: +- For more about OpenTelemetry JavaScript: +- For help or feedback on this project, join us in [GitHub Discussions][discussions-url] + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. + +[discussions-url]: https://github.com/open-telemetry/opentelemetry-js/discussions +[license-url]: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/LICENSE +[license-image]: https://img.shields.io/badge/license-Apache_2.0-green.svg?style=flat +[npm-url]: https://www.npmjs.com/package/@opentelemetry/instrumentation-router +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-router.svg diff --git a/plugins/node/instrumentation-undici/package.json b/plugins/node/instrumentation-undici/package.json new file mode 100644 index 0000000000..b235be769a --- /dev/null +++ b/plugins/node/instrumentation-undici/package.json @@ -0,0 +1,70 @@ +{ + "name": "@opentelemetry/instrumentation-undici", + "version": "0.1.0", + "description": "OpenTelemetry undici/fetch automatic instrumentation package.", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": "open-telemetry/opentelemetry-js-contrib", + "scripts": { + "prepublishOnly": "npm run compile", + "compile": "tsc -p .", + "test": "nyc ts-mocha -p tsconfig.json test/**/*.test.ts", + "test-all-versions": "tav", + "tdd": "npm run test -- --watch-extensions ts --watch", + "clean": "rimraf build/*", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "codecov": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../../", + "watch": "tsc -w", + "precompile": "tsc --version && lerna run version:update --scope @opentelemetry/instrumentation-undici --include-dependencies", + "prewatch": "npm run precompile", + "version:update": "node ../../../scripts/version-update.js" + }, + "keywords": [ + "opentelemetry", + "fetch", + "undici", + "nodejs", + "tracing", + "instrumentation" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/sdk-metrics": "^1.8.0", + "@opentelemetry/sdk-trace-base": "^1.8.0", + "@opentelemetry/sdk-trace-node": "^1.8.0", + "@types/mocha": "7.0.2", + "@types/node": "18.6.5", + "mocha": "7.2.0", + "nyc": "15.1.0", + "rimraf": "5.0.5", + "semver": "^7.6.0", + "superagent": "8.0.9", + "test-all-versions": "6.0.0", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "undici": "6.10.1" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + }, + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.50.0" + }, + "homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-undici#readme", + "sideEffects": false +} diff --git a/plugins/node/instrumentation-undici/src/enums/SemanticAttributes.ts b/plugins/node/instrumentation-undici/src/enums/SemanticAttributes.ts new file mode 100644 index 0000000000..0e65b51aeb --- /dev/null +++ b/plugins/node/instrumentation-undici/src/enums/SemanticAttributes.ts @@ -0,0 +1,168 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// DO NOT EDIT, this is an Auto-generated file from scripts/semconv/templates//templates/SemanticAttributes.ts.j2 +export const SemanticAttributes = { + /** + * State of the HTTP connection in the HTTP connection pool. + */ + HTTP_CONNECTION_STATE: 'http.connection.state', + + /** + * Describes a class of error the operation ended with. + * + * Note: The `error.type` SHOULD be predictable and SHOULD have low cardinality. +Instrumentations SHOULD document the list of errors they report. + +The cardinality of `error.type` within one instrumentation library SHOULD be low. +Telemetry consumers that aggregate data from multiple instrumentation libraries and applications +should be prepared for `error.type` to have high cardinality at query time when no +additional filters are applied. + +If the operation has completed successfully, instrumentations SHOULD NOT set `error.type`. + +If a specific domain defines its own set of error identifiers (such as HTTP or gRPC status codes), +it's RECOMMENDED to: + +* Use a domain-specific attribute +* Set `error.type` to capture all errors, regardless of whether they are defined within the domain-specific set or not. + */ + ERROR_TYPE: 'error.type', + + /** + * The size of the request payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the [Content-Length](https://www.rfc-editor.org/rfc/rfc9110.html#field.content-length) header. For requests using transport encoding, this should be the compressed size. + */ + HTTP_REQUEST_BODY_SIZE: 'http.request.body.size', + + /** + * HTTP request method. + * + * Note: HTTP request method value SHOULD be "known" to the instrumentation. +By default, this convention defines "known" methods as the ones listed in [RFC9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-methods) +and the PATCH method defined in [RFC5789](https://www.rfc-editor.org/rfc/rfc5789.html). + +If the HTTP request method is not known to instrumentation, it MUST set the `http.request.method` attribute to `_OTHER`. + +If the HTTP instrumentation could end up converting valid HTTP request methods to `_OTHER`, then it MUST provide a way to override +the list of known HTTP methods. If this override is done via environment variable, then the environment variable MUST be named +OTEL_INSTRUMENTATION_HTTP_KNOWN_METHODS and support a comma-separated list of case-sensitive known HTTP methods +(this list MUST be a full override of the default known method, it is not a list of known methods in addition to the defaults). + +HTTP method names are case-sensitive and `http.request.method` attribute value MUST match a known HTTP method name exactly. +Instrumentations for specific web frameworks that consider HTTP methods to be case insensitive, SHOULD populate a canonical equivalent. +Tracing instrumentations that do so, MUST also set `http.request.method_original` to the original value. + */ + HTTP_REQUEST_METHOD: 'http.request.method', + + /** + * Original HTTP method sent by the client in the request line. + */ + HTTP_REQUEST_METHOD_ORIGINAL: 'http.request.method_original', + + /** + * The ordinal number of request resending attempt (for any reason, including redirects). + * + * Note: The resend count SHOULD be updated each time an HTTP request gets resent by the client, regardless of what was the cause of the resending (e.g. redirection, authorization failure, 503 Server Unavailable, network issues, or any other). + */ + HTTP_REQUEST_RESEND_COUNT: 'http.request.resend_count', + + /** + * The size of the response payload body in bytes. This is the number of bytes transferred excluding headers and is often, but not always, present as the [Content-Length](https://www.rfc-editor.org/rfc/rfc9110.html#field.content-length) header. For requests using transport encoding, this should be the compressed size. + */ + HTTP_RESPONSE_BODY_SIZE: 'http.response.body.size', + + /** + * [HTTP response status code](https://tools.ietf.org/html/rfc7231#section-6). + */ + HTTP_RESPONSE_STATUS_CODE: 'http.response.status_code', + + /** + * The matched route, that is, the path template in the format used by the respective server framework. + * + * Note: MUST NOT be populated when this is not supported by the HTTP server framework as the route attribute should have low-cardinality and the URI path can NOT substitute it. +SHOULD include the [application root](/docs/http/http-spans.md#http-server-definitions) if there is one. + */ + HTTP_ROUTE: 'http.route', + + /** + * Peer address of the network connection - IP address or Unix domain socket name. + */ + NETWORK_PEER_ADDRESS: 'network.peer.address', + + /** + * Peer port number of the network connection. + */ + NETWORK_PEER_PORT: 'network.peer.port', + + /** + * [OSI application layer](https://osi-model.com/application-layer/) or non-OSI equivalent. + * + * Note: The value SHOULD be normalized to lowercase. + */ + NETWORK_PROTOCOL_NAME: 'network.protocol.name', + + /** + * Version of the protocol specified in `network.protocol.name`. + * + * Note: `network.protocol.version` refers to the version of the protocol used and might be different from the protocol client's version. If the HTTP client has a version of `0.27.2`, but sends HTTP version `1.1`, this attribute should be set to `1.1`. + */ + NETWORK_PROTOCOL_VERSION: 'network.protocol.version', + + /** + * Server domain name if available without reverse DNS lookup; otherwise, IP address or Unix domain socket name. + * + * Note: When observed from the client side, and when communicating through an intermediary, `server.address` SHOULD represent the server address behind any intermediaries, for example proxies, if it's available. + */ + SERVER_ADDRESS: 'server.address', + + /** + * Server port number. + * + * Note: When observed from the client side, and when communicating through an intermediary, `server.port` SHOULD represent the server port behind any intermediaries, for example proxies, if it's available. + */ + SERVER_PORT: 'server.port', + + /** + * Absolute URL describing a network resource according to [RFC3986](https://www.rfc-editor.org/rfc/rfc3986). + * + * Note: For network calls, URL usually has `scheme://host[:port][path][?query][#fragment]` format, where the fragment is not transmitted over HTTP, but if it is known, it SHOULD be included nevertheless. +`url.full` MUST NOT contain credentials passed via URL in form of `https://username:password@www.example.com/`. In such case username and password SHOULD be redacted and attribute's value SHOULD be `https://REDACTED:REDACTED@www.example.com/`. +`url.full` SHOULD capture the absolute URL when it is available (or can be reconstructed) and SHOULD NOT be validated or modified except for sanitizing purposes. + */ + URL_FULL: 'url.full', + + /** + * The [URI path](https://www.rfc-editor.org/rfc/rfc3986#section-3.3) component. + */ + URL_PATH: 'url.path', + + /** + * The [URI query](https://www.rfc-editor.org/rfc/rfc3986#section-3.4) component. + * + * Note: Sensitive content provided in query string SHOULD be scrubbed when instrumentations can identify it. + */ + URL_QUERY: 'url.query', + + /** + * The [URI scheme](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) component identifying the used protocol. + */ + URL_SCHEME: 'url.scheme', + + /** + * Value of the [HTTP User-Agent](https://www.rfc-editor.org/rfc/rfc9110.html#field.user-agent) header sent by the client. + */ + USER_AGENT_ORIGINAL: 'user_agent.original', +}; diff --git a/plugins/node/instrumentation-undici/src/index.ts b/plugins/node/instrumentation-undici/src/index.ts new file mode 100644 index 0000000000..80c6504fbc --- /dev/null +++ b/plugins/node/instrumentation-undici/src/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { UndiciInstrumentation } from './undici'; +export { + UndiciRequest, + UndiciResponse, + IgnoreRequestFunction, + RequestHookFunction, + StartSpanHookFunction, + UndiciInstrumentationConfig, +} from './types'; diff --git a/plugins/node/instrumentation-undici/src/internal-types.ts b/plugins/node/instrumentation-undici/src/internal-types.ts new file mode 100644 index 0000000000..fee0e7294a --- /dev/null +++ b/plugins/node/instrumentation-undici/src/internal-types.ts @@ -0,0 +1,48 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Channel } from 'diagnostics_channel'; + +import { UndiciRequest, UndiciResponse } from './types'; + +export interface ListenerRecord { + name: string; + channel: Channel; + onMessage: (message: any, name: string) => void; +} + +export interface RequestMessage { + request: UndiciRequest; +} + +export interface RequestHeadersMessage { + request: UndiciRequest; + socket: any; +} + +export interface ResponseHeadersMessage { + request: UndiciRequest; + response: UndiciResponse; +} + +export interface RequestTrailersMessage { + request: UndiciRequest; + response: UndiciResponse; +} + +export interface RequestErrorMessage { + request: UndiciRequest; + error: Error; +} diff --git a/plugins/node/instrumentation-undici/src/types.ts b/plugins/node/instrumentation-undici/src/types.ts new file mode 100644 index 0000000000..0d28e1fff9 --- /dev/null +++ b/plugins/node/instrumentation-undici/src/types.ts @@ -0,0 +1,75 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import type { Attributes, Span } from '@opentelemetry/api'; + +export interface UndiciRequest { + origin: string; + method: string; + path: string; + /** + * Serialized string of headers in the form `name: value\r\n` for v5 + * Array of strings v6 + */ + headers: string | string[]; + /** + * Helper method to add headers (from v6) + */ + addHeader: (name: string, value: string) => void; + throwOnError: boolean; + completed: boolean; + aborted: boolean; + idempotent: boolean; + contentLength: number | null; + contentType: string | null; + body: any; +} + +export interface UndiciResponse { + headers: Buffer[]; + statusCode: number; +} + +export interface IgnoreRequestFunction { + (request: T): boolean; +} + +export interface RequestHookFunction { + (span: Span, request: T): void; +} + +export interface StartSpanHookFunction { + (request: T): Attributes; +} + +// This package will instrument HTTP requests made through `undici` or `fetch` global API +// so it seems logical to have similar options than the HTTP instrumentation +export interface UndiciInstrumentationConfig + extends InstrumentationConfig { + /** Not trace all outgoing requests that matched with custom function */ + ignoreRequestHook?: IgnoreRequestFunction; + /** Function for adding custom attributes before request is handled */ + requestHook?: RequestHookFunction; + /** Function for adding custom attributes before a span is started */ + startSpanHook?: StartSpanHookFunction; + /** Require parent to create span for outgoing requests */ + requireParentforSpans?: boolean; + /** Map the following HTTP headers to span attributes. */ + headersToSpanAttributes?: { + requestHeaders?: string[]; + responseHeaders?: string[]; + }; +} diff --git a/plugins/node/instrumentation-undici/src/undici.ts b/plugins/node/instrumentation-undici/src/undici.ts new file mode 100644 index 0000000000..262bea42cd --- /dev/null +++ b/plugins/node/instrumentation-undici/src/undici.ts @@ -0,0 +1,483 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as diagch from 'diagnostics_channel'; +import { URL } from 'url'; + +import { + InstrumentationBase, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { + Attributes, + context, + Histogram, + HrTime, + INVALID_SPAN_CONTEXT, + propagation, + Span, + SpanKind, + SpanStatusCode, + trace, + ValueType, +} from '@opentelemetry/api'; + +import { VERSION } from './version'; + +import { + ListenerRecord, + RequestHeadersMessage, + RequestMessage, + RequestTrailersMessage, + ResponseHeadersMessage, +} from './internal-types'; +import { UndiciInstrumentationConfig, UndiciRequest } from './types'; +import { SemanticAttributes } from './enums/SemanticAttributes'; +import { + hrTime, + hrTimeDuration, + hrTimeToMilliseconds, +} from '@opentelemetry/core'; + +interface InstrumentationRecord { + span: Span; + attributes: Attributes; + startTime: HrTime; +} + +// A combination of https://github.com/elastic/apm-agent-nodejs and +// https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts +export class UndiciInstrumentation extends InstrumentationBase { + // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for + // unsubscribing. + private _channelSubs!: Array; + private _recordFromReq = new WeakMap(); + + private _httpClientDurationHistogram!: Histogram; + constructor(config?: UndiciInstrumentationConfig) { + super('@opentelemetry/instrumentation-undici', VERSION, config); + this.setConfig(config); + } + + // No need to instrument files/modules + protected override init() { + return undefined; + } + + override disable(): void { + if (!this._config.enabled) { + return; + } + + this._channelSubs.forEach(sub => sub.channel.unsubscribe(sub.onMessage)); + this._channelSubs.length = 0; + this._config.enabled = false; + } + + override enable(): void { + if (this._config.enabled) { + return; + } + this._config.enabled = true; + + // This method is called by the `InstrumentationAbstract` constructor before + // ours is called. So we need to ensure the property is initalized + this._channelSubs = this._channelSubs || []; + this.subscribeToChannel( + 'undici:request:create', + this.onRequestCreated.bind(this) + ); + this.subscribeToChannel( + 'undici:client:sendHeaders', + this.onRequestHeaders.bind(this) + ); + this.subscribeToChannel( + 'undici:request:headers', + this.onResponseHeaders.bind(this) + ); + this.subscribeToChannel('undici:request:trailers', this.onDone.bind(this)); + this.subscribeToChannel('undici:request:error', this.onError.bind(this)); + } + + override setConfig(config?: UndiciInstrumentationConfig): void { + super.setConfig(config); + + if (config?.enabled) { + this.enable(); + } else { + this.disable(); + } + } + + protected override _updateMetricInstruments() { + this._httpClientDurationHistogram = this.meter.createHistogram( + 'http.client.request.duration', + { + description: 'Measures the duration of outbound HTTP requests.', + unit: 's', + valueType: ValueType.DOUBLE, + advice: { + explicitBucketBoundaries: [ + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, + 7.5, 10, + ], + }, + } + ); + } + + private _getConfig(): UndiciInstrumentationConfig { + return this._config as UndiciInstrumentationConfig; + } + + private subscribeToChannel( + diagnosticChannel: string, + onMessage: ListenerRecord['onMessage'] + ) { + const channel = diagch.channel(diagnosticChannel); + channel.subscribe(onMessage); + this._channelSubs.push({ + name: diagnosticChannel, + channel, + onMessage, + }); + } + + // This is the 1st message we receive for each request (fired after request creation). Here we will + // create the span and populate some atttributes, then link the span to the request for further + // span processing + private onRequestCreated({ request }: RequestMessage): void { + // Ignore if: + // - instrumentation is disabled + // - ignored by config + // - method is 'CONNECT' + const config = this._getConfig(); + const shouldIgnoreReq = safeExecuteInTheMiddle( + () => + !config.enabled || + request.method === 'CONNECT' || + config.ignoreRequestHook?.(request), + e => e && this._diag.error('caught ignoreRequestHook error: ', e), + true + ); + + if (shouldIgnoreReq) { + return; + } + + const startTime = hrTime(); + const requestUrl = new URL(request.origin + request.path); + const urlScheme = requestUrl.protocol.replace(':', ''); + const requestMethod = this.getRequestMethod(request.method); + const attributes: Attributes = { + [SemanticAttributes.HTTP_REQUEST_METHOD]: requestMethod, + [SemanticAttributes.HTTP_REQUEST_METHOD_ORIGINAL]: request.method, + [SemanticAttributes.URL_FULL]: requestUrl.toString(), + [SemanticAttributes.URL_PATH]: requestUrl.pathname, + [SemanticAttributes.URL_QUERY]: requestUrl.search, + [SemanticAttributes.URL_SCHEME]: urlScheme, + }; + + const schemePorts: Record = { https: '443', http: '80' }; + const serverAddress = requestUrl.hostname; + const serverPort = requestUrl.port || schemePorts[urlScheme]; + + attributes[SemanticAttributes.SERVER_ADDRESS] = serverAddress; + if (serverPort && !isNaN(Number(serverPort))) { + attributes[SemanticAttributes.SERVER_PORT] = Number(serverPort); + } + + // Get user agent from headers + let userAgent; + if (Array.isArray(request.headers)) { + const idx = request.headers.findIndex( + h => h.toLowerCase() === 'user-agent' + ); + userAgent = request.headers[idx + 1]; + } else if (typeof request.headers === 'string') { + const headers = request.headers.split('\r\n'); + const uaHeader = headers.find(h => + h.toLowerCase().startsWith('user-agent') + ); + userAgent = + uaHeader && uaHeader.substring(uaHeader.indexOf(':') + 1).trim(); + } + + if (userAgent) { + attributes[SemanticAttributes.USER_AGENT_ORIGINAL] = userAgent; + } + + // Get attributes from the hook if present + const hookAttributes = safeExecuteInTheMiddle( + () => config.startSpanHook?.(request), + e => e && this._diag.error('caught startSpanHook error: ', e), + true + ); + if (hookAttributes) { + Object.entries(hookAttributes).forEach(([key, val]) => { + attributes[key] = val; + }); + } + + // Check if parent span is required via config and: + // - if a parent is required but not present, we use a `NoopSpan` to still + // propagate context without recording it. + // - create a span otherwise + const activeCtx = context.active(); + const currentSpan = trace.getSpan(activeCtx); + let span: Span; + + if (config.requireParentforSpans && !currentSpan) { + span = trace.wrapSpanContext(INVALID_SPAN_CONTEXT); + } else { + span = this.tracer.startSpan( + requestMethod === '_OTHER' ? 'HTTP' : requestMethod, + { + kind: SpanKind.CLIENT, + attributes: attributes, + }, + activeCtx + ); + } + + // Execute the request hook if defined + safeExecuteInTheMiddle( + () => config.requestHook?.(span, request), + e => e && this._diag.error('caught requestHook error: ', e), + true + ); + + // Context propagation goes last so no hook can tamper + // the propagation headers + const requestContext = trace.setSpan(context.active(), span); + const addedHeaders: Record = {}; + propagation.inject(requestContext, addedHeaders); + + const headerEntries = Object.entries(addedHeaders); + + for (let i = 0; i < headerEntries.length; i++) { + const [k, v] = headerEntries[i]; + + if (typeof request.headers === 'string') { + request.headers += `${k}: ${v}\r\n`; + } else { + request.addHeader(k, v); + } + } + this._recordFromReq.set(request, { span, attributes, startTime }); + } + + // This is the 2nd message we receive for each request. It is fired when connection with + // the remote is established and about to send the first byte. Here we do have info about the + // remote address and port so we can populate some `network.*` attributes into the span + private onRequestHeaders({ request, socket }: RequestHeadersMessage): void { + const record = this._recordFromReq.get(request as UndiciRequest); + + if (!record) { + return; + } + + const config = this._getConfig(); + const { span } = record; + const { remoteAddress, remotePort } = socket; + const spanAttributes: Attributes = { + [SemanticAttributes.NETWORK_PEER_ADDRESS]: remoteAddress, + [SemanticAttributes.NETWORK_PEER_PORT]: remotePort, + }; + + // After hooks have been processed (which may modify request headers) + // we can collect the headers based on the configuration + if (config.headersToSpanAttributes?.requestHeaders) { + const headersToAttribs = new Set( + config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase()) + ); + + // headers could be in form + // ['name: value', ...] for v5 + // ['name', 'value', ...] for v6 + const rawHeaders = Array.isArray(request.headers) + ? request.headers + : request.headers.split('\r\n'); + rawHeaders.forEach((h, idx) => { + const sepIndex = h.indexOf(':'); + const hasSeparator = sepIndex !== -1; + const name = ( + hasSeparator ? h.substring(0, sepIndex) : h + ).toLowerCase(); + const value = hasSeparator + ? h.substring(sepIndex + 1) + : rawHeaders[idx + 1]; + + if (headersToAttribs.has(name)) { + spanAttributes[`http.request.header.${name}`] = value.trim(); + } + }); + } + + span.setAttributes(spanAttributes); + } + + // This is the 3rd message we get for each request and it's fired when the server + // headers are received, body may not be accessible yet. + // From the response headers we can set the status and content length + private onResponseHeaders({ + request, + response, + }: ResponseHeadersMessage): void { + const record = this._recordFromReq.get(request); + + if (!record) { + return; + } + + const { span, attributes } = record; + const spanAttributes: Attributes = { + [SemanticAttributes.HTTP_RESPONSE_STATUS_CODE]: response.statusCode, + }; + + const config = this._getConfig(); + const headersToAttribs = new Set(); + + if (config.headersToSpanAttributes?.responseHeaders) { + config.headersToSpanAttributes?.responseHeaders.forEach(name => + headersToAttribs.add(name.toLowerCase()) + ); + } + + for (let idx = 0; idx < response.headers.length; idx = idx + 2) { + const name = response.headers[idx].toString().toLowerCase(); + const value = response.headers[idx + 1]; + + if (headersToAttribs.has(name)) { + spanAttributes[`http.response.header.${name}`] = value.toString(); + } + + if (name === 'content-length') { + const contentLength = Number(value.toString()); + if (!isNaN(contentLength)) { + spanAttributes['http.response.header.content-length'] = contentLength; + } + } + } + + span.setAttributes(spanAttributes); + span.setStatus({ + code: + response.statusCode >= 400 + ? SpanStatusCode.ERROR + : SpanStatusCode.UNSET, + }); + record.attributes = Object.assign(attributes, spanAttributes); + } + + // This is the last event we receive if the request went without any errors + private onDone({ request }: RequestTrailersMessage): void { + const record = this._recordFromReq.get(request); + + if (!record) { + return; + } + + const { span, attributes, startTime } = record; + + // End the span + span.end(); + this._recordFromReq.delete(request); + + // Record metrics + this.recordRequestDuration(attributes, startTime); + } + + // This is the event we get when something is wrong in the request like + // - invalid options when calling `fetch` global API or any undici method for request + // - connectivity errors such as unreachable host + // - requests aborted through an `AbortController.signal` + // NOTE: server errors are considered valid responses and it's the lib consumer + // who should deal with that. + private onError({ request, error }: any): void { + const record = this._recordFromReq.get(request); + + if (!record) { + return; + } + + const { span, attributes, startTime } = record; + + // NOTE: in `undici@6.3.0` when request aborted the error type changes from + // a custom error (`RequestAbortedError`) to a built-in `DOMException` carrying + // some differences: + // - `code` is from DOMEXception (ABORT_ERR: 20) + // - `message` changes + // - stacktrace is smaller and contains node internal frames + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + span.end(); + this._recordFromReq.delete(request); + + // Record metrics (with the error) + attributes[SemanticAttributes.ERROR_TYPE] = error.message; + this.recordRequestDuration(attributes, startTime); + } + + private recordRequestDuration(attributes: Attributes, startTime: HrTime) { + // Time to record metrics + const metricsAttributes: Attributes = {}; + // Get the attribs already in span attributes + const keysToCopy = [ + SemanticAttributes.HTTP_RESPONSE_STATUS_CODE, + SemanticAttributes.HTTP_REQUEST_METHOD, + SemanticAttributes.SERVER_ADDRESS, + SemanticAttributes.SERVER_PORT, + SemanticAttributes.URL_SCHEME, + SemanticAttributes.ERROR_TYPE, + ]; + keysToCopy.forEach(key => { + if (key in attributes) { + metricsAttributes[key] = attributes[key]; + } + }); + + // Take the duration and record it + const durationSeconds = + hrTimeToMilliseconds(hrTimeDuration(startTime, hrTime())) / 1000; + this._httpClientDurationHistogram.record( + durationSeconds, + metricsAttributes + ); + } + + private getRequestMethod(original: string): string { + const knownMethods = { + CONNECT: true, + OPTIONS: true, + HEAD: true, + GET: true, + POST: true, + PUT: true, + PATCH: true, + DELETE: true, + TRACE: true, + }; + + if (original.toUpperCase() in knownMethods) { + return original.toUpperCase(); + } + + return '_OTHER'; + } +} diff --git a/plugins/node/instrumentation-undici/test/fetch.test.ts b/plugins/node/instrumentation-undici/test/fetch.test.ts new file mode 100644 index 0000000000..96fa7090f4 --- /dev/null +++ b/plugins/node/instrumentation-undici/test/fetch.test.ts @@ -0,0 +1,414 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as assert from 'assert'; + +import { + SpanKind, + SpanStatusCode, + context, + propagation, + trace, +} from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; + +import { UndiciInstrumentation } from '../src/undici'; + +import { MockPropagation } from './utils/mock-propagation'; +import { MockServer } from './utils/mock-server'; +import { assertSpan } from './utils/assertSpan'; + +const instrumentation = new UndiciInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +const protocol = 'http'; +const hostname = 'localhost'; +const mockServer = new MockServer(); +const memoryExporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider(); +provider.addSpanProcessor(new SimpleSpanProcessor(memoryExporter)); +instrumentation.setTracerProvider(provider); + +describe('UndiciInstrumentation `fetch` tests', function () { + before(function (done) { + // Do not test if the `fetch` global API is not available + // This applies to nodejs < v18 or nodejs < v16.15 wihtout the flag + // `--experimental-global-fetch` set + // https://nodejs.org/api/globals.html#fetch + if (typeof globalThis.fetch !== 'function') { + this.skip(); + } + + propagation.setGlobalPropagator(new MockPropagation()); + context.setGlobalContextManager(new AsyncHooksContextManager().enable()); + mockServer.start(done); + mockServer.mockListener((req, res) => { + // There are some situations where there is no way to access headers + // for trace propagation asserts like: + // const resp = await fetch('http://host:port') + // so we need to do the assertion here + try { + assert.ok( + req.headers[MockPropagation.TRACE_CONTEXT_KEY], + `trace propagation for ${MockPropagation.TRACE_CONTEXT_KEY} works` + ); + assert.ok( + req.headers[MockPropagation.SPAN_CONTEXT_KEY], + `trace propagation for ${MockPropagation.SPAN_CONTEXT_KEY} works` + ); + } catch (assertErr) { + // The exception will hang the server and the test so we set a header + // back to the test to make an assertion + res.setHeader('propagation-error', (assertErr as Error).message); + } + + // Retur a valid response always + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.setHeader('foo-server', 'bar'); + res.write(JSON.stringify({ success: true })); + res.end(); + }); + }); + + after(function (done) { + context.disable(); + propagation.disable(); + mockServer.mockListener(undefined); + mockServer.stop(done); + }); + + beforeEach(function () { + memoryExporter.reset(); + }); + + describe('disable()', function () { + it('should not create spans when disabled', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Disable via config + instrumentation.setConfig({ enabled: false }); + + const fetchUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const response = await fetch(fetchUrl); + assert.ok( + response.headers.get('propagation-error') != null, + 'propagation is not set if instrumentation disabled' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0, 'no spans are created'); + }); + }); + + describe('enable()', function () { + beforeEach(function () { + instrumentation.enable(); + }); + afterEach(function () { + // Empty configuration & disable + instrumentation.setConfig({ enabled: false }); + }); + + it('should create valid spans even if the configuration hooks fail', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Set the bad configuration + instrumentation.setConfig({ + enabled: true, + ignoreRequestHook: () => { + throw new Error('ignoreRequestHook error'); + }, + requestHook: () => { + throw new Error('requestHook error'); + }, + startSpanHook: () => { + throw new Error('startSpanHook error'); + }, + }); + + const fetchUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const response = await fetch(fetchUrl); + assert.ok( + response.headers.get('propagation-error') == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: response.status, + httpMethod: 'GET', + path: '/', + query: '?query=test', + resHeaders: response.headers, + }); + }); + + it('should create valid spans with empty configuration', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + const fetchUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const response = await fetch(fetchUrl); + assert.ok( + response.headers.get('propagation-error') == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: response.status, + httpMethod: 'GET', + path: '/', + query: '?query=test', + resHeaders: response.headers, + }); + }); + + it('should create valid spans with the given configuration', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Set configuration + instrumentation.setConfig({ + enabled: true, + ignoreRequestHook: req => { + return req.path.indexOf('/ignore/path') !== -1; + }, + requestHook: (span, req) => { + // We should mind the type of headers + if (typeof req.headers === 'string') { + req.headers += 'x-requested-with: undici\r\n'; + } else { + req.headers.push('x-requested-with', 'undici'); + } + }, + startSpanHook: request => { + return { + 'test.hook.attribute': 'hook-value', + }; + }, + headersToSpanAttributes: { + requestHeaders: ['foo-client', 'x-requested-with'], + responseHeaders: ['foo-server'], + }, + }); + + // Do some requests + const ignoreResponse = await fetch( + `${protocol}://${hostname}:${mockServer.port}/ignore/path` + ); + const reqInit = { + headers: new Headers({ + 'user-agent': 'custom', + 'foo-client': 'bar', + }), + }; + assert.ok( + ignoreResponse.headers.get('propagation-error'), + 'propagation is not set for ignored requests' + ); + + const queryResponse = await fetch( + `${protocol}://${hostname}:${mockServer.port}/?query=test`, + reqInit + ); + assert.ok( + queryResponse.headers.get('propagation-error') == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: queryResponse.status, + httpMethod: 'GET', + path: '/', + query: '?query=test', + reqHeaders: reqInit.headers, + resHeaders: queryResponse.headers, + }); + assert.strictEqual( + span.attributes['http.request.header.foo-client'], + 'bar', + 'request headers from fetch options are captured' + ); + assert.strictEqual( + span.attributes['http.request.header.x-requested-with'], + 'undici', + 'request headers from requestHook are captured' + ); + assert.strictEqual( + span.attributes['http.response.header.foo-server'], + 'bar', + 'response headers from the server are captured' + ); + assert.strictEqual( + span.attributes['test.hook.attribute'], + 'hook-value', + 'startSpanHook is called' + ); + }); + + it('should not create spans without parent if required in configuration', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + instrumentation.setConfig({ + enabled: true, + requireParentforSpans: true, + }); + + const fetchUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const response = await fetch(fetchUrl); + assert.ok( + response.headers.get('propagation-error') == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0, 'no spans are created'); + }); + + it('should not create spans with parent if required in configuration', function (done) { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + instrumentation.setConfig({ + enabled: true, + requireParentforSpans: true, + }); + + const tracer = provider.getTracer('default'); + const span = tracer.startSpan('parentSpan', { + kind: SpanKind.INTERNAL, + }); + + context.with(trace.setSpan(context.active(), span), async () => { + const fetchUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const response = await fetch(fetchUrl); + + span.end(); + assert.ok( + response.headers.get('propagation-error') == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2, 'child span is created'); + assert.strictEqual( + spans.filter(span => span.kind === SpanKind.CLIENT).length, + 1, + 'child span is created' + ); + assert.strictEqual( + spans.filter(span => span.kind === SpanKind.INTERNAL).length, + 1, + 'parent span is present' + ); + + done(); + }); + }); + + it('should capture errors using fetch API', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + let fetchError; + try { + const fetchUrl = 'http://unexistent-host-name/path'; + await fetch(fetchUrl); + } catch (err) { + // Expected error + fetchError = err as Error; + } + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'unexistent-host-name', + httpMethod: 'GET', + path: '/path', + error: fetchError, + noNetPeer: true, // do not check network attribs + forceStatus: { + code: SpanStatusCode.ERROR, + message: 'getaddrinfo ENOTFOUND unexistent-host-name', + }, + }); + }); + + it('should capture error if fetch request is aborted', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + let fetchError; + const controller = new AbortController(); + const fetchUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const fetchPromise = fetch(fetchUrl, { signal: controller.signal }); + controller.abort(); + try { + await fetchPromise; + } catch (err) { + // Expected error + fetchError = err as Error; + } + + // Let the error be published to diagnostics channel + await new Promise(r => setTimeout(r, 5)); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpMethod: 'GET', + path: '/', + query: '?query=test', + error: fetchError, + noNetPeer: true, // do not check network attribs + forceStatus: { + code: SpanStatusCode.ERROR, + message: 'The operation was aborted.', + }, + }); + }); + }); +}); diff --git a/plugins/node/instrumentation-undici/test/metrics.test.ts b/plugins/node/instrumentation-undici/test/metrics.test.ts new file mode 100644 index 0000000000..0fc633ef19 --- /dev/null +++ b/plugins/node/instrumentation-undici/test/metrics.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as assert from 'assert'; + +import { context, propagation } from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + AggregationTemporality, + DataPointType, + InMemoryMetricExporter, + MeterProvider, +} from '@opentelemetry/sdk-metrics'; + +import { UndiciInstrumentation } from '../src/undici'; + +import { MockServer } from './utils/mock-server'; +import { MockMetricsReader } from './utils/mock-metrics-reader'; +import { SemanticAttributes } from '../src/enums/SemanticAttributes'; + +const instrumentation = new UndiciInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +const protocol = 'http'; +const hostname = 'localhost'; +const mockServer = new MockServer(); +const provider = new NodeTracerProvider(); +const meterProvider = new MeterProvider(); +const metricsMemoryExporter = new InMemoryMetricExporter( + AggregationTemporality.DELTA +); +const metricReader = new MockMetricsReader(metricsMemoryExporter); +meterProvider.addMetricReader(metricReader); +instrumentation.setTracerProvider(provider); +instrumentation.setMeterProvider(meterProvider); + +describe('UndiciInstrumentation metrics tests', function () { + before(function (done) { + // Do not test if the `fetch` global API is not available + // This applies to nodejs < v18 or nodejs < v16.15 without the flag + // `--experimental-global-fetch` set + // https://nodejs.org/api/globals.html#fetch + if (typeof globalThis.fetch !== 'function') { + this.skip(); + } + + context.setGlobalContextManager(new AsyncHooksContextManager().enable()); + mockServer.start(done); + mockServer.mockListener((req, res) => { + // Return a valid response always + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.write(JSON.stringify({ success: true })); + res.end(); + }); + + // enable instrumentation for all tests + instrumentation.enable(); + }); + + after(function (done) { + instrumentation.disable(); + context.disable(); + propagation.disable(); + mockServer.mockListener(undefined); + mockServer.stop(done); + }); + + beforeEach(function () { + metricsMemoryExporter.reset(); + }); + + describe('with fetch API', function () { + before(function (done) { + // Do not test if the `fetch` global API is not available + // This applies to nodejs < v18 or nodejs < v16.15 without the flag + // `--experimental-global-fetch` set + // https://nodejs.org/api/globals.html#fetch + if (typeof globalThis.fetch !== 'function') { + this.skip(); + } + + done(); + }); + + it('should report "http.client.request.duration" metric', async () => { + const fetchUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + await fetch(fetchUrl); + + await metricReader.collectAndExport(); + const resourceMetrics = metricsMemoryExporter.getMetrics(); + const scopeMetrics = resourceMetrics[0].scopeMetrics; + const metrics = scopeMetrics[0].metrics; + + assert.strictEqual(scopeMetrics.length, 1, 'scopeMetrics count'); + assert.strictEqual(metrics.length, 1, 'metrics count'); + assert.strictEqual( + metrics[0].descriptor.name, + 'http.client.request.duration' + ); + assert.strictEqual( + metrics[0].descriptor.description, + 'Measures the duration of outbound HTTP requests.' + ); + assert.strictEqual(metrics[0].descriptor.unit, 's'); + assert.strictEqual(metrics[0].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual(metrics[0].dataPoints.length, 1); + + const metricAttributes = metrics[0].dataPoints[0].attributes; + assert.strictEqual( + metricAttributes[SemanticAttributes.URL_SCHEME], + 'http' + ); + assert.strictEqual( + metricAttributes[SemanticAttributes.HTTP_REQUEST_METHOD], + 'GET' + ); + assert.strictEqual( + metricAttributes[SemanticAttributes.SERVER_ADDRESS], + 'localhost' + ); + assert.strictEqual( + metricAttributes[SemanticAttributes.SERVER_PORT], + mockServer.port + ); + assert.strictEqual( + metricAttributes[SemanticAttributes.HTTP_RESPONSE_STATUS_CODE], + 200 + ); + }); + + it('should have error.type in "http.client.request.duration" metric', async () => { + const fetchUrl = 'http://unknownhost/'; + + try { + await fetch(fetchUrl); + } catch (err) { + // Expected error, do nothing + } + + await metricReader.collectAndExport(); + const resourceMetrics = metricsMemoryExporter.getMetrics(); + const scopeMetrics = resourceMetrics[0].scopeMetrics; + const metrics = scopeMetrics[0].metrics; + + assert.strictEqual(scopeMetrics.length, 1, 'scopeMetrics count'); + assert.strictEqual(metrics.length, 1, 'metrics count'); + assert.strictEqual( + metrics[0].descriptor.name, + 'http.client.request.duration' + ); + assert.strictEqual( + metrics[0].descriptor.description, + 'Measures the duration of outbound HTTP requests.' + ); + assert.strictEqual(metrics[0].descriptor.unit, 's'); + assert.strictEqual(metrics[0].dataPointType, DataPointType.HISTOGRAM); + assert.strictEqual(metrics[0].dataPoints.length, 1); + + const metricAttributes = metrics[0].dataPoints[0].attributes; + assert.strictEqual( + metricAttributes[SemanticAttributes.URL_SCHEME], + 'http' + ); + assert.strictEqual( + metricAttributes[SemanticAttributes.HTTP_REQUEST_METHOD], + 'GET' + ); + assert.strictEqual( + metricAttributes[SemanticAttributes.SERVER_ADDRESS], + 'unknownhost' + ); + assert.strictEqual(metricAttributes[SemanticAttributes.SERVER_PORT], 80); + assert.ok( + metricAttributes[SemanticAttributes.ERROR_TYPE], + `the metric contains "${SemanticAttributes.ERROR_TYPE}" attribute if request failed` + ); + }); + }); +}); diff --git a/plugins/node/instrumentation-undici/test/undici.test.ts b/plugins/node/instrumentation-undici/test/undici.test.ts new file mode 100644 index 0000000000..fd48803a3f --- /dev/null +++ b/plugins/node/instrumentation-undici/test/undici.test.ts @@ -0,0 +1,746 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as assert from 'assert'; +import { Writable } from 'stream'; + +import { + SpanKind, + SpanStatusCode, + context, + propagation, + trace, +} from '@opentelemetry/api'; +import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'; +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; + +import { UndiciInstrumentation } from '../src/undici'; + +import { MockPropagation } from './utils/mock-propagation'; +import { MockServer } from './utils/mock-server'; +import { assertSpan } from './utils/assertSpan'; + +import type { fetch, stream, request, Client, Dispatcher } from 'undici'; + +type PromisedValue = T extends Promise ? R : never; + +const instrumentation = new UndiciInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +// Reference to the `undici` module +let undici: { + fetch: typeof fetch; + request: typeof request; + stream: typeof stream; + Client: typeof Client; +}; + +const protocol = 'http'; +const hostname = 'localhost'; +const mockServer = new MockServer(); +const memoryExporter = new InMemorySpanExporter(); +const provider = new NodeTracerProvider(); +provider.addSpanProcessor(new SimpleSpanProcessor(memoryExporter)); +instrumentation.setTracerProvider(provider); + +// Undici docs (https://github.com/nodejs/undici#garbage-collection) suggest +// that an undici response body should always be consumed. +async function consumeResponseBody(body: Dispatcher.ResponseData['body']) { + return new Promise(resolve => { + const devNull = new Writable({ + write(_chunk, _encoding, cb) { + setImmediate(cb); + }, + }); + body.pipe(devNull); + body.on('end', resolve); + }); +} + +describe('UndiciInstrumentation `undici` tests', function () { + before(function (done) { + // Load `undici`. It may fail if nodejs version is <18 because the module uses + // features only available from that version. In that case skip the test. + try { + undici = require('undici'); + } catch (loadErr) { + this.skip(); + } + + propagation.setGlobalPropagator(new MockPropagation()); + context.setGlobalContextManager(new AsyncHooksContextManager().enable()); + mockServer.start(done); + mockServer.mockListener((req, res) => { + // There are some situations where there is no way to access headers + // for trace propagation asserts like: + // const resp = await fetch('http://host:port') + // so we need to do the assertion here + try { + assert.ok( + req.headers[MockPropagation.TRACE_CONTEXT_KEY], + `trace propagation for ${MockPropagation.TRACE_CONTEXT_KEY} works` + ); + assert.ok( + req.headers[MockPropagation.SPAN_CONTEXT_KEY], + `trace propagation for ${MockPropagation.SPAN_CONTEXT_KEY} works` + ); + } catch (assertErr) { + // The exception will hang the server and the test so we set a header + // back to the test to make an assertion + res.setHeader('propagation-error', (assertErr as Error).message); + } + + // Retur a valid response always + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.setHeader('foo-server', 'bar'); + res.write(JSON.stringify({ success: true })); + res.end(); + }); + }); + + after(function (done) { + context.disable(); + propagation.disable(); + mockServer.mockListener(undefined); + mockServer.stop(done); + }); + + beforeEach(function () { + memoryExporter.reset(); + }); + + describe('disable()', function () { + it('should not create spans when disabled', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Disable via config + instrumentation.setConfig({ enabled: false }); + + const requestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const { headers, body } = await undici.request(requestUrl); + await consumeResponseBody(body); + + assert.ok( + headers['propagation-error'] != null, + 'propagation is not set if instrumentation disabled' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0, 'no spans are created'); + }); + }); + + describe('enable()', function () { + beforeEach(function () { + instrumentation.enable(); + // Set configuration + instrumentation.setConfig({ + enabled: true, + ignoreRequestHook: req => { + return req.path.indexOf('/ignore/path') !== -1; + }, + requestHook: (span, req) => { + // We should mind the type of headers + if (typeof req.headers === 'string') { + req.headers += 'x-requested-with: undici\r\n'; + } else { + req.headers.push('x-requested-with', 'undici'); + } + }, + startSpanHook: request => { + return { + 'test.hook.attribute': 'hook-value', + }; + }, + headersToSpanAttributes: { + requestHeaders: ['foo-client', 'x-requested-with'], + responseHeaders: ['foo-server'], + }, + }); + }); + afterEach(function () { + // Empty configuration & disable + instrumentation.setConfig({ enabled: false }); + }); + + it('should ignore requests based on the result of ignoreRequestHook', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Do some requests + const headers = { + 'user-agent': 'custom', + 'foo-client': 'bar', + }; + + const ignoreRequestUrl = `${protocol}://${hostname}:${mockServer.port}/ignore/path`; + const ignoreResponse = await undici.request(ignoreRequestUrl, { + headers, + }); + await consumeResponseBody(ignoreResponse.body); + + assert.ok( + ignoreResponse.headers['propagation-error'], + 'propagation is not set for ignored requests' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.ok(spans.length === 0, 'ignoreRequestHook is filtering requests'); + }); + + it('should create valid spans for different request methods', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Do some requests + const headers = { + 'user-agent': 'custom', + 'foo-client': 'bar', + }; + + // In version v5 if `undici` you get the following error when requesting with a method + // that is not one of the known ones in uppercase. Using + // + // SocketError: other side closed + // at Socket.onSocketEnd (node_modules/undici/lib/client.js:1118:22) + // at endReadableNT (internal/streams/readable.js:1333:12) + // at processTicksAndRejections (internal/process/task_queues.js:82:21) + let firstQueryResponse: PromisedValue>; + let secondQueryResponse: PromisedValue>; + try { + const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + firstQueryResponse = await undici.request(queryRequestUrl, { + headers, + // @ts-expect-error - method type expects in uppercase + method: 'get', + }); + await consumeResponseBody(firstQueryResponse.body); + + secondQueryResponse = await undici.request(queryRequestUrl, { + headers, + // @ts-expect-error - method type expects known HTTP method (GET, POST, PUT, ...) + method: 'custom', + }); + await consumeResponseBody(secondQueryResponse.body); + } catch (undiciErr) { + const { stack } = undiciErr as Error; + + if (stack?.startsWith('SocketError: other side closed')) { + this.skip(); + } + } + + assert.ok( + firstQueryResponse!.headers['propagation-error'] === undefined, + 'propagation is set for instrumented requests' + ); + assert.ok( + secondQueryResponse!.headers['propagation-error'] === undefined, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2); + assertSpan(spans[0], { + hostname: 'localhost', + httpStatusCode: firstQueryResponse!.statusCode, + httpMethod: 'GET', + path: '/', + query: '?query=test', + reqHeaders: headers, + resHeaders: firstQueryResponse!.headers, + }); + assert.strictEqual( + spans[0].attributes['http.request.method_original'], + 'get', + 'request original method is captured' + ); + + assertSpan(spans[1], { + hostname: 'localhost', + httpStatusCode: secondQueryResponse!.statusCode, + spanName: 'HTTP', + httpMethod: '_OTHER', + path: '/', + query: '?query=test', + reqHeaders: headers, + resHeaders: secondQueryResponse!.headers, + }); + assert.strictEqual( + spans[1].attributes['http.request.method_original'], + 'custom', + 'request original method is captured' + ); + }); + + it('should create valid spans for "request" method', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Do some requests + const headers = { + 'user-agent': 'custom', + 'foo-client': 'bar', + }; + + const ignoreRequestUrl = `${protocol}://${hostname}:${mockServer.port}/ignore/path`; + const ignoreResponse = await undici.request(ignoreRequestUrl, { + headers, + }); + await consumeResponseBody(ignoreResponse.body); + + assert.ok( + ignoreResponse.headers['propagation-error'], + 'propagation is not set for ignored requests' + ); + + const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const queryResponse = await undici.request(queryRequestUrl, { headers }); + await consumeResponseBody(queryResponse.body); + + assert.ok( + queryResponse.headers['propagation-error'] == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: queryResponse.statusCode, + httpMethod: 'GET', + path: '/', + query: '?query=test', + reqHeaders: headers, + resHeaders: queryResponse.headers, + }); + assert.strictEqual( + span.attributes['http.request.header.foo-client'], + 'bar', + 'request headers from fetch options are captured' + ); + assert.strictEqual( + span.attributes['http.request.header.x-requested-with'], + 'undici', + 'request headers from requestHook are captured' + ); + assert.strictEqual( + span.attributes['http.response.header.foo-server'], + 'bar', + 'response headers from the server are captured' + ); + assert.strictEqual( + span.attributes['test.hook.attribute'], + 'hook-value', + 'startSpanHook is called' + ); + }); + + it('should create valid spans for "fetch" method', async function () { + // Fetch method is available from node v16.5 + // we want to skip this test for lowe versions + // https://github.com/nodejs/undici/blob/08839e450aa6dd1b0e2c019d6e5869cd5b966be1/index.js#L95 + if (typeof undici.fetch === 'undefined') { + this.skip(); + } + + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Do some requests + const headers = { + 'user-agent': 'custom', + 'foo-client': 'bar', + }; + const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const queryResponse = await undici.fetch(queryRequestUrl, { headers }); + await queryResponse.text(); + + assert.ok( + queryResponse.headers.get('propagation-error') == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: queryResponse.status, + httpMethod: 'GET', + path: '/', + query: '?query=test', + reqHeaders: headers, + resHeaders: queryResponse.headers as unknown as Headers, + }); + assert.strictEqual( + span.attributes['http.request.header.foo-client'], + 'bar', + 'request headers from fetch options are captured' + ); + assert.strictEqual( + span.attributes['http.request.header.x-requested-with'], + 'undici', + 'request headers from requestHook are captured' + ); + assert.strictEqual( + span.attributes['http.response.header.foo-server'], + 'bar', + 'response headers from the server are captured' + ); + assert.strictEqual( + span.attributes['test.hook.attribute'], + 'hook-value', + 'startSpanHook is called' + ); + }); + + it('should create valid spans for "stream" method', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Do some requests + const headers = { + 'user-agent': 'custom', + 'foo-client': 'bar', + }; + // https://undici.nodejs.org/#/docs/api/Dispatcher?id=example-1-basic-get-stream-request + const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const queryResponse: Record = {}; + const bufs: any[] = []; + await undici.stream( + queryRequestUrl, + { opaque: { bufs }, headers } as any, + ({ statusCode, headers, opaque }) => { + queryResponse.statusCode = statusCode; + queryResponse.headers = headers; + return new Writable({ + write(chunk, encoding, callback) { + (opaque as any).bufs.push(chunk); + callback(); + }, + }); + } + ); + + assert.ok( + queryResponse.headers['propagation-error'] == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: queryResponse.statusCode, + httpMethod: 'GET', + path: '/', + query: '?query=test', + reqHeaders: headers, + resHeaders: queryResponse.headers as unknown as Headers, + }); + assert.strictEqual( + span.attributes['http.request.header.foo-client'], + 'bar', + 'request headers from fetch options are captured' + ); + assert.strictEqual( + span.attributes['http.request.header.x-requested-with'], + 'undici', + 'request headers from requestHook are captured' + ); + assert.strictEqual( + span.attributes['http.response.header.foo-server'], + 'bar', + 'response headers from the server are captured' + ); + assert.strictEqual( + span.attributes['test.hook.attribute'], + 'hook-value', + 'startSpanHook is called' + ); + }); + + it('should create valid spans for "dispatch" method', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Do some requests + const headers = { + 'user-agent': 'custom', + 'foo-client': 'bar', + }; + + const queryRequestUrl = `${protocol}://${hostname}:${mockServer.port}`; + const queryResponse: Record = {}; + const client = new undici.Client(queryRequestUrl); + await new Promise((resolve, reject) => { + client.dispatch( + { + path: '/?query=test', + method: 'GET', + headers, + }, + { + onHeaders: (statusCode, headers) => { + queryResponse.statusCode = statusCode; + queryResponse.headers = headers; + return true; // unidici types require to return boolean + }, + onError: reject, + onComplete: resolve, + // Although the types say these following handlers are optional they must + // be defined to avoid a TypeError + onConnect: () => undefined, + onData: () => true, + } + ); + }); + + assert.ok( + queryResponse.headers['propagation-error'] == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: queryResponse.statusCode, + httpMethod: 'GET', + path: '/', + query: '?query=test', + reqHeaders: headers, + resHeaders: queryResponse.headers as unknown as Headers, + }); + assert.strictEqual( + span.attributes['http.request.header.foo-client'], + 'bar', + 'request headers from fetch options are captured' + ); + assert.strictEqual( + span.attributes['http.request.header.x-requested-with'], + 'undici', + 'request headers from requestHook are captured' + ); + assert.strictEqual( + span.attributes['http.response.header.foo-server'], + 'bar', + 'response headers from the server are captured' + ); + assert.strictEqual( + span.attributes['test.hook.attribute'], + 'hook-value', + 'startSpanHook is called' + ); + }); + + it('should create valid spans even if the configuration hooks fail', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + // Set the bad configuration + instrumentation.setConfig({ + enabled: true, + ignoreRequestHook: () => { + throw new Error('ignoreRequestHook error'); + }, + requestHook: () => { + throw new Error('requestHook error'); + }, + startSpanHook: () => { + throw new Error('startSpanHook error'); + }, + }); + + const requestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const { headers, statusCode, body } = await undici.request(requestUrl); + await consumeResponseBody(body); + + assert.ok( + headers['propagation-error'] == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpStatusCode: statusCode, + httpMethod: 'GET', + path: '/', + query: '?query=test', + resHeaders: headers, + }); + }); + + it('should not create spans without parent if required in configuration', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + instrumentation.setConfig({ + enabled: true, + requireParentforSpans: true, + }); + + const requestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const response = await undici.request(requestUrl); + await consumeResponseBody(response.body); + + assert.ok( + response.headers['propagation-error'] == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0, 'no spans are created'); + }); + + it('should create spans with parent if required in configuration', function (done) { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + instrumentation.setConfig({ + enabled: true, + requireParentforSpans: true, + }); + + const tracer = provider.getTracer('default'); + const span = tracer.startSpan('parentSpan', { + kind: SpanKind.INTERNAL, + }); + + context.with(trace.setSpan(context.active(), span), async () => { + const requestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const response = await undici.request(requestUrl); + await consumeResponseBody(response.body); + + span.end(); + assert.ok( + response.headers['propagation-error'] == null, + 'propagation is set for instrumented requests' + ); + + spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 2, 'child span is created'); + assert.strictEqual( + spans.filter(span => span.kind === SpanKind.CLIENT).length, + 1, + 'child span is created' + ); + assert.strictEqual( + spans.filter(span => span.kind === SpanKind.INTERNAL).length, + 1, + 'parent span is present' + ); + + done(); + }); + }); + + it('should capture errors while doing request', async function () { + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + let fetchError; + try { + const requestUrl = 'http://unexistent-host-name/path'; + await undici.request(requestUrl); + } catch (err) { + // Expected error + fetchError = err as Error; + } + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'unexistent-host-name', + httpMethod: 'GET', + path: '/path', + error: fetchError, + noNetPeer: true, // do not check network attribs + forceStatus: { + code: SpanStatusCode.ERROR, + message: 'getaddrinfo ENOTFOUND unexistent-host-name', + }, + }); + }); + + it('should capture error if undici request is aborted', async function () { + // AbortController was added in: v15.0.0, v14.17.0 + // but we still run tests for node v14 + // https://nodejs.org/api/globals.html#class-abortcontroller + if (typeof AbortController === 'undefined') { + this.skip(); + } + + let spans = memoryExporter.getFinishedSpans(); + assert.strictEqual(spans.length, 0); + + let requestError; + const controller = new AbortController(); + const requestUrl = `${protocol}://${hostname}:${mockServer.port}/?query=test`; + const requestPromise = undici.request(requestUrl, { + signal: controller.signal, + }); + controller.abort(); + try { + await requestPromise; + } catch (err) { + // Expected error + requestError = err as Error; + } + + // Let the error be published to diagnostics channel + await new Promise(r => setTimeout(r, 5)); + + spans = memoryExporter.getFinishedSpans(); + const span = spans[0]; + assert.ok(span, 'a span is present'); + assert.strictEqual(spans.length, 1); + assertSpan(span, { + hostname: 'localhost', + httpMethod: 'GET', + path: '/', + query: '?query=test', + error: requestError, + noNetPeer: true, // do not check network attribs + forceStatus: { + code: SpanStatusCode.ERROR, + message: requestError?.message, + }, + }); + }); + }); +}); diff --git a/plugins/node/instrumentation-undici/test/utils/assertSpan.ts b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts new file mode 100644 index 0000000000..3ab0a66d7d --- /dev/null +++ b/plugins/node/instrumentation-undici/test/utils/assertSpan.ts @@ -0,0 +1,191 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + SpanKind, + SpanStatus, + Exception, + SpanStatusCode, +} from '@opentelemetry/api'; +import { hrTimeToNanoseconds } from '@opentelemetry/core'; +import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import * as assert from 'assert'; +import { SemanticAttributes } from '../../src/enums/SemanticAttributes'; + +type IncomingHttpHeaders = Record; + +export const assertSpan = ( + span: ReadableSpan, + validations: { + httpStatusCode?: number; + httpMethod: string; + spanName?: string; + resHeaders?: Headers | IncomingHttpHeaders; + hostname: string; + reqHeaders?: Headers | IncomingHttpHeaders; + path?: string | null; + query?: string | null; + forceStatus?: SpanStatus; + noNetPeer?: boolean; // we don't expect net peer info when request throw before being sent + error?: Exception; + } +) => { + assert.strictEqual(span.spanContext().traceId.length, 32); + assert.strictEqual(span.spanContext().spanId.length, 16); + assert.strictEqual(span.kind, SpanKind.CLIENT, 'span.kind is correct'); + assert.strictEqual( + span.name, + validations.spanName || validations.httpMethod, + 'span.name is correct' + ); + assert.strictEqual( + span.attributes[SemanticAttributes.HTTP_REQUEST_METHOD], + validations.httpMethod, + `attributes['${SemanticAttributes.HTTP_REQUEST_METHOD}'] is correct` + ); + + if (validations.path) { + assert.strictEqual( + span.attributes[SemanticAttributes.URL_PATH], + validations.path, + `attributes['${SemanticAttributes.URL_PATH}'] is correct` + ); + } + + if (validations.query) { + assert.strictEqual( + span.attributes[SemanticAttributes.URL_QUERY], + validations.query, + `attributes['${SemanticAttributes.URL_QUERY}'] is correct` + ); + } + + assert.strictEqual( + span.attributes[SemanticAttributes.HTTP_RESPONSE_STATUS_CODE], + validations.httpStatusCode, + `attributes['${SemanticAttributes.HTTP_RESPONSE_STATUS_CODE}'] is correct ${ + span.attributes[SemanticAttributes.HTTP_RESPONSE_STATUS_CODE] + }` + ); + + assert.strictEqual(span.links.length, 0, 'there are no links'); + + if (validations.error) { + assert.strictEqual(span.events.length, 1, 'span contains one error event'); + assert.strictEqual( + span.events[0].name, + 'exception', + 'error event name is correct' + ); + + const eventAttributes = span.events[0].attributes; + assert.ok(eventAttributes != null, 'event has attributes'); + assert.deepStrictEqual( + Object.keys(eventAttributes), + ['exception.type', 'exception.message', 'exception.stacktrace'], + 'the event attribute names are correct' + ); + } else { + assert.strictEqual(span.events.length, 0, 'span contains no events'); + } + + // Error message changes between version se we will + // only assert its presence + if (validations.forceStatus) { + assert.equal( + span.status.code, + validations.forceStatus.code, + 'span `status.code` is correct' + ); + assert.ok(span.status.message, 'span `status.message` is present'); + } else { + const { httpStatusCode } = validations; + const isStatusUnset = + httpStatusCode && httpStatusCode >= 100 && httpStatusCode < 400; + assert.equal( + span.status.code, + isStatusUnset ? SpanStatusCode.UNSET : SpanStatusCode.ERROR, + 'span `status.code` is correct' + ); + } + + assert.ok(span.endTime, 'must be finished'); + assert.ok( + hrTimeToNanoseconds(span.duration) > 0, + 'must have positive duration' + ); + + if (validations.resHeaders) { + // Headers were added in v17.5.0, v16.15.0 + // https://nodejs.org/api/globals.html#class-headers + const { resHeaders } = validations; + const contentLengthHeader = getHeader(resHeaders, 'content-length'); + + if (contentLengthHeader) { + const contentLength = Number(contentLengthHeader); + + assert.strictEqual( + span.attributes['http.response.header.content-length'], + contentLength + ); + } + } + + assert.strictEqual( + span.attributes[SemanticAttributes.SERVER_ADDRESS], + validations.hostname, + 'must be consistent (SERVER_ADDRESS and hostname)' + ); + if (!validations.noNetPeer) { + assert.ok( + span.attributes[SemanticAttributes.NETWORK_PEER_ADDRESS], + `must have ${SemanticAttributes.NETWORK_PEER_ADDRESS}` + ); + assert.ok( + span.attributes[SemanticAttributes.NETWORK_PEER_PORT], + `must have ${SemanticAttributes.NETWORK_PEER_PORT}` + ); + } + assert.ok( + (span.attributes[SemanticAttributes.URL_FULL] as string).indexOf( + span.attributes[SemanticAttributes.SERVER_ADDRESS] as string + ) > -1, + `${SemanticAttributes.URL_FULL} & ${SemanticAttributes.SERVER_ADDRESS} must be consistent` + ); + + if (validations.reqHeaders) { + const userAgent = getHeader(validations.reqHeaders, 'user-agent'); + + if (userAgent) { + assert.strictEqual( + span.attributes[SemanticAttributes.USER_AGENT_ORIGINAL], + userAgent + ); + } + } +}; + +/** + * Gets a header by name regardless of the type + */ +function getHeader( + headers: Headers | IncomingHttpHeaders, + name: string +): string | string[] | null | undefined { + if (typeof headers.get === 'function') { + return headers.get(name); + } + return (headers as IncomingHttpHeaders)[name]; +} diff --git a/plugins/node/instrumentation-undici/test/utils/mock-metrics-reader.ts b/plugins/node/instrumentation-undici/test/utils/mock-metrics-reader.ts new file mode 100644 index 0000000000..c3ffbf0ae6 --- /dev/null +++ b/plugins/node/instrumentation-undici/test/utils/mock-metrics-reader.ts @@ -0,0 +1,47 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MetricReader, PushMetricExporter } from '@opentelemetry/sdk-metrics'; + +export class MockMetricsReader extends MetricReader { + constructor(private _exporter: PushMetricExporter) { + super({ + aggregationTemporalitySelector: + _exporter.selectAggregationTemporality?.bind(_exporter), + }); + } + + protected onForceFlush(): Promise { + return Promise.resolve(undefined); + } + + protected onShutdown(): Promise { + return Promise.resolve(undefined); + } + + public async collectAndExport(): Promise { + const result = await this.collect(); + await new Promise((resolve, reject) => { + this._exporter.export(result.resourceMetrics, result => { + if (result.error != null) { + reject(result.error); + } else { + resolve(); + } + }); + }); + } +} diff --git a/plugins/node/instrumentation-undici/test/utils/mock-propagation.ts b/plugins/node/instrumentation-undici/test/utils/mock-propagation.ts new file mode 100644 index 0000000000..5c49e661d6 --- /dev/null +++ b/plugins/node/instrumentation-undici/test/utils/mock-propagation.ts @@ -0,0 +1,52 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Context, + TextMapPropagator, + trace, + TraceFlags, +} from '@opentelemetry/api'; + +export class MockPropagation implements TextMapPropagator { + static TRACE_CONTEXT_KEY = 'x-mock-trace-id'; + static SPAN_CONTEXT_KEY = 'x-mock-span-id'; + extract(context: Context, carrier: Record) { + const extractedSpanContext = { + traceId: carrier[MockPropagation.TRACE_CONTEXT_KEY] as string, + spanId: carrier[MockPropagation.SPAN_CONTEXT_KEY] as string, + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }; + if (extractedSpanContext.traceId && extractedSpanContext.spanId) { + return trace.setSpanContext(context, extractedSpanContext); + } + return context; + } + inject(context: Context, carrier: Record): void { + const spanContext = trace.getSpanContext(context); + + if (spanContext) { + carrier[MockPropagation.TRACE_CONTEXT_KEY] = spanContext.traceId; + carrier[MockPropagation.SPAN_CONTEXT_KEY] = spanContext.spanId; + } + } + fields(): string[] { + return [ + MockPropagation.TRACE_CONTEXT_KEY, + MockPropagation.SPAN_CONTEXT_KEY, + ]; + } +} diff --git a/plugins/node/instrumentation-undici/test/utils/mock-server.ts b/plugins/node/instrumentation-undici/test/utils/mock-server.ts new file mode 100644 index 0000000000..8a160c8117 --- /dev/null +++ b/plugins/node/instrumentation-undici/test/utils/mock-server.ts @@ -0,0 +1,74 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as http from 'http'; + +export class MockServer { + private _port: number | undefined; + private _httpServer: http.Server | undefined; + private _reqListener: http.RequestListener | undefined; + + get port(): number { + return this._port || 0; + } + + mockListener(handler: http.RequestListener | undefined): void { + this._reqListener = handler; + } + + start(cb: (err?: Error) => void) { + this._httpServer = http.createServer((req, res) => { + // Use the mock listener if defined + if (typeof this._reqListener === 'function') { + return this._reqListener(req, res); + } + + // If no mock function is provided fallback to a basic response + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.write(JSON.stringify({ success: true })); + res.end(); + }); + + this._httpServer.listen(0, () => { + const addr = this._httpServer!.address(); + if (addr == null) { + cb(new Error('unexpected addr null')); + return; + } + + if (typeof addr === 'string') { + cb(new Error(`unexpected addr ${addr}`)); + return; + } + + if (addr.port <= 0) { + cb(new Error('Could not get port')); + return; + } + this._port = addr.port; + cb(); + }); + } + + stop(cb: (err?: Error) => void) { + if (this._httpServer) { + this._httpServer.close(); + this._httpServer = undefined; + this._reqListener = undefined; + } + cb(); + } +} diff --git a/plugins/node/instrumentation-undici/tsconfig.json b/plugins/node/instrumentation-undici/tsconfig.json new file mode 100644 index 0000000000..28be80d266 --- /dev/null +++ b/plugins/node/instrumentation-undici/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/release-please-config.json b/release-please-config.json index 6db17aa16a..c0cace7d26 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -31,6 +31,7 @@ "plugins/node/instrumentation-runtime-node": {}, "plugins/node/instrumentation-socket.io": {}, "plugins/node/instrumentation-tedious": {}, + "plugins/node/instrumentation-undici": {}, "plugins/node/opentelemetry-instrumentation-aws-lambda": {}, "plugins/node/opentelemetry-instrumentation-aws-sdk": {}, "plugins/node/opentelemetry-instrumentation-bunyan": {},