@goopil/clusterkit-otlp-meter 1.1.3 → 1.3.0

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 CHANGED
@@ -61,6 +61,7 @@ orchestrator.use(otlp).run(async () => {
61
61
  | Option | Type | Default | Description |
62
62
  |--------|------|---------|-------------|
63
63
  | `endpoint` | `string` | `http://localhost:4318/v1/metrics` (HTTP) or `localhost:4317` (gRPC) | OTLP collector endpoint URL |
64
+ | `headers` | `Record<string, string>` | — | Custom headers attached to every export request (e.g. `Authorization` for authenticated collectors). Applies to `http` only — with `grpc` they are ignored (warning logged); use exporter `metadata` instead |
64
65
  | `protocol` | `'http' \| 'grpc'` | `'http'` | OTLP transport protocol |
65
66
  | `instrumentation` | `boolean` | `true` | Collect Node.js host/process metrics |
66
67
  | `prefix` | `string` | `'clusterkit.'` | Metric name prefix |
@@ -98,6 +99,9 @@ If the host application has already registered its own OpenTelemetry global mete
98
99
  provider, the plugin does **not** replace it: it logs a warning and keeps using its own
99
100
  provider for the `clusterkit.*` metrics.
100
101
 
102
+ When the plugin did register the global provider itself, `uninstall()` releases that
103
+ registration so the application no longer resolves meters from the shut-down provider.
104
+
101
105
  ## Metrics exposed
102
106
 
103
107
  With the default prefix (`clusterkit.`):
@@ -106,8 +110,21 @@ With the default prefix (`clusterkit.`):
106
110
  - `clusterkit.worker.restarts` (Counter)
107
111
  - `clusterkit.worker.crashes` (Counter)
108
112
  - `clusterkit.circuit_breaker.trips` (Counter)
113
+ - `clusterkit.worker.rss_bytes` (ObservableGauge, attributes `worker.id`, `process.pid`)
114
+ - `clusterkit.worker.heap_used_bytes` (ObservableGauge, attributes `worker.id`, `process.pid`)
115
+ - `clusterkit.worker.eventloop_lag_ms` (ObservableGauge, attributes `worker.id`, `process.pid`)
116
+ - `clusterkit.worker.heartbeat_age_seconds` (ObservableGauge, attributes `worker.id`, `process.pid`)
117
+ - `clusterkit.worker.recycles` (Counter, attribute `reason`: rss / maxAge / wedged)
118
+ - `clusterkit.worker.wedged.kills` (Counter)
119
+ - `clusterkit.fleet.active_workers` (ObservableGauge)
120
+ - `clusterkit.fleet.target_workers` (ObservableGauge)
121
+ - `clusterkit.fleet.quarantined_slots` (ObservableGauge)
122
+ - `clusterkit.recovery.duration_seconds` (Gauge)
109
123
 
110
124
  Plus Node.js host/process metrics from `@opentelemetry/host-metrics` when `instrumentation: true`.
125
+ Worker-sourced series (`worker.rss_bytes`, `worker.heap_used_bytes`, `worker.eventloop_lag_ms`,
126
+ `worker.heartbeat_age_seconds`) only appear when core health monitoring is enabled and
127
+ heartbeats flow; the fleet gauges and event counters report regardless.
111
128
 
112
129
  In single-worker mode (`workers: 1`), the orchestrator runs the app directly in the
113
130
  primary process without forking. The plugin collects host metrics on the primary since
package/dist/index.cjs CHANGED
@@ -38,7 +38,7 @@ let _opentelemetry_sdk_metrics = require("@opentelemetry/sdk-metrics");
38
38
  let _opentelemetry_semantic_conventions = require("@opentelemetry/semantic-conventions");
39
39
 
40
40
  //#region package.json
41
- var version = "1.1.3";
41
+ var version = "1.3.0";
42
42
 
43
43
  //#endregion
44
44
  //#region src/index.ts
@@ -52,7 +52,7 @@ function isMissingModuleError(err) {
52
52
  return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
53
53
  }
54
54
  function createOtlpMeterPlugin(options = {}) {
55
- const { protocol = "http", instrumentation = true, prefix = "clusterkit.", attributes = {}, exportIntervalMs = 6e4, serviceName = "clusterkit", endpoint } = options;
55
+ const { protocol = "http", instrumentation = true, prefix = "clusterkit.", attributes = {}, exportIntervalMs = 6e4, serviceName = "clusterkit", endpoint, headers } = options;
56
56
  if (!Number.isFinite(exportIntervalMs) || exportIntervalMs < 1e3) throw new TypeError("otlp-meter plugin: exportIntervalMs must be a finite number >= 1000 (minimum 1s to avoid flooding the collector)");
57
57
  const resolvedEndpoint = endpoint ?? (protocol === "grpc" ? DEFAULT_GRPC_ENDPOINT : DEFAULT_HTTP_ENDPOINT);
58
58
  if (protocol === "http") try {
@@ -62,9 +62,11 @@ function createOtlpMeterPlugin(options = {}) {
62
62
  }
63
63
  let meterProvider;
64
64
  let isShutdown = false;
65
+ let pluginSetGlobalProvider = false;
65
66
  let pluginLog = null;
66
67
  let primaryOrchestrator;
67
68
  const primaryListeners = [];
69
+ const workerHealth = /* @__PURE__ */ new Map();
68
70
  const clearPrimaryListeners = () => {
69
71
  if (!primaryOrchestrator) return;
70
72
  for (const { event, listener } of primaryListeners) primaryOrchestrator.off(event, listener);
@@ -73,8 +75,14 @@ function createOtlpMeterPlugin(options = {}) {
73
75
  };
74
76
  async function createExporter() {
75
77
  const exporterModuleName = protocol === "grpc" ? "@opentelemetry/exporter-metrics-otlp-grpc" : "@opentelemetry/exporter-metrics-otlp-http";
78
+ if (protocol === "grpc" && headers && Object.keys(headers).length > 0) pluginLog?.warn("headers are not supported by the gRPC exporter — configure metadata via the exporter's own options or use protocol: 'http'");
76
79
  try {
77
- return new (await (import(exporterModuleName))).OTLPMetricExporter({ url: resolvedEndpoint });
80
+ const mod = await import(exporterModuleName);
81
+ const config = protocol === "grpc" ? { url: resolvedEndpoint } : {
82
+ url: resolvedEndpoint,
83
+ headers
84
+ };
85
+ return new mod.OTLPMetricExporter(config);
78
86
  } catch (err) {
79
87
  if (isMissingModuleError(err)) throw new Error(`otlp-meter plugin: protocol '${protocol}' requires ${exporterModuleName} — install it or use protocol '${protocol === "grpc" ? "http" : "grpc"}'`);
80
88
  throw err;
@@ -120,7 +128,8 @@ function createOtlpMeterPlugin(options = {}) {
120
128
  resource,
121
129
  readers: [metricReader]
122
130
  });
123
- if (!_opentelemetry_api.metrics.setGlobalMeterProvider(meterProvider)) log?.warn("A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider");
131
+ if (_opentelemetry_api.metrics.setGlobalMeterProvider(meterProvider)) pluginSetGlobalProvider = true;
132
+ else log?.warn("A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider");
124
133
  const meter = meterProvider.getMeter("@goopil/clusterkit", PLUGIN_VERSION);
125
134
  if (node_cluster.default.isPrimary) {
126
135
  clearPrimaryListeners();
@@ -132,6 +141,53 @@ function createOtlpMeterPlugin(options = {}) {
132
141
  const workerRestartsCounter = meter.createCounter(`${prefix}worker.restarts`, { description: "Total number of worker restarts" });
133
142
  const workerCrashesCounter = meter.createCounter(`${prefix}worker.crashes`, { description: "Total number of worker crashes" });
134
143
  const circuitBreakerTripsCounter = meter.createCounter(`${prefix}circuit_breaker.trips`, { description: "Total number of circuit breaker trips" });
144
+ const workerRssGauge = meter.createObservableGauge(`${prefix}worker.rss_bytes`, {
145
+ description: "Resident set size per worker from health heartbeats",
146
+ unit: "By"
147
+ });
148
+ const workerHeapGauge = meter.createObservableGauge(`${prefix}worker.heap_used_bytes`, {
149
+ description: "V8 heap used per worker from health heartbeats",
150
+ unit: "By"
151
+ });
152
+ const workerLagGauge = meter.createObservableGauge(`${prefix}worker.eventloop_lag_ms`, {
153
+ description: "Event loop lag per worker from health heartbeats",
154
+ unit: "ms"
155
+ });
156
+ const workerHeartbeatAgeGauge = meter.createObservableGauge(`${prefix}worker.heartbeat_age_seconds`, {
157
+ description: "Seconds since the last health heartbeat per worker",
158
+ unit: "s"
159
+ });
160
+ const observeWorkerHealth = (result, pick) => {
161
+ for (const [workerId, sample] of workerHealth) result.observe(pick(sample), {
162
+ "worker.id": workerId,
163
+ "process.pid": sample.pid
164
+ });
165
+ };
166
+ workerRssGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.rss));
167
+ workerHeapGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.heapUsed));
168
+ workerLagGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.eventLoopLagMs));
169
+ workerHeartbeatAgeGauge.addCallback((result) => {
170
+ const now = Date.now();
171
+ observeWorkerHealth(result, (s) => Math.max(0, (now - s.lastBeatAt) / 1e3));
172
+ });
173
+ const workerRecyclesCounter = meter.createCounter(`${prefix}worker.recycles`, { description: "Total number of worker recycles by reason" });
174
+ const wedgedKillsCounter = meter.createCounter(`${prefix}worker.wedged.kills`, { description: "Total number of workers killed for being unresponsive" });
175
+ const recoveryDurationGauge = meter.createGauge(`${prefix}recovery.duration_seconds`, {
176
+ description: "Duration of the last fleet degraded-to-recovered cycle",
177
+ unit: "s"
178
+ });
179
+ const fleetTargetGauge = meter.createObservableGauge(`${prefix}fleet.target_workers`, { description: "Target worker count (live fleet health)" });
180
+ const fleetActiveGauge = meter.createObservableGauge(`${prefix}fleet.active_workers`, { description: "Currently active workers (live fleet health)" });
181
+ const fleetQuarantinedGauge = meter.createObservableGauge(`${prefix}fleet.quarantined_slots`, { description: "Quarantined worker slots (live fleet health)" });
182
+ fleetTargetGauge.addCallback((result) => {
183
+ result.observe(orchestrator.getFleetHealth().target);
184
+ });
185
+ fleetActiveGauge.addCallback((result) => {
186
+ result.observe(orchestrator.getFleetHealth().active);
187
+ });
188
+ fleetQuarantinedGauge.addCallback((result) => {
189
+ result.observe(orchestrator.getFleetHealth().quarantined);
190
+ });
135
191
  const bind = (event, listener) => {
136
192
  orchestrator.on(event, listener);
137
193
  primaryListeners.push({
@@ -148,6 +204,27 @@ function createOtlpMeterPlugin(options = {}) {
148
204
  bind("circuit-breaker:tripped", () => {
149
205
  circuitBreakerTripsCounter.add(1);
150
206
  });
207
+ bind("worker:health", ({ workerId, pid, rss, heapUsed, eventLoopLagMs }) => {
208
+ workerHealth.set(workerId, {
209
+ pid,
210
+ rss,
211
+ heapUsed,
212
+ eventLoopLagMs,
213
+ lastBeatAt: Date.now()
214
+ });
215
+ });
216
+ bind("worker:exit", ({ workerId }) => {
217
+ workerHealth.delete(workerId);
218
+ });
219
+ bind("worker:recycle", ({ reason }) => {
220
+ workerRecyclesCounter.add(1, { reason });
221
+ });
222
+ bind("worker:wedged", () => {
223
+ wedgedKillsCounter.add(1);
224
+ });
225
+ bind("fleet:recovered", ({ degradedDurationMs }) => {
226
+ recoveryDurationGauge.record(degradedDurationMs / 1e3);
227
+ });
151
228
  if (orchestrator.workerCount === 1 && instrumentation) await startHostMetrics(meterProvider);
152
229
  } else {
153
230
  log?.debug("Plugin installed on worker process");
@@ -159,6 +236,11 @@ function createOtlpMeterPlugin(options = {}) {
159
236
  },
160
237
  async uninstall() {
161
238
  clearPrimaryListeners();
239
+ workerHealth.clear();
240
+ if (pluginSetGlobalProvider) {
241
+ _opentelemetry_api.metrics.disable();
242
+ pluginSetGlobalProvider = false;
243
+ }
162
244
  await shutdownProvider();
163
245
  },
164
246
  async shutdown() {
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["pkgJson.version","withLoggerPrefix","resourceFromAttributes","ATTR_SERVICE_NAME","ATTR_SERVICE_INSTANCE_ID","randomUUID","os","PeriodicExportingMetricReader","MeterProvider","metrics","cluster"],"sources":["../package.json","../src/index.ts"],"sourcesContent":["","import cluster from \"node:cluster\";\nimport { randomUUID } from \"node:crypto\";\nimport os from \"node:os\";\nimport { type Logger, type Orchestrator, type ResolvedConfig, withLoggerPrefix } from \"@goopil/clusterkit\";\nimport { metrics } from \"@opentelemetry/api\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport { MeterProvider, PeriodicExportingMetricReader, type PushMetricExporter } from \"@opentelemetry/sdk-metrics\";\nimport { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME } from \"@opentelemetry/semantic-conventions\";\nimport pkgJson from \"../package.json\" with { type: \"json\" };\nimport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\nexport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\ntype PrimaryEvent = \"worker:crash\" | \"worker:restart\" | \"circuit-breaker:tripped\";\n\nconst DEFAULT_HTTP_ENDPOINT = \"http://localhost:4318/v1/metrics\";\nconst DEFAULT_GRPC_ENDPOINT = \"localhost:4317\";\n\nconst ATTR_HOST_NAME = \"host.name\";\nconst ATTR_PROCESS_PID = \"process.pid\";\n\nconst PLUGIN_VERSION: string = pkgJson.version;\n\nfunction isMissingModuleError(err: unknown): boolean {\n const code = (err as NodeJS.ErrnoException | undefined)?.code;\n return code === \"ERR_MODULE_NOT_FOUND\" || code === \"MODULE_NOT_FOUND\";\n}\n\nexport function createOtlpMeterPlugin(options: OtlpMeterPluginOptions = {}): OtlpMeterPlugin {\n const {\n protocol = \"http\",\n instrumentation = true,\n prefix = \"clusterkit.\",\n attributes = {},\n exportIntervalMs = 60_000,\n serviceName = \"clusterkit\",\n endpoint,\n } = options;\n\n if (!Number.isFinite(exportIntervalMs) || exportIntervalMs < 1_000) {\n throw new TypeError(\n \"otlp-meter plugin: exportIntervalMs must be a finite number >= 1000 (minimum 1s to avoid flooding the collector)\",\n );\n }\n\n const resolvedEndpoint = endpoint ?? (protocol === \"grpc\" ? DEFAULT_GRPC_ENDPOINT : DEFAULT_HTTP_ENDPOINT);\n\n if (protocol === \"http\") {\n try {\n new URL(resolvedEndpoint);\n } catch {\n throw new TypeError(`otlp-meter plugin: invalid endpoint URL \"${resolvedEndpoint}\"`);\n }\n }\n\n let meterProvider: MeterProvider | undefined;\n let isShutdown = false;\n let pluginLog: Logger | null = null;\n let primaryOrchestrator: Orchestrator | undefined;\n const primaryListeners: Array<{ event: PrimaryEvent; listener: () => void }> = [];\n\n const clearPrimaryListeners = (): void => {\n if (!primaryOrchestrator) return;\n for (const { event, listener } of primaryListeners) {\n primaryOrchestrator.off(event, listener);\n }\n primaryListeners.length = 0;\n primaryOrchestrator = undefined;\n };\n\n async function createExporter(): Promise<PushMetricExporter> {\n const exporterModuleName =\n protocol === \"grpc\" ? \"@opentelemetry/exporter-metrics-otlp-grpc\" : \"@opentelemetry/exporter-metrics-otlp-http\";\n\n try {\n const mod = await import(exporterModuleName);\n return new mod.OTLPMetricExporter({ url: resolvedEndpoint }) as PushMetricExporter;\n } catch (err) {\n if (isMissingModuleError(err)) {\n throw new Error(\n `otlp-meter plugin: protocol '${protocol}' requires ${exporterModuleName} — install it or use protocol '${protocol === \"grpc\" ? \"http\" : \"grpc\"}'`,\n );\n }\n throw err;\n }\n }\n\n async function startHostMetrics(provider: MeterProvider): Promise<void> {\n try {\n const mod = await import(\"@opentelemetry/host-metrics\");\n const hostMetrics = new mod.HostMetrics({ meterProvider: provider });\n hostMetrics.start();\n } catch (err) {\n if (isMissingModuleError(err)) {\n pluginLog?.warn(\"host-metrics package not installed — skipping process metrics\");\n } else {\n throw err;\n }\n }\n }\n\n async function shutdownProvider(): Promise<void> {\n if (isShutdown || !meterProvider) return;\n isShutdown = true;\n await meterProvider.shutdown();\n meterProvider = undefined;\n }\n\n return {\n name: \"otlp-meter\",\n\n get meterProvider() {\n return meterProvider;\n },\n\n async install(orchestrator: Orchestrator, logger: Logger | null, _config: ResolvedConfig): Promise<void> {\n const log = withLoggerPrefix(logger, \"clusterkit:otlp-meter\");\n pluginLog = log;\n\n // Reinstalling the same instance after a shutdown must not orphan the old\n // provider or leave the latch stuck at `true` (the new provider's shutdown\n // would otherwise be a permanent no-op).\n if (meterProvider) await shutdownProvider();\n isShutdown = false;\n\n const resource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_INSTANCE_ID]: randomUUID(),\n [ATTR_HOST_NAME]: os.hostname(),\n [ATTR_PROCESS_PID]: process.pid,\n ...attributes,\n });\n\n const exporter = await createExporter();\n\n const metricReader = new PeriodicExportingMetricReader({\n exporter,\n exportIntervalMillis: exportIntervalMs,\n });\n\n meterProvider = new MeterProvider({\n resource,\n readers: [metricReader],\n });\n\n // setGlobalMeterProvider() refuses to override an existing registration\n // and returns false — never clobber the app's own OTel setup.\n if (!metrics.setGlobalMeterProvider(meterProvider)) {\n log?.warn(\n \"A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider\",\n );\n }\n\n const meter = meterProvider.getMeter(\"@goopil/clusterkit\", PLUGIN_VERSION);\n\n if (cluster.isPrimary) {\n clearPrimaryListeners();\n log?.debug(\"Plugin installed on primary process\");\n primaryOrchestrator = orchestrator;\n\n const activeWorkersGauge = meter.createObservableGauge(`${prefix}active_workers`, {\n description: \"Number of active cluster workers\",\n });\n activeWorkersGauge.addCallback((result) => {\n result.observe(orchestrator.getMetrics().activeWorkers);\n });\n\n const workerRestartsCounter = meter.createCounter(`${prefix}worker.restarts`, {\n description: \"Total number of worker restarts\",\n });\n const workerCrashesCounter = meter.createCounter(`${prefix}worker.crashes`, {\n description: \"Total number of worker crashes\",\n });\n const circuitBreakerTripsCounter = meter.createCounter(`${prefix}circuit_breaker.trips`, {\n description: \"Total number of circuit breaker trips\",\n });\n\n const bind = (event: PrimaryEvent, listener: () => void): void => {\n orchestrator.on(event, listener);\n primaryListeners.push({ event, listener });\n };\n\n bind(\"worker:crash\", () => {\n workerCrashesCounter.add(1);\n });\n bind(\"worker:restart\", () => {\n workerRestartsCounter.add(1);\n });\n bind(\"circuit-breaker:tripped\", () => {\n circuitBreakerTripsCounter.add(1);\n });\n\n const singleWorker = orchestrator.workerCount === 1;\n if (singleWorker && instrumentation) {\n await startHostMetrics(meterProvider);\n }\n } else {\n log?.debug(\"Plugin installed on worker process\");\n\n if (instrumentation) {\n await startHostMetrics(meterProvider);\n }\n }\n\n orchestrator.registerOnShutdown(async () => {\n await shutdownProvider();\n });\n },\n\n async uninstall(): Promise<void> {\n clearPrimaryListeners();\n await shutdownProvider();\n },\n\n async shutdown(): Promise<void> {\n await shutdownProvider();\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["pkgJson.version","withLoggerPrefix","resourceFromAttributes","ATTR_SERVICE_NAME","ATTR_SERVICE_INSTANCE_ID","randomUUID","os","PeriodicExportingMetricReader","MeterProvider","metrics","cluster"],"sources":["../package.json","../src/index.ts"],"sourcesContent":["","import cluster from \"node:cluster\";\nimport { randomUUID } from \"node:crypto\";\nimport os from \"node:os\";\nimport {\n type Logger,\n type Orchestrator,\n type OrchestratorEvents,\n type ResolvedConfig,\n withLoggerPrefix,\n} from \"@goopil/clusterkit\";\nimport { metrics, type ObservableResult } from \"@opentelemetry/api\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport { MeterProvider, PeriodicExportingMetricReader, type PushMetricExporter } from \"@opentelemetry/sdk-metrics\";\nimport { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME } from \"@opentelemetry/semantic-conventions\";\nimport pkgJson from \"../package.json\" with { type: \"json\" };\nimport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\nexport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\ntype PrimaryEvent =\n | \"worker:crash\"\n | \"worker:restart\"\n | \"circuit-breaker:tripped\"\n | \"worker:health\"\n | \"worker:exit\"\n | \"worker:recycle\"\n | \"worker:wedged\"\n | \"fleet:recovered\";\n\nconst DEFAULT_HTTP_ENDPOINT = \"http://localhost:4318/v1/metrics\";\nconst DEFAULT_GRPC_ENDPOINT = \"localhost:4317\";\n\nconst ATTR_HOST_NAME = \"host.name\";\nconst ATTR_PROCESS_PID = \"process.pid\";\n\nconst PLUGIN_VERSION: string = pkgJson.version;\n\nfunction isMissingModuleError(err: unknown): boolean {\n const code = (err as NodeJS.ErrnoException | undefined)?.code;\n return code === \"ERR_MODULE_NOT_FOUND\" || code === \"MODULE_NOT_FOUND\";\n}\n\nexport function createOtlpMeterPlugin(options: OtlpMeterPluginOptions = {}): OtlpMeterPlugin {\n const {\n protocol = \"http\",\n instrumentation = true,\n prefix = \"clusterkit.\",\n attributes = {},\n exportIntervalMs = 60_000,\n serviceName = \"clusterkit\",\n endpoint,\n headers,\n } = options;\n\n if (!Number.isFinite(exportIntervalMs) || exportIntervalMs < 1_000) {\n throw new TypeError(\n \"otlp-meter plugin: exportIntervalMs must be a finite number >= 1000 (minimum 1s to avoid flooding the collector)\",\n );\n }\n\n const resolvedEndpoint = endpoint ?? (protocol === \"grpc\" ? DEFAULT_GRPC_ENDPOINT : DEFAULT_HTTP_ENDPOINT);\n\n if (protocol === \"http\") {\n try {\n new URL(resolvedEndpoint);\n } catch {\n throw new TypeError(`otlp-meter plugin: invalid endpoint URL \"${resolvedEndpoint}\"`);\n }\n }\n\n let meterProvider: MeterProvider | undefined;\n let isShutdown = false;\n let pluginSetGlobalProvider = false;\n let pluginLog: Logger | null = null;\n let primaryOrchestrator: Orchestrator | undefined;\n interface WorkerHealthSample {\n pid: number;\n rss: number;\n heapUsed: number;\n eventLoopLagMs: number;\n lastBeatAt: number;\n }\n\n // Listeners stored payload-agnostic; the concrete payload type is inferred at\n // the bind() call site via OrchestratorEvents[E].\n const primaryListeners: Array<{ event: PrimaryEvent; listener: (...args: never[]) => void }> = [];\n const workerHealth = new Map<number, WorkerHealthSample>();\n\n const clearPrimaryListeners = (): void => {\n if (!primaryOrchestrator) return;\n for (const { event, listener } of primaryListeners) {\n primaryOrchestrator.off(event, listener);\n }\n primaryListeners.length = 0;\n primaryOrchestrator = undefined;\n };\n\n async function createExporter(): Promise<PushMetricExporter> {\n const exporterModuleName =\n protocol === \"grpc\" ? \"@opentelemetry/exporter-metrics-otlp-grpc\" : \"@opentelemetry/exporter-metrics-otlp-http\";\n\n // The gRPC exporter config omits `headers` (gRPC uses `metadata` instead):\n // forwarding them would be silently dropped, leaving exports unauthenticated.\n if (protocol === \"grpc\" && headers && Object.keys(headers).length > 0) {\n pluginLog?.warn(\n \"headers are not supported by the gRPC exporter — configure metadata via the exporter's own options or use protocol: 'http'\",\n );\n }\n\n try {\n const mod = await import(exporterModuleName);\n const config = protocol === \"grpc\" ? { url: resolvedEndpoint } : { url: resolvedEndpoint, headers };\n return new mod.OTLPMetricExporter(config) as PushMetricExporter;\n } catch (err) {\n if (isMissingModuleError(err)) {\n throw new Error(\n `otlp-meter plugin: protocol '${protocol}' requires ${exporterModuleName} — install it or use protocol '${protocol === \"grpc\" ? \"http\" : \"grpc\"}'`,\n );\n }\n throw err;\n }\n }\n\n async function startHostMetrics(provider: MeterProvider): Promise<void> {\n try {\n const mod = await import(\"@opentelemetry/host-metrics\");\n const hostMetrics = new mod.HostMetrics({ meterProvider: provider });\n hostMetrics.start();\n } catch (err) {\n if (isMissingModuleError(err)) {\n pluginLog?.warn(\"host-metrics package not installed — skipping process metrics\");\n } else {\n throw err;\n }\n }\n }\n\n async function shutdownProvider(): Promise<void> {\n if (isShutdown || !meterProvider) return;\n isShutdown = true;\n await meterProvider.shutdown();\n meterProvider = undefined;\n }\n\n return {\n name: \"otlp-meter\",\n\n get meterProvider() {\n return meterProvider;\n },\n\n async install(orchestrator: Orchestrator, logger: Logger | null, _config: ResolvedConfig): Promise<void> {\n const log = withLoggerPrefix(logger, \"clusterkit:otlp-meter\");\n pluginLog = log;\n\n // Reinstalling the same instance after a shutdown must not orphan the old\n // provider or leave the latch stuck at `true` (the new provider's shutdown\n // would otherwise be a permanent no-op).\n if (meterProvider) await shutdownProvider();\n isShutdown = false;\n\n const resource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_INSTANCE_ID]: randomUUID(),\n [ATTR_HOST_NAME]: os.hostname(),\n [ATTR_PROCESS_PID]: process.pid,\n ...attributes,\n });\n\n const exporter = await createExporter();\n\n const metricReader = new PeriodicExportingMetricReader({\n exporter,\n exportIntervalMillis: exportIntervalMs,\n });\n\n meterProvider = new MeterProvider({\n resource,\n readers: [metricReader],\n });\n\n // setGlobalMeterProvider() refuses to override an existing registration\n // and returns false — never clobber the app's own OTel setup.\n if (metrics.setGlobalMeterProvider(meterProvider)) {\n pluginSetGlobalProvider = true;\n } else {\n log?.warn(\n \"A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider\",\n );\n }\n\n const meter = meterProvider.getMeter(\"@goopil/clusterkit\", PLUGIN_VERSION);\n\n if (cluster.isPrimary) {\n clearPrimaryListeners();\n log?.debug(\"Plugin installed on primary process\");\n primaryOrchestrator = orchestrator;\n\n const activeWorkersGauge = meter.createObservableGauge(`${prefix}active_workers`, {\n description: \"Number of active cluster workers\",\n });\n activeWorkersGauge.addCallback((result) => {\n result.observe(orchestrator.getMetrics().activeWorkers);\n });\n\n const workerRestartsCounter = meter.createCounter(`${prefix}worker.restarts`, {\n description: \"Total number of worker restarts\",\n });\n const workerCrashesCounter = meter.createCounter(`${prefix}worker.crashes`, {\n description: \"Total number of worker crashes\",\n });\n const circuitBreakerTripsCounter = meter.createCounter(`${prefix}circuit_breaker.trips`, {\n description: \"Total number of circuit breaker trips\",\n });\n\n const workerRssGauge = meter.createObservableGauge(`${prefix}worker.rss_bytes`, {\n description: \"Resident set size per worker from health heartbeats\",\n unit: \"By\",\n });\n const workerHeapGauge = meter.createObservableGauge(`${prefix}worker.heap_used_bytes`, {\n description: \"V8 heap used per worker from health heartbeats\",\n unit: \"By\",\n });\n const workerLagGauge = meter.createObservableGauge(`${prefix}worker.eventloop_lag_ms`, {\n description: \"Event loop lag per worker from health heartbeats\",\n unit: \"ms\",\n });\n const workerHeartbeatAgeGauge = meter.createObservableGauge(`${prefix}worker.heartbeat_age_seconds`, {\n description: \"Seconds since the last health heartbeat per worker\",\n unit: \"s\",\n });\n\n const observeWorkerHealth = (\n result: ObservableResult<number>,\n pick: (sample: WorkerHealthSample) => number,\n ): void => {\n for (const [workerId, sample] of workerHealth) {\n result.observe(pick(sample), { \"worker.id\": workerId, \"process.pid\": sample.pid });\n }\n };\n\n workerRssGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.rss));\n workerHeapGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.heapUsed));\n workerLagGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.eventLoopLagMs));\n workerHeartbeatAgeGauge.addCallback((result) => {\n const now = Date.now();\n observeWorkerHealth(result, (s) => Math.max(0, (now - s.lastBeatAt) / 1000));\n });\n\n const workerRecyclesCounter = meter.createCounter(`${prefix}worker.recycles`, {\n description: \"Total number of worker recycles by reason\",\n });\n const wedgedKillsCounter = meter.createCounter(`${prefix}worker.wedged.kills`, {\n description: \"Total number of workers killed for being unresponsive\",\n });\n const recoveryDurationGauge = meter.createGauge(`${prefix}recovery.duration_seconds`, {\n description: \"Duration of the last fleet degraded-to-recovered cycle\",\n unit: \"s\",\n });\n\n const fleetTargetGauge = meter.createObservableGauge(`${prefix}fleet.target_workers`, {\n description: \"Target worker count (live fleet health)\",\n });\n const fleetActiveGauge = meter.createObservableGauge(`${prefix}fleet.active_workers`, {\n description: \"Currently active workers (live fleet health)\",\n });\n const fleetQuarantinedGauge = meter.createObservableGauge(`${prefix}fleet.quarantined_slots`, {\n description: \"Quarantined worker slots (live fleet health)\",\n });\n\n fleetTargetGauge.addCallback((result) => {\n result.observe(orchestrator.getFleetHealth().target);\n });\n fleetActiveGauge.addCallback((result) => {\n result.observe(orchestrator.getFleetHealth().active);\n });\n fleetQuarantinedGauge.addCallback((result) => {\n result.observe(orchestrator.getFleetHealth().quarantined);\n });\n\n const bind = <E extends PrimaryEvent>(event: E, listener: (...args: OrchestratorEvents[E]) => void): void => {\n orchestrator.on(event, listener);\n primaryListeners.push({ event, listener });\n };\n\n bind(\"worker:crash\", () => {\n workerCrashesCounter.add(1);\n });\n bind(\"worker:restart\", () => {\n workerRestartsCounter.add(1);\n });\n bind(\"circuit-breaker:tripped\", () => {\n circuitBreakerTripsCounter.add(1);\n });\n\n bind(\"worker:health\", ({ workerId, pid, rss, heapUsed, eventLoopLagMs }) => {\n workerHealth.set(workerId, { pid, rss, heapUsed, eventLoopLagMs, lastBeatAt: Date.now() });\n });\n bind(\"worker:exit\", ({ workerId }) => {\n workerHealth.delete(workerId);\n });\n\n bind(\"worker:recycle\", ({ reason }) => {\n workerRecyclesCounter.add(1, { reason });\n });\n bind(\"worker:wedged\", () => {\n wedgedKillsCounter.add(1);\n });\n bind(\"fleet:recovered\", ({ degradedDurationMs }) => {\n recoveryDurationGauge.record(degradedDurationMs / 1000);\n });\n\n const singleWorker = orchestrator.workerCount === 1;\n if (singleWorker && instrumentation) {\n await startHostMetrics(meterProvider);\n }\n } else {\n log?.debug(\"Plugin installed on worker process\");\n\n if (instrumentation) {\n await startHostMetrics(meterProvider);\n }\n }\n\n orchestrator.registerOnShutdown(async () => {\n await shutdownProvider();\n });\n },\n\n async uninstall(): Promise<void> {\n clearPrimaryListeners();\n workerHealth.clear();\n // If the plugin's provider became the global one, release the slot:\n // metrics.disable() is the API's public unregister and restores the exact\n // pre-install state (getMeterProvider() falls back to the noop provider).\n // Restoring the captured noop prior via setGlobalMeterProvider() instead\n // would leave the slot occupied and block the app from registering later.\n if (pluginSetGlobalProvider) {\n metrics.disable();\n pluginSetGlobalProvider = false;\n }\n await shutdownProvider();\n },\n\n async shutdown(): Promise<void> {\n await shutdownProvider();\n },\n };\n}\n"],"mappings":"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"}
package/dist/index.d.cts CHANGED
@@ -9,6 +9,14 @@ interface OtlpMeterPluginOptions {
9
9
  * If you pass a full URL, it overrides the default for the selected protocol.
10
10
  */
11
11
  endpoint?: string;
12
+ /**
13
+ * Custom headers attached to every OTLP export request
14
+ * (e.g. `Authorization` for authenticated collectors).
15
+ * Applies to `protocol: 'http'` only — with `'grpc'` the gRPC exporter
16
+ * does not support headers; they are ignored and a warning is logged.
17
+ * Configure metadata on the exporter directly instead.
18
+ */
19
+ headers?: Record<string, string>;
12
20
  /** OTLP transport protocol. @default 'http' */
13
21
  protocol?: "http" | "grpc";
14
22
  /** Collect Node.js host/process metrics (CPU, memory, GC, event loop). @default true */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/index.ts"],"mappings":";;;UAGiB;;;;;;;EAOf;;EAGA;;EAGA;;EAGA;;EAGA,aAAa;;EAGb;;EAGA;;UAGe,wBAAwB;;;;;WAK9B,eAAe;;;;;EAMxB,YAAY;;;;iBCdE,sBAAsB,UAAS,yBAA8B"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/index.ts"],"mappings":";;;UAGiB;;;;;;;EAOf;;;;;;;;EASA,UAAU;;EAGV;;EAGA;;EAGA;;EAGA,aAAa;;EAGb;;EAGA;;UAGe,wBAAwB;;;;;WAK9B,eAAe;;;;;EAMxB,YAAY;;;;iBCTE,sBAAsB,UAAS,yBAA8B"}
package/dist/index.d.mts CHANGED
@@ -9,6 +9,14 @@ interface OtlpMeterPluginOptions {
9
9
  * If you pass a full URL, it overrides the default for the selected protocol.
10
10
  */
11
11
  endpoint?: string;
12
+ /**
13
+ * Custom headers attached to every OTLP export request
14
+ * (e.g. `Authorization` for authenticated collectors).
15
+ * Applies to `protocol: 'http'` only — with `'grpc'` the gRPC exporter
16
+ * does not support headers; they are ignored and a warning is logged.
17
+ * Configure metadata on the exporter directly instead.
18
+ */
19
+ headers?: Record<string, string>;
12
20
  /** OTLP transport protocol. @default 'http' */
13
21
  protocol?: "http" | "grpc";
14
22
  /** Collect Node.js host/process metrics (CPU, memory, GC, event loop). @default true */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/index.ts"],"mappings":";;;UAGiB;;;;;;;EAOf;;EAGA;;EAGA;;EAGA;;EAGA,aAAa;;EAGb;;EAGA;;UAGe,wBAAwB;;;;;WAK9B,eAAe;;;;;EAMxB,YAAY;;;;iBCdE,sBAAsB,UAAS,yBAA8B"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/index.ts"],"mappings":";;;UAGiB;;;;;;;EAOf;;;;;;;;EASA,UAAU;;EAGV;;EAGA;;EAGA;;EAGA,aAAa;;EAGb;;EAGA;;UAGe,wBAAwB;;;;;WAK9B,eAAe;;;;;EAMxB,YAAY;;;;iBCTE,sBAAsB,UAAS,yBAA8B"}
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk
8
8
  import { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
9
9
 
10
10
  //#region package.json
11
- var version = "1.1.3";
11
+ var version = "1.3.0";
12
12
 
13
13
  //#endregion
14
14
  //#region src/index.ts
@@ -22,7 +22,7 @@ function isMissingModuleError(err) {
22
22
  return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
23
23
  }
24
24
  function createOtlpMeterPlugin(options = {}) {
25
- const { protocol = "http", instrumentation = true, prefix = "clusterkit.", attributes = {}, exportIntervalMs = 6e4, serviceName = "clusterkit", endpoint } = options;
25
+ const { protocol = "http", instrumentation = true, prefix = "clusterkit.", attributes = {}, exportIntervalMs = 6e4, serviceName = "clusterkit", endpoint, headers } = options;
26
26
  if (!Number.isFinite(exportIntervalMs) || exportIntervalMs < 1e3) throw new TypeError("otlp-meter plugin: exportIntervalMs must be a finite number >= 1000 (minimum 1s to avoid flooding the collector)");
27
27
  const resolvedEndpoint = endpoint ?? (protocol === "grpc" ? DEFAULT_GRPC_ENDPOINT : DEFAULT_HTTP_ENDPOINT);
28
28
  if (protocol === "http") try {
@@ -32,9 +32,11 @@ function createOtlpMeterPlugin(options = {}) {
32
32
  }
33
33
  let meterProvider;
34
34
  let isShutdown = false;
35
+ let pluginSetGlobalProvider = false;
35
36
  let pluginLog = null;
36
37
  let primaryOrchestrator;
37
38
  const primaryListeners = [];
39
+ const workerHealth = /* @__PURE__ */ new Map();
38
40
  const clearPrimaryListeners = () => {
39
41
  if (!primaryOrchestrator) return;
40
42
  for (const { event, listener } of primaryListeners) primaryOrchestrator.off(event, listener);
@@ -43,8 +45,14 @@ function createOtlpMeterPlugin(options = {}) {
43
45
  };
44
46
  async function createExporter() {
45
47
  const exporterModuleName = protocol === "grpc" ? "@opentelemetry/exporter-metrics-otlp-grpc" : "@opentelemetry/exporter-metrics-otlp-http";
48
+ if (protocol === "grpc" && headers && Object.keys(headers).length > 0) pluginLog?.warn("headers are not supported by the gRPC exporter — configure metadata via the exporter's own options or use protocol: 'http'");
46
49
  try {
47
- return new (await (import(exporterModuleName))).OTLPMetricExporter({ url: resolvedEndpoint });
50
+ const mod = await import(exporterModuleName);
51
+ const config = protocol === "grpc" ? { url: resolvedEndpoint } : {
52
+ url: resolvedEndpoint,
53
+ headers
54
+ };
55
+ return new mod.OTLPMetricExporter(config);
48
56
  } catch (err) {
49
57
  if (isMissingModuleError(err)) throw new Error(`otlp-meter plugin: protocol '${protocol}' requires ${exporterModuleName} — install it or use protocol '${protocol === "grpc" ? "http" : "grpc"}'`);
50
58
  throw err;
@@ -90,7 +98,8 @@ function createOtlpMeterPlugin(options = {}) {
90
98
  resource,
91
99
  readers: [metricReader]
92
100
  });
93
- if (!metrics.setGlobalMeterProvider(meterProvider)) log?.warn("A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider");
101
+ if (metrics.setGlobalMeterProvider(meterProvider)) pluginSetGlobalProvider = true;
102
+ else log?.warn("A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider");
94
103
  const meter = meterProvider.getMeter("@goopil/clusterkit", PLUGIN_VERSION);
95
104
  if (cluster.isPrimary) {
96
105
  clearPrimaryListeners();
@@ -102,6 +111,53 @@ function createOtlpMeterPlugin(options = {}) {
102
111
  const workerRestartsCounter = meter.createCounter(`${prefix}worker.restarts`, { description: "Total number of worker restarts" });
103
112
  const workerCrashesCounter = meter.createCounter(`${prefix}worker.crashes`, { description: "Total number of worker crashes" });
104
113
  const circuitBreakerTripsCounter = meter.createCounter(`${prefix}circuit_breaker.trips`, { description: "Total number of circuit breaker trips" });
114
+ const workerRssGauge = meter.createObservableGauge(`${prefix}worker.rss_bytes`, {
115
+ description: "Resident set size per worker from health heartbeats",
116
+ unit: "By"
117
+ });
118
+ const workerHeapGauge = meter.createObservableGauge(`${prefix}worker.heap_used_bytes`, {
119
+ description: "V8 heap used per worker from health heartbeats",
120
+ unit: "By"
121
+ });
122
+ const workerLagGauge = meter.createObservableGauge(`${prefix}worker.eventloop_lag_ms`, {
123
+ description: "Event loop lag per worker from health heartbeats",
124
+ unit: "ms"
125
+ });
126
+ const workerHeartbeatAgeGauge = meter.createObservableGauge(`${prefix}worker.heartbeat_age_seconds`, {
127
+ description: "Seconds since the last health heartbeat per worker",
128
+ unit: "s"
129
+ });
130
+ const observeWorkerHealth = (result, pick) => {
131
+ for (const [workerId, sample] of workerHealth) result.observe(pick(sample), {
132
+ "worker.id": workerId,
133
+ "process.pid": sample.pid
134
+ });
135
+ };
136
+ workerRssGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.rss));
137
+ workerHeapGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.heapUsed));
138
+ workerLagGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.eventLoopLagMs));
139
+ workerHeartbeatAgeGauge.addCallback((result) => {
140
+ const now = Date.now();
141
+ observeWorkerHealth(result, (s) => Math.max(0, (now - s.lastBeatAt) / 1e3));
142
+ });
143
+ const workerRecyclesCounter = meter.createCounter(`${prefix}worker.recycles`, { description: "Total number of worker recycles by reason" });
144
+ const wedgedKillsCounter = meter.createCounter(`${prefix}worker.wedged.kills`, { description: "Total number of workers killed for being unresponsive" });
145
+ const recoveryDurationGauge = meter.createGauge(`${prefix}recovery.duration_seconds`, {
146
+ description: "Duration of the last fleet degraded-to-recovered cycle",
147
+ unit: "s"
148
+ });
149
+ const fleetTargetGauge = meter.createObservableGauge(`${prefix}fleet.target_workers`, { description: "Target worker count (live fleet health)" });
150
+ const fleetActiveGauge = meter.createObservableGauge(`${prefix}fleet.active_workers`, { description: "Currently active workers (live fleet health)" });
151
+ const fleetQuarantinedGauge = meter.createObservableGauge(`${prefix}fleet.quarantined_slots`, { description: "Quarantined worker slots (live fleet health)" });
152
+ fleetTargetGauge.addCallback((result) => {
153
+ result.observe(orchestrator.getFleetHealth().target);
154
+ });
155
+ fleetActiveGauge.addCallback((result) => {
156
+ result.observe(orchestrator.getFleetHealth().active);
157
+ });
158
+ fleetQuarantinedGauge.addCallback((result) => {
159
+ result.observe(orchestrator.getFleetHealth().quarantined);
160
+ });
105
161
  const bind = (event, listener) => {
106
162
  orchestrator.on(event, listener);
107
163
  primaryListeners.push({
@@ -118,6 +174,27 @@ function createOtlpMeterPlugin(options = {}) {
118
174
  bind("circuit-breaker:tripped", () => {
119
175
  circuitBreakerTripsCounter.add(1);
120
176
  });
177
+ bind("worker:health", ({ workerId, pid, rss, heapUsed, eventLoopLagMs }) => {
178
+ workerHealth.set(workerId, {
179
+ pid,
180
+ rss,
181
+ heapUsed,
182
+ eventLoopLagMs,
183
+ lastBeatAt: Date.now()
184
+ });
185
+ });
186
+ bind("worker:exit", ({ workerId }) => {
187
+ workerHealth.delete(workerId);
188
+ });
189
+ bind("worker:recycle", ({ reason }) => {
190
+ workerRecyclesCounter.add(1, { reason });
191
+ });
192
+ bind("worker:wedged", () => {
193
+ wedgedKillsCounter.add(1);
194
+ });
195
+ bind("fleet:recovered", ({ degradedDurationMs }) => {
196
+ recoveryDurationGauge.record(degradedDurationMs / 1e3);
197
+ });
121
198
  if (orchestrator.workerCount === 1 && instrumentation) await startHostMetrics(meterProvider);
122
199
  } else {
123
200
  log?.debug("Plugin installed on worker process");
@@ -129,6 +206,11 @@ function createOtlpMeterPlugin(options = {}) {
129
206
  },
130
207
  async uninstall() {
131
208
  clearPrimaryListeners();
209
+ workerHealth.clear();
210
+ if (pluginSetGlobalProvider) {
211
+ metrics.disable();
212
+ pluginSetGlobalProvider = false;
213
+ }
132
214
  await shutdownProvider();
133
215
  },
134
216
  async shutdown() {
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["pkgJson.version"],"sources":["../package.json","../src/index.ts"],"sourcesContent":["","import cluster from \"node:cluster\";\nimport { randomUUID } from \"node:crypto\";\nimport os from \"node:os\";\nimport { type Logger, type Orchestrator, type ResolvedConfig, withLoggerPrefix } from \"@goopil/clusterkit\";\nimport { metrics } from \"@opentelemetry/api\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport { MeterProvider, PeriodicExportingMetricReader, type PushMetricExporter } from \"@opentelemetry/sdk-metrics\";\nimport { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME } from \"@opentelemetry/semantic-conventions\";\nimport pkgJson from \"../package.json\" with { type: \"json\" };\nimport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\nexport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\ntype PrimaryEvent = \"worker:crash\" | \"worker:restart\" | \"circuit-breaker:tripped\";\n\nconst DEFAULT_HTTP_ENDPOINT = \"http://localhost:4318/v1/metrics\";\nconst DEFAULT_GRPC_ENDPOINT = \"localhost:4317\";\n\nconst ATTR_HOST_NAME = \"host.name\";\nconst ATTR_PROCESS_PID = \"process.pid\";\n\nconst PLUGIN_VERSION: string = pkgJson.version;\n\nfunction isMissingModuleError(err: unknown): boolean {\n const code = (err as NodeJS.ErrnoException | undefined)?.code;\n return code === \"ERR_MODULE_NOT_FOUND\" || code === \"MODULE_NOT_FOUND\";\n}\n\nexport function createOtlpMeterPlugin(options: OtlpMeterPluginOptions = {}): OtlpMeterPlugin {\n const {\n protocol = \"http\",\n instrumentation = true,\n prefix = \"clusterkit.\",\n attributes = {},\n exportIntervalMs = 60_000,\n serviceName = \"clusterkit\",\n endpoint,\n } = options;\n\n if (!Number.isFinite(exportIntervalMs) || exportIntervalMs < 1_000) {\n throw new TypeError(\n \"otlp-meter plugin: exportIntervalMs must be a finite number >= 1000 (minimum 1s to avoid flooding the collector)\",\n );\n }\n\n const resolvedEndpoint = endpoint ?? (protocol === \"grpc\" ? DEFAULT_GRPC_ENDPOINT : DEFAULT_HTTP_ENDPOINT);\n\n if (protocol === \"http\") {\n try {\n new URL(resolvedEndpoint);\n } catch {\n throw new TypeError(`otlp-meter plugin: invalid endpoint URL \"${resolvedEndpoint}\"`);\n }\n }\n\n let meterProvider: MeterProvider | undefined;\n let isShutdown = false;\n let pluginLog: Logger | null = null;\n let primaryOrchestrator: Orchestrator | undefined;\n const primaryListeners: Array<{ event: PrimaryEvent; listener: () => void }> = [];\n\n const clearPrimaryListeners = (): void => {\n if (!primaryOrchestrator) return;\n for (const { event, listener } of primaryListeners) {\n primaryOrchestrator.off(event, listener);\n }\n primaryListeners.length = 0;\n primaryOrchestrator = undefined;\n };\n\n async function createExporter(): Promise<PushMetricExporter> {\n const exporterModuleName =\n protocol === \"grpc\" ? \"@opentelemetry/exporter-metrics-otlp-grpc\" : \"@opentelemetry/exporter-metrics-otlp-http\";\n\n try {\n const mod = await import(exporterModuleName);\n return new mod.OTLPMetricExporter({ url: resolvedEndpoint }) as PushMetricExporter;\n } catch (err) {\n if (isMissingModuleError(err)) {\n throw new Error(\n `otlp-meter plugin: protocol '${protocol}' requires ${exporterModuleName} — install it or use protocol '${protocol === \"grpc\" ? \"http\" : \"grpc\"}'`,\n );\n }\n throw err;\n }\n }\n\n async function startHostMetrics(provider: MeterProvider): Promise<void> {\n try {\n const mod = await import(\"@opentelemetry/host-metrics\");\n const hostMetrics = new mod.HostMetrics({ meterProvider: provider });\n hostMetrics.start();\n } catch (err) {\n if (isMissingModuleError(err)) {\n pluginLog?.warn(\"host-metrics package not installed — skipping process metrics\");\n } else {\n throw err;\n }\n }\n }\n\n async function shutdownProvider(): Promise<void> {\n if (isShutdown || !meterProvider) return;\n isShutdown = true;\n await meterProvider.shutdown();\n meterProvider = undefined;\n }\n\n return {\n name: \"otlp-meter\",\n\n get meterProvider() {\n return meterProvider;\n },\n\n async install(orchestrator: Orchestrator, logger: Logger | null, _config: ResolvedConfig): Promise<void> {\n const log = withLoggerPrefix(logger, \"clusterkit:otlp-meter\");\n pluginLog = log;\n\n // Reinstalling the same instance after a shutdown must not orphan the old\n // provider or leave the latch stuck at `true` (the new provider's shutdown\n // would otherwise be a permanent no-op).\n if (meterProvider) await shutdownProvider();\n isShutdown = false;\n\n const resource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_INSTANCE_ID]: randomUUID(),\n [ATTR_HOST_NAME]: os.hostname(),\n [ATTR_PROCESS_PID]: process.pid,\n ...attributes,\n });\n\n const exporter = await createExporter();\n\n const metricReader = new PeriodicExportingMetricReader({\n exporter,\n exportIntervalMillis: exportIntervalMs,\n });\n\n meterProvider = new MeterProvider({\n resource,\n readers: [metricReader],\n });\n\n // setGlobalMeterProvider() refuses to override an existing registration\n // and returns false — never clobber the app's own OTel setup.\n if (!metrics.setGlobalMeterProvider(meterProvider)) {\n log?.warn(\n \"A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider\",\n );\n }\n\n const meter = meterProvider.getMeter(\"@goopil/clusterkit\", PLUGIN_VERSION);\n\n if (cluster.isPrimary) {\n clearPrimaryListeners();\n log?.debug(\"Plugin installed on primary process\");\n primaryOrchestrator = orchestrator;\n\n const activeWorkersGauge = meter.createObservableGauge(`${prefix}active_workers`, {\n description: \"Number of active cluster workers\",\n });\n activeWorkersGauge.addCallback((result) => {\n result.observe(orchestrator.getMetrics().activeWorkers);\n });\n\n const workerRestartsCounter = meter.createCounter(`${prefix}worker.restarts`, {\n description: \"Total number of worker restarts\",\n });\n const workerCrashesCounter = meter.createCounter(`${prefix}worker.crashes`, {\n description: \"Total number of worker crashes\",\n });\n const circuitBreakerTripsCounter = meter.createCounter(`${prefix}circuit_breaker.trips`, {\n description: \"Total number of circuit breaker trips\",\n });\n\n const bind = (event: PrimaryEvent, listener: () => void): void => {\n orchestrator.on(event, listener);\n primaryListeners.push({ event, listener });\n };\n\n bind(\"worker:crash\", () => {\n workerCrashesCounter.add(1);\n });\n bind(\"worker:restart\", () => {\n workerRestartsCounter.add(1);\n });\n bind(\"circuit-breaker:tripped\", () => {\n circuitBreakerTripsCounter.add(1);\n });\n\n const singleWorker = orchestrator.workerCount === 1;\n if (singleWorker && instrumentation) {\n await startHostMetrics(meterProvider);\n }\n } else {\n log?.debug(\"Plugin installed on worker process\");\n\n if (instrumentation) {\n await startHostMetrics(meterProvider);\n }\n }\n\n orchestrator.registerOnShutdown(async () => {\n await shutdownProvider();\n });\n },\n\n async uninstall(): Promise<void> {\n clearPrimaryListeners();\n await shutdownProvider();\n },\n\n async shutdown(): Promise<void> {\n await shutdownProvider();\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":["pkgJson.version"],"sources":["../package.json","../src/index.ts"],"sourcesContent":["","import cluster from \"node:cluster\";\nimport { randomUUID } from \"node:crypto\";\nimport os from \"node:os\";\nimport {\n type Logger,\n type Orchestrator,\n type OrchestratorEvents,\n type ResolvedConfig,\n withLoggerPrefix,\n} from \"@goopil/clusterkit\";\nimport { metrics, type ObservableResult } from \"@opentelemetry/api\";\nimport { resourceFromAttributes } from \"@opentelemetry/resources\";\nimport { MeterProvider, PeriodicExportingMetricReader, type PushMetricExporter } from \"@opentelemetry/sdk-metrics\";\nimport { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME } from \"@opentelemetry/semantic-conventions\";\nimport pkgJson from \"../package.json\" with { type: \"json\" };\nimport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\nexport type { OtlpMeterPlugin, OtlpMeterPluginOptions } from \"./types.js\";\n\ntype PrimaryEvent =\n | \"worker:crash\"\n | \"worker:restart\"\n | \"circuit-breaker:tripped\"\n | \"worker:health\"\n | \"worker:exit\"\n | \"worker:recycle\"\n | \"worker:wedged\"\n | \"fleet:recovered\";\n\nconst DEFAULT_HTTP_ENDPOINT = \"http://localhost:4318/v1/metrics\";\nconst DEFAULT_GRPC_ENDPOINT = \"localhost:4317\";\n\nconst ATTR_HOST_NAME = \"host.name\";\nconst ATTR_PROCESS_PID = \"process.pid\";\n\nconst PLUGIN_VERSION: string = pkgJson.version;\n\nfunction isMissingModuleError(err: unknown): boolean {\n const code = (err as NodeJS.ErrnoException | undefined)?.code;\n return code === \"ERR_MODULE_NOT_FOUND\" || code === \"MODULE_NOT_FOUND\";\n}\n\nexport function createOtlpMeterPlugin(options: OtlpMeterPluginOptions = {}): OtlpMeterPlugin {\n const {\n protocol = \"http\",\n instrumentation = true,\n prefix = \"clusterkit.\",\n attributes = {},\n exportIntervalMs = 60_000,\n serviceName = \"clusterkit\",\n endpoint,\n headers,\n } = options;\n\n if (!Number.isFinite(exportIntervalMs) || exportIntervalMs < 1_000) {\n throw new TypeError(\n \"otlp-meter plugin: exportIntervalMs must be a finite number >= 1000 (minimum 1s to avoid flooding the collector)\",\n );\n }\n\n const resolvedEndpoint = endpoint ?? (protocol === \"grpc\" ? DEFAULT_GRPC_ENDPOINT : DEFAULT_HTTP_ENDPOINT);\n\n if (protocol === \"http\") {\n try {\n new URL(resolvedEndpoint);\n } catch {\n throw new TypeError(`otlp-meter plugin: invalid endpoint URL \"${resolvedEndpoint}\"`);\n }\n }\n\n let meterProvider: MeterProvider | undefined;\n let isShutdown = false;\n let pluginSetGlobalProvider = false;\n let pluginLog: Logger | null = null;\n let primaryOrchestrator: Orchestrator | undefined;\n interface WorkerHealthSample {\n pid: number;\n rss: number;\n heapUsed: number;\n eventLoopLagMs: number;\n lastBeatAt: number;\n }\n\n // Listeners stored payload-agnostic; the concrete payload type is inferred at\n // the bind() call site via OrchestratorEvents[E].\n const primaryListeners: Array<{ event: PrimaryEvent; listener: (...args: never[]) => void }> = [];\n const workerHealth = new Map<number, WorkerHealthSample>();\n\n const clearPrimaryListeners = (): void => {\n if (!primaryOrchestrator) return;\n for (const { event, listener } of primaryListeners) {\n primaryOrchestrator.off(event, listener);\n }\n primaryListeners.length = 0;\n primaryOrchestrator = undefined;\n };\n\n async function createExporter(): Promise<PushMetricExporter> {\n const exporterModuleName =\n protocol === \"grpc\" ? \"@opentelemetry/exporter-metrics-otlp-grpc\" : \"@opentelemetry/exporter-metrics-otlp-http\";\n\n // The gRPC exporter config omits `headers` (gRPC uses `metadata` instead):\n // forwarding them would be silently dropped, leaving exports unauthenticated.\n if (protocol === \"grpc\" && headers && Object.keys(headers).length > 0) {\n pluginLog?.warn(\n \"headers are not supported by the gRPC exporter — configure metadata via the exporter's own options or use protocol: 'http'\",\n );\n }\n\n try {\n const mod = await import(exporterModuleName);\n const config = protocol === \"grpc\" ? { url: resolvedEndpoint } : { url: resolvedEndpoint, headers };\n return new mod.OTLPMetricExporter(config) as PushMetricExporter;\n } catch (err) {\n if (isMissingModuleError(err)) {\n throw new Error(\n `otlp-meter plugin: protocol '${protocol}' requires ${exporterModuleName} — install it or use protocol '${protocol === \"grpc\" ? \"http\" : \"grpc\"}'`,\n );\n }\n throw err;\n }\n }\n\n async function startHostMetrics(provider: MeterProvider): Promise<void> {\n try {\n const mod = await import(\"@opentelemetry/host-metrics\");\n const hostMetrics = new mod.HostMetrics({ meterProvider: provider });\n hostMetrics.start();\n } catch (err) {\n if (isMissingModuleError(err)) {\n pluginLog?.warn(\"host-metrics package not installed — skipping process metrics\");\n } else {\n throw err;\n }\n }\n }\n\n async function shutdownProvider(): Promise<void> {\n if (isShutdown || !meterProvider) return;\n isShutdown = true;\n await meterProvider.shutdown();\n meterProvider = undefined;\n }\n\n return {\n name: \"otlp-meter\",\n\n get meterProvider() {\n return meterProvider;\n },\n\n async install(orchestrator: Orchestrator, logger: Logger | null, _config: ResolvedConfig): Promise<void> {\n const log = withLoggerPrefix(logger, \"clusterkit:otlp-meter\");\n pluginLog = log;\n\n // Reinstalling the same instance after a shutdown must not orphan the old\n // provider or leave the latch stuck at `true` (the new provider's shutdown\n // would otherwise be a permanent no-op).\n if (meterProvider) await shutdownProvider();\n isShutdown = false;\n\n const resource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_INSTANCE_ID]: randomUUID(),\n [ATTR_HOST_NAME]: os.hostname(),\n [ATTR_PROCESS_PID]: process.pid,\n ...attributes,\n });\n\n const exporter = await createExporter();\n\n const metricReader = new PeriodicExportingMetricReader({\n exporter,\n exportIntervalMillis: exportIntervalMs,\n });\n\n meterProvider = new MeterProvider({\n resource,\n readers: [metricReader],\n });\n\n // setGlobalMeterProvider() refuses to override an existing registration\n // and returns false — never clobber the app's own OTel setup.\n if (metrics.setGlobalMeterProvider(meterProvider)) {\n pluginSetGlobalProvider = true;\n } else {\n log?.warn(\n \"A global OpenTelemetry meter provider is already registered — leaving it untouched; clusterkit meters use the plugin's own provider\",\n );\n }\n\n const meter = meterProvider.getMeter(\"@goopil/clusterkit\", PLUGIN_VERSION);\n\n if (cluster.isPrimary) {\n clearPrimaryListeners();\n log?.debug(\"Plugin installed on primary process\");\n primaryOrchestrator = orchestrator;\n\n const activeWorkersGauge = meter.createObservableGauge(`${prefix}active_workers`, {\n description: \"Number of active cluster workers\",\n });\n activeWorkersGauge.addCallback((result) => {\n result.observe(orchestrator.getMetrics().activeWorkers);\n });\n\n const workerRestartsCounter = meter.createCounter(`${prefix}worker.restarts`, {\n description: \"Total number of worker restarts\",\n });\n const workerCrashesCounter = meter.createCounter(`${prefix}worker.crashes`, {\n description: \"Total number of worker crashes\",\n });\n const circuitBreakerTripsCounter = meter.createCounter(`${prefix}circuit_breaker.trips`, {\n description: \"Total number of circuit breaker trips\",\n });\n\n const workerRssGauge = meter.createObservableGauge(`${prefix}worker.rss_bytes`, {\n description: \"Resident set size per worker from health heartbeats\",\n unit: \"By\",\n });\n const workerHeapGauge = meter.createObservableGauge(`${prefix}worker.heap_used_bytes`, {\n description: \"V8 heap used per worker from health heartbeats\",\n unit: \"By\",\n });\n const workerLagGauge = meter.createObservableGauge(`${prefix}worker.eventloop_lag_ms`, {\n description: \"Event loop lag per worker from health heartbeats\",\n unit: \"ms\",\n });\n const workerHeartbeatAgeGauge = meter.createObservableGauge(`${prefix}worker.heartbeat_age_seconds`, {\n description: \"Seconds since the last health heartbeat per worker\",\n unit: \"s\",\n });\n\n const observeWorkerHealth = (\n result: ObservableResult<number>,\n pick: (sample: WorkerHealthSample) => number,\n ): void => {\n for (const [workerId, sample] of workerHealth) {\n result.observe(pick(sample), { \"worker.id\": workerId, \"process.pid\": sample.pid });\n }\n };\n\n workerRssGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.rss));\n workerHeapGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.heapUsed));\n workerLagGauge.addCallback((result) => observeWorkerHealth(result, (s) => s.eventLoopLagMs));\n workerHeartbeatAgeGauge.addCallback((result) => {\n const now = Date.now();\n observeWorkerHealth(result, (s) => Math.max(0, (now - s.lastBeatAt) / 1000));\n });\n\n const workerRecyclesCounter = meter.createCounter(`${prefix}worker.recycles`, {\n description: \"Total number of worker recycles by reason\",\n });\n const wedgedKillsCounter = meter.createCounter(`${prefix}worker.wedged.kills`, {\n description: \"Total number of workers killed for being unresponsive\",\n });\n const recoveryDurationGauge = meter.createGauge(`${prefix}recovery.duration_seconds`, {\n description: \"Duration of the last fleet degraded-to-recovered cycle\",\n unit: \"s\",\n });\n\n const fleetTargetGauge = meter.createObservableGauge(`${prefix}fleet.target_workers`, {\n description: \"Target worker count (live fleet health)\",\n });\n const fleetActiveGauge = meter.createObservableGauge(`${prefix}fleet.active_workers`, {\n description: \"Currently active workers (live fleet health)\",\n });\n const fleetQuarantinedGauge = meter.createObservableGauge(`${prefix}fleet.quarantined_slots`, {\n description: \"Quarantined worker slots (live fleet health)\",\n });\n\n fleetTargetGauge.addCallback((result) => {\n result.observe(orchestrator.getFleetHealth().target);\n });\n fleetActiveGauge.addCallback((result) => {\n result.observe(orchestrator.getFleetHealth().active);\n });\n fleetQuarantinedGauge.addCallback((result) => {\n result.observe(orchestrator.getFleetHealth().quarantined);\n });\n\n const bind = <E extends PrimaryEvent>(event: E, listener: (...args: OrchestratorEvents[E]) => void): void => {\n orchestrator.on(event, listener);\n primaryListeners.push({ event, listener });\n };\n\n bind(\"worker:crash\", () => {\n workerCrashesCounter.add(1);\n });\n bind(\"worker:restart\", () => {\n workerRestartsCounter.add(1);\n });\n bind(\"circuit-breaker:tripped\", () => {\n circuitBreakerTripsCounter.add(1);\n });\n\n bind(\"worker:health\", ({ workerId, pid, rss, heapUsed, eventLoopLagMs }) => {\n workerHealth.set(workerId, { pid, rss, heapUsed, eventLoopLagMs, lastBeatAt: Date.now() });\n });\n bind(\"worker:exit\", ({ workerId }) => {\n workerHealth.delete(workerId);\n });\n\n bind(\"worker:recycle\", ({ reason }) => {\n workerRecyclesCounter.add(1, { reason });\n });\n bind(\"worker:wedged\", () => {\n wedgedKillsCounter.add(1);\n });\n bind(\"fleet:recovered\", ({ degradedDurationMs }) => {\n recoveryDurationGauge.record(degradedDurationMs / 1000);\n });\n\n const singleWorker = orchestrator.workerCount === 1;\n if (singleWorker && instrumentation) {\n await startHostMetrics(meterProvider);\n }\n } else {\n log?.debug(\"Plugin installed on worker process\");\n\n if (instrumentation) {\n await startHostMetrics(meterProvider);\n }\n }\n\n orchestrator.registerOnShutdown(async () => {\n await shutdownProvider();\n });\n },\n\n async uninstall(): Promise<void> {\n clearPrimaryListeners();\n workerHealth.clear();\n // If the plugin's provider became the global one, release the slot:\n // metrics.disable() is the API's public unregister and restores the exact\n // pre-install state (getMeterProvider() falls back to the noop provider).\n // Restoring the captured noop prior via setGlobalMeterProvider() instead\n // would leave the slot occupied and block the app from registering later.\n if (pluginSetGlobalProvider) {\n metrics.disable();\n pluginSetGlobalProvider = false;\n }\n await shutdownProvider();\n },\n\n async shutdown(): Promise<void> {\n await shutdownProvider();\n },\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goopil/clusterkit-otlp-meter",
3
- "version": "1.1.3",
3
+ "version": "1.3.0",
4
4
  "description": "OpenTelemetry OTLP metrics plugin for @goopil/clusterkit: exports orchestration counters/gauges and per-worker host metrics via OTLP/HTTP or OTLP/gRPC to a collector.",
5
5
  "keywords": [
6
6
  "opentelemetry",
@@ -52,7 +52,7 @@
52
52
  "@opentelemetry/resources": "^2.0.0",
53
53
  "@opentelemetry/semantic-conventions": "^1.30.0",
54
54
  "@opentelemetry/sdk-metrics": "^2.0.0",
55
- "@goopil/clusterkit": "^1.2.5"
55
+ "@goopil/clusterkit": "^1.3.0"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "@opentelemetry/exporter-metrics-otlp-http": {
@@ -79,7 +79,7 @@
79
79
  "tsdown": "^0.22.14",
80
80
  "typescript": "^7.0.2",
81
81
  "vitest": "^4.1.11",
82
- "@goopil/clusterkit": "1.2.5"
82
+ "@goopil/clusterkit": "1.3.0"
83
83
  },
84
84
  "scripts": {
85
85
  "build": "tsdown",