From a512fe4ed843c83211e12741c8335f7c17ed37b6 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 19 Dec 2024 18:31:12 +0000 Subject: [PATCH] feat: add OpenTelemetry metrics implementation Adds a `Metrics` interface implementation that is powered by an OpenTelemetry backend. See the readme for a usage example. --- .release-please-manifest.json | 2 +- .release-please.json | 1 + .../metrics-opentelemetry/CODE_OF_CONDUCT.md | 3 + packages/metrics-opentelemetry/LICENSE-APACHE | 201 +++++++ packages/metrics-opentelemetry/LICENSE-MIT | 19 + packages/metrics-opentelemetry/README.md | 85 +++ packages/metrics-opentelemetry/package.json | 70 +++ .../src/counter-group.ts | 38 ++ packages/metrics-opentelemetry/src/counter.ts | 18 + .../src/histogram-group.ts | 34 ++ .../metrics-opentelemetry/src/histogram.ts | 26 + packages/metrics-opentelemetry/src/index.ts | 557 ++++++++++++++++++ .../metrics-opentelemetry/src/metric-group.ts | 69 +++ packages/metrics-opentelemetry/src/metric.ts | 44 ++ .../src/summary-group.ts | 43 ++ packages/metrics-opentelemetry/src/summary.ts | 26 + .../src/system-metrics.browser.ts | 3 + .../src/system-metrics.ts | 504 ++++++++++++++++ .../metrics-opentelemetry/test/index.spec.ts | 56 ++ packages/metrics-opentelemetry/tsconfig.json | 18 + packages/metrics-opentelemetry/typedoc.json | 5 + 21 files changed, 1821 insertions(+), 1 deletion(-) create mode 100644 packages/metrics-opentelemetry/CODE_OF_CONDUCT.md create mode 100644 packages/metrics-opentelemetry/LICENSE-APACHE create mode 100644 packages/metrics-opentelemetry/LICENSE-MIT create mode 100644 packages/metrics-opentelemetry/README.md create mode 100644 packages/metrics-opentelemetry/package.json create mode 100644 packages/metrics-opentelemetry/src/counter-group.ts create mode 100644 packages/metrics-opentelemetry/src/counter.ts create mode 100644 packages/metrics-opentelemetry/src/histogram-group.ts create mode 100644 packages/metrics-opentelemetry/src/histogram.ts create mode 100644 packages/metrics-opentelemetry/src/index.ts create mode 100644 packages/metrics-opentelemetry/src/metric-group.ts create mode 100644 packages/metrics-opentelemetry/src/metric.ts create mode 100644 packages/metrics-opentelemetry/src/summary-group.ts create mode 100644 packages/metrics-opentelemetry/src/summary.ts create mode 100644 packages/metrics-opentelemetry/src/system-metrics.browser.ts create mode 100644 packages/metrics-opentelemetry/src/system-metrics.ts create mode 100644 packages/metrics-opentelemetry/test/index.spec.ts create mode 100644 packages/metrics-opentelemetry/tsconfig.json create mode 100644 packages/metrics-opentelemetry/typedoc.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fee723a0b4..ead184fc1c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{"packages/auto-tls":"1.0.2","packages/config":"1.0.0","packages/connection-encrypter-plaintext":"2.0.12","packages/connection-encrypter-tls":"2.0.12","packages/crypto":"5.0.8","packages/interface":"2.3.0","packages/interface-compliance-tests":"6.2.2","packages/interface-internal":"2.2.1","packages/kad-dht":"14.1.6","packages/keychain":"5.0.11","packages/libp2p":"2.4.2","packages/logger":"5.1.5","packages/metrics-devtools":"1.1.12","packages/metrics-prometheus":"4.2.10","packages/metrics-simple":"1.2.8","packages/multistream-select":"6.0.10","packages/peer-collections":"6.0.13","packages/peer-discovery-bootstrap":"11.0.16","packages/peer-discovery-mdns":"11.0.16","packages/peer-id":"5.0.9","packages/peer-record":"8.0.13","packages/peer-store":"11.0.13","packages/pnet":"2.0.16","packages/protocol-autonat":"2.0.15","packages/protocol-dcutr":"2.0.14","packages/protocol-echo":"2.1.5","packages/protocol-fetch":"2.0.14","packages/protocol-identify":"3.0.14","packages/protocol-perf":"4.0.16","packages/protocol-ping":"2.0.14","packages/pubsub":"10.0.14","packages/pubsub-floodsub":"10.1.14","packages/record":"4.0.4","packages/stream-multiplexer-mplex":"11.0.16","packages/transport-circuit-relay-v2":"3.1.6","packages/transport-memory":"1.0.2","packages/transport-tcp":"10.0.14","packages/transport-webrtc":"5.0.22","packages/transport-websockets":"9.1.0","packages/transport-webtransport":"5.0.21","packages/upnp-nat":"3.0.3","packages/utils":"6.3.0"} +{"packages/auto-tls":"1.0.2","packages/config":"1.0.0","packages/connection-encrypter-plaintext":"2.0.12","packages/connection-encrypter-tls":"2.0.12","packages/crypto":"5.0.8","packages/interface":"2.3.0","packages/interface-compliance-tests":"6.2.2","packages/interface-internal":"2.2.1","packages/kad-dht":"14.1.6","packages/keychain":"5.0.11","packages/libp2p":"2.4.2","packages/logger":"5.1.5","packages/metrics-devtools":"1.1.12","packages/metrics-opentelemetry": "0.0.0","packages/metrics-prometheus":"4.2.10","packages/metrics-simple":"1.2.8","packages/multistream-select":"6.0.10","packages/peer-collections":"6.0.13","packages/peer-discovery-bootstrap":"11.0.16","packages/peer-discovery-mdns":"11.0.16","packages/peer-id":"5.0.9","packages/peer-record":"8.0.13","packages/peer-store":"11.0.13","packages/pnet":"2.0.16","packages/protocol-autonat":"2.0.15","packages/protocol-dcutr":"2.0.14","packages/protocol-echo":"2.1.5","packages/protocol-fetch":"2.0.14","packages/protocol-identify":"3.0.14","packages/protocol-perf":"4.0.16","packages/protocol-ping":"2.0.14","packages/pubsub":"10.0.14","packages/pubsub-floodsub":"10.1.14","packages/record":"4.0.4","packages/stream-multiplexer-mplex":"11.0.16","packages/transport-circuit-relay-v2":"3.1.6","packages/transport-memory":"1.0.2","packages/transport-tcp":"10.0.14","packages/transport-webrtc":"5.0.22","packages/transport-websockets":"9.1.0","packages/transport-webtransport":"5.0.21","packages/upnp-nat":"3.0.3","packages/utils":"6.3.0"} diff --git a/.release-please.json b/.release-please.json index 4ccf28091e..4ed6957a83 100644 --- a/.release-please.json +++ b/.release-please.json @@ -22,6 +22,7 @@ "packages/libp2p": {}, "packages/logger": {}, "packages/metrics-devtools": {}, + "packages/metrics-opentelemetry": {}, "packages/metrics-prometheus": {}, "packages/metrics-simple": {}, "packages/multistream-select": {}, diff --git a/packages/metrics-opentelemetry/CODE_OF_CONDUCT.md b/packages/metrics-opentelemetry/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..6b0fa54c54 --- /dev/null +++ b/packages/metrics-opentelemetry/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Contributor Code of Conduct + +This project follows the [`IPFS Community Code of Conduct`](https://github.com/ipfs/community/blob/master/code-of-conduct.md) diff --git a/packages/metrics-opentelemetry/LICENSE-APACHE b/packages/metrics-opentelemetry/LICENSE-APACHE new file mode 100644 index 0000000000..b09cd7856d --- /dev/null +++ b/packages/metrics-opentelemetry/LICENSE-APACHE @@ -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/packages/metrics-opentelemetry/LICENSE-MIT b/packages/metrics-opentelemetry/LICENSE-MIT new file mode 100644 index 0000000000..72dc60d84b --- /dev/null +++ b/packages/metrics-opentelemetry/LICENSE-MIT @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/metrics-opentelemetry/README.md b/packages/metrics-opentelemetry/README.md new file mode 100644 index 0000000000..50e323c09d --- /dev/null +++ b/packages/metrics-opentelemetry/README.md @@ -0,0 +1,85 @@ +# @libp2p/opentelemetry-metrics + +[![libp2p.io](https://img.shields.io/badge/project-libp2p-yellow.svg?style=flat-square)](http://libp2p.io/) +[![Discuss](https://img.shields.io/discourse/https/discuss.libp2p.io/posts.svg?style=flat-square)](https://discuss.libp2p.io) +[![codecov](https://img.shields.io/codecov/c/github/libp2p/js-libp2p.svg?style=flat-square)](https://codecov.io/gh/libp2p/js-libp2p) +[![CI](https://img.shields.io/github/actions/workflow/status/libp2p/js-libp2p/main.yml?branch=main\&style=flat-square)](https://github.com/libp2p/js-libp2p/actions/workflows/main.yml?query=branch%3Amain) + +> Opentelemetry metrics gathering for libp2p + +# About + + + +Uses [OpenTelemetry](https://opentelemetry.io/) to store metrics and method +traces in libp2p. + +## Example - Node.js + +Use with [OpenTelemetry Desktop Viewer](https://github.com/CtrlSpice/otel-desktop-viewer): + +```ts +import { createLibp2p } from 'libp2p' +import { openTelemetryMetrics } from '@libp2p/opentelemetry-metrics' +import { PrometheusExporter } from '@opentelemetry/exporter-prometheus' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' +import { NodeSDK } from '@opentelemetry/sdk-node' + +const sdk = new NodeSDK({ + traceExporter: new OTLPTraceExporter({ + url: 'http://127.0.0.1:4318/v1/traces' + }), + metricReader: new PrometheusExporter({ + port: 9464 + }), + serviceName: 'my-app' +}) +sdk.start() + +const node = await createLibp2p({ + // ... other options + metrics: openTelemetryMetrics() +}) +``` + +# Install + +```console +$ npm i @libp2p/simple-metrics +``` + +## Browser ` +``` + +# API Docs + +- + +# License + +Licensed under either of + +- Apache 2.0, ([LICENSE-APACHE](https://github.com/libp2p/js-libp2p/blob/main/packages/metrics-simple/LICENSE-APACHE) / ) +- MIT ([LICENSE-MIT](https://github.com/libp2p/js-libp2p/blob/main/packages/metrics-simple/LICENSE-MIT) / ) + +# Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/packages/metrics-opentelemetry/package.json b/packages/metrics-opentelemetry/package.json new file mode 100644 index 0000000000..07abfb6650 --- /dev/null +++ b/packages/metrics-opentelemetry/package.json @@ -0,0 +1,70 @@ +{ + "name": "@libp2p/opentelemetry-metrics", + "version": "0.0.0", + "description": "Opentelemetry metrics gathering for libp2p", + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/libp2p/js-libp2p/tree/main/packages/metrics-opentelemetry#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/libp2p/js-libp2p.git" + }, + "bugs": { + "url": "https://github.com/libp2p/js-libp2p/issues" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "types": "./dist/src/index.d.ts", + "files": [ + "src", + "dist", + "!dist/test", + "!**/*.tsbuildinfo" + ], + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "eslintConfig": { + "extends": "ipfs", + "parserOptions": { + "project": true, + "sourceType": "module" + } + }, + "scripts": { + "clean": "aegir clean", + "lint": "aegir lint", + "dep-check": "aegir dep-check", + "doc-check": "aegir doc-check", + "build": "aegir build", + "test": "aegir test", + "test:node": "aegir test -t node --cov", + "test:electron-main": "aegir test -t electron-main" + }, + "dependencies": { + "@libp2p/interface": "^2.3.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-prometheus": "^0.56.0", + "@opentelemetry/sdk-metrics": "^1.29.0", + "it-foreach": "^2.1.1", + "it-stream-types": "^2.0.2" + }, + "devDependencies": { + "@opentelemetry/auto-instrumentations-node": "^0.54.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.56.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.56.0", + "@opentelemetry/sdk-node": "^0.56.0", + "@opentelemetry/sdk-trace-node": "^1.29.0", + "@types/tdigest": "^0.1.4", + "aegir": "^45.0.5" + }, + "browser": { + "./dist/src/system-metrics.js": "./dist/src/system-metrics.browser.js" + }, + "sideEffects": false +} diff --git a/packages/metrics-opentelemetry/src/counter-group.ts b/packages/metrics-opentelemetry/src/counter-group.ts new file mode 100644 index 0000000000..356c49df37 --- /dev/null +++ b/packages/metrics-opentelemetry/src/counter-group.ts @@ -0,0 +1,38 @@ +import type { CounterGroup, StopTimer } from '@libp2p/interface' +import type { UpDownCounter as OTelCounter } from '@opentelemetry/api' + +export class OpenTelemetryCounterGroup implements CounterGroup { + private readonly label: string + private readonly counter: OTelCounter + + constructor (label: string, counter: OTelCounter) { + this.label = label + this.counter = counter + } + + update (values: Record): void { + Object.entries(values).forEach(([key, value]) => { + this.counter.add(value, { + [this.label]: key + }) + }) + } + + increment (values: Record): void { + Object.entries(values).forEach(([key, value]) => { + this.counter.add(value === true ? 1 : value, { + [this.label]: key + }) + }) + } + + reset (): void { + // no-op + } + + timer (key: string): StopTimer { + return () => { + // no-op + } + } +} diff --git a/packages/metrics-opentelemetry/src/counter.ts b/packages/metrics-opentelemetry/src/counter.ts new file mode 100644 index 0000000000..ab15babc93 --- /dev/null +++ b/packages/metrics-opentelemetry/src/counter.ts @@ -0,0 +1,18 @@ +import type { Counter } from '@libp2p/interface' +import type { Counter as OTelCounter } from '@opentelemetry/api' + +export class OpenTelemetryCounter implements Counter { + private readonly counter: OTelCounter + + constructor (counter: OTelCounter) { + this.counter = counter + } + + increment (value?: number): void { + this.counter.add(value ?? 1) + } + + reset (): void { + // no-op + } +} diff --git a/packages/metrics-opentelemetry/src/histogram-group.ts b/packages/metrics-opentelemetry/src/histogram-group.ts new file mode 100644 index 0000000000..1586294902 --- /dev/null +++ b/packages/metrics-opentelemetry/src/histogram-group.ts @@ -0,0 +1,34 @@ +import type { HistogramGroup, StopTimer } from '@libp2p/interface' +import type { Histogram as OTelHistogram } from '@opentelemetry/api' + +export class OpenTelemetryHistogramGroup implements HistogramGroup { + private readonly label: string + private readonly histogram: OTelHistogram + + constructor (label: string, histogram: OTelHistogram) { + this.label = label + this.histogram = histogram + } + + observe (values: Record): void { + Object.entries(values).forEach(([key, value]) => { + this.histogram.record(value, { + [this.label]: key + }) + }) + } + + reset (): void { + this.histogram.record(0) + } + + timer (key: string): StopTimer { + const start = Date.now() + + return () => { + this.histogram.record(Date.now() - start, { + [this.label]: key + }) + } + } +} diff --git a/packages/metrics-opentelemetry/src/histogram.ts b/packages/metrics-opentelemetry/src/histogram.ts new file mode 100644 index 0000000000..9720d6f2cd --- /dev/null +++ b/packages/metrics-opentelemetry/src/histogram.ts @@ -0,0 +1,26 @@ +import type { Histogram, StopTimer } from '@libp2p/interface' +import type { Histogram as OTelHistogram } from '@opentelemetry/api' + +export class OpenTelemetryHistogram implements Histogram { + private readonly histogram: OTelHistogram + + constructor (histogram: OTelHistogram) { + this.histogram = histogram + } + + observe (value: number): void { + this.histogram.record(value) + } + + reset (): void { + this.histogram.record(0) + } + + timer (): StopTimer { + const start = Date.now() + + return () => { + this.observe(Date.now() - start) + } + } +} diff --git a/packages/metrics-opentelemetry/src/index.ts b/packages/metrics-opentelemetry/src/index.ts new file mode 100644 index 0000000000..01731b811a --- /dev/null +++ b/packages/metrics-opentelemetry/src/index.ts @@ -0,0 +1,557 @@ +/** + * @packageDocumentation + * + * Uses [OpenTelemetry](https://opentelemetry.io/) to store metrics and method + * traces in libp2p. + * + * @example Node.js + * + * Use with [OpenTelemetry Desktop Viewer](https://github.com/CtrlSpice/otel-desktop-viewer): + * + * ```ts + * import { createLibp2p } from 'libp2p' + * import { openTelemetryMetrics } from '@libp2p/opentelemetry-metrics' + * import { PrometheusExporter } from '@opentelemetry/exporter-prometheus' + * import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' + * import { NodeSDK } from '@opentelemetry/sdk-node' + * + * const sdk = new NodeSDK({ + * traceExporter: new OTLPTraceExporter({ + * url: 'http://127.0.0.1:4318/v1/traces' + * }), + * metricReader: new PrometheusExporter({ + * port: 9464 + * }), + * serviceName: 'my-app' + * }) + * sdk.start() + * + * const node = await createLibp2p({ + * // ... other options + * metrics: openTelemetryMetrics() + * }) + * ``` + */ + +import { InvalidParametersError, serviceCapabilities } from '@libp2p/interface' +import { trace, metrics, context, SpanStatusCode } from '@opentelemetry/api' +import each from 'it-foreach' +import { OpenTelemetryCounterGroup } from './counter-group.js' +import { OpenTelemetryCounter } from './counter.js' +import { OpenTelemetryHistogramGroup } from './histogram-group.js' +import { OpenTelemetryHistogram } from './histogram.js' +import { OpenTelemetryMetricGroup } from './metric-group.js' +import { OpenTelemetryMetric } from './metric.js' +import { OpenTelemetrySummaryGroup } from './summary-group.js' +import { OpenTelemetrySummary } from './summary.js' +import { collectSystemMetrics } from './system-metrics.js' +import type { MultiaddrConnection, Stream, Connection, Metric, MetricGroup, Metrics, CalculatedMetricOptions, MetricOptions, Counter, CounterGroup, Histogram, HistogramOptions, HistogramGroup, Summary, SummaryOptions, SummaryGroup, CalculatedHistogramOptions, CalculatedSummaryOptions, NodeInfo, TraceFunctionOptions, TraceGeneratorFunctionOptions, TraceAttributes } from '@libp2p/interface' +import type { Span, Attributes } from '@opentelemetry/api' +import type { Duplex } from 'it-stream-types' + +// see https://betterstack.com/community/guides/observability/opentelemetry-metrics-nodejs/#prerequisites + +export interface OpenTelemetryComponents { + nodeInfo: NodeInfo +} + +export interface OpenTelemetryMetricsInit { + /** + * The app name used to create the tracer + * + * @default 'js-libp2p' + */ + appName?: string + + /** + * The app version used to create the tracer. + * + * The version number of the running version of libp2p is used as the default. + */ + appVersion?: string + + /** + * On Node.js platforms the current filesystem usage is reported as the metric + * `nodejs_fs_usage_bytes` using the `statfs` function from `node:fs` - the + * default location to stat is the current working directory, configured this + * location here + */ + statfsLocation?: string + + /** + * The meter name used for creating metrics + * + * @default 'js-libp2p' + */ + meterName?: string +} + +class OpenTelemetryMetrics implements Metrics { + private transferStats: Map + private readonly tracer: ReturnType + private readonly meterName: string + + constructor (components: OpenTelemetryComponents, init?: OpenTelemetryMetricsInit) { + this.tracer = trace.getTracer(init?.appName ?? components.nodeInfo.name, init?.appVersion ?? components.nodeInfo.version) + + // holds global and per-protocol sent/received stats + this.transferStats = new Map() + this.meterName = init?.meterName ?? components.nodeInfo.name + + this.registerCounterGroup('libp2p_data_transfer_bytes_total', { + label: 'protocol', + calculate: () => { + const output: Record = {} + + for (const [key, value] of this.transferStats.entries()) { + output[key] = value + } + + // reset counts for next time + this.transferStats = new Map() + + return output + } + }) + + collectSystemMetrics(this, init) + } + + readonly [Symbol.toStringTag] = '@libp2p/metrics-opentelemetry' + + readonly [serviceCapabilities]: string[] = [ + '@libp2p/metrics' + ] + + /** + * Increment the transfer stat for the passed key, making sure + * it exists first + */ + _incrementValue (key: string, value: number): void { + const existing = this.transferStats.get(key) ?? 0 + + this.transferStats.set(key, existing + value) + } + + /** + * Override the sink/source of the stream to count the bytes + * in and out + */ + _track (stream: Duplex>, name: string): void { + const self = this + + const sink = stream.sink + stream.sink = async function trackedSink (source) { + await sink(each(source, buf => { + self._incrementValue(`${name} sent`, buf.byteLength) + })) + } + + const source = stream.source + stream.source = each(source, buf => { + self._incrementValue(`${name} received`, buf.byteLength) + }) + } + + trackMultiaddrConnection (maConn: MultiaddrConnection): void { + this._track(maConn, 'global') + } + + trackProtocolStream (stream: Stream, connection: Connection): void { + if (stream.protocol == null) { + // protocol not negotiated yet, should not happen as the upgrader + // calls this handler after protocol negotiation + return + } + + this._track(stream, stream.protocol) + } + + registerMetric (name: string, opts: CalculatedMetricOptions): void + registerMetric (name: string, opts?: MetricOptions): Metric + registerMetric (name: string, opts: CalculatedMetricOptions | MetricOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + + if (isCalculatedMetricOptions(opts)) { + const calculate = opts.calculate + const counter = meter.createObservableGauge(name, { + description: opts?.help ?? name + }) + counter.addCallback(async (result) => { + result.observe(await calculate()) + }) + + return + } + + return new OpenTelemetryMetric(meter.createGauge(name, { + description: opts?.help ?? name + })) + } + + registerMetricGroup (name: string, opts: CalculatedMetricOptions>): void + registerMetricGroup (name: string, opts?: MetricOptions): MetricGroup + registerMetricGroup (name: string, opts: CalculatedMetricOptions | MetricOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + const label = opts?.label ?? name + + if (isCalculatedMetricOptions>>(opts)) { + const calculate = opts.calculate + const gauge = meter.createObservableGauge(name, { + description: opts?.help ?? name + }) + gauge.addCallback(async (observable) => { + const observed = await calculate() + + for (const [key, value] of Object.entries(observed)) { + observable.observe(value, { + [label]: key + }) + } + }) + + return + } + + return new OpenTelemetryMetricGroup(label, meter.createGauge(name, { + description: opts?.help ?? name + })) + } + + registerCounter (name: string, opts: CalculatedMetricOptions): void + registerCounter (name: string, opts?: MetricOptions): Counter + registerCounter (name: string, opts: CalculatedMetricOptions | MetricOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + + if (isCalculatedMetricOptions(opts)) { + const calculate = opts.calculate + const counter = meter.createObservableCounter(name, { + description: opts?.help ?? name + }) + counter.addCallback(async (result) => { + result.observe(await calculate()) + }) + + return + } + + return new OpenTelemetryCounter(meter.createCounter(name, { + description: opts?.help ?? name + })) + } + + registerCounterGroup (name: string, opts: CalculatedMetricOptions>): void + registerCounterGroup (name: string, opts?: MetricOptions): CounterGroup + registerCounterGroup (name: string, opts: CalculatedMetricOptions | MetricOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + const label = opts?.label ?? name + + if (isCalculatedMetricOptions>>(opts)) { + const values: Record = {} + const calculate = opts.calculate + const counter = meter.createObservableGauge(name, { + description: opts?.help ?? name + }) + counter.addCallback(async (observable) => { + const observed = await calculate() + + for (const [key, value] of Object.entries(observed)) { + if (values[key] == null) { + values[key] = 0 + } + + values[key] += value + + observable.observe(values[key], { + [label]: key + }) + } + }) + + return + } + + return new OpenTelemetryCounterGroup(label, meter.createCounter(name, { + description: opts?.help ?? name + })) + } + + registerHistogram (name: string, opts: CalculatedHistogramOptions): void + registerHistogram (name: string, opts?: HistogramOptions): Histogram + registerHistogram (name: string, opts: CalculatedHistogramOptions | HistogramOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + + if (isCalculatedMetricOptions(opts)) { + return + } + + return new OpenTelemetryHistogram(meter.createHistogram(name, { + advice: { + explicitBucketBoundaries: opts.buckets + }, + description: opts?.help ?? name + })) + } + + registerHistogramGroup (name: string, opts: CalculatedHistogramOptions>): void + registerHistogramGroup (name: string, opts?: HistogramOptions): HistogramGroup + registerHistogramGroup (name: string, opts: CalculatedHistogramOptions | HistogramOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + const label = opts?.label ?? name + + if (isCalculatedMetricOptions>>(opts)) { + return + } + + return new OpenTelemetryHistogramGroup(label, meter.createHistogram(name, { + advice: { + explicitBucketBoundaries: opts.buckets + }, + description: opts?.help ?? name + })) + } + + registerSummary (name: string, opts: CalculatedSummaryOptions): void + registerSummary (name: string, opts?: SummaryOptions): Summary + registerSummary (name: string, opts: CalculatedSummaryOptions | SummaryOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + + if (isCalculatedMetricOptions(opts)) { + return + } + + return new OpenTelemetrySummary(meter.createGauge(name, { + description: opts?.help ?? name + })) + } + + registerSummaryGroup (name: string, opts: CalculatedSummaryOptions>): void + registerSummaryGroup (name: string, opts?: SummaryOptions): SummaryGroup + registerSummaryGroup (name: string, opts: CalculatedSummaryOptions | SummaryOptions = {}): any { + if (name == null || name.trim() === '') { + throw new InvalidParametersError('Metric name is required') + } + + const meter = metrics.getMeterProvider().getMeter(this.meterName) + const label = opts?.label ?? name + + if (isCalculatedMetricOptions(opts)) { + return + } + + return new OpenTelemetrySummaryGroup(label, meter.createGauge(name, { + description: opts?.help ?? name + })) + } + + createTraceContext (): any { + return context.active() + } + + traceFunction any> (name: string, fn: F, options?: TraceFunctionOptions, ReturnType>): F { + // @ts-expect-error returned function could be different to T + return (...args: Parameters): any => { + const optionsIndex = options?.optionsIndex ?? 0 + // make sure we have an options object + const opts = { + ...(args[optionsIndex] ?? {}) + } + args[optionsIndex] = opts + + // skip tracing if no context is passed + if (opts.context == null) { + return fn.apply(null, args) + } + + const attributes = {} + + // extract the parent context from the options object + const parentContext = opts.context + const span = this.tracer.startSpan(name, { + attributes: options?.getAttributesFromArgs?.(args, attributes) + }, parentContext) + + const childContext = trace.setSpan(parentContext, span) + opts.context = childContext + let result: any + + try { + result = context.with(childContext, fn, undefined, ...args) + } catch (err: any) { + span.recordException(err) + span.setStatus({ code: SpanStatusCode.ERROR, message: err.toString() }) + span.end() + throw err + } + + if (isPromise(result)) { + return wrapPromise(result, span, attributes, options) + } + + if (isGenerator(result)) { + return wrapGenerator(result, span, attributes, options) + } + + if (isAsyncGenerator(result)) { + return wrapAsyncGenerator(result, span, attributes, options) + } + + setAttributes(span, options?.getAttributesFromReturnValue?.(result, attributes)) + + span.setStatus({ code: SpanStatusCode.OK }) + span.end() + + return result + } + } +} + +export function openTelemetryMetrics (init: OpenTelemetryMetricsInit = {}): (components: OpenTelemetryComponents) => Metrics { + return (components: OpenTelemetryComponents) => new OpenTelemetryMetrics(components, init) +} + +function isPromise (obj?: any): obj is Promise { + return typeof obj?.then === 'function' +} + +async function wrapPromise (promise: Promise, span: Span, attributes: TraceAttributes, options?: TraceFunctionOptions): Promise { + return promise + .then(res => { + setAttributes(span, options?.getAttributesFromReturnValue?.(res, attributes)) + span.setStatus({ code: SpanStatusCode.OK }) + return res + }) + .catch(err => { + span.recordException(err) + span.setStatus({ code: SpanStatusCode.ERROR, message: err.toString() }) + }) + .finally(() => { + span.end() + }) +} + +function isGenerator (obj?: any): obj is Generator { + return obj?.[Symbol.iterator] != null +} + +function wrapGenerator (gen: Generator, span: Span, attributes: TraceAttributes, options?: TraceGeneratorFunctionOptions): Generator { + const iter = gen[Symbol.iterator]() + let index = 0 + + const wrapped: Generator = { + next: () => { + try { + const res = iter.next() + + if (res.done === true) { + setAttributes(span, options?.getAttributesFromReturnValue?.(res.value, attributes)) + span.setStatus({ code: SpanStatusCode.OK }) + span.end() + } else { + setAttributes(span, options?.getAttributesFromYieldedValue?.(res.value, attributes, ++index)) + } + + return res + } catch (err: any) { + span.recordException(err) + span.setStatus({ code: SpanStatusCode.ERROR, message: err.toString() }) + span.end() + + throw err + } + }, + return: (value) => { + return iter.return(value) + }, + throw: (err) => { + return iter.throw(err) + }, + [Symbol.iterator]: () => { + return wrapped + } + } + + return wrapped +} + +function isAsyncGenerator (obj?: any): obj is AsyncGenerator { + return obj?.[Symbol.asyncIterator] != null +} + +function wrapAsyncGenerator (gen: AsyncGenerator, span: Span, attributes: TraceAttributes, options?: TraceGeneratorFunctionOptions): AsyncGenerator { + const iter = gen[Symbol.asyncIterator]() + let index = 0 + + const wrapped: AsyncGenerator = { + next: async () => { + try { + const res = await iter.next() + + if (res.done === true) { + setAttributes(span, options?.getAttributesFromReturnValue?.(res.value, attributes)) + span.setStatus({ code: SpanStatusCode.OK }) + span.end() + } else { + setAttributes(span, options?.getAttributesFromYieldedValue?.(res.value, attributes, ++index)) + } + + return res + } catch (err: any) { + span.recordException(err) + span.setStatus({ code: SpanStatusCode.ERROR, message: err.toString() }) + span.end() + + throw err + } + }, + return: async (value) => { + return iter.return(value) + }, + throw: async (err) => { + return iter.throw(err) + }, + [Symbol.asyncIterator]: () => { + return wrapped + } + } + + return wrapped +} + +function isCalculatedMetricOptions (opts?: any): opts is T { + return opts?.calculate != null +} + +function setAttributes (span: Span, attributes?: Attributes): void { + if (attributes != null) { + span.setAttributes(attributes) + } +} diff --git a/packages/metrics-opentelemetry/src/metric-group.ts b/packages/metrics-opentelemetry/src/metric-group.ts new file mode 100644 index 0000000000..004f7f7f6b --- /dev/null +++ b/packages/metrics-opentelemetry/src/metric-group.ts @@ -0,0 +1,69 @@ +import type { MetricGroup, StopTimer } from '@libp2p/interface' +import type { Gauge } from '@opentelemetry/api' + +export class OpenTelemetryMetricGroup implements MetricGroup { + private readonly label: string + private readonly gauge: Gauge + private readonly lastValues: Record + + constructor (label: string, gauge: Gauge) { + this.label = label + this.gauge = gauge + this.lastValues = {} + } + + update (values: Record): void { + Object.entries(values).forEach(([key, value]) => { + this.lastValues[key] = value + this.gauge.record(value, { + [this.label]: key + }) + }) + } + + increment (values: Record): void { + Object.entries(values).forEach(([key, value]) => { + if (this.lastValues[key] == null) { + this.lastValues[key] = 0 + } + + this.lastValues[key] += value === true ? 1 : value + this.gauge.record(this.lastValues[key], { + [this.label]: key + }) + }) + } + + decrement (values: Record): void { + Object.entries(values).forEach(([key, value]) => { + if (this.lastValues[key] == null) { + this.lastValues[key] = 0 + } + + this.lastValues[key] -= value === true ? 1 : value + this.gauge.record(this.lastValues[key], { + [this.label]: key + }) + }) + } + + reset (): void { + Object.keys(this.lastValues).forEach(key => { + this.lastValues[key] = 0 + this.gauge.record(0, { + [this.label]: key + }) + }) + } + + timer (key: string): StopTimer { + const start = Date.now() + + return () => { + this.lastValues[key] = Date.now() - start + this.gauge.record(this.lastValues[key], { + [this.label]: key + }) + } + } +} diff --git a/packages/metrics-opentelemetry/src/metric.ts b/packages/metrics-opentelemetry/src/metric.ts new file mode 100644 index 0000000000..963be34a4a --- /dev/null +++ b/packages/metrics-opentelemetry/src/metric.ts @@ -0,0 +1,44 @@ +import type { Metric, StopTimer } from '@libp2p/interface' +import type { Gauge } from '@opentelemetry/api' + +export class OpenTelemetryMetric implements Metric { + private readonly gauge: Gauge + private lastValue: number + + constructor (gauge: Gauge) { + this.gauge = gauge + this.lastValue = 0 + this.update(0) + } + + update (value: number): void { + this.lastValue = value + this.gauge.record(value, { + attrName: 'attrValue' + }) + } + + increment (value: number = 1): void { + this.lastValue += value + this.gauge.record(this.lastValue) + } + + decrement (value: number = 1): void { + this.lastValue -= value + this.gauge.record(this.lastValue) + } + + reset (): void { + this.gauge.record(0) + this.lastValue = 0 + } + + timer (): StopTimer { + const start = Date.now() + + return () => { + this.lastValue = Date.now() - start + this.gauge.record(this.lastValue) + } + } +} diff --git a/packages/metrics-opentelemetry/src/summary-group.ts b/packages/metrics-opentelemetry/src/summary-group.ts new file mode 100644 index 0000000000..5035999963 --- /dev/null +++ b/packages/metrics-opentelemetry/src/summary-group.ts @@ -0,0 +1,43 @@ +import type { HistogramGroup, StopTimer } from '@libp2p/interface' +import type { Gauge } from '@opentelemetry/api' + +export class OpenTelemetrySummaryGroup implements HistogramGroup { + private readonly label: string + private readonly gauge: Gauge + private readonly lastValues: Record + + constructor (label: string, gauge: Gauge) { + this.label = label + this.gauge = gauge + this.lastValues = {} + } + + observe (values: Record): void { + Object.entries(values).forEach(([key, value]) => { + this.lastValues[key] = value + this.gauge.record(value, { + [this.label]: key + }) + }) + } + + reset (): void { + Object.keys(this.lastValues).forEach(key => { + this.lastValues[key] = 0 + this.gauge.record(0, { + [this.label]: key + }) + }) + } + + timer (key: string): StopTimer { + const start = Date.now() + + return () => { + this.lastValues[key] = Date.now() - start + this.gauge.record(this.lastValues[key], { + [this.label]: key + }) + } + } +} diff --git a/packages/metrics-opentelemetry/src/summary.ts b/packages/metrics-opentelemetry/src/summary.ts new file mode 100644 index 0000000000..765b234502 --- /dev/null +++ b/packages/metrics-opentelemetry/src/summary.ts @@ -0,0 +1,26 @@ +import type { StopTimer, Summary } from '@libp2p/interface' +import type { Gauge } from '@opentelemetry/api' + +export class OpenTelemetrySummary implements Summary { + private readonly gauge: Gauge + + constructor (gauge: Gauge) { + this.gauge = gauge + } + + observe (value: number): void { + this.gauge.record(value) + } + + reset (): void { + this.gauge.record(0) + } + + timer (): StopTimer { + const start = Date.now() + + return () => { + this.observe(Date.now() - start) + } + } +} diff --git a/packages/metrics-opentelemetry/src/system-metrics.browser.ts b/packages/metrics-opentelemetry/src/system-metrics.browser.ts new file mode 100644 index 0000000000..56f73c95f0 --- /dev/null +++ b/packages/metrics-opentelemetry/src/system-metrics.browser.ts @@ -0,0 +1,3 @@ +export function collectSystemMetrics (): void { + +} diff --git a/packages/metrics-opentelemetry/src/system-metrics.ts b/packages/metrics-opentelemetry/src/system-metrics.ts new file mode 100644 index 0000000000..a6ab63b78b --- /dev/null +++ b/packages/metrics-opentelemetry/src/system-metrics.ts @@ -0,0 +1,504 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { statfs } from 'node:fs/promises' +import { totalmem } from 'node:os' +import { monitorEventLoopDelay, PerformanceObserver, constants as PerfHooksConstants } from 'node:perf_hooks' +import { getHeapSpaceStatistics } from 'node:v8' +import type { Metrics } from '@libp2p/interface' + +export interface SystemMetricsOptions { + statfsLocation?: string +} + +export function collectSystemMetrics (metrics: Metrics, init?: SystemMetricsOptions): void { + metrics.registerMetricGroup('nodejs_memory_usage_bytes', { + label: 'memory', + calculate: () => { + return { + ...process.memoryUsage() + } + } + }) + const totalMemoryMetric = metrics.registerMetric('nodejs_memory_total_bytes') + totalMemoryMetric.update(totalmem()) + + metrics.registerMetricGroup('nodejs_fs_usage_bytes', { + label: 'filesystem', + calculate: async () => { + const stats = await statfs(init?.statfsLocation ?? process.cwd()) + const total = stats.bsize * stats.blocks + const available = stats.bsize * stats.bavail + + return { + total, + free: stats.bsize * stats.bfree, + available, + used: (available / total) * 100 + } + } + }) + + collectProcessCPUMetrics(metrics) + collectProcessStartTime(metrics) + collectMemoryHeap(metrics) + collectOpenFileDescriptors(metrics) + collectMaxFileDescriptors(metrics) + collectEventLoopStats(metrics) + collectProcessResources(metrics) + collectProcessHandles(metrics) + collectProcessRequests(metrics) + collectHeapSizeAndUsed(metrics) + collectHeapSpacesSizeAndUsed(metrics) + collectNodeVersion(metrics) + collectGcStats(metrics) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/processCpuTotal.js + */ +function collectProcessCPUMetrics (metrics: Metrics): void { + let lastCpuUsage = process.cpuUsage() + const cpuUserSecondsTotal = metrics.registerCounter('process_cpu_user_seconds_total', { + help: 'Total user CPU time spent in seconds.' + }) + const cpuSystemSecondsTotal = metrics.registerCounter('process_cpu_system_seconds_total', { + help: 'Total system CPU time spent in seconds.' + }) + + metrics.registerCounter('process_cpu_seconds_total', { + help: 'Total user and system CPU time spent in seconds.', + calculate: () => { + const cpuUsage = process.cpuUsage() + const userUsageMicros = cpuUsage.user - lastCpuUsage.user + const systemUsageMicros = cpuUsage.system - lastCpuUsage.system + lastCpuUsage = cpuUsage + + cpuUserSecondsTotal.increment(userUsageMicros / 1e6) + cpuSystemSecondsTotal.increment(systemUsageMicros / 1e6) + + return (userUsageMicros + systemUsageMicros) / 1e6 + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/processStartTime.js + */ +function collectProcessStartTime (metrics: Metrics): void { + const metric = metrics.registerMetric('process_start_time_seconds', { + help: 'Start time of the process since unix epoch in seconds.' + }) + + metric.update(Math.round(Date.now() / 1000 - process.uptime())) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/osMemoryHeap.js + */ +function collectMemoryHeap (metrics: Metrics): void { + metrics.registerMetric('process_resident_memory_bytes', { + help: 'Resident memory size in bytes.', + calculate: () => { + try { + return process.memoryUsage().rss + } catch {} + return 0 + } + }) + metrics.registerMetric('process_virtual_memory_bytes', { + help: 'Virtual memory size in bytes.', + calculate: () => { + // this involves doing sync io in prom-client so skip it + // https://github.com/siimon/prom-client/blob/c1d76c5d497ef803f6bd90c56c713c3fa811c3e0/lib/metrics/osMemoryHeapLinux.js#L53C5-L54C52 + return 0 + } + }) + metrics.registerMetric('process_heap_bytes', { + help: 'Process heap size in bytes.', + calculate: () => { + try { + return process.memoryUsage().heapTotal + } catch {} + return 0 + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/processOpenFileDescriptors.js + */ +function collectOpenFileDescriptors (metrics: Metrics): void { + if (process.platform !== 'linux') { + return + } + + metrics.registerMetric('process_open_fds', { + help: 'Number of open file descriptors.', + calculate: () => { + try { + const fds = readdirSync('/proc/self/fd') + // Minus 1 to not count the fd that was used by readdirSync(), + // it's now closed. + return fds.length - 1 + } catch {} + + return 0 + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/processMaxFileDescriptors.js + */ +function collectMaxFileDescriptors (metrics: Metrics): void { + let maxFds: number | undefined + + // This will fail if a linux-like procfs is not available. + try { + const limits = readFileSync('/proc/self/limits', 'utf8') + const lines = limits.split('\n') + for (const line of lines) { + if (line.startsWith('Max open files')) { + const parts = line.split(/ +/) + maxFds = Number(parts[1]) + break + } + } + } catch { + return + } + + if (maxFds == null) { + return + } + + const metric = metrics.registerMetric('process_max_fds', { + help: 'Maximum number of open file descriptors.' + }) + metric.update(maxFds) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/eventLoopLag.js + */ +function collectEventLoopStats (metrics: Metrics): void { + try { + const histogram = monitorEventLoopDelay() + histogram.enable() + + metrics.registerMetric('nodejs_eventloop_lag_seconds', { + help: 'Lag of event loop in seconds.', + calculate: async () => { + const start = process.hrtime() + + return new Promise(resolve => { + setImmediate(() => { + const delta = process.hrtime(start) + const nanosec = delta[0] * 1e9 + delta[1] + const seconds = nanosec / 1e9 + + lagMin.update(histogram.min / 1e9) + lagMax.update(histogram.max / 1e9) + lagMean.update(histogram.mean / 1e9) + lagStddev.update(histogram.stddev / 1e9) + lagP50.update(histogram.percentile(50) / 1e9) + lagP90.update(histogram.percentile(90) / 1e9) + lagP99.update(histogram.percentile(99) / 1e9) + + histogram.reset() + + resolve(seconds) + }) + }) + } + }) + const lagMin = metrics.registerMetric('nodejs_eventloop_lag_min_seconds', { + help: 'The minimum recorded event loop delay.' + }) + const lagMax = metrics.registerMetric('nodejs_eventloop_lag_max_seconds', { + help: 'The maximum recorded event loop delay.' + }) + const lagMean = metrics.registerMetric('nodejs_eventloop_lag_mean_seconds', { + help: 'The mean of the recorded event loop delays.' + }) + const lagStddev = metrics.registerMetric('nodejs_eventloop_lag_stddev_seconds', { + help: 'The standard deviation of the recorded event loop delays.' + }) + const lagP50 = metrics.registerMetric('nodejs_eventloop_lag_p50_seconds', { + help: 'The 50th percentile of the recorded event loop delays.' + }) + const lagP90 = metrics.registerMetric('nodejs_eventloop_lag_p90_seconds', { + help: 'The 90th percentile of the recorded event loop delays.' + }) + const lagP99 = metrics.registerMetric('nodejs_eventloop_lag_p99_seconds', { + help: 'The 99th percentile of the recorded event loop delays.' + }) + } catch (err: any) { + if (err.code === 'ERR_NOT_IMPLEMENTED') { + return // Bun + } + + throw err + } +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/processResources.js + */ +function collectProcessResources (metrics: Metrics): void { + // Don't do anything if the function does not exist in previous nodes (exists in node@17.3.0) + if (typeof process.getActiveResourcesInfo !== 'function') { + return + } + + metrics.registerMetricGroup('nodejs_active_resources', { + help: 'Number of active resources that are currently keeping the event loop alive, grouped by async resource type.', + label: 'type', + calculate: () => { + const resources = process.getActiveResourcesInfo() + + const data: Record = {} + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i] + + if (Object.hasOwn(data, resource)) { + data[resource] += 1 + } else { + data[resource] = 1 + } + } + + return data + } + }) + + metrics.registerMetric('nodejs_active_resources_total', { + help: 'Total number of active resources.', + calculate: () => { + const resources = process.getActiveResourcesInfo() + + return resources.length + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/processHandles.js + */ +function collectProcessHandles (metrics: Metrics): void { + // Don't do anything if the function is removed in later nodes (exists in node@6-12...) + // @ts-expect-error not part of the public API + if (typeof process._getActiveHandles !== 'function') { + return + } + + metrics.registerMetricGroup('nodejs_active_handles', { + help: 'Number of active libuv handles grouped by handle type. Every handle type is C++ class name.', + label: 'type', + calculate: () => { + // @ts-expect-error not part of the public API + const resources = process._getActiveHandles() + + const data: Record = {} + + for (let i = 0; i < resources.length; i++) { + const listElement = resources[i] + + if (listElement == null || typeof listElement.constructor === 'undefined') { + continue + } + + if (Object.hasOwnProperty.call(data, listElement.constructor.name)) { + data[listElement.constructor.name] += 1 + } else { + data[listElement.constructor.name] = 1 + } + } + + return data + } + }) + + metrics.registerMetric('nodejs_active_handles_total', { + help: 'Total number of active handles.', + calculate: () => { + // @ts-expect-error not part of the public API + const resources = process._getActiveHandles() + + return resources.length + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/processRequests.js + */ +function collectProcessRequests (metrics: Metrics): void { + // Don't do anything if the function is removed in later nodes (exists in node@6) + // @ts-expect-error not part of the public API + if (typeof process._getActiveRequests !== 'function') { + return + } + + metrics.registerMetricGroup('nodejs_active_requests', { + help: 'Number of active libuv requests grouped by request type. Every request type is C++ class name.', + label: 'type', + calculate: () => { + // @ts-expect-error not part of the public API + const resources = process._getActiveRequests() + + const data: Record = {} + + for (let i = 0; i < resources.length; i++) { + const listElement = resources[i] + + if (listElement == null || typeof listElement.constructor === 'undefined') { + continue + } + + if (Object.hasOwnProperty.call(data, listElement.constructor.name)) { + data[listElement.constructor.name] += 1 + } else { + data[listElement.constructor.name] = 1 + } + } + + return data + } + }) + + metrics.registerMetric('nodejs_active_requests_total', { + help: 'Total number of active requests.', + calculate: () => { + // @ts-expect-error not part of the public API + const resources = process._getActiveRequests() + + return resources.length + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/heapSizeAndUsed.js + */ +function collectHeapSizeAndUsed (metrics: Metrics): void { + const heapSizeUsed = metrics.registerMetric('nodejs_heap_size_used_bytes', { + help: 'Process heap size used from Node.js in bytes.' + }) + const externalMemUsed = metrics.registerMetric('nodejs_external_memory_bytes', { + help: 'Node.js external memory size in bytes.' + }) + + metrics.registerMetric('nodejs_heap_size_total_bytes', { + help: 'Process heap size from Node.js in bytes.', + calculate: () => { + try { + const memUsage = process.memoryUsage() + + heapSizeUsed.update(memUsage.heapUsed) + if (memUsage.external !== undefined) { + externalMemUsed.update(memUsage.external) + } + + return memUsage.heapTotal + } catch {} + + return 0 + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/heapSpacesSizeAndUsed.js + */ +function collectHeapSpacesSizeAndUsed (metrics: Metrics): void { + try { + getHeapSpaceStatistics() + } catch (err: any) { + if (err.code === 'ERR_NOT_IMPLEMENTED') { + return // Bun + } + throw err + } + + const used = metrics.registerMetricGroup('nodejs_heap_space_size_used_bytes', { + help: 'Process heap space size used from Node.js in bytes.', + label: 'space' + }) + const available = metrics.registerMetricGroup('nodejs_heap_space_size_available_bytes', { + help: 'Process heap space size available from Node.js in bytes.', + label: 'space' + }) + + metrics.registerMetricGroup('nodejs_heap_space_size_total_bytes', { + help: 'Process heap space size total from Node.js in bytes.', + label: 'space', + calculate: () => { + const data: Record = {} + + for (const space of getHeapSpaceStatistics()) { + const spaceName = space.space_name.substr(0, space.space_name.indexOf('_space')) + + used.update({ + [spaceName]: space.space_used_size + }) + + available.update({ + [spaceName]: space.space_available_size + }) + + data[spaceName] = space.space_size + } + + return data + } + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/version.js + */ +function collectNodeVersion (metrics: Metrics): void { + const metric = metrics.registerMetricGroup('nodejs_version_info', { + help: 'Node.js version info.' + }) + + const version = process.version + const versionSegments = version.slice(1).split('.').map(Number) + + // @ts-expect-error use internal API to get same result as prom-client + metric.gauge.record(1, { + version, + major: versionSegments[0], + minor: versionSegments[1], + patch: versionSegments[2] + }) +} + +/** + * @see https://github.com/siimon/prom-client/blob/master/lib/metrics/gc.js + */ +function collectGcStats (metrics: Metrics): void { + const histogram = metrics.registerHistogramGroup('nodejs_gc_duration_seconds_bucket', { + buckets: [0.001, 0.01, 0.1, 1, 2, 5], + label: 'kind' + }) + + const kinds: string[] = [] + kinds[PerfHooksConstants.NODE_PERFORMANCE_GC_MAJOR] = 'major' + kinds[PerfHooksConstants.NODE_PERFORMANCE_GC_MINOR] = 'minor' + kinds[PerfHooksConstants.NODE_PERFORMANCE_GC_INCREMENTAL] = 'incremental' + kinds[PerfHooksConstants.NODE_PERFORMANCE_GC_WEAKCB] = 'weakcb' + + const obs = new PerformanceObserver(list => { + const entry = list.getEntries()[0] + // @ts-expect-error types are incomplete + const kind = kinds[entry.detail.kind] + // Convert duration from milliseconds to seconds + histogram.observe({ + [kind]: entry.duration / 1000 + }) + }) + + obs.observe({ entryTypes: ['gc'] }) +} diff --git a/packages/metrics-opentelemetry/test/index.spec.ts b/packages/metrics-opentelemetry/test/index.spec.ts new file mode 100644 index 0000000000..6e1c263946 --- /dev/null +++ b/packages/metrics-opentelemetry/test/index.spec.ts @@ -0,0 +1,56 @@ +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { + PeriodicExportingMetricReader, + ConsoleMetricExporter +} from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' +import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-node' +import delay from 'delay' +import { openTelemetryMetrics, type OpenTelemetryComponents } from '../src/index.js' +import type { Metrics } from '@libp2p/interface' + +const sdk = new NodeSDK({ + traceExporter: new ConsoleSpanExporter(), + metricReader: new PeriodicExportingMetricReader({ + exporter: new ConsoleMetricExporter() + }), + instrumentations: [getNodeAutoInstrumentations()] +}) + +sdk.start() + +describe('opentelemetry-metrics', () => { + let metrics: Metrics + let components: OpenTelemetryComponents + + beforeEach(() => { + components = { + nodeInfo: { + name: 'my-app', + version: '1.0.0' + } + } + }) + + it('should wrap a method', async () => { + metrics = openTelemetryMetrics()(components) + + function innerOperation (): void { + + } + + const innerFn = metrics.traceFunction('innerOperation', innerOperation) + + function operation (): void { + innerFn() + } + + const fn = metrics.traceFunction('operation', operation) + + while (true) { + fn() + + await delay(1000) + } + }) +}) diff --git a/packages/metrics-opentelemetry/tsconfig.json b/packages/metrics-opentelemetry/tsconfig.json new file mode 100644 index 0000000000..7f69ee51fe --- /dev/null +++ b/packages/metrics-opentelemetry/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "aegir/src/config/tsconfig.aegir.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "src", + "test" +, "../../otel-test.js" ], + "references": [ + { + "path": "../interface" + }, + { + "path": "../logger" + } + ] +} diff --git a/packages/metrics-opentelemetry/typedoc.json b/packages/metrics-opentelemetry/typedoc.json new file mode 100644 index 0000000000..f599dc728d --- /dev/null +++ b/packages/metrics-opentelemetry/typedoc.json @@ -0,0 +1,5 @@ +{ + "entryPoints": [ + "./src/index.ts" + ] +}