@frontmcp/observability 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/index.mjs CHANGED
@@ -319,7 +319,7 @@ var init_otlp_sink = __esm({
319
319
  });
320
320
 
321
321
  // libs/observability/src/plugin/observability.plugin.ts
322
- import { trace as trace5 } from "@opentelemetry/api";
322
+ import { trace as trace6 } from "@opentelemetry/api";
323
323
  import {
324
324
  AgentCallHook,
325
325
  DynamicPlugin,
@@ -770,43 +770,6 @@ import {
770
770
  trace as trace4
771
771
  } from "@opentelemetry/api";
772
772
 
773
- // libs/observability/src/otel/trace-context-bridge.ts
774
- import {
775
- TraceFlags,
776
- trace,
777
- context as otelContext
778
- } from "@opentelemetry/api";
779
- function frontmcpToOTelSpanContext(tc) {
780
- return {
781
- traceId: tc.traceId,
782
- spanId: tc.parentId,
783
- traceFlags: tc.traceFlags & TraceFlags.SAMPLED ? TraceFlags.SAMPLED : TraceFlags.NONE,
784
- isRemote: true
785
- };
786
- }
787
- function otelToFrontmcpContext(sc) {
788
- const flags = sc.traceFlags.toString(16).padStart(2, "0");
789
- return {
790
- traceId: sc.traceId,
791
- parentId: sc.spanId,
792
- traceFlags: sc.traceFlags,
793
- raw: `00-${sc.traceId}-${sc.spanId}-${flags}`
794
- };
795
- }
796
- function createOTelContextFromTrace(tc) {
797
- const spanContext = frontmcpToOTelSpanContext(tc);
798
- const span = trace.wrapSpanContext(spanContext);
799
- return trace.setSpan(otelContext.active(), span);
800
- }
801
-
802
- // libs/observability/src/plugin/observability.hooks.ts
803
- import {
804
- context as otelContext3,
805
- SpanKind as SpanKind11,
806
- trace as trace3
807
- } from "@opentelemetry/api";
808
- import { sha256Hex } from "@frontmcp/utils";
809
-
810
773
  // libs/observability/src/otel/otel.types.ts
811
774
  var McpAttributes = {
812
775
  /** MCP protocol method name (e.g., "tools/call", "resources/read") */
@@ -920,6 +883,43 @@ var EnduserAttributes = {
920
883
  SCOPE: "enduser.scope"
921
884
  };
922
885
 
886
+ // libs/observability/src/otel/trace-context-bridge.ts
887
+ import {
888
+ TraceFlags,
889
+ trace,
890
+ context as otelContext
891
+ } from "@opentelemetry/api";
892
+ function frontmcpToOTelSpanContext(tc) {
893
+ return {
894
+ traceId: tc.traceId,
895
+ spanId: tc.parentId,
896
+ traceFlags: tc.traceFlags & TraceFlags.SAMPLED ? TraceFlags.SAMPLED : TraceFlags.NONE,
897
+ isRemote: true
898
+ };
899
+ }
900
+ function otelToFrontmcpContext(sc) {
901
+ const flags = sc.traceFlags.toString(16).padStart(2, "0");
902
+ return {
903
+ traceId: sc.traceId,
904
+ parentId: sc.spanId,
905
+ traceFlags: sc.traceFlags,
906
+ raw: `00-${sc.traceId}-${sc.spanId}-${flags}`
907
+ };
908
+ }
909
+ function createOTelContextFromTrace(tc) {
910
+ const spanContext = frontmcpToOTelSpanContext(tc);
911
+ const span = trace.wrapSpanContext(spanContext);
912
+ return trace.setSpan(otelContext.active(), span);
913
+ }
914
+
915
+ // libs/observability/src/plugin/observability.hooks.ts
916
+ import {
917
+ context as otelContext3,
918
+ SpanKind as SpanKind11,
919
+ trace as trace3
920
+ } from "@opentelemetry/api";
921
+ import { sha256Hex } from "@frontmcp/utils";
922
+
923
923
  // libs/observability/src/otel/spans/auth.span.ts
924
924
  import { SpanKind as SpanKind2 } from "@opentelemetry/api";
925
925
 
@@ -1698,6 +1698,97 @@ function reportStartup(data) {
1698
1698
  emitStartupReport(tracer, data);
1699
1699
  }
1700
1700
 
1701
+ // libs/observability/src/telemetry/telemetry.counters.ts
1702
+ import { metrics } from "@opentelemetry/api";
1703
+ function snapshotKey(name, attributes) {
1704
+ const keys = Object.keys(attributes).sort();
1705
+ if (keys.length === 0) return name;
1706
+ const tail = keys.map((k) => `${k}=${attributes[k]}`).join(",");
1707
+ return `${name}{${tail}}`;
1708
+ }
1709
+ var snapshotStore = /* @__PURE__ */ new Map();
1710
+ var TelemetryCounterImpl = class {
1711
+ constructor(name, otelCounter) {
1712
+ this.name = name;
1713
+ this.otelCounter = otelCounter;
1714
+ }
1715
+ inc(by = 1, attributes = {}) {
1716
+ if (!Number.isFinite(by) || by < 0) {
1717
+ return;
1718
+ }
1719
+ try {
1720
+ this.otelCounter.add(by, attributes);
1721
+ } catch {
1722
+ }
1723
+ const key = snapshotKey(this.name, attributes);
1724
+ const existing = snapshotStore.get(key);
1725
+ if (existing) {
1726
+ existing.count += by;
1727
+ } else {
1728
+ snapshotStore.set(key, { name: this.name, count: by, attributes: { ...attributes } });
1729
+ }
1730
+ }
1731
+ };
1732
+ var METER_NAME = "@frontmcp/observability";
1733
+ var counterCache = /* @__PURE__ */ new Map();
1734
+ function createCounter(name, description) {
1735
+ const existing = counterCache.get(name);
1736
+ if (existing) return existing;
1737
+ const meter = metrics.getMeter(METER_NAME);
1738
+ const otelCounter = meter.createCounter(name, description ? { description } : void 0);
1739
+ const counter = new TelemetryCounterImpl(name, otelCounter);
1740
+ counterCache.set(name, counter);
1741
+ return counter;
1742
+ }
1743
+ function getMetricSnapshot() {
1744
+ return Array.from(snapshotStore.values()).map((entry) => ({
1745
+ name: entry.name,
1746
+ count: entry.count,
1747
+ attributes: { ...entry.attributes }
1748
+ }));
1749
+ }
1750
+ function getCounterTotal(name) {
1751
+ let sum = 0;
1752
+ for (const entry of snapshotStore.values()) {
1753
+ if (entry.name === name) sum += entry.count;
1754
+ }
1755
+ return sum;
1756
+ }
1757
+ function resetMetricSnapshot() {
1758
+ snapshotStore.clear();
1759
+ }
1760
+ function resetTelemetrySnapshotForTesting() {
1761
+ snapshotStore.clear();
1762
+ }
1763
+ function resetCounterCacheForTesting() {
1764
+ counterCache.clear();
1765
+ }
1766
+ var KNOWN_BUNDLE_SOURCES = ["static", "npm", "saas-pull", "webhook", "filesystem", "unknown"];
1767
+ function normalizeBundleSource(value) {
1768
+ if (typeof value !== "string") return "unknown";
1769
+ return KNOWN_BUNDLE_SOURCES.includes(value) ? value : "unknown";
1770
+ }
1771
+ var KNOWN_ERROR_REASONS = ["network", "timeout", "invalid_response", "parse_error", "unknown"];
1772
+ function normalizeErrorReason(err) {
1773
+ if (!err) return "unknown";
1774
+ const name = err instanceof Error ? err.name : "";
1775
+ const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
1776
+ const haystack = `${name} ${message}`.toLowerCase();
1777
+ if (name === "TimeoutError" || haystack.includes("timeout") || haystack.includes("timed out")) {
1778
+ return "timeout";
1779
+ }
1780
+ if (name === "NetworkError" || name === "FetchError" || haystack.includes("network") || haystack.includes("econnrefused") || haystack.includes("econnreset") || haystack.includes("enotfound") || haystack.includes("socket") || haystack.includes("dns")) {
1781
+ return "network";
1782
+ }
1783
+ if (name === "SyntaxError" || haystack.includes("parse") || haystack.includes("json")) {
1784
+ return "parse_error";
1785
+ }
1786
+ if (name === "TypeError" || name === "RangeError" || haystack.includes("invalid") || haystack.includes("malformed") || haystack.includes("schema")) {
1787
+ return "invalid_response";
1788
+ }
1789
+ return "unknown";
1790
+ }
1791
+
1701
1792
  // libs/observability/src/telemetry/telemetry.accessor.ts
1702
1793
  var TelemetrySpan = class {
1703
1794
  constructor(span) {
@@ -1855,6 +1946,20 @@ var TelemetryAccessor = class {
1855
1946
  activeSpan.setAttributes(attrs);
1856
1947
  }
1857
1948
  }
1949
+ /**
1950
+ * Create (or retrieve cached) a named counter.
1951
+ *
1952
+ * Counter increments are exported through the global OTel MeterProvider
1953
+ * when one is configured, and are always recorded in an in-memory snapshot
1954
+ * store (`getMetricSnapshot()` from `@frontmcp/observability`) so tests can
1955
+ * assert behavior without standing up a full OTel pipeline.
1956
+ *
1957
+ * @param name - OTel-style snake_case name, e.g. `frontmcp_skills_bundle_pulls_total`
1958
+ * @param description - human-readable description
1959
+ */
1960
+ createCounter(name, description) {
1961
+ return createCounter(name, description);
1962
+ }
1858
1963
  /**
1859
1964
  * Get the trace ID of the current request.
1860
1965
  * Useful for including in external API calls or logs.
@@ -1870,8 +1975,55 @@ var TelemetryAccessor = class {
1870
1975
  }
1871
1976
  };
1872
1977
 
1978
+ // libs/observability/src/telemetry/telemetry.factory.ts
1979
+ import { SpanStatusCode as SpanStatusCode3, trace as trace5 } from "@opentelemetry/api";
1980
+ var TelemetryFactory = class {
1981
+ constructor(tracerName = "@frontmcp/observability") {
1982
+ this.tracer = trace5.getTracer(tracerName);
1983
+ }
1984
+ /**
1985
+ * Create (or retrieve cached) a named counter. Increments propagate to
1986
+ * the OTel meter (when configured) and to the in-memory snapshot store.
1987
+ *
1988
+ * @param name - OTel-style snake_case name with `_total` suffix.
1989
+ * @param description - human-readable description (deduped: subsequent
1990
+ * calls with a different description return the
1991
+ * cached counter unchanged).
1992
+ */
1993
+ createCounter(name, description) {
1994
+ return createCounter(name, description);
1995
+ }
1996
+ /**
1997
+ * Start a span. Caller is responsible for calling `end()` /
1998
+ * `endWithError()`. The span parents to the currently-active OTel
1999
+ * context; if none is active it becomes a root span.
2000
+ */
2001
+ startSpan(name, attributes, parent) {
2002
+ const opts = attributes ? { attributes } : void 0;
2003
+ const span = parent ? this.tracer.startSpan(name, opts, parent) : this.tracer.startSpan(name, opts);
2004
+ return new TelemetrySpan(span);
2005
+ }
2006
+ /**
2007
+ * Convenience wrapper — start a span, run `fn` with it, end on success
2008
+ * or error. Same lifecycle as `TelemetryAccessor.withSpan` but without
2009
+ * the per-request base attributes.
2010
+ */
2011
+ async withSpan(name, fn, attributes) {
2012
+ const span = this.startSpan(name, attributes);
2013
+ try {
2014
+ const result = await fn(span);
2015
+ span.end();
2016
+ return result;
2017
+ } catch (err) {
2018
+ span.endWithError(err instanceof Error ? err : String(err));
2019
+ throw err;
2020
+ }
2021
+ }
2022
+ };
2023
+
1873
2024
  // libs/observability/src/telemetry/telemetry.tokens.ts
1874
2025
  var TELEMETRY_ACCESSOR = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-accessor");
2026
+ var TELEMETRY_FACTORY = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-factory");
1875
2027
 
1876
2028
  // libs/observability/src/plugin/observability.plugin.ts
1877
2029
  var PromptHook = FlowHooksOf("prompts:get-prompt");
@@ -2305,7 +2457,7 @@ ObservabilityPlugin.dynamicProviders = (input) => {
2305
2457
  providers.push({
2306
2458
  name: "observability:tracer",
2307
2459
  provide: OTEL_TRACER,
2308
- useValue: trace5.getTracer("@frontmcp/observability")
2460
+ useValue: trace6.getTracer("@frontmcp/observability")
2309
2461
  });
2310
2462
  providers.push({
2311
2463
  name: "observability:config",
@@ -2319,6 +2471,11 @@ ObservabilityPlugin.dynamicProviders = (input) => {
2319
2471
  inject: () => [FRONTMCP_CONTEXT],
2320
2472
  useFactory: (ctx) => new TelemetryAccessor(ctx)
2321
2473
  });
2474
+ providers.push({
2475
+ name: "observability:telemetry-factory",
2476
+ provide: TELEMETRY_FACTORY,
2477
+ useValue: new TelemetryFactory()
2478
+ });
2322
2479
  }
2323
2480
  if (options.logging !== false) {
2324
2481
  const loggingOpts = options.logging;
@@ -2756,12 +2913,12 @@ function resolveRequestLogOptions(input) {
2756
2913
 
2757
2914
  // libs/observability/src/otel/context-propagator.ts
2758
2915
  import {
2759
- trace as trace6
2916
+ trace as trace7
2760
2917
  } from "@opentelemetry/api";
2761
2918
  var TRACEPARENT_HEADER = "traceparent";
2762
2919
  var FrontMcpPropagator = class {
2763
2920
  inject(context, carrier, setter) {
2764
- const span = trace6.getSpan(context);
2921
+ const span = trace7.getSpan(context);
2765
2922
  if (!span) return;
2766
2923
  const sc = span.spanContext();
2767
2924
  if (!sc || !isValidSpanContext(sc)) return;
@@ -2781,8 +2938,8 @@ var FrontMcpPropagator = class {
2781
2938
  traceFlags: parsed.traceFlags,
2782
2939
  isRemote: true
2783
2940
  };
2784
- const span = trace6.wrapSpanContext(spanContext);
2785
- return trace6.setSpan(context, span);
2941
+ const span = trace7.wrapSpanContext(spanContext);
2942
+ return trace7.setSpan(context, span);
2786
2943
  }
2787
2944
  fields() {
2788
2945
  return [TRACEPARENT_HEADER];
@@ -2877,7 +3034,7 @@ function startHookSpan(tracer, options) {
2877
3034
  }
2878
3035
 
2879
3036
  // libs/observability/src/otel/spans/pretty-span-exporter.ts
2880
- import { SpanKind as SpanKind14, SpanStatusCode as SpanStatusCode3 } from "@opentelemetry/api";
3037
+ import { SpanKind as SpanKind14, SpanStatusCode as SpanStatusCode4 } from "@opentelemetry/api";
2881
3038
  var RESET = "\x1B[0m";
2882
3039
  var DIM = "\x1B[2m";
2883
3040
  var BOLD = "\x1B[1m";
@@ -2929,7 +3086,7 @@ var PrettySpanExporter = class {
2929
3086
  const traceShort = span.spanContext().traceId.slice(0, 8);
2930
3087
  const durationMs = (span.duration[0] * 1e3 + span.duration[1] / 1e6).toFixed(1);
2931
3088
  const kind = KIND_LABELS[span.kind] ?? "UNKNOWN";
2932
- const statusOk = span.status.code !== SpanStatusCode3.ERROR;
3089
+ const statusOk = span.status.code !== SpanStatusCode4.ERROR;
2933
3090
  const attrs = Object.entries(span.attributes).filter(([k]) => SHOW_ATTRS.has(k)).map(([k, v]) => {
2934
3091
  const short = k.replace("frontmcp.", "").replace("mcp.", "");
2935
3092
  return `${short}=${v}`;
@@ -3013,6 +3170,8 @@ export {
3013
3170
  FrontMcpAttributes,
3014
3171
  FrontMcpPropagator,
3015
3172
  HttpAttributes,
3173
+ KNOWN_BUNDLE_SOURCES,
3174
+ KNOWN_ERROR_REASONS,
3016
3175
  LOG_LEVEL_TO_OTEL_SEVERITY,
3017
3176
  McpAttributes,
3018
3177
  OTEL_CONFIG,
@@ -3027,11 +3186,14 @@ export {
3027
3186
  StdoutSink,
3028
3187
  StructuredLogTransport,
3029
3188
  TELEMETRY_ACCESSOR,
3189
+ TELEMETRY_FACTORY,
3030
3190
  TelemetryAccessor,
3191
+ TelemetryFactory,
3031
3192
  TelemetrySpan,
3032
3193
  WinstonSink,
3033
3194
  assertSpanAttribute,
3034
3195
  assertSpanExists,
3196
+ createCounter,
3035
3197
  createOTelContextFromTrace,
3036
3198
  createSink,
3037
3199
  createSinks,
@@ -3042,11 +3204,18 @@ export {
3042
3204
  findSpan,
3043
3205
  findSpansByAttribute,
3044
3206
  frontmcpToOTelSpanContext,
3207
+ getCounterTotal,
3045
3208
  getFinishedSpans,
3209
+ getMetricSnapshot,
3210
+ normalizeBundleSource,
3211
+ normalizeErrorReason,
3046
3212
  otelToFrontmcpContext,
3047
3213
  recordHookEvent,
3048
3214
  redactFields,
3049
3215
  reportStartup,
3216
+ resetCounterCacheForTesting,
3217
+ resetMetricSnapshot,
3218
+ resetTelemetrySnapshotForTesting,
3050
3219
  sessionTracingId,
3051
3220
  setAuthMode,
3052
3221
  setAuthResult,
package/esm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/observability",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
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.1.2",
55
- "@frontmcp/utils": "1.1.2",
54
+ "@frontmcp/sdk": "1.2.1",
55
+ "@frontmcp/utils": "1.2.1",
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
@@ -4,7 +4,8 @@ export { McpAttributes, FrontMcpAttributes, HttpAttributes, RpcAttributes, Endus
4
4
  export type { TracingOptions, OTelSetupOptions, TraceContextLike, StartSpanOptions, HttpServerSpanOptions, RpcSpanOptions, ToolSpanOptions, ResourceSpanOptions, PromptSpanOptions, HookSpanOptions, FetchSpanOptions, TransportSpanOptions, AuthSpanOptions, StartupTelemetryData, } from './otel';
5
5
  export { StructuredLogTransport, LOG_LEVEL_TO_OTEL_SEVERITY, StdoutSink, ConsoleSink, WinstonSink, PinoSink, CallbackSink, OtlpSink, createSink, createSinks, redactFields, } from './logging';
6
6
  export type { StructuredLogEntry, StructuredLogError, StructuredLogTransportOptions, LogSink, SinkConfig, StdoutSinkConfig, ConsoleSinkConfig, WinstonSinkConfig, PinoSinkConfig, CallbackSinkConfig, OtlpSinkConfig, OtlpSinkOptions, WinstonLike, PinoLike, ContextAccessor, ContextSnapshot, } from './logging';
7
- export { TelemetryAccessor, TelemetrySpan, TELEMETRY_ACCESSOR } from './telemetry';
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
+ export type { TelemetryCounter, CounterSnapshotEntry, KnownBundleSource, KnownErrorReason } from './telemetry';
8
9
  export { createTestTracer, getFinishedSpans, assertSpanExists, assertSpanAttribute, findSpan, findSpansByAttribute, } from './testing';
9
10
  export type { TestTracer } from './testing';
10
11
  export { RequestLogCollector, REQUEST_LOG_COLLECTOR } from './request-log';
package/index.js CHANGED
@@ -322,6 +322,8 @@ __export(index_exports, {
322
322
  FrontMcpAttributes: () => FrontMcpAttributes,
323
323
  FrontMcpPropagator: () => FrontMcpPropagator,
324
324
  HttpAttributes: () => HttpAttributes,
325
+ KNOWN_BUNDLE_SOURCES: () => KNOWN_BUNDLE_SOURCES,
326
+ KNOWN_ERROR_REASONS: () => KNOWN_ERROR_REASONS,
325
327
  LOG_LEVEL_TO_OTEL_SEVERITY: () => LOG_LEVEL_TO_OTEL_SEVERITY,
326
328
  McpAttributes: () => McpAttributes,
327
329
  OTEL_CONFIG: () => OTEL_CONFIG,
@@ -336,11 +338,14 @@ __export(index_exports, {
336
338
  StdoutSink: () => StdoutSink,
337
339
  StructuredLogTransport: () => StructuredLogTransport,
338
340
  TELEMETRY_ACCESSOR: () => TELEMETRY_ACCESSOR,
341
+ TELEMETRY_FACTORY: () => TELEMETRY_FACTORY,
339
342
  TelemetryAccessor: () => TelemetryAccessor,
343
+ TelemetryFactory: () => TelemetryFactory,
340
344
  TelemetrySpan: () => TelemetrySpan,
341
345
  WinstonSink: () => WinstonSink,
342
346
  assertSpanAttribute: () => assertSpanAttribute,
343
347
  assertSpanExists: () => assertSpanExists,
348
+ createCounter: () => createCounter,
344
349
  createOTelContextFromTrace: () => createOTelContextFromTrace,
345
350
  createSink: () => createSink,
346
351
  createSinks: () => createSinks,
@@ -351,11 +356,18 @@ __export(index_exports, {
351
356
  findSpan: () => findSpan,
352
357
  findSpansByAttribute: () => findSpansByAttribute,
353
358
  frontmcpToOTelSpanContext: () => frontmcpToOTelSpanContext,
359
+ getCounterTotal: () => getCounterTotal,
354
360
  getFinishedSpans: () => getFinishedSpans,
361
+ getMetricSnapshot: () => getMetricSnapshot,
362
+ normalizeBundleSource: () => normalizeBundleSource,
363
+ normalizeErrorReason: () => normalizeErrorReason,
355
364
  otelToFrontmcpContext: () => otelToFrontmcpContext,
356
365
  recordHookEvent: () => recordHookEvent,
357
366
  redactFields: () => redactFields,
358
367
  reportStartup: () => reportStartup,
368
+ resetCounterCacheForTesting: () => resetCounterCacheForTesting,
369
+ resetMetricSnapshot: () => resetMetricSnapshot,
370
+ resetTelemetrySnapshotForTesting: () => resetTelemetrySnapshotForTesting,
359
371
  sessionTracingId: () => sessionTracingId,
360
372
  setAuthMode: () => setAuthMode,
361
373
  setAuthResult: () => setAuthResult,
@@ -378,7 +390,7 @@ __export(index_exports, {
378
390
  module.exports = __toCommonJS(index_exports);
379
391
 
380
392
  // libs/observability/src/plugin/observability.plugin.ts
381
- var import_api14 = require("@opentelemetry/api");
393
+ var import_api16 = require("@opentelemetry/api");
382
394
  var import_sdk2 = require("@frontmcp/sdk");
383
395
 
384
396
  // libs/observability/src/logging/sinks/stdout.sink.ts
@@ -810,36 +822,7 @@ var RequestLogCollector = class {
810
822
  var REQUEST_LOG_COLLECTOR = /* @__PURE__ */ Symbol.for("frontmcp:observability:request-log-collector");
811
823
 
812
824
  // libs/observability/src/telemetry/telemetry.accessor.ts
813
- var import_api13 = require("@opentelemetry/api");
814
-
815
- // libs/observability/src/otel/trace-context-bridge.ts
816
- var import_api = require("@opentelemetry/api");
817
- function frontmcpToOTelSpanContext(tc) {
818
- return {
819
- traceId: tc.traceId,
820
- spanId: tc.parentId,
821
- traceFlags: tc.traceFlags & import_api.TraceFlags.SAMPLED ? import_api.TraceFlags.SAMPLED : import_api.TraceFlags.NONE,
822
- isRemote: true
823
- };
824
- }
825
- function otelToFrontmcpContext(sc) {
826
- const flags = sc.traceFlags.toString(16).padStart(2, "0");
827
- return {
828
- traceId: sc.traceId,
829
- parentId: sc.spanId,
830
- traceFlags: sc.traceFlags,
831
- raw: `00-${sc.traceId}-${sc.spanId}-${flags}`
832
- };
833
- }
834
- function createOTelContextFromTrace(tc) {
835
- const spanContext = frontmcpToOTelSpanContext(tc);
836
- const span = import_api.trace.wrapSpanContext(spanContext);
837
- return import_api.trace.setSpan(import_api.context.active(), span);
838
- }
839
-
840
- // libs/observability/src/plugin/observability.hooks.ts
841
- var import_api12 = require("@opentelemetry/api");
842
- var import_utils = require("@frontmcp/utils");
825
+ var import_api14 = require("@opentelemetry/api");
843
826
 
844
827
  // libs/observability/src/otel/otel.types.ts
845
828
  var McpAttributes = {
@@ -954,6 +937,35 @@ var EnduserAttributes = {
954
937
  SCOPE: "enduser.scope"
955
938
  };
956
939
 
940
+ // libs/observability/src/otel/trace-context-bridge.ts
941
+ var import_api = require("@opentelemetry/api");
942
+ function frontmcpToOTelSpanContext(tc) {
943
+ return {
944
+ traceId: tc.traceId,
945
+ spanId: tc.parentId,
946
+ traceFlags: tc.traceFlags & import_api.TraceFlags.SAMPLED ? import_api.TraceFlags.SAMPLED : import_api.TraceFlags.NONE,
947
+ isRemote: true
948
+ };
949
+ }
950
+ function otelToFrontmcpContext(sc) {
951
+ const flags = sc.traceFlags.toString(16).padStart(2, "0");
952
+ return {
953
+ traceId: sc.traceId,
954
+ parentId: sc.spanId,
955
+ traceFlags: sc.traceFlags,
956
+ raw: `00-${sc.traceId}-${sc.spanId}-${flags}`
957
+ };
958
+ }
959
+ function createOTelContextFromTrace(tc) {
960
+ const spanContext = frontmcpToOTelSpanContext(tc);
961
+ const span = import_api.trace.wrapSpanContext(spanContext);
962
+ return import_api.trace.setSpan(import_api.context.active(), span);
963
+ }
964
+
965
+ // libs/observability/src/plugin/observability.hooks.ts
966
+ var import_api12 = require("@opentelemetry/api");
967
+ var import_utils = require("@frontmcp/utils");
968
+
957
969
  // libs/observability/src/otel/spans/auth.span.ts
958
970
  var import_api3 = require("@opentelemetry/api");
959
971
 
@@ -1727,6 +1739,97 @@ function reportStartup(data) {
1727
1739
  emitStartupReport(tracer, data);
1728
1740
  }
1729
1741
 
1742
+ // libs/observability/src/telemetry/telemetry.counters.ts
1743
+ var import_api13 = require("@opentelemetry/api");
1744
+ function snapshotKey(name, attributes) {
1745
+ const keys = Object.keys(attributes).sort();
1746
+ if (keys.length === 0) return name;
1747
+ const tail = keys.map((k) => `${k}=${attributes[k]}`).join(",");
1748
+ return `${name}{${tail}}`;
1749
+ }
1750
+ var snapshotStore = /* @__PURE__ */ new Map();
1751
+ var TelemetryCounterImpl = class {
1752
+ constructor(name, otelCounter) {
1753
+ this.name = name;
1754
+ this.otelCounter = otelCounter;
1755
+ }
1756
+ inc(by = 1, attributes = {}) {
1757
+ if (!Number.isFinite(by) || by < 0) {
1758
+ return;
1759
+ }
1760
+ try {
1761
+ this.otelCounter.add(by, attributes);
1762
+ } catch {
1763
+ }
1764
+ const key = snapshotKey(this.name, attributes);
1765
+ const existing = snapshotStore.get(key);
1766
+ if (existing) {
1767
+ existing.count += by;
1768
+ } else {
1769
+ snapshotStore.set(key, { name: this.name, count: by, attributes: { ...attributes } });
1770
+ }
1771
+ }
1772
+ };
1773
+ var METER_NAME = "@frontmcp/observability";
1774
+ var counterCache = /* @__PURE__ */ new Map();
1775
+ function createCounter(name, description) {
1776
+ const existing = counterCache.get(name);
1777
+ if (existing) return existing;
1778
+ const meter = import_api13.metrics.getMeter(METER_NAME);
1779
+ const otelCounter = meter.createCounter(name, description ? { description } : void 0);
1780
+ const counter = new TelemetryCounterImpl(name, otelCounter);
1781
+ counterCache.set(name, counter);
1782
+ return counter;
1783
+ }
1784
+ function getMetricSnapshot() {
1785
+ return Array.from(snapshotStore.values()).map((entry) => ({
1786
+ name: entry.name,
1787
+ count: entry.count,
1788
+ attributes: { ...entry.attributes }
1789
+ }));
1790
+ }
1791
+ function getCounterTotal(name) {
1792
+ let sum = 0;
1793
+ for (const entry of snapshotStore.values()) {
1794
+ if (entry.name === name) sum += entry.count;
1795
+ }
1796
+ return sum;
1797
+ }
1798
+ function resetMetricSnapshot() {
1799
+ snapshotStore.clear();
1800
+ }
1801
+ function resetTelemetrySnapshotForTesting() {
1802
+ snapshotStore.clear();
1803
+ }
1804
+ function resetCounterCacheForTesting() {
1805
+ counterCache.clear();
1806
+ }
1807
+ var KNOWN_BUNDLE_SOURCES = ["static", "npm", "saas-pull", "webhook", "filesystem", "unknown"];
1808
+ function normalizeBundleSource(value) {
1809
+ if (typeof value !== "string") return "unknown";
1810
+ return KNOWN_BUNDLE_SOURCES.includes(value) ? value : "unknown";
1811
+ }
1812
+ var KNOWN_ERROR_REASONS = ["network", "timeout", "invalid_response", "parse_error", "unknown"];
1813
+ function normalizeErrorReason(err) {
1814
+ if (!err) return "unknown";
1815
+ const name = err instanceof Error ? err.name : "";
1816
+ const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
1817
+ const haystack = `${name} ${message}`.toLowerCase();
1818
+ if (name === "TimeoutError" || haystack.includes("timeout") || haystack.includes("timed out")) {
1819
+ return "timeout";
1820
+ }
1821
+ if (name === "NetworkError" || name === "FetchError" || haystack.includes("network") || haystack.includes("econnrefused") || haystack.includes("econnreset") || haystack.includes("enotfound") || haystack.includes("socket") || haystack.includes("dns")) {
1822
+ return "network";
1823
+ }
1824
+ if (name === "SyntaxError" || haystack.includes("parse") || haystack.includes("json")) {
1825
+ return "parse_error";
1826
+ }
1827
+ if (name === "TypeError" || name === "RangeError" || haystack.includes("invalid") || haystack.includes("malformed") || haystack.includes("schema")) {
1828
+ return "invalid_response";
1829
+ }
1830
+ return "unknown";
1831
+ }
1832
+
1730
1833
  // libs/observability/src/telemetry/telemetry.accessor.ts
1731
1834
  var TelemetrySpan = class {
1732
1835
  constructor(span) {
@@ -1751,14 +1854,14 @@ var TelemetrySpan = class {
1751
1854
  /** Record an error on this span */
1752
1855
  recordError(error) {
1753
1856
  this.span.recordException(error);
1754
- this.span.setStatus({ code: import_api13.SpanStatusCode.ERROR, message: error.message });
1857
+ this.span.setStatus({ code: import_api14.SpanStatusCode.ERROR, message: error.message });
1755
1858
  this.hasError = true;
1756
1859
  return this;
1757
1860
  }
1758
1861
  /** End the span (preserves ERROR status if already set) */
1759
1862
  end() {
1760
1863
  if (!this.hasError) {
1761
- this.span.setStatus({ code: import_api13.SpanStatusCode.OK });
1864
+ this.span.setStatus({ code: import_api14.SpanStatusCode.OK });
1762
1865
  }
1763
1866
  this.span.end();
1764
1867
  }
@@ -1766,7 +1869,7 @@ var TelemetrySpan = class {
1766
1869
  endWithError(error) {
1767
1870
  const msg = typeof error === "string" ? error : error.message;
1768
1871
  if (error instanceof Error) this.span.recordException(error);
1769
- this.span.setStatus({ code: import_api13.SpanStatusCode.ERROR, message: msg });
1872
+ this.span.setStatus({ code: import_api14.SpanStatusCode.ERROR, message: msg });
1770
1873
  this.span.end();
1771
1874
  }
1772
1875
  /** Get the underlying OTel span (escape hatch) */
@@ -1777,7 +1880,7 @@ var TelemetrySpan = class {
1777
1880
  var TelemetryAccessor = class {
1778
1881
  constructor(ctx) {
1779
1882
  this.ctx = ctx;
1780
- this.tracer = import_api13.trace.getTracer("@frontmcp/observability");
1883
+ this.tracer = import_api14.trace.getTracer("@frontmcp/observability");
1781
1884
  this.fallbackContext = createOTelContextFromTrace(ctx.traceContext);
1782
1885
  this.sessionHash = sessionTracingId(ctx.sessionId);
1783
1886
  this.baseAttributes = {
@@ -1813,7 +1916,7 @@ var TelemetryAccessor = class {
1813
1916
  const span = this.tracer.startSpan(
1814
1917
  name,
1815
1918
  {
1816
- kind: import_api13.SpanKind.INTERNAL,
1919
+ kind: import_api14.SpanKind.INTERNAL,
1817
1920
  attributes: { ...this.baseAttributes, ...attributes }
1818
1921
  },
1819
1922
  this.getActiveContext()
@@ -1862,11 +1965,11 @@ var TelemetryAccessor = class {
1862
1965
  } else {
1863
1966
  const span = this.tracer.startSpan(
1864
1967
  name,
1865
- { kind: import_api13.SpanKind.INTERNAL, attributes: this.baseAttributes },
1968
+ { kind: import_api14.SpanKind.INTERNAL, attributes: this.baseAttributes },
1866
1969
  this.fallbackContext
1867
1970
  );
1868
1971
  if (attributes) span.addEvent(name, attributes);
1869
- span.setStatus({ code: import_api13.SpanStatusCode.OK });
1972
+ span.setStatus({ code: import_api14.SpanStatusCode.OK });
1870
1973
  span.end();
1871
1974
  }
1872
1975
  }
@@ -1884,12 +1987,26 @@ var TelemetryAccessor = class {
1884
1987
  activeSpan.setAttributes(attrs);
1885
1988
  }
1886
1989
  }
1990
+ /**
1991
+ * Create (or retrieve cached) a named counter.
1992
+ *
1993
+ * Counter increments are exported through the global OTel MeterProvider
1994
+ * when one is configured, and are always recorded in an in-memory snapshot
1995
+ * store (`getMetricSnapshot()` from `@frontmcp/observability`) so tests can
1996
+ * assert behavior without standing up a full OTel pipeline.
1997
+ *
1998
+ * @param name - OTel-style snake_case name, e.g. `frontmcp_skills_bundle_pulls_total`
1999
+ * @param description - human-readable description
2000
+ */
2001
+ createCounter(name, description) {
2002
+ return createCounter(name, description);
2003
+ }
1887
2004
  /**
1888
2005
  * Get the trace ID of the current request.
1889
2006
  * Useful for including in external API calls or logs.
1890
2007
  */
1891
2008
  get traceId() {
1892
- return import_api13.trace.getSpan(this.fallbackContext)?.spanContext().traceId ?? "";
2009
+ return import_api14.trace.getSpan(this.fallbackContext)?.spanContext().traceId ?? "";
1893
2010
  }
1894
2011
  /**
1895
2012
  * Get the session tracing ID (privacy-safe hash).
@@ -1899,8 +2016,55 @@ var TelemetryAccessor = class {
1899
2016
  }
1900
2017
  };
1901
2018
 
2019
+ // libs/observability/src/telemetry/telemetry.factory.ts
2020
+ var import_api15 = require("@opentelemetry/api");
2021
+ var TelemetryFactory = class {
2022
+ constructor(tracerName = "@frontmcp/observability") {
2023
+ this.tracer = import_api15.trace.getTracer(tracerName);
2024
+ }
2025
+ /**
2026
+ * Create (or retrieve cached) a named counter. Increments propagate to
2027
+ * the OTel meter (when configured) and to the in-memory snapshot store.
2028
+ *
2029
+ * @param name - OTel-style snake_case name with `_total` suffix.
2030
+ * @param description - human-readable description (deduped: subsequent
2031
+ * calls with a different description return the
2032
+ * cached counter unchanged).
2033
+ */
2034
+ createCounter(name, description) {
2035
+ return createCounter(name, description);
2036
+ }
2037
+ /**
2038
+ * Start a span. Caller is responsible for calling `end()` /
2039
+ * `endWithError()`. The span parents to the currently-active OTel
2040
+ * context; if none is active it becomes a root span.
2041
+ */
2042
+ startSpan(name, attributes, parent) {
2043
+ const opts = attributes ? { attributes } : void 0;
2044
+ const span = parent ? this.tracer.startSpan(name, opts, parent) : this.tracer.startSpan(name, opts);
2045
+ return new TelemetrySpan(span);
2046
+ }
2047
+ /**
2048
+ * Convenience wrapper — start a span, run `fn` with it, end on success
2049
+ * or error. Same lifecycle as `TelemetryAccessor.withSpan` but without
2050
+ * the per-request base attributes.
2051
+ */
2052
+ async withSpan(name, fn, attributes) {
2053
+ const span = this.startSpan(name, attributes);
2054
+ try {
2055
+ const result = await fn(span);
2056
+ span.end();
2057
+ return result;
2058
+ } catch (err) {
2059
+ span.endWithError(err instanceof Error ? err : String(err));
2060
+ throw err;
2061
+ }
2062
+ }
2063
+ };
2064
+
1902
2065
  // libs/observability/src/telemetry/telemetry.tokens.ts
1903
2066
  var TELEMETRY_ACCESSOR = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-accessor");
2067
+ var TELEMETRY_FACTORY = /* @__PURE__ */ Symbol.for("frontmcp:observability:telemetry-factory");
1904
2068
 
1905
2069
  // libs/observability/src/plugin/observability.plugin.ts
1906
2070
  var PromptHook = (0, import_sdk2.FlowHooksOf)("prompts:get-prompt");
@@ -2334,7 +2498,7 @@ ObservabilityPlugin.dynamicProviders = (input) => {
2334
2498
  providers.push({
2335
2499
  name: "observability:tracer",
2336
2500
  provide: OTEL_TRACER,
2337
- useValue: import_api14.trace.getTracer("@frontmcp/observability")
2501
+ useValue: import_api16.trace.getTracer("@frontmcp/observability")
2338
2502
  });
2339
2503
  providers.push({
2340
2504
  name: "observability:config",
@@ -2348,6 +2512,11 @@ ObservabilityPlugin.dynamicProviders = (input) => {
2348
2512
  inject: () => [import_sdk2.FRONTMCP_CONTEXT],
2349
2513
  useFactory: (ctx) => new TelemetryAccessor(ctx)
2350
2514
  });
2515
+ providers.push({
2516
+ name: "observability:telemetry-factory",
2517
+ provide: TELEMETRY_FACTORY,
2518
+ useValue: new TelemetryFactory()
2519
+ });
2351
2520
  }
2352
2521
  if (options.logging !== false) {
2353
2522
  const loggingOpts = options.logging;
@@ -2784,11 +2953,11 @@ function resolveRequestLogOptions(input) {
2784
2953
  }
2785
2954
 
2786
2955
  // libs/observability/src/otel/context-propagator.ts
2787
- var import_api15 = require("@opentelemetry/api");
2956
+ var import_api17 = require("@opentelemetry/api");
2788
2957
  var TRACEPARENT_HEADER = "traceparent";
2789
2958
  var FrontMcpPropagator = class {
2790
2959
  inject(context, carrier, setter) {
2791
- const span = import_api15.trace.getSpan(context);
2960
+ const span = import_api17.trace.getSpan(context);
2792
2961
  if (!span) return;
2793
2962
  const sc = span.spanContext();
2794
2963
  if (!sc || !isValidSpanContext(sc)) return;
@@ -2808,8 +2977,8 @@ var FrontMcpPropagator = class {
2808
2977
  traceFlags: parsed.traceFlags,
2809
2978
  isRemote: true
2810
2979
  };
2811
- const span = import_api15.trace.wrapSpanContext(spanContext);
2812
- return import_api15.trace.setSpan(context, span);
2980
+ const span = import_api17.trace.wrapSpanContext(spanContext);
2981
+ return import_api17.trace.setSpan(context, span);
2813
2982
  }
2814
2983
  fields() {
2815
2984
  return [TRACEPARENT_HEADER];
@@ -2877,7 +3046,7 @@ function setupOTel(options) {
2877
3046
  }
2878
3047
 
2879
3048
  // libs/observability/src/otel/spans/hook.span.ts
2880
- var import_api16 = require("@opentelemetry/api");
3049
+ var import_api18 = require("@opentelemetry/api");
2881
3050
  function recordHookEvent(parentSpan, stage, owner) {
2882
3051
  const attributes = {};
2883
3052
  if (owner) {
@@ -2897,14 +3066,14 @@ function startHookSpan(tracer, options) {
2897
3066
  }
2898
3067
  return startSpan(tracer, {
2899
3068
  name: `hook ${options.stage}`,
2900
- kind: import_api16.SpanKind.INTERNAL,
3069
+ kind: import_api18.SpanKind.INTERNAL,
2901
3070
  attributes,
2902
3071
  parentContext: options.parentContext
2903
3072
  });
2904
3073
  }
2905
3074
 
2906
3075
  // libs/observability/src/otel/spans/pretty-span-exporter.ts
2907
- var import_api17 = require("@opentelemetry/api");
3076
+ var import_api19 = require("@opentelemetry/api");
2908
3077
  var RESET = "\x1B[0m";
2909
3078
  var DIM = "\x1B[2m";
2910
3079
  var BOLD = "\x1B[1m";
@@ -2913,11 +3082,11 @@ var GREEN = "\x1B[32m";
2913
3082
  var RED = "\x1B[31m";
2914
3083
  var YELLOW = "\x1B[33m";
2915
3084
  var KIND_LABELS = {
2916
- [import_api17.SpanKind.INTERNAL]: "INTERNAL",
2917
- [import_api17.SpanKind.SERVER]: "SERVER",
2918
- [import_api17.SpanKind.CLIENT]: "CLIENT",
2919
- [import_api17.SpanKind.PRODUCER]: "PRODUCER",
2920
- [import_api17.SpanKind.CONSUMER]: "CONSUMER"
3085
+ [import_api19.SpanKind.INTERNAL]: "INTERNAL",
3086
+ [import_api19.SpanKind.SERVER]: "SERVER",
3087
+ [import_api19.SpanKind.CLIENT]: "CLIENT",
3088
+ [import_api19.SpanKind.PRODUCER]: "PRODUCER",
3089
+ [import_api19.SpanKind.CONSUMER]: "CONSUMER"
2921
3090
  };
2922
3091
  var SHOW_ATTRS = /* @__PURE__ */ new Set([
2923
3092
  "rpc.system",
@@ -2956,7 +3125,7 @@ var PrettySpanExporter = class {
2956
3125
  const traceShort = span.spanContext().traceId.slice(0, 8);
2957
3126
  const durationMs = (span.duration[0] * 1e3 + span.duration[1] / 1e6).toFixed(1);
2958
3127
  const kind = KIND_LABELS[span.kind] ?? "UNKNOWN";
2959
- const statusOk = span.status.code !== import_api17.SpanStatusCode.ERROR;
3128
+ const statusOk = span.status.code !== import_api19.SpanStatusCode.ERROR;
2960
3129
  const attrs = Object.entries(span.attributes).filter(([k]) => SHOW_ATTRS.has(k)).map(([k, v]) => {
2961
3130
  const short = k.replace("frontmcp.", "").replace("mcp.", "");
2962
3131
  return `${short}=${v}`;
@@ -2965,7 +3134,7 @@ var PrettySpanExporter = class {
2965
3134
  const eventSummary = eventCount > 0 ? ` (${eventCount} events)` : "";
2966
3135
  if (this.useAnsi) {
2967
3136
  const statusColor = statusOk ? GREEN : RED;
2968
- const kindColor = span.kind === import_api17.SpanKind.SERVER ? CYAN : DIM;
3137
+ const kindColor = span.kind === import_api19.SpanKind.SERVER ? CYAN : DIM;
2969
3138
  return [
2970
3139
  `${DIM}\u21B3${RESET}`,
2971
3140
  `${BOLD}${statusColor}SPAN${RESET}`,
@@ -3037,6 +3206,8 @@ function findSpansByAttribute(spans, key, value) {
3037
3206
  FrontMcpAttributes,
3038
3207
  FrontMcpPropagator,
3039
3208
  HttpAttributes,
3209
+ KNOWN_BUNDLE_SOURCES,
3210
+ KNOWN_ERROR_REASONS,
3040
3211
  LOG_LEVEL_TO_OTEL_SEVERITY,
3041
3212
  McpAttributes,
3042
3213
  OTEL_CONFIG,
@@ -3051,11 +3222,14 @@ function findSpansByAttribute(spans, key, value) {
3051
3222
  StdoutSink,
3052
3223
  StructuredLogTransport,
3053
3224
  TELEMETRY_ACCESSOR,
3225
+ TELEMETRY_FACTORY,
3054
3226
  TelemetryAccessor,
3227
+ TelemetryFactory,
3055
3228
  TelemetrySpan,
3056
3229
  WinstonSink,
3057
3230
  assertSpanAttribute,
3058
3231
  assertSpanExists,
3232
+ createCounter,
3059
3233
  createOTelContextFromTrace,
3060
3234
  createSink,
3061
3235
  createSinks,
@@ -3066,11 +3240,18 @@ function findSpansByAttribute(spans, key, value) {
3066
3240
  findSpan,
3067
3241
  findSpansByAttribute,
3068
3242
  frontmcpToOTelSpanContext,
3243
+ getCounterTotal,
3069
3244
  getFinishedSpans,
3245
+ getMetricSnapshot,
3246
+ normalizeBundleSource,
3247
+ normalizeErrorReason,
3070
3248
  otelToFrontmcpContext,
3071
3249
  recordHookEvent,
3072
3250
  redactFields,
3073
3251
  reportStartup,
3252
+ resetCounterCacheForTesting,
3253
+ resetMetricSnapshot,
3254
+ resetTelemetrySnapshotForTesting,
3074
3255
  sessionTracingId,
3075
3256
  setAuthMode,
3076
3257
  setAuthResult,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/observability",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
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.1.2",
55
- "@frontmcp/utils": "1.1.2",
54
+ "@frontmcp/sdk": "1.2.1",
55
+ "@frontmcp/utils": "1.2.1",
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",
@@ -1,3 +1,6 @@
1
- export { TelemetryAccessor, TelemetrySpan } from './telemetry.accessor';
2
- export { TELEMETRY_ACCESSOR } from './telemetry.tokens';
3
1
  import './telemetry.context-extension';
2
+ export { TelemetryAccessor, TelemetrySpan } from './telemetry.accessor';
3
+ export { TELEMETRY_ACCESSOR, TELEMETRY_FACTORY } from './telemetry.tokens';
4
+ export { TelemetryFactory } from './telemetry.factory';
5
+ export { createCounter, getMetricSnapshot, getCounterTotal, resetMetricSnapshot, resetTelemetrySnapshotForTesting, resetCounterCacheForTesting, normalizeBundleSource, normalizeErrorReason, KNOWN_BUNDLE_SOURCES, KNOWN_ERROR_REASONS, } from './telemetry.counters';
6
+ export type { TelemetryCounter, CounterSnapshotEntry, KnownBundleSource, KnownErrorReason } from './telemetry.counters';
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { type Span } from '@opentelemetry/api';
35
35
  import { type TraceContextLike } from '../otel/trace-context-bridge';
36
+ import { type TelemetryCounter } from './telemetry.counters';
36
37
  /**
37
38
  * Minimal context shape — avoids tight coupling to FrontMcpContext.
38
39
  */
@@ -138,6 +139,18 @@ export declare class TelemetryAccessor {
138
139
  * @param attrs — key-value attributes
139
140
  */
140
141
  setAttributes(attrs: Record<string, string | number | boolean>): void;
142
+ /**
143
+ * Create (or retrieve cached) a named counter.
144
+ *
145
+ * Counter increments are exported through the global OTel MeterProvider
146
+ * when one is configured, and are always recorded in an in-memory snapshot
147
+ * store (`getMetricSnapshot()` from `@frontmcp/observability`) so tests can
148
+ * assert behavior without standing up a full OTel pipeline.
149
+ *
150
+ * @param name - OTel-style snake_case name, e.g. `frontmcp_skills_bundle_pulls_total`
151
+ * @param description - human-readable description
152
+ */
153
+ createCounter(name: string, description?: string): TelemetryCounter;
141
154
  /**
142
155
  * Get the trace ID of the current request.
143
156
  * Useful for including in external API calls or logs.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * TelemetryCounter — minimal counter abstraction for FrontMCP.
3
+ *
4
+ * Backed by `@opentelemetry/api` `metrics.getMeter(...)` so installations
5
+ * that wire up an OTel MeterProvider get real counter exports. Independently,
6
+ * every increment is also recorded in an in-memory snapshot store so tests
7
+ * (and operators without a configured meter provider) can introspect counter
8
+ * activity without standing up a full OTel pipeline.
9
+ *
10
+ * IMPORTANT: ObservabilityPlugin does NOT register a `MeterProvider` — it only
11
+ * wires up tracing. To export counters to a real metrics backend (Prometheus,
12
+ * OTLP, etc.) the host application must call `metrics.setGlobalMeterProvider()`
13
+ * with its own configured `MeterProvider`. Without that step, counters are
14
+ * observable only via `getMetricSnapshot()` / `getCounterTotal()`.
15
+ *
16
+ * Naming convention follows OTel-style snake_case with a `_total` suffix:
17
+ * - `frontmcp_skills_bundle_pulls_total`
18
+ * - `frontmcp_skills_signature_failures_total`
19
+ * - `frontmcp_skills_signature_verifications_total`
20
+ * - `frontmcp_skills_replay_rejects_total`
21
+ * - `frontmcp_skills_replay_checks_total`
22
+ *
23
+ * Attributes should be lowercase snake_case and bounded cardinality
24
+ * (e.g. `status`, `source`, `reason`).
25
+ *
26
+ * Bounded vocabularies (enforced by helpers below):
27
+ * - `source`: 'static' | 'npm' | 'saas-pull' | 'filesystem' | 'unknown'
28
+ * - `reason` (errors): 'network' | 'timeout' | 'invalid_response' |
29
+ * 'parse_error' | 'unknown'
30
+ * - `status`: 'ok' | 'error'
31
+ *
32
+ * Anything outside the whitelist coerces to `'unknown'` (or `'other'` for
33
+ * subsystem-specific reason classifiers like `classifyReason()` in
34
+ * bundle-signature.ts) so untrusted input cannot create unbounded label
35
+ * cardinality.
36
+ */
37
+ /**
38
+ * Public counter handle — increments propagate to both the OTel meter
39
+ * (when configured) and the in-memory snapshot store.
40
+ */
41
+ export interface TelemetryCounter {
42
+ /**
43
+ * Increment the counter.
44
+ *
45
+ * @param by - amount to add (default 1, must be >= 0)
46
+ * @param attributes - bounded-cardinality labels
47
+ */
48
+ inc(by?: number, attributes?: Record<string, string>): void;
49
+ /** The metric name, useful for diagnostics/snapshot keys. */
50
+ readonly name: string;
51
+ }
52
+ /**
53
+ * Create (or retrieve cached) named counter.
54
+ *
55
+ * Counters with the same name are de-duped — subsequent calls return the
56
+ * same handle. This prevents accidentally exporting two counters with the
57
+ * same name but slightly different descriptions.
58
+ */
59
+ export declare function createCounter(name: string, description?: string): TelemetryCounter;
60
+ /**
61
+ * Snapshot of all counters recorded since process start (or last reset).
62
+ * Useful for assertions in tests.
63
+ *
64
+ * Each entry is a (name, attributes, count) tuple. Counters with attributes
65
+ * are reported per unique attribute combination.
66
+ */
67
+ export interface CounterSnapshotEntry {
68
+ name: string;
69
+ count: number;
70
+ attributes: Record<string, string>;
71
+ }
72
+ /**
73
+ * Read a snapshot of every counter increment recorded so far. Returned
74
+ * array is a fresh copy — mutating it does not affect the store.
75
+ */
76
+ export declare function getMetricSnapshot(): CounterSnapshotEntry[];
77
+ /**
78
+ * Lookup helper — sum of all increments for a given counter name (across
79
+ * every attribute combination).
80
+ */
81
+ export declare function getCounterTotal(name: string): number;
82
+ /**
83
+ * Test helper — clear the in-memory snapshot store. Does not reset the
84
+ * underlying OTel meter (which has its own lifecycle).
85
+ *
86
+ * @deprecated Prefer {@link resetTelemetrySnapshotForTesting}, which is
87
+ * spelled to make its test-only intent explicit. Kept for backwards
88
+ * compatibility.
89
+ */
90
+ export declare function resetMetricSnapshot(): void;
91
+ /**
92
+ * Test helper — clear the in-memory snapshot store between specs to prevent
93
+ * cross-test leakage. The snapshot store is process-global; without an
94
+ * explicit reset between tests, counter increments from one spec can show
95
+ * up in another's assertions.
96
+ *
97
+ * @internal Test-only API. Production code MUST NOT call this.
98
+ */
99
+ export declare function resetTelemetrySnapshotForTesting(): void;
100
+ /**
101
+ * Test helper — clear the counter cache. Used only by tests that need to
102
+ * exercise the create-path repeatedly.
103
+ *
104
+ * @internal Test-only API. Production code MUST NOT call this.
105
+ */
106
+ export declare function resetCounterCacheForTesting(): void;
107
+ /**
108
+ * @deprecated Use {@link resetCounterCacheForTesting} instead. The leading
109
+ * underscore violates project naming conventions (CLAUDE.md). Kept temporarily
110
+ * for backwards compatibility with existing specs.
111
+ */
112
+ export declare const _resetCounterCacheForTesting: typeof resetCounterCacheForTesting;
113
+ /**
114
+ * Bounded vocabulary for the `source` counter attribute. Anything outside
115
+ * this set is coerced to `'unknown'` to prevent cardinality explosions when
116
+ * upstream callers pass URLs, tenant IDs, or other unbounded strings.
117
+ */
118
+ export declare const KNOWN_BUNDLE_SOURCES: readonly ["static", "npm", "saas-pull", "webhook", "filesystem", "unknown"];
119
+ export type KnownBundleSource = (typeof KNOWN_BUNDLE_SOURCES)[number];
120
+ /**
121
+ * Coerce a free-text source label to the bounded `KnownBundleSource` set.
122
+ * Unknown / undefined / non-string inputs map to `'unknown'`.
123
+ */
124
+ export declare function normalizeBundleSource(value: unknown): KnownBundleSource;
125
+ /**
126
+ * Bounded vocabulary for `reason` counter attributes derived from generic
127
+ * errors. Subsystem-specific classifiers (e.g. signature/replay) MAY define
128
+ * their own narrower vocabularies, but should use this one for catch-all
129
+ * "an Error was thrown" paths.
130
+ */
131
+ export declare const KNOWN_ERROR_REASONS: readonly ["network", "timeout", "invalid_response", "parse_error", "unknown"];
132
+ export type KnownErrorReason = (typeof KNOWN_ERROR_REASONS)[number];
133
+ /**
134
+ * Map an arbitrary error to a low-cardinality `reason` label. Inspects the
135
+ * error name and a small substring of the message — never echoes the raw
136
+ * message back as a label, which would let untrusted error sources spawn
137
+ * unbounded timeseries.
138
+ */
139
+ export declare function normalizeErrorReason(err: unknown): KnownErrorReason;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * TelemetryFactory — SCOPE-scoped helper for creating counters and spans
3
+ * without requiring an active request context.
4
+ *
5
+ * `TelemetryAccessor` is CONTEXT-scoped (one per request) so it can read
6
+ * the active flow span and attach base attributes (request ID, session ID,
7
+ * etc). That's correct for tool/resource/prompt code paths but the wrong
8
+ * lifetime for scope-lifetime singletons:
9
+ *
10
+ * - `BundleStore` is constructed at scope-init and lives for the life
11
+ * of the scope. Its `swap()` may be invoked from a source's onChange
12
+ * callback (no request context) or from a meta-tool call (request
13
+ * context). Either way it just needs counters that work, and spans
14
+ * parented to whatever active OTel context is on the stack.
15
+ *
16
+ * The factory wraps the same process-global `createCounter` exported from
17
+ * this package, plus a tracer so callers can start spans. `startSpan`
18
+ * uses `trace.setSpan(context.active(), ...)` semantics implicitly via
19
+ * `tracer.startSpan` so callers nest correctly when a parent span exists.
20
+ *
21
+ * @see TELEMETRY_FACTORY
22
+ */
23
+ import { type Context as OTelContext } from '@opentelemetry/api';
24
+ import { TelemetrySpan } from './telemetry.accessor';
25
+ import { type TelemetryCounter } from './telemetry.counters';
26
+ export declare class TelemetryFactory {
27
+ private readonly tracer;
28
+ constructor(tracerName?: string);
29
+ /**
30
+ * Create (or retrieve cached) a named counter. Increments propagate to
31
+ * the OTel meter (when configured) and to the in-memory snapshot store.
32
+ *
33
+ * @param name - OTel-style snake_case name with `_total` suffix.
34
+ * @param description - human-readable description (deduped: subsequent
35
+ * calls with a different description return the
36
+ * cached counter unchanged).
37
+ */
38
+ createCounter(name: string, description?: string): TelemetryCounter;
39
+ /**
40
+ * Start a span. Caller is responsible for calling `end()` /
41
+ * `endWithError()`. The span parents to the currently-active OTel
42
+ * context; if none is active it becomes a root span.
43
+ */
44
+ startSpan(name: string, attributes?: Record<string, string | number | boolean>, parent?: OTelContext): TelemetrySpan;
45
+ /**
46
+ * Convenience wrapper — start a span, run `fn` with it, end on success
47
+ * or error. Same lifecycle as `TelemetryAccessor.withSpan` but without
48
+ * the per-request base attributes.
49
+ */
50
+ withSpan<T>(name: string, fn: (span: TelemetrySpan) => Promise<T>, attributes?: Record<string, string | number | boolean>): Promise<T>;
51
+ }
@@ -5,3 +5,14 @@
5
5
  * Registered by ObservabilityPlugin when tracing is enabled.
6
6
  */
7
7
  export declare const TELEMETRY_ACCESSOR: unique symbol;
8
+ /**
9
+ * DI token for the SCOPE-level TelemetryFactory.
10
+ *
11
+ * Unlike `TELEMETRY_ACCESSOR` (CONTEXT-scoped), this factory is resolvable
12
+ * at scope-init time without an active request context. It's intended for
13
+ * scope-lifetime singletons (e.g. `BundleStore`, security guards) that need
14
+ * to create process-global counters / spans before any request arrives.
15
+ *
16
+ * Registered by ObservabilityPlugin when tracing is enabled.
17
+ */
18
+ export declare const TELEMETRY_FACTORY: unique symbol;