@peerkit/metrics 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @peerkit/metrics
2
+
3
+ App-side OpenTelemetry SDK bootstrap for peerkit. Configures a global OTel
4
+ `MeterProvider` that exports metrics over OTLP/HTTP, and bridges OTel
5
+ diagnostics into the `peerkit.metrics` logtape logger.
6
+
7
+ This package is consumed **only by applications**. Library packages (e.g.
8
+ `@peerkit/transport-libp2p-core`) depend on `@opentelemetry/api` directly
9
+ and call `metrics.getMeter(...)` to obtain their meter, per the
10
+ [OpenTelemetry library guidelines][otel-lib]. They do not depend on the
11
+ SDK, and do not depend on this package.
12
+
13
+ [otel-lib]: https://opentelemetry.io/docs/specs/otel/library-guidelines/#api-and-minimal-implementation
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @peerkit/metrics
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { initMetrics, shutdownMetrics } from "@peerkit/metrics";
25
+
26
+ await initMetrics({
27
+ serviceName: "my-peerkit-app",
28
+ serviceVersion: "1.0.0",
29
+ otlpEndpoint: "http://localhost:4318/v1/metrics",
30
+ });
31
+
32
+ // On shutdown:
33
+ await shutdownMetrics();
34
+ ```
35
+
36
+ If `initMetrics` is never called, library instruments bind to OpenTelemetry's
37
+ no-op meter — they still work but record nothing, at zero cost.
38
+
39
+ ## Configuration
40
+
41
+ | Option | Default | Description |
42
+ | ---------------------- | ----------------------------------------- | ----------------------------------------- |
43
+ | `serviceName` | _required_ | `service.name` resource attribute |
44
+ | `serviceVersion` | _unset_ | `service.version` resource attribute |
45
+ | `otlpEndpoint` | `http://localhost:4318/v1/metrics` | OTLP/HTTP metrics endpoint |
46
+ | `exportIntervalMillis` | `60000` | Periodic export interval |
47
+ | `exportTimeoutMillis` | `30000` | Per-export timeout |
48
+ | `headers` | `{}` | Extra OTLP request headers (auth, tenant) |
49
+ | `resourceAttributes` | `{}` | Additional OTel resource attributes |
50
+ | `reader` | OTLP/HTTP `PeriodicExportingMetricReader` | Override reader (mainly for tests) |
51
+
52
+ Exporter diagnostics are routed to the `peerkit.metrics` logtape logger.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@peerkit/metrics",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "OpenTelemetry-based metric collection for peerkit",
5
+ "keywords": [
6
+ "peerkit",
7
+ "metrics",
8
+ "opentelemetry",
9
+ "otlp"
10
+ ],
11
+ "author": "Holochain Dev Team <devcore@holochain.org>",
12
+ "license": "CAL-1.0",
13
+ "bugs": {
14
+ "url": "https://github.com/holochain/peerkit/issues"
15
+ },
16
+ "homepage": "https://github.com/holochain/peerkit#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/holochain/peerkit.git"
20
+ },
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "import": "./dist/index.js",
25
+ "types": "./dist/index.d.ts"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "test": "vitest --run"
30
+ },
31
+ "dependencies": {
32
+ "@logtape/logtape": "^2.0.5",
33
+ "@opentelemetry/api": "^1.9.0",
34
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0",
35
+ "@opentelemetry/resources": "^2.7.0",
36
+ "@opentelemetry/sdk-metrics": "^2.7.0",
37
+ "@opentelemetry/semantic-conventions": "^1.40.0"
38
+ },
39
+ "devDependencies": {}
40
+ }
package/src/config.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { DiagLogLevel, metrics } from "@opentelemetry/api";
2
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
3
+ import { resourceFromAttributes } from "@opentelemetry/resources";
4
+ import {
5
+ MeterProvider,
6
+ PeriodicExportingMetricReader,
7
+ type MetricReader,
8
+ } from "@opentelemetry/sdk-metrics";
9
+ import {
10
+ ATTR_SERVICE_NAME,
11
+ ATTR_SERVICE_VERSION,
12
+ } from "@opentelemetry/semantic-conventions";
13
+ import { installDiagLogger, uninstallDiagLogger } from "./diag.js";
14
+
15
+ export interface MetricsConfig {
16
+ /** `service.name` resource attribute. Required. */
17
+ serviceName: string;
18
+ /** Optional `service.version` resource attribute. */
19
+ serviceVersion?: string;
20
+ /** OTLP/HTTP metrics endpoint. Default: `http://localhost:4318/v1/metrics`. */
21
+ otlpEndpoint?: string;
22
+ /** Export interval. Default: 60 000 ms. */
23
+ exportIntervalMillis?: number;
24
+ /** Export timeout. Default: 30 000 ms. */
25
+ exportTimeoutMillis?: number;
26
+ /** Extra OTLP request headers (auth, tenant, …). */
27
+ headers?: Record<string, string>;
28
+ /** Additional resource attributes. */
29
+ resourceAttributes?: Record<string, string>;
30
+ /**
31
+ * Optional metric reader to use instead of the default OTLP/HTTP reader.
32
+ * Primarily for tests with `InMemoryMetricExporter`.
33
+ */
34
+ reader?: MetricReader;
35
+ /**
36
+ * Minimum level of OpenTelemetry SDK diagnostic messages forwarded to the
37
+ * `peerkit.metrics` logtape logger. Default: {@link DiagLogLevel.WARN}.
38
+ * Lower (e.g. {@link DiagLogLevel.DEBUG}) is helpful when investigating
39
+ * why metrics are not being exported.
40
+ */
41
+ diagLogLevel?: DiagLogLevel;
42
+ }
43
+
44
+ let activeProvider: MeterProvider | undefined;
45
+
46
+ /**
47
+ * Initialise the global metrics pipeline.
48
+ *
49
+ * Builds a {@link MeterProvider} that exports to the configured OTLP endpoint
50
+ * (or to a caller-supplied {@link MetricReader}) and registers it as the
51
+ * global meter provider for `@opentelemetry/api`.
52
+ *
53
+ * Must be called at most once between {@link shutdownMetrics} calls; a second
54
+ * call without prior shutdown rejects.
55
+ */
56
+ export async function initMetrics(cfg: MetricsConfig): Promise<void> {
57
+ if (activeProvider) {
58
+ throw new Error(
59
+ "Metrics already initialised. Call shutdownMetrics() before re-initialising.",
60
+ );
61
+ }
62
+
63
+ installDiagLogger(cfg.diagLogLevel);
64
+
65
+ const resourceAttrs: Record<string, string> = {
66
+ [ATTR_SERVICE_NAME]: cfg.serviceName,
67
+ ...(cfg.serviceVersion && { [ATTR_SERVICE_VERSION]: cfg.serviceVersion }),
68
+ ...(cfg.resourceAttributes ?? {}),
69
+ };
70
+ const resource = resourceFromAttributes(resourceAttrs);
71
+
72
+ const reader =
73
+ cfg.reader ??
74
+ new PeriodicExportingMetricReader({
75
+ exporter: new OTLPMetricExporter({
76
+ url: cfg.otlpEndpoint ?? "http://localhost:4318/v1/metrics",
77
+ headers: cfg.headers,
78
+ timeoutMillis: cfg.exportTimeoutMillis ?? 30_000,
79
+ }),
80
+ exportIntervalMillis: cfg.exportIntervalMillis ?? 60_000,
81
+ });
82
+
83
+ const provider = new MeterProvider({ resource, readers: [reader] });
84
+ metrics.setGlobalMeterProvider(provider);
85
+ activeProvider = provider;
86
+ }
87
+
88
+ /**
89
+ * Flush pending metrics, shut the provider down, and restore the OpenTelemetry
90
+ * default no-op meter provider.
91
+ *
92
+ * Safe to call when metrics have not been initialised.
93
+ */
94
+ export async function shutdownMetrics(): Promise<void> {
95
+ if (!activeProvider) {
96
+ return;
97
+ }
98
+ const provider = activeProvider;
99
+ activeProvider = undefined;
100
+ try {
101
+ await provider.forceFlush();
102
+ } finally {
103
+ try {
104
+ await provider.shutdown();
105
+ } finally {
106
+ metrics.disable();
107
+ uninstallDiagLogger();
108
+ }
109
+ }
110
+ }
package/src/diag.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import { diag, DiagLogLevel, type DiagLogger } from "@opentelemetry/api";
3
+
4
+ const logger = getLogger(["peerkit", "metrics"]);
5
+
6
+ /**
7
+ * Bridge OpenTelemetry's `diag` channel to the peerkit logtape logger,
8
+ * so exporter errors and SDK diagnostics surface through the standard
9
+ * peerkit logging pipeline.
10
+ *
11
+ * `level` filters messages on the OTel side before they reach logtape:
12
+ * - `ERROR` — only outright failures.
13
+ * - `WARN` (default) — failures + retries / dropped data.
14
+ * - `INFO` / `DEBUG` / `VERBOSE` — increasingly chatty; useful when
15
+ * diagnosing why metrics are not exporting.
16
+ */
17
+ export function installDiagLogger(
18
+ level: DiagLogLevel = DiagLogLevel.WARN,
19
+ ): void {
20
+ const diagLogger: DiagLogger = {
21
+ error: (message, ...args) => logger.error(message, { args }),
22
+ warn: (message, ...args) => logger.warn(message, { args }),
23
+ info: (message, ...args) => logger.info(message, { args }),
24
+ debug: (message, ...args) => logger.debug(message, { args }),
25
+ verbose: (message, ...args) => logger.debug(message, { args }),
26
+ };
27
+ diag.setLogger(diagLogger, level);
28
+ }
29
+
30
+ /**
31
+ * Restore the OpenTelemetry default diag logger.
32
+ */
33
+ export function uninstallDiagLogger(): void {
34
+ diag.disable();
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./config.js";
@@ -0,0 +1,64 @@
1
+ import {
2
+ AggregationTemporality,
3
+ InMemoryMetricExporter,
4
+ PeriodicExportingMetricReader,
5
+ } from "@opentelemetry/sdk-metrics";
6
+ import { metrics } from "@opentelemetry/api";
7
+ import { afterEach, describe, expect, test } from "vitest";
8
+ import { initMetrics, shutdownMetrics } from "../src/index.js";
9
+
10
+ const makeReader = () => {
11
+ const exporter = new InMemoryMetricExporter(
12
+ AggregationTemporality.CUMULATIVE,
13
+ );
14
+ const reader = new PeriodicExportingMetricReader({
15
+ exporter,
16
+ exportIntervalMillis: 60_000,
17
+ });
18
+ return { exporter, reader };
19
+ };
20
+
21
+ describe("initMetrics", () => {
22
+ afterEach(async () => {
23
+ await shutdownMetrics();
24
+ });
25
+
26
+ test("records a counter that the configured reader observes", async () => {
27
+ const { exporter, reader } = makeReader();
28
+ await initMetrics({ serviceName: "test-service", reader });
29
+
30
+ const counter = metrics.getMeter("test").createCounter("test.counter");
31
+ counter.add(7, { kind: "unit" });
32
+
33
+ await reader.forceFlush();
34
+ const collected = exporter.getMetrics();
35
+ const found = collected
36
+ .flatMap((rm) => rm.scopeMetrics)
37
+ .flatMap((sm) => sm.metrics)
38
+ .find((m) => m.descriptor.name === "test.counter");
39
+ expect(found).toBeDefined();
40
+ expect(found?.dataPoints[0]?.value).toBe(7);
41
+ });
42
+
43
+ test("calling initMetrics twice without shutdown rejects", async () => {
44
+ const { reader: r1 } = makeReader();
45
+ const { reader: r2 } = makeReader();
46
+ await initMetrics({ serviceName: "svc", reader: r1 });
47
+ await expect(
48
+ initMetrics({ serviceName: "svc", reader: r2 }),
49
+ ).rejects.toThrow(/already initialised/i);
50
+ });
51
+
52
+ test("shutdownMetrics reverts the global provider to the OTel no-op", async () => {
53
+ const { reader } = makeReader();
54
+ await initMetrics({ serviceName: "svc", reader });
55
+ const beforeProvider = metrics.getMeterProvider();
56
+ await shutdownMetrics();
57
+ const afterProvider = metrics.getMeterProvider();
58
+ expect(afterProvider).not.toBe(beforeProvider);
59
+ });
60
+
61
+ test("shutdownMetrics is safe to call when not initialised", async () => {
62
+ await expect(shutdownMetrics()).resolves.toBeUndefined();
63
+ });
64
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "src",
6
+ "outDir": "dist"
7
+ },
8
+ "include": ["src"],
9
+ "references": []
10
+ }