@frontmcp/observability 1.2.1 → 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/esm/index.mjs CHANGED
@@ -3116,6 +3116,239 @@ init_winston_sink();
3116
3116
  init_pino_sink();
3117
3117
  init_otlp_sink();
3118
3118
 
3119
+ // libs/observability/src/prometheus/render.ts
3120
+ var METRIC_NAME_REGEX = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
3121
+ var LABEL_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
3122
+ var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
3123
+ function escapeLabelValue(value) {
3124
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
3125
+ }
3126
+ function sortedLabelString(attributes) {
3127
+ const keys = Object.keys(attributes).filter((k) => LABEL_NAME_REGEX.test(k)).sort();
3128
+ if (keys.length === 0) return "";
3129
+ const pairs = keys.map((k) => `${k}="${escapeLabelValue(attributes[k] ?? "")}"`);
3130
+ return `{${pairs.join(",")}}`;
3131
+ }
3132
+ function formatFloat(value) {
3133
+ if (!Number.isFinite(value)) return "";
3134
+ if (Number.isInteger(value)) return String(value);
3135
+ return String(value);
3136
+ }
3137
+ function renderPrometheusExposition(counters, gauges = [], options = {}) {
3138
+ const byName = /* @__PURE__ */ new Map();
3139
+ for (const entry of counters) {
3140
+ if (!METRIC_NAME_REGEX.test(entry.name)) continue;
3141
+ const valueStr = formatFloat(entry.count);
3142
+ if (valueStr === "") continue;
3143
+ const labels = sortedLabelString(entry.attributes ?? {});
3144
+ const line = `${entry.name}${labels} ${valueStr}`;
3145
+ let group = byName.get(entry.name);
3146
+ if (!group) {
3147
+ group = {
3148
+ type: "counter",
3149
+ help: options.counterHelp?.[entry.name],
3150
+ lines: []
3151
+ };
3152
+ byName.set(entry.name, group);
3153
+ }
3154
+ group.lines.push(line);
3155
+ }
3156
+ for (const entry of gauges) {
3157
+ if (!METRIC_NAME_REGEX.test(entry.name)) continue;
3158
+ const valueStr = formatFloat(entry.value);
3159
+ if (valueStr === "") continue;
3160
+ const labels = sortedLabelString(entry.attributes ?? {});
3161
+ const line = `${entry.name}${labels} ${valueStr}`;
3162
+ let group = byName.get(entry.name);
3163
+ if (!group) {
3164
+ group = { type: "gauge", help: entry.help, lines: [] };
3165
+ byName.set(entry.name, group);
3166
+ } else if (entry.help && !group.help) {
3167
+ group.help = entry.help;
3168
+ }
3169
+ group.lines.push(line);
3170
+ }
3171
+ if (byName.size === 0) return "";
3172
+ const sortedNames = Array.from(byName.keys()).sort();
3173
+ const blocks = [];
3174
+ for (const name of sortedNames) {
3175
+ const group = byName.get(name);
3176
+ if (!group) continue;
3177
+ const block = [];
3178
+ if (group.help) {
3179
+ block.push(`# HELP ${name} ${group.help.replace(/\n/g, " ")}`);
3180
+ }
3181
+ block.push(`# TYPE ${name} ${group.type}`);
3182
+ block.push(...group.lines.slice().sort());
3183
+ blocks.push(block.join("\n"));
3184
+ }
3185
+ return blocks.join("\n") + "\n";
3186
+ }
3187
+ function renderJsonExposition(counters, gauges = []) {
3188
+ return {
3189
+ counters: counters.filter((c) => METRIC_NAME_REGEX.test(c.name) && Number.isFinite(c.count)).map((c) => ({ name: c.name, count: c.count, attributes: { ...c.attributes ?? {} } })),
3190
+ gauges: gauges.filter((g) => METRIC_NAME_REGEX.test(g.name) && Number.isFinite(g.value)).map((g) => ({
3191
+ name: g.name,
3192
+ value: g.value,
3193
+ attributes: { ...g.attributes ?? {} },
3194
+ ...g.help ? { help: g.help } : {}
3195
+ }))
3196
+ };
3197
+ }
3198
+
3199
+ // libs/observability/src/process-stats/process-stats.collector.ts
3200
+ import { readdirSync } from "@frontmcp/utils";
3201
+ var NS_PER_SECOND = 1e9;
3202
+ var MICROS_PER_SECOND = 1e6;
3203
+ function defaultMonitorEventLoopDelay() {
3204
+ try {
3205
+ const { monitorEventLoopDelay } = __require("node:perf_hooks");
3206
+ const histogram = monitorEventLoopDelay({ resolution: 10 });
3207
+ histogram.enable();
3208
+ return histogram;
3209
+ } catch {
3210
+ return void 0;
3211
+ }
3212
+ }
3213
+ function defaultGetActiveHandles() {
3214
+ const proc = process;
3215
+ if (typeof proc._getActiveHandles !== "function") return void 0;
3216
+ try {
3217
+ return proc._getActiveHandles();
3218
+ } catch {
3219
+ return void 0;
3220
+ }
3221
+ }
3222
+ function defaultGetActiveRequests() {
3223
+ const proc = process;
3224
+ if (typeof proc._getActiveRequests !== "function") return void 0;
3225
+ try {
3226
+ return proc._getActiveRequests();
3227
+ } catch {
3228
+ return void 0;
3229
+ }
3230
+ }
3231
+ function defaultReadFdCount() {
3232
+ if (process.platform !== "linux") return void 0;
3233
+ try {
3234
+ return readdirSync("/proc/self/fd").length;
3235
+ } catch {
3236
+ return void 0;
3237
+ }
3238
+ }
3239
+ var ProcessStatsCollector = class {
3240
+ constructor(init = {}) {
3241
+ this.options = init.options ?? {};
3242
+ this.cpuUsage = init.cpuUsage ?? ((prev) => process.cpuUsage(prev));
3243
+ this.memoryUsage = init.memoryUsage ?? (() => process.memoryUsage());
3244
+ this.uptime = init.uptime ?? (() => process.uptime());
3245
+ this.getActiveHandles = init.getActiveHandles ?? defaultGetActiveHandles;
3246
+ this.getActiveRequests = init.getActiveRequests ?? defaultGetActiveRequests;
3247
+ this.readFdCount = init.readFdCount ?? defaultReadFdCount;
3248
+ this.cpuStart = this.cpuUsage();
3249
+ if (this.options.eventLoopLag !== false) {
3250
+ this.histogram = init.monitorEventLoopDelay ? init.monitorEventLoopDelay() : defaultMonitorEventLoopDelay();
3251
+ }
3252
+ }
3253
+ collect() {
3254
+ const entries = [];
3255
+ const cpu = this.cpuUsage(this.cpuStart);
3256
+ entries.push({
3257
+ name: "frontmcp_process_cpu_seconds_total",
3258
+ value: cpu.user / MICROS_PER_SECOND,
3259
+ attributes: { mode: "user" },
3260
+ help: "CPU time consumed since collector start, by mode (seconds)"
3261
+ });
3262
+ entries.push({
3263
+ name: "frontmcp_process_cpu_seconds_total",
3264
+ value: cpu.system / MICROS_PER_SECOND,
3265
+ attributes: { mode: "system" }
3266
+ });
3267
+ const mem = this.memoryUsage();
3268
+ entries.push({
3269
+ name: "frontmcp_process_resident_memory_bytes",
3270
+ value: mem.rss,
3271
+ help: "Resident memory size in bytes"
3272
+ });
3273
+ entries.push({
3274
+ name: "frontmcp_process_heap_bytes",
3275
+ value: mem.heapTotal,
3276
+ help: "Total V8 heap size in bytes"
3277
+ });
3278
+ entries.push({
3279
+ name: "frontmcp_process_heap_used_bytes",
3280
+ value: mem.heapUsed,
3281
+ help: "Used V8 heap size in bytes"
3282
+ });
3283
+ entries.push({
3284
+ name: "frontmcp_process_external_bytes",
3285
+ value: mem.external,
3286
+ help: "Memory used by C++ objects bound to JS in bytes"
3287
+ });
3288
+ entries.push({
3289
+ name: "frontmcp_process_uptime_seconds",
3290
+ value: this.uptime(),
3291
+ help: "Time since process start in seconds"
3292
+ });
3293
+ if (this.options.eventLoopLag !== false && this.histogram) {
3294
+ const meanSeconds = this.histogram.mean / NS_PER_SECOND;
3295
+ const p99Seconds = this.histogram.percentile(99) / NS_PER_SECOND;
3296
+ if (Number.isFinite(meanSeconds)) {
3297
+ entries.push({
3298
+ name: "frontmcp_nodejs_eventloop_lag_seconds",
3299
+ value: meanSeconds,
3300
+ attributes: { quantile: "mean" },
3301
+ help: "Event-loop lag in seconds, sampled at 10ms resolution since the last scrape"
3302
+ });
3303
+ }
3304
+ if (Number.isFinite(p99Seconds)) {
3305
+ entries.push({
3306
+ name: "frontmcp_nodejs_eventloop_lag_seconds",
3307
+ value: p99Seconds,
3308
+ attributes: { quantile: "p99" }
3309
+ });
3310
+ }
3311
+ this.histogram.reset();
3312
+ }
3313
+ if (this.options.activeHandles !== false) {
3314
+ const handles = this.getActiveHandles?.();
3315
+ if (handles !== void 0) {
3316
+ entries.push({
3317
+ name: "frontmcp_nodejs_active_handles",
3318
+ value: handles.length,
3319
+ help: "Currently active libuv handles (sockets, timers, etc.)"
3320
+ });
3321
+ }
3322
+ const requests = this.getActiveRequests?.();
3323
+ if (requests !== void 0) {
3324
+ entries.push({
3325
+ name: "frontmcp_nodejs_active_requests",
3326
+ value: requests.length,
3327
+ help: "Currently active libuv requests (file I/O, DNS, etc.)"
3328
+ });
3329
+ }
3330
+ }
3331
+ if (this.options.fdCount !== false) {
3332
+ const fdCount = this.readFdCount();
3333
+ if (typeof fdCount === "number") {
3334
+ entries.push({
3335
+ name: "frontmcp_nodejs_open_fds",
3336
+ value: fdCount,
3337
+ help: "Number of open file descriptors (Linux only)"
3338
+ });
3339
+ }
3340
+ }
3341
+ return entries;
3342
+ }
3343
+ /**
3344
+ * Release the perf_hooks histogram listener. Call when the collector is
3345
+ * no longer used (e.g., when reconfiguring the server).
3346
+ */
3347
+ close() {
3348
+ this.histogram?.disable?.();
3349
+ }
3350
+ };
3351
+
3119
3352
  // libs/observability/src/testing/index.ts
3120
3353
  import {
3121
3354
  BasicTracerProvider,
@@ -3178,8 +3411,10 @@ export {
3178
3411
  OTEL_TRACER,
3179
3412
  ObservabilityPlugin,
3180
3413
  OtlpSink,
3414
+ PROMETHEUS_CONTENT_TYPE,
3181
3415
  PinoSink,
3182
3416
  PrettySpanExporter,
3417
+ ProcessStatsCollector,
3183
3418
  REQUEST_LOG_COLLECTOR,
3184
3419
  RequestLogCollector,
3185
3420
  RpcAttributes,
@@ -3212,6 +3447,8 @@ export {
3212
3447
  otelToFrontmcpContext,
3213
3448
  recordHookEvent,
3214
3449
  redactFields,
3450
+ renderJsonExposition,
3451
+ renderPrometheusExposition,
3215
3452
  reportStartup,
3216
3453
  resetCounterCacheForTesting,
3217
3454
  resetMetricSnapshot,
package/esm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/observability",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "OpenTelemetry instrumentation, structured JSON logging, and request log objects for FrontMCP",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "license": "Apache-2.0",
@@ -51,8 +51,8 @@
51
51
  "@opentelemetry/api": "^1.9.0"
52
52
  },
53
53
  "peerDependencies": {
54
- "@frontmcp/sdk": "1.2.1",
55
- "@frontmcp/utils": "1.2.1",
54
+ "@frontmcp/sdk": "1.3.0",
55
+ "@frontmcp/utils": "1.3.0",
56
56
  "@opentelemetry/sdk-trace-base": "^1.25.0",
57
57
  "@opentelemetry/sdk-node": "^0.52.0",
58
58
  "@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
package/index.d.ts CHANGED
@@ -6,6 +6,9 @@ export { StructuredLogTransport, LOG_LEVEL_TO_OTEL_SEVERITY, StdoutSink, Console
6
6
  export type { StructuredLogEntry, StructuredLogError, StructuredLogTransportOptions, LogSink, SinkConfig, StdoutSinkConfig, ConsoleSinkConfig, WinstonSinkConfig, PinoSinkConfig, CallbackSinkConfig, OtlpSinkConfig, OtlpSinkOptions, WinstonLike, PinoLike, ContextAccessor, ContextSnapshot, } from './logging';
7
7
  export { TelemetryAccessor, TelemetrySpan, TelemetryFactory, TELEMETRY_ACCESSOR, TELEMETRY_FACTORY, createCounter, getMetricSnapshot, getCounterTotal, resetMetricSnapshot, resetTelemetrySnapshotForTesting, resetCounterCacheForTesting, normalizeBundleSource, normalizeErrorReason, KNOWN_BUNDLE_SOURCES, KNOWN_ERROR_REASONS, } from './telemetry';
8
8
  export type { TelemetryCounter, CounterSnapshotEntry, KnownBundleSource, KnownErrorReason } from './telemetry';
9
+ export { PROMETHEUS_CONTENT_TYPE, renderJsonExposition, renderPrometheusExposition } from './prometheus';
10
+ export type { GaugeSnapshotEntry, RenderOptions } from './prometheus';
11
+ export { ProcessStatsCollector } from './process-stats';
9
12
  export { createTestTracer, getFinishedSpans, assertSpanExists, assertSpanAttribute, findSpan, findSpansByAttribute, } from './testing';
10
13
  export type { TestTracer } from './testing';
11
14
  export { RequestLogCollector, REQUEST_LOG_COLLECTOR } from './request-log';
package/index.js CHANGED
@@ -330,8 +330,10 @@ __export(index_exports, {
330
330
  OTEL_TRACER: () => OTEL_TRACER,
331
331
  ObservabilityPlugin: () => ObservabilityPlugin,
332
332
  OtlpSink: () => OtlpSink,
333
+ PROMETHEUS_CONTENT_TYPE: () => PROMETHEUS_CONTENT_TYPE,
333
334
  PinoSink: () => PinoSink,
334
335
  PrettySpanExporter: () => PrettySpanExporter,
336
+ ProcessStatsCollector: () => ProcessStatsCollector,
335
337
  REQUEST_LOG_COLLECTOR: () => REQUEST_LOG_COLLECTOR,
336
338
  RequestLogCollector: () => RequestLogCollector,
337
339
  RpcAttributes: () => RpcAttributes,
@@ -364,6 +366,8 @@ __export(index_exports, {
364
366
  otelToFrontmcpContext: () => otelToFrontmcpContext,
365
367
  recordHookEvent: () => recordHookEvent,
366
368
  redactFields: () => redactFields,
369
+ renderJsonExposition: () => renderJsonExposition,
370
+ renderPrometheusExposition: () => renderPrometheusExposition,
367
371
  reportStartup: () => reportStartup,
368
372
  resetCounterCacheForTesting: () => resetCounterCacheForTesting,
369
373
  resetMetricSnapshot: () => resetMetricSnapshot,
@@ -3155,6 +3159,239 @@ init_winston_sink();
3155
3159
  init_pino_sink();
3156
3160
  init_otlp_sink();
3157
3161
 
3162
+ // libs/observability/src/prometheus/render.ts
3163
+ var METRIC_NAME_REGEX = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
3164
+ var LABEL_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
3165
+ var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
3166
+ function escapeLabelValue(value) {
3167
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
3168
+ }
3169
+ function sortedLabelString(attributes) {
3170
+ const keys = Object.keys(attributes).filter((k) => LABEL_NAME_REGEX.test(k)).sort();
3171
+ if (keys.length === 0) return "";
3172
+ const pairs = keys.map((k) => `${k}="${escapeLabelValue(attributes[k] ?? "")}"`);
3173
+ return `{${pairs.join(",")}}`;
3174
+ }
3175
+ function formatFloat(value) {
3176
+ if (!Number.isFinite(value)) return "";
3177
+ if (Number.isInteger(value)) return String(value);
3178
+ return String(value);
3179
+ }
3180
+ function renderPrometheusExposition(counters, gauges = [], options = {}) {
3181
+ const byName = /* @__PURE__ */ new Map();
3182
+ for (const entry of counters) {
3183
+ if (!METRIC_NAME_REGEX.test(entry.name)) continue;
3184
+ const valueStr = formatFloat(entry.count);
3185
+ if (valueStr === "") continue;
3186
+ const labels = sortedLabelString(entry.attributes ?? {});
3187
+ const line = `${entry.name}${labels} ${valueStr}`;
3188
+ let group = byName.get(entry.name);
3189
+ if (!group) {
3190
+ group = {
3191
+ type: "counter",
3192
+ help: options.counterHelp?.[entry.name],
3193
+ lines: []
3194
+ };
3195
+ byName.set(entry.name, group);
3196
+ }
3197
+ group.lines.push(line);
3198
+ }
3199
+ for (const entry of gauges) {
3200
+ if (!METRIC_NAME_REGEX.test(entry.name)) continue;
3201
+ const valueStr = formatFloat(entry.value);
3202
+ if (valueStr === "") continue;
3203
+ const labels = sortedLabelString(entry.attributes ?? {});
3204
+ const line = `${entry.name}${labels} ${valueStr}`;
3205
+ let group = byName.get(entry.name);
3206
+ if (!group) {
3207
+ group = { type: "gauge", help: entry.help, lines: [] };
3208
+ byName.set(entry.name, group);
3209
+ } else if (entry.help && !group.help) {
3210
+ group.help = entry.help;
3211
+ }
3212
+ group.lines.push(line);
3213
+ }
3214
+ if (byName.size === 0) return "";
3215
+ const sortedNames = Array.from(byName.keys()).sort();
3216
+ const blocks = [];
3217
+ for (const name of sortedNames) {
3218
+ const group = byName.get(name);
3219
+ if (!group) continue;
3220
+ const block = [];
3221
+ if (group.help) {
3222
+ block.push(`# HELP ${name} ${group.help.replace(/\n/g, " ")}`);
3223
+ }
3224
+ block.push(`# TYPE ${name} ${group.type}`);
3225
+ block.push(...group.lines.slice().sort());
3226
+ blocks.push(block.join("\n"));
3227
+ }
3228
+ return blocks.join("\n") + "\n";
3229
+ }
3230
+ function renderJsonExposition(counters, gauges = []) {
3231
+ return {
3232
+ counters: counters.filter((c) => METRIC_NAME_REGEX.test(c.name) && Number.isFinite(c.count)).map((c) => ({ name: c.name, count: c.count, attributes: { ...c.attributes ?? {} } })),
3233
+ gauges: gauges.filter((g) => METRIC_NAME_REGEX.test(g.name) && Number.isFinite(g.value)).map((g) => ({
3234
+ name: g.name,
3235
+ value: g.value,
3236
+ attributes: { ...g.attributes ?? {} },
3237
+ ...g.help ? { help: g.help } : {}
3238
+ }))
3239
+ };
3240
+ }
3241
+
3242
+ // libs/observability/src/process-stats/process-stats.collector.ts
3243
+ var import_utils2 = require("@frontmcp/utils");
3244
+ var NS_PER_SECOND = 1e9;
3245
+ var MICROS_PER_SECOND = 1e6;
3246
+ function defaultMonitorEventLoopDelay() {
3247
+ try {
3248
+ const { monitorEventLoopDelay } = require("node:perf_hooks");
3249
+ const histogram = monitorEventLoopDelay({ resolution: 10 });
3250
+ histogram.enable();
3251
+ return histogram;
3252
+ } catch {
3253
+ return void 0;
3254
+ }
3255
+ }
3256
+ function defaultGetActiveHandles() {
3257
+ const proc = process;
3258
+ if (typeof proc._getActiveHandles !== "function") return void 0;
3259
+ try {
3260
+ return proc._getActiveHandles();
3261
+ } catch {
3262
+ return void 0;
3263
+ }
3264
+ }
3265
+ function defaultGetActiveRequests() {
3266
+ const proc = process;
3267
+ if (typeof proc._getActiveRequests !== "function") return void 0;
3268
+ try {
3269
+ return proc._getActiveRequests();
3270
+ } catch {
3271
+ return void 0;
3272
+ }
3273
+ }
3274
+ function defaultReadFdCount() {
3275
+ if (process.platform !== "linux") return void 0;
3276
+ try {
3277
+ return (0, import_utils2.readdirSync)("/proc/self/fd").length;
3278
+ } catch {
3279
+ return void 0;
3280
+ }
3281
+ }
3282
+ var ProcessStatsCollector = class {
3283
+ constructor(init = {}) {
3284
+ this.options = init.options ?? {};
3285
+ this.cpuUsage = init.cpuUsage ?? ((prev) => process.cpuUsage(prev));
3286
+ this.memoryUsage = init.memoryUsage ?? (() => process.memoryUsage());
3287
+ this.uptime = init.uptime ?? (() => process.uptime());
3288
+ this.getActiveHandles = init.getActiveHandles ?? defaultGetActiveHandles;
3289
+ this.getActiveRequests = init.getActiveRequests ?? defaultGetActiveRequests;
3290
+ this.readFdCount = init.readFdCount ?? defaultReadFdCount;
3291
+ this.cpuStart = this.cpuUsage();
3292
+ if (this.options.eventLoopLag !== false) {
3293
+ this.histogram = init.monitorEventLoopDelay ? init.monitorEventLoopDelay() : defaultMonitorEventLoopDelay();
3294
+ }
3295
+ }
3296
+ collect() {
3297
+ const entries = [];
3298
+ const cpu = this.cpuUsage(this.cpuStart);
3299
+ entries.push({
3300
+ name: "frontmcp_process_cpu_seconds_total",
3301
+ value: cpu.user / MICROS_PER_SECOND,
3302
+ attributes: { mode: "user" },
3303
+ help: "CPU time consumed since collector start, by mode (seconds)"
3304
+ });
3305
+ entries.push({
3306
+ name: "frontmcp_process_cpu_seconds_total",
3307
+ value: cpu.system / MICROS_PER_SECOND,
3308
+ attributes: { mode: "system" }
3309
+ });
3310
+ const mem = this.memoryUsage();
3311
+ entries.push({
3312
+ name: "frontmcp_process_resident_memory_bytes",
3313
+ value: mem.rss,
3314
+ help: "Resident memory size in bytes"
3315
+ });
3316
+ entries.push({
3317
+ name: "frontmcp_process_heap_bytes",
3318
+ value: mem.heapTotal,
3319
+ help: "Total V8 heap size in bytes"
3320
+ });
3321
+ entries.push({
3322
+ name: "frontmcp_process_heap_used_bytes",
3323
+ value: mem.heapUsed,
3324
+ help: "Used V8 heap size in bytes"
3325
+ });
3326
+ entries.push({
3327
+ name: "frontmcp_process_external_bytes",
3328
+ value: mem.external,
3329
+ help: "Memory used by C++ objects bound to JS in bytes"
3330
+ });
3331
+ entries.push({
3332
+ name: "frontmcp_process_uptime_seconds",
3333
+ value: this.uptime(),
3334
+ help: "Time since process start in seconds"
3335
+ });
3336
+ if (this.options.eventLoopLag !== false && this.histogram) {
3337
+ const meanSeconds = this.histogram.mean / NS_PER_SECOND;
3338
+ const p99Seconds = this.histogram.percentile(99) / NS_PER_SECOND;
3339
+ if (Number.isFinite(meanSeconds)) {
3340
+ entries.push({
3341
+ name: "frontmcp_nodejs_eventloop_lag_seconds",
3342
+ value: meanSeconds,
3343
+ attributes: { quantile: "mean" },
3344
+ help: "Event-loop lag in seconds, sampled at 10ms resolution since the last scrape"
3345
+ });
3346
+ }
3347
+ if (Number.isFinite(p99Seconds)) {
3348
+ entries.push({
3349
+ name: "frontmcp_nodejs_eventloop_lag_seconds",
3350
+ value: p99Seconds,
3351
+ attributes: { quantile: "p99" }
3352
+ });
3353
+ }
3354
+ this.histogram.reset();
3355
+ }
3356
+ if (this.options.activeHandles !== false) {
3357
+ const handles = this.getActiveHandles?.();
3358
+ if (handles !== void 0) {
3359
+ entries.push({
3360
+ name: "frontmcp_nodejs_active_handles",
3361
+ value: handles.length,
3362
+ help: "Currently active libuv handles (sockets, timers, etc.)"
3363
+ });
3364
+ }
3365
+ const requests = this.getActiveRequests?.();
3366
+ if (requests !== void 0) {
3367
+ entries.push({
3368
+ name: "frontmcp_nodejs_active_requests",
3369
+ value: requests.length,
3370
+ help: "Currently active libuv requests (file I/O, DNS, etc.)"
3371
+ });
3372
+ }
3373
+ }
3374
+ if (this.options.fdCount !== false) {
3375
+ const fdCount = this.readFdCount();
3376
+ if (typeof fdCount === "number") {
3377
+ entries.push({
3378
+ name: "frontmcp_nodejs_open_fds",
3379
+ value: fdCount,
3380
+ help: "Number of open file descriptors (Linux only)"
3381
+ });
3382
+ }
3383
+ }
3384
+ return entries;
3385
+ }
3386
+ /**
3387
+ * Release the perf_hooks histogram listener. Call when the collector is
3388
+ * no longer used (e.g., when reconfiguring the server).
3389
+ */
3390
+ close() {
3391
+ this.histogram?.disable?.();
3392
+ }
3393
+ };
3394
+
3158
3395
  // libs/observability/src/testing/index.ts
3159
3396
  var import_sdk_trace_base = require("@opentelemetry/sdk-trace-base");
3160
3397
  function createTestTracer(name = "test") {
@@ -3214,8 +3451,10 @@ function findSpansByAttribute(spans, key, value) {
3214
3451
  OTEL_TRACER,
3215
3452
  ObservabilityPlugin,
3216
3453
  OtlpSink,
3454
+ PROMETHEUS_CONTENT_TYPE,
3217
3455
  PinoSink,
3218
3456
  PrettySpanExporter,
3457
+ ProcessStatsCollector,
3219
3458
  REQUEST_LOG_COLLECTOR,
3220
3459
  RequestLogCollector,
3221
3460
  RpcAttributes,
@@ -3248,6 +3487,8 @@ function findSpansByAttribute(spans, key, value) {
3248
3487
  otelToFrontmcpContext,
3249
3488
  recordHookEvent,
3250
3489
  redactFields,
3490
+ renderJsonExposition,
3491
+ renderPrometheusExposition,
3251
3492
  reportStartup,
3252
3493
  resetCounterCacheForTesting,
3253
3494
  resetMetricSnapshot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/observability",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "OpenTelemetry instrumentation, structured JSON logging, and request log objects for FrontMCP",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "license": "Apache-2.0",
@@ -51,8 +51,8 @@
51
51
  "@opentelemetry/api": "^1.9.0"
52
52
  },
53
53
  "peerDependencies": {
54
- "@frontmcp/sdk": "1.2.1",
55
- "@frontmcp/utils": "1.2.1",
54
+ "@frontmcp/sdk": "1.3.0",
55
+ "@frontmcp/utils": "1.3.0",
56
56
  "@opentelemetry/sdk-trace-base": "^1.25.0",
57
57
  "@opentelemetry/sdk-node": "^0.52.0",
58
58
  "@opentelemetry/exporter-trace-otlp-http": "^0.52.0",
@@ -0,0 +1 @@
1
+ export { ProcessStatsCollector } from './process-stats.collector';
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Process-stats collector (issue #397).
3
+ *
4
+ * Emits per-scrape Node.js process metrics in `GaugeSnapshotEntry` shape so
5
+ * the Prometheus serializer can pass them straight through. Pure read of
6
+ * `process.*` and `perf_hooks.monitorEventLoopDelay()` — no I/O beyond the
7
+ * single Linux-only `/proc/self/fd` directory probe (wrapped in try/catch
8
+ * so non-Linux platforms silently skip that gauge).
9
+ *
10
+ * Design choices:
11
+ * - CPU is emitted as a monotonic counter-shaped gauge built from
12
+ * `process.cpuUsage()` deltas — operators graph `rate()` of this.
13
+ * - Event-loop lag uses `perf_hooks.monitorEventLoopDelay({ resolution: 10 })`
14
+ * started at construction; `collect()` reads mean + p99 and calls
15
+ * `reset()` so each scrape sees a fresh window.
16
+ * - `process._getActiveHandles()` / `_getActiveRequests()` are
17
+ * undocumented but stable since Node 14; guarded behind feature
18
+ * detection so they no-op on edge runtimes.
19
+ * - All probes are individually toggleable via `MetricsProcessOptions`.
20
+ */
21
+ import type { GaugeSnapshotEntry } from '../prometheus/render';
22
+ interface MetricsProcessOptions {
23
+ eventLoopLag?: boolean;
24
+ fdCount?: boolean;
25
+ activeHandles?: boolean;
26
+ }
27
+ interface ELDHistogram {
28
+ mean: number;
29
+ percentile(p: number): number;
30
+ reset(): void;
31
+ disable?(): void;
32
+ }
33
+ /**
34
+ * Optional dependency injection points used by tests; production callers
35
+ * never pass these.
36
+ */
37
+ interface ProcessStatsCollectorOptions {
38
+ options?: MetricsProcessOptions;
39
+ cpuUsage?: (prev?: NodeJS.CpuUsage) => NodeJS.CpuUsage;
40
+ memoryUsage?: () => NodeJS.MemoryUsage;
41
+ uptime?: () => number;
42
+ monitorEventLoopDelay?: () => ELDHistogram | undefined;
43
+ getActiveHandles?: () => unknown[] | undefined;
44
+ getActiveRequests?: () => unknown[] | undefined;
45
+ readFdCount?: () => number | undefined;
46
+ }
47
+ export declare class ProcessStatsCollector {
48
+ private readonly options;
49
+ private readonly cpuUsage;
50
+ private readonly memoryUsage;
51
+ private readonly uptime;
52
+ private readonly histogram?;
53
+ private readonly getActiveHandles?;
54
+ private readonly getActiveRequests?;
55
+ private readonly readFdCount;
56
+ private cpuStart;
57
+ constructor(init?: ProcessStatsCollectorOptions);
58
+ collect(): GaugeSnapshotEntry[];
59
+ /**
60
+ * Release the perf_hooks histogram listener. Call when the collector is
61
+ * no longer used (e.g., when reconfiguring the server).
62
+ */
63
+ close(): void;
64
+ }
65
+ export {};
@@ -0,0 +1,2 @@
1
+ export { PROMETHEUS_CONTENT_TYPE, renderJsonExposition, renderPrometheusExposition } from './render';
2
+ export type { GaugeSnapshotEntry, RenderOptions } from './render';
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Prometheus text-exposition serializer (zero-dep).
3
+ *
4
+ * Implements the Prometheus 0.0.4 text format documented at
5
+ * https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format
6
+ * without pulling in `prom-client` — keeps `@frontmcp/observability`'s
7
+ * runtime footprint to `@opentelemetry/api` only (issue #397).
8
+ *
9
+ * Counter snapshot entries come from
10
+ * `@frontmcp/observability`'s in-memory store (`getMetricSnapshot()`); gauge
11
+ * entries come from the `ProcessStatsCollector` introduced alongside this
12
+ * serializer.
13
+ */
14
+ import type { CounterSnapshotEntry } from '../telemetry/telemetry.counters';
15
+ /**
16
+ * A single gauge sample at a point in time. Values are emitted unchanged
17
+ * (no resampling, no rate calculation).
18
+ */
19
+ export interface GaugeSnapshotEntry {
20
+ /** Metric name (must match `/^[a-zA-Z_:][a-zA-Z0-9_:]*$/`). */
21
+ name: string;
22
+ /** Sample value. NaN / non-finite values are dropped. */
23
+ value: number;
24
+ /** Optional bounded-cardinality labels. */
25
+ attributes?: Record<string, string>;
26
+ /** Optional `# HELP` line. */
27
+ help?: string;
28
+ }
29
+ /**
30
+ * Optional rendering knobs.
31
+ */
32
+ export interface RenderOptions {
33
+ /** Map of counter-name → `# HELP` text. Counter names not in the map omit the HELP line. */
34
+ counterHelp?: Record<string, string>;
35
+ }
36
+ /**
37
+ * Content-Type emitted alongside the text format (canonical Prometheus
38
+ * 0.0.4 value — pinned so consumers can match exactly).
39
+ */
40
+ export declare const PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
41
+ /**
42
+ * Render counter + gauge snapshots as Prometheus 0.0.4 text exposition.
43
+ *
44
+ * - Groups entries by metric name so `# HELP` / `# TYPE` lines emit once
45
+ * per metric.
46
+ * - Drops entries whose metric name fails Prometheus naming validation.
47
+ * - Sorts metric names alphabetically so output is byte-deterministic.
48
+ * - Empty input → empty string.
49
+ */
50
+ export declare function renderPrometheusExposition(counters: readonly CounterSnapshotEntry[], gauges?: readonly GaugeSnapshotEntry[], options?: RenderOptions): string;
51
+ /**
52
+ * Render counter + gauge snapshots as a JSON envelope — useful when
53
+ * a consumer prefers JSON over Prometheus text parsing.
54
+ */
55
+ export declare function renderJsonExposition(counters: readonly CounterSnapshotEntry[], gauges?: readonly GaugeSnapshotEntry[]): {
56
+ counters: CounterSnapshotEntry[];
57
+ gauges: GaugeSnapshotEntry[];
58
+ };