diff --git a/examples/undici/README.md b/examples/undici/README.md new file mode 100644 index 0000000000..7b256f4c1b --- /dev/null +++ b/examples/undici/README.md @@ -0,0 +1,81 @@ +# Overview + +OpenTelemetry Undici Instrumentation allows the user to automatically collect trace data and export them to the backend of choice (we can use Zipkin or Jaeger for this example), to give observability to distributed systems. + +This is a simple example that demonstrates tracing HTTP request from client to server. The example +shows key aspects of tracing such as + +- Root Span (on Client) +- Child Span (on Client) +- Child Span from a Remote Parent (on Server) +- SpanContext Propagation (from Client to Server) +- Span Events +- Span Attributes + +## Installation + +```sh +# from this directory +npm install +``` + +Setup [Zipkin Tracing](https://zipkin.io/pages/quickstart.html) +or +Setup [Jaeger Tracing](https://www.jaegertracing.io/docs/latest/getting-started/#all-in-one) + +## Run the Application + +### Zipkin + +- Run the server + + ```sh + # from this directory + npm run zipkin:server + ``` + +- Run the client + + ```sh + # from this directory + npm run zipkin:client + ``` + +#### Zipkin UI + +`zipkin:server` script should output the `traceid` in the terminal (e.g `traceid: 4815c3d576d930189725f1f1d1bdfcc6`). +Go to Zipkin with your browser (e.g ) + +

+ +### Jaeger + +- Run the server + + ```sh + # from this directory + npm run jaeger:server + ``` + +- Run the client + + ```sh + # from this directory + npm run jaeger:client + ``` + +#### Jaeger UI + +`jaeger:server` script should output the `traceid` in the terminal (e.g `traceid: 4815c3d576d930189725f1f1d1bdfcc6`). +Go to Jaeger with your browser (e.g ) + +

+ +## Useful links + +- For more information on OpenTelemetry, visit: +- For more information on OpenTelemetry for Node.js, visit: + +## LICENSE + +Apache License 2.0 diff --git a/examples/undici/client.js b/examples/undici/client.js new file mode 100644 index 0000000000..ba07903158 --- /dev/null +++ b/examples/undici/client.js @@ -0,0 +1,24 @@ +'use strict'; + +const undici = require('undici'); +const tracer = require('./tracer')('example-undici-client'); + +/** A function which makes requests and handles response. */ +async function makeRequests(type) { + tracer.startActiveSpan('makeRequests with global fetch', async (span) => { + const fetchResponse = await fetch('localhost:8080/helloworld'); + console.log('response with global fetch: ' + await fetchResponse.text()); + + const undiciResponse = await undici.fetch('localhost:8080/helloworld'); + console.log('response with undici fetch: ' + await undiciResponse.text()); + span.end(); + }); + + // The process must live for at least the interval past any traces that + // must be exported, or some risk being lost if they are recorded after the + // last export. + console.log('Sleeping 5 seconds before shutdown to ensure all records are flushed.'); + setTimeout(() => { console.log('Completed.'); }, 5000); +} + +makeRequests(); diff --git a/examples/undici/docker-compose.yml b/examples/undici/docker-compose.yml new file mode 100644 index 0000000000..87f10a0dec --- /dev/null +++ b/examples/undici/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.7' + +services: + jaeger: + image: jaegertracing/all-in-one + ports: + - "16686:16686" + - "4318:4318" + environment: + - LOG_LEVEL=debug + networks: + - undici-example + zipkin: + image: openzipkin/zipkin + container_name: zipkin + ports: + # Port used for the Zipkin UI and HTTP Api + - 9411:9411 + +networks: + undici-example: \ No newline at end of file diff --git a/examples/undici/package.json b/examples/undici/package.json new file mode 100644 index 0000000000..21d1c8f798 --- /dev/null +++ b/examples/undici/package.json @@ -0,0 +1,48 @@ +{ + "name": "undici-example", + "private": true, + "version": "0.46.0", + "description": "Example of Undici integration with OpenTelemetry", + "main": "index.js", + "scripts": { + "docker:start": "docker compose -f .docker-compose.yml up -d", + "docker:stop": "docker compose -f ./docker-compose.yml down", + "zipkin:server": "cross-env EXPORTER=zipkin node ./server.js", + "zipkin:client": "cross-env EXPORTER=zipkin node ./client.js", + "jaeger:server": "cross-env EXPORTER=jaeger node ./server.js", + "jaeger:client": "cross-env EXPORTER=jaeger node ./client.js" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/open-telemetry/opentelemetry-js.git" + }, + "keywords": [ + "opentelemetry", + "undici", + "fetch", + "tracing" + ], + "engines": { + "node": ">=14" + }, + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/open-telemetry/opentelemetry-js/issues" + }, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/exporter-zipkin": "1.19.0", + "@opentelemetry/instrumentation": "0.46.0", + "@opentelemetry/instrumentation-undici": "0.46.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.46.0", + "@opentelemetry/resources": "1.19.0", + "@opentelemetry/sdk-trace-base": "1.19.0", + "@opentelemetry/sdk-trace-node": "1.19.0", + "@opentelemetry/semantic-conventions": "1.19.0" + }, + "homepage": "https://github.com/open-telemetry/opentelemetry-js/tree/main/examples/undici", + "devDependencies": { + "cross-env": "^6.0.0" + } +} diff --git a/examples/undici/server.js b/examples/undici/server.js new file mode 100644 index 0000000000..62b117e711 --- /dev/null +++ b/examples/undici/server.js @@ -0,0 +1,45 @@ +'use strict'; + +const api = require('@opentelemetry/api'); +const tracer = require('./tracer')('example-undici-server'); +const http = require('http'); + +/** Starts a HTTP server that receives requests on sample server port. */ +function startServer(port) { + // Creates a server + const server = http.createServer(handleRequest); + // Starts the server + server.listen(port, (err) => { + if (err) { + throw err; + } + console.log(`Node HTTP listening on ${port}`); + }); +} + +/** A function which handles requests and send response. */ +function handleRequest(request, response) { + const currentSpan = api.trace.getActiveSpan(); + // display traceid in the terminal + const traceId = currentSpan.spanContext().traceId; + console.log(`traceId: ${traceId}`); + const span = tracer.startSpan('handleRequest', { + kind: 1, // server + attributes: { key: 'value' }, + }); + // Annotate our span to capture metadata about the operation + span.addEvent('invoking handleRequest'); + + const body = []; + request.on('error', (err) => console.log(err)); + request.on('data', (chunk) => body.push(chunk)); + request.on('end', () => { + // deliberately sleeping to mock some action. + setTimeout(() => { + span.end(); + response.end('Hello World!'); + }, 2000); + }); +} + +startServer(8080); diff --git a/examples/undici/tracer.js b/examples/undici/tracer.js new file mode 100644 index 0000000000..7def3bed83 --- /dev/null +++ b/examples/undici/tracer.js @@ -0,0 +1,42 @@ +'use strict'; + +const opentelemetry = require('@opentelemetry/api'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); +const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); +const { Resource } = require('@opentelemetry/resources'); +const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions'); +const { SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto'); +const { ZipkinExporter } = require('@opentelemetry/exporter-zipkin'); +const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici'); + +const EXPORTER = process.env.EXPORTER || ''; + +module.exports = (serviceName) => { + const provider = new NodeTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: serviceName, + }), + }); + + let exporter; + if (EXPORTER.toLowerCase().startsWith('z')) { + exporter = new ZipkinExporter(); + } else { + exporter = new OTLPTraceExporter(); + } + + provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); + + // Initialize the OpenTelemetry APIs to use the NodeTracerProvider bindings + provider.register(); + + registerInstrumentations({ + // // when boostraping with lerna for testing purposes + instrumentations: [ + new UndiciInstrumentation(), + ], + }); + + return opentelemetry.trace.getTracer('undici-example'); +}; diff --git a/experimental/packages/opentelemetry-instrumentation-undici/.eslintignore b/experimental/packages/opentelemetry-instrumentation-undici/.eslintignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/.eslintignore @@ -0,0 +1 @@ +build diff --git a/experimental/packages/opentelemetry-instrumentation-undici/.eslintrc.js b/experimental/packages/opentelemetry-instrumentation-undici/.eslintrc.js new file mode 100644 index 0000000000..9baf1b4956 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "env": { + "mocha": true, + "node": true + }, + ...require('../../../eslint.base.js') +} diff --git a/experimental/packages/opentelemetry-instrumentation-undici/.npmignore b/experimental/packages/opentelemetry-instrumentation-undici/.npmignore new file mode 100644 index 0000000000..9505ba9450 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/.npmignore @@ -0,0 +1,4 @@ +/bin +/coverage +/doc +/test diff --git a/experimental/packages/opentelemetry-instrumentation-undici/LICENSE b/experimental/packages/opentelemetry-instrumentation-undici/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/experimental/packages/opentelemetry-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/experimental/packages/opentelemetry-instrumentation-undici/README.md b/experimental/packages/opentelemetry-instrumentation-undici/README.md new file mode 100644 index 0000000000..f507fc1157 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/README.md @@ -0,0 +1,69 @@ +# 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 [`fetch`](https://nodejs.org/docs/latest/api/globals.html#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()], +}); + +``` + + +See [examples/http](https://github.com/open-telemetry/opentelemetry-js/tree/main/examples/fetch) for a short example. + +### Undici/Fetch instrumentation Options + + + +Undici instrumentation has few options available to choose from. You can set the following: + +| Options | Type | Description | +| ------- | ---- | ----------- | +| [`onRequest`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-undici/src/types.ts#19) | `UndiciRequestHook` | Function for adding custom attributes before request is handled | + +## 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/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-http +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-http.svg diff --git a/experimental/packages/opentelemetry-instrumentation-undici/package.json b/experimental/packages/opentelemetry-instrumentation-undici/package.json new file mode 100644 index 0000000000..b26848169c --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/package.json @@ -0,0 +1,76 @@ +{ + "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", + "scripts": { + "prepublishOnly": "npm run compile", + "compile": "tsc --build", + "clean": "tsc --build --clean", + "test": "nyc ts-mocha -p tsconfig.json test/**/*.test.ts", + "tdd": "npm run test -- --watch-extensions ts --watch", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "codecov": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../../", + "version": "node ../../../scripts/version-update.js", + "watch": "tsc --build --watch", + "precompile": "cross-var lerna run version --scope $npm_package_name --include-dependencies", + "prewatch": "node ../../../scripts/version-update.js", + "peer-api-check": "node ../../../scripts/peer-api-check.js" + }, + "keywords": [ + "opentelemetry", + "fetch", + "undici", + "nodejs", + "tracing", + "profiling", + "instrumentation" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts", + "doc", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@opentelemetry/api": "1.7.0", + "@opentelemetry/sdk-metrics": "1.19.0", + "@opentelemetry/sdk-trace-base": "1.19.0", + "@opentelemetry/sdk-trace-node": "1.19.0", + "@types/node": "18.6.5", + "codecov": "3.8.3", + "cross-var": "1.1.0", + "lerna": "6.6.2", + "mocha": "10.2.0", + "nock": "13.3.8", + "nyc": "15.1.0", + "sinon": "15.1.2", + "superagent": "8.0.9", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "undici": "6.2.1" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "dependencies": { + "@opentelemetry/core": "1.19.0", + "@opentelemetry/instrumentation": "0.46.0", + "@opentelemetry/semantic-conventions": "1.19.0" + }, + "homepage": "https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-undici", + "sideEffects": false +} diff --git a/experimental/packages/opentelemetry-instrumentation-undici/src/enums/SemanticAttributes.ts b/experimental/packages/opentelemetry-instrumentation-undici/src/enums/SemanticAttributes.ts new file mode 100644 index 0000000000..0e65b51aeb --- /dev/null +++ b/experimental/packages/opentelemetry-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/experimental/packages/opentelemetry-instrumentation-undici/src/index.ts b/experimental/packages/opentelemetry-instrumentation-undici/src/index.ts new file mode 100644 index 0000000000..fda578e789 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/src/index.ts @@ -0,0 +1,17 @@ +/* + * 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 * from './undici'; diff --git a/experimental/packages/opentelemetry-instrumentation-undici/src/internal-types.ts b/experimental/packages/opentelemetry-instrumentation-undici/src/internal-types.ts new file mode 100644 index 0000000000..e3a12c3d14 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/src/internal-types.ts @@ -0,0 +1,43 @@ +/* + * 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, UnidiciResponse } 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: UnidiciResponse; +} + +export interface RequestErrorMessage { + request: UndiciRequest; + error: Error; +} diff --git a/experimental/packages/opentelemetry-instrumentation-undici/src/types.ts b/experimental/packages/opentelemetry-instrumentation-undici/src/types.ts new file mode 100644 index 0000000000..26b8080129 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/src/types.ts @@ -0,0 +1,73 @@ +/* + * 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'; + +// TODO: notes about support +// - `fetch` API is added in node v16.15.0 +// - `undici` supports node >=18 + +// TODO: `Request` class was added in node v16.15.0, make it work with v14 +// also we do not get that object from the diagnostics channel message but the +// core request from https://github.com/nodejs/undici/blob/main/lib/core/request.js +// which is not typed + +export interface UndiciRequest { + origin: string; + method: string; + path: string; + /** + * Serialized string of headers in the form `name: value\r\n` + */ + headers: string; + throwOnError: boolean; + completed: boolean; + aborted: boolean; + idempotent: boolean; + contentLength: number | null; + contentType: string | null; + body: any; +} + +export interface UnidiciResponse { + headers: Buffer[]; + statusCode: number; +} + +// 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?: (request: RequestType) => boolean; + /** Function for adding custom attributes after response is handled */ + applyCustomAttributesOnSpan?: ( + span: Span, + request: RequestType, + response: Response + ) => void; + /** Function for adding custom attributes before request is handled */ + requestHook?: (span: Span, request: RequestType) => void; + /** Function for adding custom attributes before a span is started in outgoingRequest */ + startSpanHook?: (request: RequestType) => Attributes; + /** 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/experimental/packages/opentelemetry-instrumentation-undici/src/undici.ts b/experimental/packages/opentelemetry-instrumentation-undici/src/undici.ts new file mode 100644 index 0000000000..bdca38b6bd --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/src/undici.ts @@ -0,0 +1,449 @@ +/* + * 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, + diag, + Histogram, + HrTime, + INVALID_SPAN_CONTEXT, + propagation, + Span, + SpanKind, + SpanStatusCode, + trace, + ValueType, +} from '@opentelemetry/api'; + +import { VERSION } from './version'; + +import { + ListenerRecord, + RequestHeadersMessage, + RequestMessage, + ResponseHeadersMessage, +} from './internal-types'; +import { UndiciInstrumentationConfig, UndiciRequest } from './types'; +import { SemanticAttributes } from './enums/SemanticAttributes'; +import { + hrTime, + hrTimeDuration, + hrTimeToMilliseconds, +} from '@opentelemetry/core'; + +interface IntrumentationRecord { + 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); + // Force load fetch API (since it's lazy loaded in Node 18) + // `fetch` Added in: v17.5.0, v16.15.0 (with flag) and we suport lower verisons + // https://nodejs.org/api/globals.html#fetch + try { + fetch('').catch(() => {}); + } catch (err) { + // TODO: nicer message + diag.info('fetch API not available'); + } + + 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: 'ms', + valueType: ValueType.DOUBLE, + } + ); + } + + 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 rawHeaders = request.headers.split('\r\n'); + const reqHeaders = new Map( + rawHeaders.map(h => { + const sepIndex = h.indexOf(':'); + const name = h.substring(0, sepIndex).toLowerCase(); + const val = h.substring(sepIndex + 1).trim(); + return [name, val]; + }) + ); + + const requestUrl = new URL(request.origin + request.path); + const urlScheme = requestUrl.protocol.replace(':', ''); + const attributes: Attributes = { + [SemanticAttributes.HTTP_REQUEST_METHOD]: 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); + } + + const userAgent = reqHeaders.get('user-agent'); + 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( + `HTTP ${request.method}`, + { + 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); + + request.headers += Object.entries(addedHeaders) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join(''); + this._recordFromReq.set(request, { span, attributes, startTime }); + } + + // This is the 2nd message we recevie for each request. It is fired when connection with + // the remote is stablished and about to send the first byte. Here do have info about the + // remote addres an port so we can poupulate 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 + const rawHeaders = request.headers.split('\r\n'); + const reqHeaders = new Map( + rawHeaders.map(h => { + const sepIndex = h.indexOf(':'); + const name = h.substring(0, sepIndex).toLowerCase(); + const val = h.substring(sepIndex + 1).trim(); + return [name, val]; + }) + ); + + if (config.headersToSpanAttributes?.requestHeaders) { + config.headersToSpanAttributes.requestHeaders + .map(name => name.toLowerCase()) + .filter(name => reqHeaders.has(name)) + .forEach(name => { + spanAttributes[`http.request.header.${name}`] = reqHeaders.get(name); + }); + } + + 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, startTime } = record; + // We are currently *not* capturing response headers, even though the + // intake API does allow it, because none of the other `setHttpContext` + // uses currently do + const spanAttributes: Attributes = { + [SemanticAttributes.HTTP_RESPONSE_STATUS_CODE]: response.statusCode, + }; + + // Get headers with names lowercased but values intact + const resHeaders = new Map(); + for (let idx = 0; idx < response.headers.length; idx = idx + 2) { + resHeaders.set( + response.headers[idx].toString().toLowerCase(), + response.headers[idx + 1].toString() + ); + } + + // Put response headers as attributes based on config + const config = this._getConfig(); + if (config.headersToSpanAttributes?.responseHeaders) { + config.headersToSpanAttributes.responseHeaders + .map(name => name.toLowerCase()) + .filter(name => resHeaders.has(name)) + .forEach(name => { + spanAttributes[`http.response.header.${name}`] = resHeaders.get(name); + }); + } + + // `content-length` header is a special case + if (resHeaders.has('content-length')) { + const contentLength = Number(resHeaders.get('content-length')); + if (!isNaN(contentLength)) { + spanAttributes['http.response.header.content-length'] = contentLength; + } + } + + span.setAttributes(spanAttributes); + span.setStatus({ + code: + response.statusCode >= 400 + ? SpanStatusCode.ERROR + : SpanStatusCode.UNSET, + }); + this._recordFromReq.set(request, { + span, + startTime, + attributes: Object.assign(attributes, spanAttributes), + }); + } + + // This is the last event we receive if the request went without any errors + private onDone({ request }: RequestMessage): 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 duration = hrTimeToMilliseconds(hrTimeDuration(startTime, hrTime())); + this._httpClientDurationHistogram.record(duration, metricsAttributes); + } +} diff --git a/experimental/packages/opentelemetry-instrumentation-undici/test/fetch.test.ts b/experimental/packages/opentelemetry-instrumentation-undici/test/fetch.test.ts new file mode 100644 index 0000000000..b78e00ea0e --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/test/fetch.test.ts @@ -0,0 +1,417 @@ +/* + * 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.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'); + }, + applyCustomAttributesOnSpan: () => { + 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) => { + // TODO: maybe an intermediate request with better API + req.headers += 'x-requested-with: undici\r\n'; + }, + 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); + // TODO: here we're checking the propagation works even if the instrumentation + // is not starting any span. Not 100% sure this is the behaviour we want + 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(); + // TODO: here we're checking the propagation works even if the instrumentation + // is not starting any span. Not 100% sure this is the behaviour we want + 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; + } + + 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; + } + + // 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/experimental/packages/opentelemetry-instrumentation-undici/test/metrics.test.ts b/experimental/packages/opentelemetry-instrumentation-undici/test/metrics.test.ts new file mode 100644 index 0000000000..97a4989ff7 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/test/metrics.test.ts @@ -0,0 +1,196 @@ +/* + * 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 memoryExporter = new InMemorySpanExporter(); +const metricsMemoryExporter = new InMemoryMetricExporter( + AggregationTemporality.DELTA +); +const metricReader = new MockMetricsReader(metricsMemoryExporter); +meterProvider.addMetricReader(metricReader); +// provider.addSpanProcessor(new SimpleSpanProcessor(memoryExporter)); +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 wihtout 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 wihtout 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, 'ms'); + 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, 'ms'); + 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/experimental/packages/opentelemetry-instrumentation-undici/test/undici.test.ts b/experimental/packages/opentelemetry-instrumentation-undici/test/undici.test.ts new file mode 100644 index 0000000000..14404ab740 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/test/undici.test.ts @@ -0,0 +1,633 @@ +/* + * 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'; + +const instrumentation = new UndiciInstrumentation(); +instrumentation.enable(); +instrumentation.disable(); + +import type { Dispatcher } from 'undici'; +import * as undici from 'undici'; + +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) { + 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.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) => { + // TODO: maybe an intermediate request with better API + req.headers += 'x-requested-with: undici\r\n'; + }, + 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 ingore 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 "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 () { + 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'); + }, + applyCustomAttributesOnSpan: () => { + 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); + + // TODO: here we're checking the propagation works even if the instrumentation + // is not starting any span. Not 100% sure this is the behaviour we want + 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(); + // TODO: here we're checking the propagation works even if the instrumentation + // is not starting any span. Not 100% sure this is the behaviour we want + 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; + } + + 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 () { + 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; + } + + // 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/experimental/packages/opentelemetry-instrumentation-undici/test/utils/assertSpan.ts b/experimental/packages/opentelemetry-instrumentation-undici/test/utils/assertSpan.ts new file mode 100644 index 0000000000..2d7413931e --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/test/utils/assertSpan.ts @@ -0,0 +1,177 @@ +/* + * 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 { DummyPropagation } from './DummyPropagation'; +import { SemanticAttributes } from '../../src/enums/SemanticAttributes'; +import type { IncomingHttpHeaders } from 'undici/types/header'; + +export const assertSpan = ( + span: ReadableSpan, + validations: { + httpStatusCode?: number; + httpMethod: 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, + `HTTP ${validations.httpMethod}`, + 'span.name is correct' + ); + // TODO: check this + // assert.strictEqual( + // span.attributes[AttributeNames.HTTP_ERROR_MESSAGE], + // span.status.message, + // `attributes['${AttributeNames.HTTP_ERROR_MESSAGE}'] 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'); + } + + const { httpStatusCode } = validations; + const isStatusUnset = + httpStatusCode && httpStatusCode >= 100 && httpStatusCode < 400; + + assert.deepStrictEqual( + span.status, + validations.forceStatus || { + code: isStatusUnset ? SpanStatusCode.UNSET : SpanStatusCode.ERROR, + }, + 'span status is correct' + ); + + assert.ok(span.endTime, 'must be finished'); + assert.ok( + hrTimeToNanoseconds(span.duration) > 0, + 'must have positive duration' + ); + + if (validations.resHeaders) { + const contentLengthHeader = + validations.resHeaders instanceof Headers + ? validations.resHeaders.get('content-length') + : validations.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 = + validations.reqHeaders instanceof Headers + ? validations.reqHeaders.get('user-agent') + : validations.reqHeaders['user-agent']; + if (userAgent) { + assert.strictEqual( + span.attributes[SemanticAttributes.USER_AGENT_ORIGINAL], + userAgent + ); + } + } +}; diff --git a/experimental/packages/opentelemetry-instrumentation-undici/test/utils/mock-metrics-reader.ts b/experimental/packages/opentelemetry-instrumentation-undici/test/utils/mock-metrics-reader.ts new file mode 100644 index 0000000000..c3ffbf0ae6 --- /dev/null +++ b/experimental/packages/opentelemetry-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/experimental/packages/opentelemetry-instrumentation-undici/test/utils/mock-propagation.ts b/experimental/packages/opentelemetry-instrumentation-undici/test/utils/mock-propagation.ts new file mode 100644 index 0000000000..5c49e661d6 --- /dev/null +++ b/experimental/packages/opentelemetry-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/experimental/packages/opentelemetry-instrumentation-undici/test/utils/mock-server.ts b/experimental/packages/opentelemetry-instrumentation-undici/test/utils/mock-server.ts new file mode 100644 index 0000000000..5fa464df03 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/test/utils/mock-server.ts @@ -0,0 +1,73 @@ +/* + * 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._reqListener = undefined; + this._httpServer.close(); + cb(); + } + } +} diff --git a/experimental/packages/opentelemetry-instrumentation-undici/tsconfig.json b/experimental/packages/opentelemetry-instrumentation-undici/tsconfig.json new file mode 100644 index 0000000000..60bb10f902 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-undici/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "build", + "rootDir": "." + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "references": [ + { + "path": "../../../api" + }, + { + "path": "../../../packages/opentelemetry-core" + }, + { + "path": "../../../packages/opentelemetry-sdk-trace-base" + }, + { + "path": "../../../packages/opentelemetry-sdk-trace-node" + }, + { + "path": "../../../packages/opentelemetry-semantic-conventions" + }, + { + "path": "../../../packages/sdk-metrics" + }, + { + "path": "../opentelemetry-instrumentation" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 3e074c7978..528031c77a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3555,6 +3555,40 @@ "proxy-from-env": "^1.1.0" } }, + "experimental/packages/opentelemetry-instrumentation-undici": { + "name": "@opentelemetry/instrumentation-undici", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.19.0", + "@opentelemetry/instrumentation": "0.46.0", + "@opentelemetry/semantic-conventions": "1.19.0" + }, + "devDependencies": { + "@opentelemetry/api": "1.7.0", + "@opentelemetry/sdk-metrics": "1.19.0", + "@opentelemetry/sdk-trace-base": "1.19.0", + "@opentelemetry/sdk-trace-node": "1.19.0", + "@types/node": "18.6.5", + "codecov": "3.8.3", + "cross-var": "1.1.0", + "lerna": "6.6.2", + "mocha": "10.2.0", + "nock": "13.3.8", + "nyc": "15.1.0", + "sinon": "15.1.2", + "superagent": "8.0.9", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "undici": "6.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "experimental/packages/opentelemetry-instrumentation-xml-http-request": { "name": "@opentelemetry/instrumentation-xml-http-request", "version": "0.46.0", @@ -7118,6 +7152,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", + "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "dev": true, @@ -9653,6 +9696,10 @@ "resolved": "experimental/packages/opentelemetry-instrumentation-http", "link": true }, + "node_modules/@opentelemetry/instrumentation-undici": { + "resolved": "experimental/packages/opentelemetry-instrumentation-undici", + "link": true + }, "node_modules/@opentelemetry/instrumentation-xml-http-request": { "resolved": "experimental/packages/opentelemetry-instrumentation-xml-http-request", "link": true @@ -31127,6 +31174,18 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.2.1.tgz", + "integrity": "sha512-7Wa9thEM6/LMnnKtxJHlc8SrTlDmxqJecgz1iy8KlsN0/iskQXOQCuPkrZLXbElPaSw5slFFyKIKXyJ3UtbApw==", + "dev": true, + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=18.0" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "dev": true, @@ -37268,6 +37327,12 @@ "version": "8.44.0", "dev": true }, + "@fastify/busboy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", + "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "dev": true + }, "@gar/promisify": { "version": "1.1.3", "dev": true @@ -41173,6 +41238,30 @@ } } }, + "@opentelemetry/instrumentation-undici": { + "version": "file:experimental/packages/opentelemetry-instrumentation-undici", + "requires": { + "@opentelemetry/api": "1.7.0", + "@opentelemetry/core": "1.19.0", + "@opentelemetry/instrumentation": "0.46.0", + "@opentelemetry/sdk-metrics": "1.19.0", + "@opentelemetry/sdk-trace-base": "1.19.0", + "@opentelemetry/sdk-trace-node": "1.19.0", + "@opentelemetry/semantic-conventions": "1.19.0", + "@types/node": "18.6.5", + "codecov": "3.8.3", + "cross-var": "1.1.0", + "lerna": "6.6.2", + "mocha": "10.2.0", + "nock": "13.3.8", + "nyc": "15.1.0", + "sinon": "15.1.2", + "superagent": "8.0.9", + "ts-mocha": "10.0.0", + "typescript": "4.4.4", + "undici": "6.2.1" + } + }, "@opentelemetry/instrumentation-xml-http-request": { "version": "file:experimental/packages/opentelemetry-instrumentation-xml-http-request", "requires": { @@ -57874,6 +57963,15 @@ "version": "1.13.6", "dev": true }, + "undici": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.2.1.tgz", + "integrity": "sha512-7Wa9thEM6/LMnnKtxJHlc8SrTlDmxqJecgz1iy8KlsN0/iskQXOQCuPkrZLXbElPaSw5slFFyKIKXyJ3UtbApw==", + "dev": true, + "requires": { + "@fastify/busboy": "^2.0.0" + } + }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "dev": true diff --git a/scripts/semconv/generate.sh b/scripts/semconv/generate.sh index dbd99464ea..5c78cba91e 100755 --- a/scripts/semconv/generate.sh +++ b/scripts/semconv/generate.sh @@ -3,43 +3,103 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" ROOT_DIR="${SCRIPT_DIR}/../../" +# Path whre to place the new semanticon conventions +# empty if you want to run the old genarator code +SRC_PATH=$1 + +echo "Path is ${SRC_PATH}" + +# With no path we're generating the leaacy semconv in its package +if [ "$SRC_PATH" == "" ]; then + # freeze the spec version to make SpanAttributess generation reproducible + SPEC_VERSION=v1.7.0 + GENERATOR_VERSION=0.7.0 + + echo "Generating semantic conventions for spec version ${SPEC_VERSION}"; + + cd ${SCRIPT_DIR} + + rm -rf opentelemetry-specification || true + mkdir opentelemetry-specification + cd opentelemetry-specification + + git init + git remote add origin https://github.com/open-telemetry/opentelemetry-specification.git + git fetch origin "$SPEC_VERSION" --depth=1 + git reset --hard FETCH_HEAD + cd ${SCRIPT_DIR} + + docker run --rm \ + -v ${SCRIPT_DIR}/opentelemetry-specification/semantic_conventions/trace:/source \ + -v ${SCRIPT_DIR}/templates:/templates \ + -v ${ROOT_DIR}/packages/opentelemetry-semantic-conventions/src/trace/:/output \ + otel/semconvgen:${GENERATOR_VERSION} \ + -f /source \ + code \ + --template /templates/SemanticAttributes.ts.j2 \ + --output /output/SemanticAttributes.ts \ + -Dclass=SemanticAttributes + + docker run --rm \ + -v ${SCRIPT_DIR}/opentelemetry-specification/semantic_conventions/resource:/source \ + -v ${SCRIPT_DIR}/templates:/templates \ + -v ${ROOT_DIR}/packages/opentelemetry-semantic-conventions/src/resource/:/output \ + otel/semconvgen:${GENERATOR_VERSION} \ + -f /source \ + code \ + --template /templates/SemanticAttributes.ts.j2 \ + --output /output/SemanticResourceAttributes.ts \ + -Dclass=SemanticResourceAttributes +fi + +# TODO: add comments here. REfs +# - https://github.com/open-telemetry/build-tools/pull/157/files +# - https://github.com/open-telemetry/semantic-conventions-java/blob/2be178a7fd62d1073fa9b4f0f0520772a6496e0b/build.gradle.kts#L107 +if [ "$SRC_PATH" != "" ]; then # freeze the spec version to make SpanAttributess generation reproducible -SPEC_VERSION=v1.7.0 -GENERATOR_VERSION=0.7.0 - -cd ${SCRIPT_DIR} - -rm -rf opentelemetry-specification || true -mkdir opentelemetry-specification -cd opentelemetry-specification - -git init -git remote add origin https://github.com/open-telemetry/opentelemetry-specification.git -git fetch origin "$SPEC_VERSION" --depth=1 -git reset --hard FETCH_HEAD -cd ${SCRIPT_DIR} - -docker run --rm \ - -v ${SCRIPT_DIR}/opentelemetry-specification/semantic_conventions/trace:/source \ - -v ${SCRIPT_DIR}/templates:/templates \ - -v ${ROOT_DIR}/packages/opentelemetry-semantic-conventions/src/trace/:/output \ - otel/semconvgen:${GENERATOR_VERSION} \ - -f /source \ - code \ - --template /templates/SemanticAttributes.ts.j2 \ - --output /output/SemanticAttributes.ts \ - -Dclass=SemanticAttributes - -docker run --rm \ - -v ${SCRIPT_DIR}/opentelemetry-specification/semantic_conventions/resource:/source \ - -v ${SCRIPT_DIR}/templates:/templates \ - -v ${ROOT_DIR}/packages/opentelemetry-semantic-conventions/src/resource/:/output \ - otel/semconvgen:${GENERATOR_VERSION} \ - -f /source \ - code \ - --template /templates/SemanticAttributes.ts.j2 \ - --output /output/SemanticResourceAttributes.ts \ - -Dclass=SemanticResourceAttributes + SPEC_VERSION=v1.24.0 + GENERATOR_VERSION=0.23.0 + REPO=semantic-conventions + + echo "Generating semantic conventions for spec version ${SPEC_VERSION}"; + + cd ${SCRIPT_DIR} + + rm -rf ${REPO} || true + mkdir ${REPO} + cd ${REPO} + + git init + git remote add origin https://github.com/open-telemetry/${REPO}.git + git fetch origin "$SPEC_VERSION" --depth=1 + git reset --hard FETCH_HEAD + cd ${SCRIPT_DIR} + + docker run --rm \ + -v ${SCRIPT_DIR}/${REPO}/model:/source \ + -v ${SCRIPT_DIR}/templates:/templates \ + -v ${ROOT_DIR}/${SRC_PATH}/:/output \ + otel/semconvgen:${GENERATOR_VERSION} \ + --only span,event,attribute_group,scope,metric\ + --yaml-root /source \ + code \ + --template /templates/SemanticAttributes.ts.j2 \ + --output /output/SemanticAttributes.ts \ + -Dclass=SemanticAttributes + + docker run --rm \ + -v ${SCRIPT_DIR}/${REPO}/model:/source \ + -v ${SCRIPT_DIR}/templates:/templates \ + -v ${ROOT_DIR}/${SRC_PATH}/:/output \ + otel/semconvgen:${GENERATOR_VERSION} \ + --only resource\ + --yaml-root /source \ + code \ + --template /templates/SemanticAttributes.ts.j2 \ + --output /output/SemanticResourceAttributes.ts \ + -Dclass=SemanticResourceAttributes +fi + # Run the automatic linting fixing task to ensure it will pass eslint cd "$ROOT_DIR" diff --git a/tsconfig.json b/tsconfig.json index acf7329087..0784fb5ee8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "experimental/packages/opentelemetry-instrumentation-fetch", "experimental/packages/opentelemetry-instrumentation-grpc", "experimental/packages/opentelemetry-instrumentation-http", + "experimental/packages/opentelemetry-instrumentation-undici", "experimental/packages/opentelemetry-instrumentation-xml-http-request", "experimental/packages/opentelemetry-sdk-node", "experimental/packages/otlp-exporter-base", @@ -111,6 +112,9 @@ { "path": "experimental/packages/opentelemetry-instrumentation-http" }, + { + "path": "experimental/packages/opentelemetry-instrumentation-undici" + }, { "path": "experimental/packages/opentelemetry-instrumentation-xml-http-request" },