From 41cc218fb8bf7b26cc487cf480ed0fd312bed70f Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 17 May 2024 17:32:37 +0100 Subject: [PATCH] feat: add devtools metrics Adds a metrics implementation that broadcasts metrics to a browser DevTools plugin. --- .release-please-manifest.json | 2 +- .release-please.json | 1 + packages/metrics-devtools/LICENSE | 4 + packages/metrics-devtools/LICENSE-APACHE | 5 + packages/metrics-devtools/LICENSE-MIT | 19 ++ packages/metrics-devtools/README.md | 68 +++++ packages/metrics-devtools/package.json | 62 +++++ packages/metrics-devtools/src/index.ts | 245 +++++++++++++++++++ packages/metrics-devtools/test/index.spec.ts | 66 +++++ packages/metrics-devtools/tsconfig.json | 27 ++ packages/metrics-devtools/typedoc.json | 5 + 11 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 packages/metrics-devtools/LICENSE create mode 100644 packages/metrics-devtools/LICENSE-APACHE create mode 100644 packages/metrics-devtools/LICENSE-MIT create mode 100644 packages/metrics-devtools/README.md create mode 100644 packages/metrics-devtools/package.json create mode 100644 packages/metrics-devtools/src/index.ts create mode 100644 packages/metrics-devtools/test/index.spec.ts create mode 100644 packages/metrics-devtools/tsconfig.json create mode 100644 packages/metrics-devtools/typedoc.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 767d65defb..aa01eed76c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{"interop":"2.0.32","packages/connection-encrypter-plaintext":"1.0.23","packages/connection-encrypter-tls":"1.0.10","packages/crypto":"4.1.1","packages/interface":"1.3.1","packages/interface-compliance-tests":"5.4.4","packages/interface-internal":"1.2.1","packages/kad-dht":"12.0.16","packages/keychain":"4.0.14","packages/libp2p":"1.5.2","packages/logger":"4.0.12","packages/metrics-prometheus":"3.0.23","packages/metrics-simple":"1.0.1","packages/multistream-select":"5.1.9","packages/peer-collections":"5.2.1","packages/peer-discovery-bootstrap":"10.0.23","packages/peer-discovery-mdns":"10.0.23","packages/peer-id":"4.1.1","packages/peer-id-factory":"4.1.1","packages/peer-record":"7.0.17","packages/peer-store":"10.0.18","packages/protocol-autonat":"1.0.20","packages/protocol-dcutr":"1.0.20","packages/protocol-echo":"1.0.6","packages/protocol-fetch":"1.0.17","packages/protocol-identify":"2.0.1","packages/protocol-perf":"3.0.23","packages/protocol-ping":"1.0.18","packages/pubsub":"9.0.18","packages/pubsub-floodsub":"9.0.19","packages/record":"4.0.1","packages/stream-multiplexer-mplex":"10.0.23","packages/transport-circuit-relay-v2":"1.0.23","packages/transport-tcp":"9.0.25","packages/transport-webrtc":"4.0.32","packages/transport-websockets":"8.0.23","packages/transport-webtransport":"4.0.31","packages/upnp-nat":"1.0.21","packages/utils":"5.4.1"} \ No newline at end of file +{"interop":"2.0.32","packages/connection-encrypter-plaintext":"1.0.23","packages/connection-encrypter-tls":"1.0.10","packages/crypto":"4.1.1","packages/interface":"1.3.1","packages/interface-compliance-tests":"5.4.4","packages/interface-internal":"1.2.1","packages/kad-dht":"12.0.16","packages/keychain":"4.0.14","packages/libp2p":"1.5.2","packages/logger":"4.0.12","packages/metrics-devtools":"0.0.1","packages/metrics-prometheus":"3.0.23","packages/metrics-simple":"1.0.1","packages/multistream-select":"5.1.9","packages/peer-collections":"5.2.1","packages/peer-discovery-bootstrap":"10.0.23","packages/peer-discovery-mdns":"10.0.23","packages/peer-id":"4.1.1","packages/peer-id-factory":"4.1.1","packages/peer-record":"7.0.17","packages/peer-store":"10.0.18","packages/protocol-autonat":"1.0.20","packages/protocol-dcutr":"1.0.20","packages/protocol-echo":"1.0.6","packages/protocol-fetch":"1.0.17","packages/protocol-identify":"2.0.1","packages/protocol-perf":"3.0.23","packages/protocol-ping":"1.0.18","packages/pubsub":"9.0.18","packages/pubsub-floodsub":"9.0.19","packages/record":"4.0.1","packages/stream-multiplexer-mplex":"10.0.23","packages/transport-circuit-relay-v2":"1.0.23","packages/transport-tcp":"9.0.25","packages/transport-webrtc":"4.0.32","packages/transport-websockets":"8.0.23","packages/transport-webtransport":"4.0.31","packages/upnp-nat":"1.0.21","packages/utils":"5.4.1"} \ No newline at end of file diff --git a/.release-please.json b/.release-please.json index c558e0fcb0..4d66941bf0 100644 --- a/.release-please.json +++ b/.release-please.json @@ -20,6 +20,7 @@ "packages/keychain": {}, "packages/libp2p": {}, "packages/logger": {}, + "packages/metrics-devtools": {}, "packages/metrics-prometheus": {}, "packages/metrics-simple": {}, "packages/multistream-select": {}, diff --git a/packages/metrics-devtools/LICENSE b/packages/metrics-devtools/LICENSE new file mode 100644 index 0000000000..20ce483c86 --- /dev/null +++ b/packages/metrics-devtools/LICENSE @@ -0,0 +1,4 @@ +This project is dual licensed under MIT and Apache-2.0. + +MIT: https://www.opensource.org/licenses/mit +Apache-2.0: https://www.apache.org/licenses/license-2.0 diff --git a/packages/metrics-devtools/LICENSE-APACHE b/packages/metrics-devtools/LICENSE-APACHE new file mode 100644 index 0000000000..14478a3b60 --- /dev/null +++ b/packages/metrics-devtools/LICENSE-APACHE @@ -0,0 +1,5 @@ +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-devtools/LICENSE-MIT b/packages/metrics-devtools/LICENSE-MIT new file mode 100644 index 0000000000..72dc60d84b --- /dev/null +++ b/packages/metrics-devtools/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-devtools/README.md b/packages/metrics-devtools/README.md new file mode 100644 index 0000000000..f862d9dc9b --- /dev/null +++ b/packages/metrics-devtools/README.md @@ -0,0 +1,68 @@ +# @libp2p/devtools-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) + +> Collect libp2p metrics and send them to browser DevTools + +# About + + + +Configure your browser-based libp2p node with DevTools metrics: + +```typescript +import { createLibp2p } from 'libp2p' +import { devToolsMetrics } from '@libp2p/devtools-metrics' + +const node = await createLibp2p({ + metrics: devToolsMetrics() +}) +``` + +Then use the [DevTools plugin](https://github.com/ipfs-shipyard/js-libp2p-devtools) +for Chrome or Firefox to inspect the state of your running node. + +# Install + +```console +$ npm i @libp2p/devtools-metrics +``` + +## Browser ` +``` + +# API Docs + +- + +# License + +Licensed under either of + +- Apache 2.0, ([LICENSE-APACHE](https://github.com/libp2p/js-libp2p/blob/main/packages/metrics-devtools/LICENSE-APACHE) / ) +- MIT ([LICENSE-MIT](https://github.com/libp2p/js-libp2p/blob/main/packages/metrics-devtools/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-devtools/package.json b/packages/metrics-devtools/package.json new file mode 100644 index 0000000000..47ca5dbafe --- /dev/null +++ b/packages/metrics-devtools/package.json @@ -0,0 +1,62 @@ +{ + "name": "@libp2p/devtools-metrics", + "version": "0.0.1", + "description": "Collect libp2p metrics and send them to browser DevTools", + "author": "", + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/libp2p/js-libp2p/tree/main/packages/metrics-devtools#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 -t browser", + "test:chrome": "aegir test -t browser --cov" + }, + "dependencies": { + "@libp2p/interface": "^1.3.1", + "@libp2p/interface-internal": "^1.2.1", + "@libp2p/logger": "^4.0.12", + "@libp2p/simple-metrics": "^1.0.1", + "multiformats": "^13.1.0" + }, + "devDependencies": { + "@libp2p/peer-id-factory": "^4.1.1", + "aegir": "^42.2.5", + "sinon-ts": "^2.0.0" + }, + "sideEffects": false +} diff --git a/packages/metrics-devtools/src/index.ts b/packages/metrics-devtools/src/index.ts new file mode 100644 index 0000000000..2fd463980e --- /dev/null +++ b/packages/metrics-devtools/src/index.ts @@ -0,0 +1,245 @@ +/** + * @packageDocumentation + * + * Configure your browser-based libp2p node with DevTools metrics: + * + * ```typescript + * import { createLibp2p } from 'libp2p' + * import { devToolsMetrics } from '@libp2p/devtools-metrics' + * + * const node = await createLibp2p({ + * metrics: devToolsMetrics() + * }) + * ``` + * + * Then use the [DevTools plugin](https://github.com/ipfs-shipyard/js-libp2p-devtools) + * for Chrome or Firefox to inspect the state of your running node. + */ + +import { start, stop } from '@libp2p/interface' +import { enable, disable } from '@libp2p/logger' +import { simpleMetrics } from '@libp2p/simple-metrics' +import { base64 } from 'multiformats/bases/base64' +import type { ComponentLogger, Connection, Libp2pEvents, Logger, Metrics, MultiaddrConnection, PeerId, PeerStore, Stream, TypedEventEmitter } from '@libp2p/interface' +import type { TransportManager, Registrar, ConnectionManager } from '@libp2p/interface-internal' + +export const LIBP2P_DEVTOOLS_METRICS_INSTANCE = '________libp2p_devtools_metrics' + +export interface Peer { + /** + * The identifier of the remote peer + */ + peerId: string + + /** + * The addresses we are connected to the peer via + */ + addresses: string[] + + /** + * The complete list of addresses the peer has, if known + */ + multiaddrs: Array<{ isCertified?: boolean, multiaddr: string }> + + /** + * Any peer store tags the peer has + */ + tags: Record + + /** + * Any peer store tags the peer has + */ + metadata: Record + + /** + * The protocols the peer supports, if known + */ + protocols: string[] +} + +export interface Status { + peerId: string + multiaddrs: string[] + protocols: string[] + peers: Peer[] +} + +export interface DevToolsMetricsInit { + /** + * How often to pass metrics to the DevTools panel + */ + intervalMs?: number + + /** + * How often to update the cached list of peers + * + * @default 1000 + */ + peerUpdateIntervalMs?: number +} + +export interface DevToolsMetricsComponents { + logger: ComponentLogger + events: TypedEventEmitter + peerId: PeerId + transportManager: TransportManager + registrar: Registrar + connectionManager: ConnectionManager + peerStore: PeerStore +} + +class DevToolsMetrics implements Metrics { + private readonly log: Logger + private readonly components: DevToolsMetricsComponents + private readonly simpleMetrics: Metrics + private readonly intervalMs?: number + private lastMetrics?: Record + private lastPeers: Peer[] + private readonly peerUpdateIntervalMs: number + private updatePeerInterval?: ReturnType + + constructor (components: DevToolsMetricsComponents, init?: Partial) { + this.log = components.logger.forComponent('libp2p:devtools-metrics') + this.intervalMs = init?.intervalMs + this.components = components + + // collect metrics + this.simpleMetrics = simpleMetrics({ + intervalMs: this.intervalMs, + onMetrics: (metrics) => { + this.lastMetrics = metrics + } + })({}) + + this.lastPeers = [] + this.peerUpdateIntervalMs = init?.peerUpdateIntervalMs ?? 1000 + + this.updatePeers = this.updatePeers.bind(this) + } + + trackMultiaddrConnection (maConn: MultiaddrConnection): void { + this.simpleMetrics.trackMultiaddrConnection(maConn) + } + + trackProtocolStream (stream: Stream, connection: Connection): void { + this.simpleMetrics.trackProtocolStream(stream, connection) + } + + registerMetric (name: any, options: any): any { + return this.simpleMetrics.registerMetric(name, options) + } + + registerMetricGroup (name: any, options: any): any { + return this.simpleMetrics.registerMetricGroup(name, options) + } + + registerCounter (name: any, options: any): any { + return this.simpleMetrics.registerCounter(name, options) + } + + registerCounterGroup (name: any, options: any): any { + return this.simpleMetrics.registerCounterGroup(name, options) + } + + async start (): Promise { + // send metrics + await start(this.simpleMetrics) + + this.updatePeerInterval = setInterval(this.updatePeers, this.peerUpdateIntervalMs) + + // let devtools know we are here + Object.defineProperty(globalThis, LIBP2P_DEVTOOLS_METRICS_INSTANCE, { + value: this, + enumerable: false, + writable: true + }) + } + + async stop (): Promise { + await stop(this.simpleMetrics) + + clearInterval(this.updatePeerInterval) + + Object.defineProperty(globalThis, LIBP2P_DEVTOOLS_METRICS_INSTANCE, { + value: undefined, + enumerable: false, + writable: true + }) + } + + getStatus (): Status { + return { + peerId: this.components.peerId.toString(), + multiaddrs: this.components.transportManager.getListeners().flatMap(listener => listener.getAddrs()).map(ma => ma.toString()), + protocols: this.components.registrar.getProtocols(), + peers: this.lastPeers + } + } + + private updatePeers (): void { + Promise.resolve() + .then(async () => { + const peers: Peer[] = [] + const connections = this.components.connectionManager.getConnectionsMap() + + for (const [peerId, conns] of connections.entries()) { + try { + const peer = await this.components.peerStore.get(peerId) + + peers.push({ + peerId: peerId.toString(), + addresses: conns.map(conn => conn.remoteAddr.toString()), + multiaddrs: peer.addresses.map(({ isCertified, multiaddr }) => ({ isCertified, multiaddr: multiaddr.toString() })), + protocols: [...peer.protocols], + tags: toObject(peer.tags, (t) => t.value), + metadata: toObject(peer.metadata, (buf) => base64.encode(buf)) + }) + } catch (err) { + this.log.error('could not load peer data from peer store', err) + + peers.push({ + peerId: peerId.toString(), + addresses: conns.map(conn => conn.remoteAddr.toString()), + multiaddrs: [], + protocols: [], + tags: {}, + metadata: {} + }) + } + } + + this.lastPeers = peers + }) + .catch(err => { + this.log.error('error updating peers', err) + }) + } + + getMetrics (): Record { + return this.lastMetrics ?? {} + } + + setDebug (namespace: string = ''): void { + if (namespace.length > 0) { + enable(namespace) + } else { + disable() + } + } +} + +export function devToolsMetrics (init?: Partial): (components: DevToolsMetricsComponents) => Metrics { + return (components) => { + return new DevToolsMetrics(components, init) + } +} + +function toObject (map: Map, transform: (value: T) => R): Record { + const output: Record = {} + + for (const [key, value] of map.entries()) { + output[key] = transform(value) + } + + return output +} diff --git a/packages/metrics-devtools/test/index.spec.ts b/packages/metrics-devtools/test/index.spec.ts new file mode 100644 index 0000000000..0c9366defe --- /dev/null +++ b/packages/metrics-devtools/test/index.spec.ts @@ -0,0 +1,66 @@ +import { TypedEventEmitter, start, stop } from '@libp2p/interface' +import { defaultLogger } from '@libp2p/logger' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { expect } from 'aegir/chai' +import { stubInterface, type StubbedInstance } from 'sinon-ts' +import { LIBP2P_DEVTOOLS_METRICS_INSTANCE, devToolsMetrics } from '../src/index.js' +import type { ComponentLogger, Libp2pEvents, Metrics, PeerId, PeerStore } from '@libp2p/interface' +import type { ConnectionManager, Registrar, TransportManager } from '@libp2p/interface-internal' + +interface StubbedComponents { + logger: ComponentLogger + events: TypedEventEmitter + peerId: PeerId + transportManager: StubbedInstance + registrar: StubbedInstance + connectionManager: StubbedInstance + peerStore: StubbedInstance +} + +describe('devtools-metrics', () => { + let components: StubbedComponents + let metrics: Metrics + + beforeEach(async () => { + components = { + logger: defaultLogger(), + events: new TypedEventEmitter(), + peerId: await createEd25519PeerId(), + transportManager: stubInterface(), + registrar: stubInterface(), + connectionManager: stubInterface(), + peerStore: stubInterface() + } + + metrics = devToolsMetrics({ + intervalMs: 10 + })(components) + + await start(metrics) + }) + + afterEach(async () => { + await stop(metrics) + }) + + it('should signal presence of metrics', () => { + expect(globalThis).to.have.property(LIBP2P_DEVTOOLS_METRICS_INSTANCE).that.is.ok() + }) + + it('should broadcast metrics', async () => { + // @ts-expect-error type is ambiguous + const metrics = globalThis[LIBP2P_DEVTOOLS_METRICS_INSTANCE].getMetrics() + + expect(metrics).to.be.ok() + }) + + it('should identify node', async () => { + components.transportManager.getListeners.returns([]) + components.registrar.getProtocols.returns([]) + + // @ts-expect-error type is ambiguous + const status = globalThis[LIBP2P_DEVTOOLS_METRICS_INSTANCE].getStatus() + + expect(status).to.have.property('peerId') + }) +}) diff --git a/packages/metrics-devtools/tsconfig.json b/packages/metrics-devtools/tsconfig.json new file mode 100644 index 0000000000..4c8f70ddb3 --- /dev/null +++ b/packages/metrics-devtools/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "aegir/src/config/tsconfig.aegir.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "src", + "test" + ], + "references": [ + { + "path": "../interface" + }, + { + "path": "../interface-internal" + }, + { + "path": "../logger" + }, + { + "path": "../peer-id-factory" + }, + { + "path": "../metrics-simple" + } + ] +} diff --git a/packages/metrics-devtools/typedoc.json b/packages/metrics-devtools/typedoc.json new file mode 100644 index 0000000000..f599dc728d --- /dev/null +++ b/packages/metrics-devtools/typedoc.json @@ -0,0 +1,5 @@ +{ + "entryPoints": [ + "./src/index.ts" + ] +}