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..1b3d8e0044 --- /dev/null +++ b/packages/metrics-devtools/package.json @@ -0,0 +1,63 @@ +{ + "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", + "race-event": "^1.3.0", + "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..fb22c5adfb --- /dev/null +++ b/packages/metrics-devtools/src/index.ts @@ -0,0 +1,360 @@ +/** + * @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, PeerUpdate, Stream, TypedEventEmitter } from '@libp2p/interface' +import type { TransportManager, Registrar, ConnectionManager } from '@libp2p/interface-internal' + +export const SOURCE_DEVTOOLS = '@libp2p/devtools-metrics:devtools' +export const SOURCE_APPLICATION = '@libp2p/devtools-metrics:node' +export const LIBP2P_DEVTOOLS_METRICS_KEY = '________libp2p_devtools_metrics' + +// let devtools know we are here +Object.defineProperty(globalThis, LIBP2P_DEVTOOLS_METRICS_KEY, { + value: true, + enumerable: false, + writable: false +}) + +/** + * Sent when new metrics are available + */ +export interface MetricsMessage { + source: '@libp2p/devtools-metrics:node' + type: 'metrics' + metrics: Record +} + +/** + * This message represents the current state of the libp2p node + */ +export interface SelfMessage { + source: '@libp2p/devtools-metrics:node' + type: 'self' + peerId: string + multiaddrs: string[] + protocols: string[] +} + +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[] +} + +/** + * This message represents the current state of the libp2p node + */ +export interface PeersMessage { + source: '@libp2p/devtools-metrics:node' + type: 'peers' + peers: Peer[] +} + +/** + * This message is sent by DevTools when no `self` message has been received + */ +export interface IdentifyMessage { + source: '@libp2p/devtools-metrics:devtools' + type: 'identify' +} + +/** + * This message is sent by DevTools when no `self` message has been received + */ +export interface EnableDebugMessage { + source: '@libp2p/devtools-metrics:devtools' + type: 'debug' + namespace: string +} + +/** + * Messages that are sent from the application page to the DevTools panel + */ +export type BrowserMessage = MetricsMessage | SelfMessage | PeersMessage + +/** + * Messages that are sent from the DevTools panel page to the application page + */ +export type DevToolsMessage = IdentifyMessage | EnableDebugMessage + +export interface DevToolsMetricsInit { + /** + * How often to pass metrics to the DevTools panel + */ + intervalMs?: 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 + + constructor (components: DevToolsMetricsComponents, init?: Partial) { + this.log = components.logger.forComponent('libp2p:devtools-metrics') + this.intervalMs = init?.intervalMs + this.components = components + + // collect information on current peers and sent it to the dev tools panel + this.sendPeers = debounce(this.sendPeers.bind(this), 1000) + + this.sendSelfUpdate = this.sendSelfUpdate.bind(this) + this.processIncomingMessage = this.processIncomingMessage.bind(this) + + // collect metrics + this.simpleMetrics = simpleMetrics({ + intervalMs: this.intervalMs, + onMetrics: (metrics) => { + const message: MetricsMessage = { + source: SOURCE_APPLICATION, + type: 'metrics', + metrics + } + + this.log('post metrics message') + window.postMessage(message, '*') + } + })({}) + } + + 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 peer updates + this.components.events.addEventListener('peer:connect', this.sendPeers) + this.components.events.addEventListener('peer:disconnect', this.sendPeers) + this.components.events.addEventListener('peer:identify', this.sendPeers) + this.components.events.addEventListener('peer:update', this.sendPeers) + + // send node status updates + this.components.events.addEventListener('self:peer:update', this.sendSelfUpdate) + + // process incoming messages from devtools + window.addEventListener('message', this.processIncomingMessage) + + // send metrics + await start(this.simpleMetrics) + } + + async stop (): Promise { + window.removeEventListener('message', this.processIncomingMessage) + this.components.events.removeEventListener('self:peer:update', this.sendSelfUpdate) + this.components.events.removeEventListener('peer:connect', this.sendPeers) + this.components.events.removeEventListener('peer:disconnect', this.sendPeers) + this.components.events.removeEventListener('peer:identify', this.sendPeers) + this.components.events.removeEventListener('peer:update', this.sendPeers) + await stop(this.simpleMetrics) + } + + private sendPeers (): void { + Promise.resolve().then(async () => { + const message: PeersMessage = { + source: '@libp2p/devtools-metrics:node', + type: 'peers', + peers: [] + } + + const connections = this.components.connectionManager.getConnectionsMap() + + for (const [peerId, conns] of connections.entries()) { + try { + const peer = await this.components.peerStore.get(peerId) + + message.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) + + message.peers.push({ + peerId: peerId.toString(), + addresses: conns.map(conn => conn.remoteAddr.toString()), + multiaddrs: [], + protocols: [], + tags: {}, + metadata: {} + }) + } + } + + window.postMessage(message, '*') + }) + .catch(err => { + this.log.error('error sending peers message', err) + }) + } + + private sendSelfUpdate (evt: CustomEvent): void { + const message: SelfMessage = { + source: SOURCE_APPLICATION, + type: 'self', + peerId: evt.detail.peer.id.toString(), + multiaddrs: evt.detail.peer.addresses.map(({ multiaddr }) => multiaddr.toString()), + protocols: [...evt.detail.peer.protocols] + } + + this.log('post node update message') + window.postMessage(message, '*') + } + + private processIncomingMessage (event: MessageEvent): void { + // Only accept messages from same frame + if (event.source !== window) { + return + } + + const message = event.data + + // Only accept messages of correct format (our messages) + if (message?.source !== SOURCE_DEVTOOLS) { + return + } + + // respond to identify request + if (message.type === 'identify') { + const message: SelfMessage = { + source: SOURCE_APPLICATION, + type: 'self', + peerId: this.components.peerId.toString(), + multiaddrs: this.components.transportManager.getListeners().flatMap(listener => listener.getAddrs()).map(ma => ma.toString()), + protocols: this.components.registrar.getProtocols() + } + + window.postMessage(message, '*') + + // also send our current peer list + this.sendPeers() + } + + // handle enabling/disabling debug namespaces + if (message.type === 'debug') { + if (message.namespace.length > 0) { + enable(message.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 +} + +function debounce (callback: () => void, wait: number = 100): () => void { + let timeout: ReturnType + let start: number | undefined + + return (): void => { + if (start == null) { + start = Date.now() + } + + if (timeout != null && Date.now() - start > wait) { + clearTimeout(timeout) + start = undefined + callback() + return + } + + clearTimeout(timeout) + timeout = setTimeout(() => { + start = undefined + callback() + }, wait) + } +} diff --git a/packages/metrics-devtools/test/index.spec.ts b/packages/metrics-devtools/test/index.spec.ts new file mode 100644 index 0000000000..37e9f68235 --- /dev/null +++ b/packages/metrics-devtools/test/index.spec.ts @@ -0,0 +1,79 @@ +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 { raceEvent } from 'race-event' +import { stubInterface, type StubbedInstance } from 'sinon-ts' +import { LIBP2P_DEVTOOLS_METRICS_KEY, SOURCE_APPLICATION, SOURCE_DEVTOOLS, devToolsMetrics, type BrowserMessage } 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 broadcast metrics', async () => { + const event = await raceEvent>(window, 'message', AbortSignal.timeout(1000), { + filter: (evt) => { + return evt.data.source === SOURCE_APPLICATION && evt.data.type === 'metrics' + } + }) + + expect(event).to.have.nested.property('data.metrics') + }) + + it('should identify node', async () => { + components.transportManager.getListeners.returns([]) + components.registrar.getProtocols.returns([]) + + // devtools asks the node to reveal itself + window.postMessage({ + source: SOURCE_DEVTOOLS, + type: 'identify' + }, '*') + + const event = await raceEvent>(window, 'message', AbortSignal.timeout(1000), { + filter: (evt) => { + return evt.data.source === SOURCE_APPLICATION && evt.data.type === 'self' + } + }) + + expect(event).to.have.nested.property('data.peerId') + }) + + it('should signal presence of metrics', () => { + expect(globalThis).to.have.property(LIBP2P_DEVTOOLS_METRICS_KEY).that.is.true() + }) +}) 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" + ] +}