@open-mercato/telemetry 0.10.1-develop.7833.1.d6cbf0e3ab → 0.10.1-develop.7841.1.af87ac614d

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.
@@ -1,2 +1,2 @@
1
- [build:telemetry] found 30 entry points
1
+ [build:telemetry] found 31 entry points
2
2
  [build:telemetry] built successfully
package/AGENTS.md CHANGED
@@ -25,7 +25,12 @@ off by default. Spec:
25
25
  - Emit spans from packages that must not depend on this one via
26
26
  `withTelemetrySpan` / `captureTelemetryTrace` from
27
27
  `@open-mercato/shared/lib/telemetry/runtime`, never a direct import.
28
- - Use semantic-convention metric/attribute names when available.
28
+ - Use semantic-convention metric/attribute names when available, unless an
29
+ auto-instrumentation the OTLP provider registers already emits that name
30
+ (`PgInstrumentation` owns `db.client.connection.*`); use `om.*` then, since
31
+ two instruments must never share a name.
32
+ - Keep the built-in metric names, units, and fixed labels synchronized with the
33
+ catalog in `packages/telemetry/README.md`.
29
34
  - Keep metric labels low-cardinality. Tenant, organization, and user IDs belong
30
35
  on span attributes, never metric labels.
31
36
  - Apply redaction at the provider boundary as well as at facade call sites.
@@ -106,6 +111,8 @@ host/queue shared runtime bridge ── absent while off
106
111
  - `src/provider/*`: noop/console/OTLP providers and global provider registry.
107
112
  - `src/init.ts`: explicit-enabled initialization and process-wide bridge
108
113
  registration.
114
+ - `src/runtime-metrics.ts`: enabled-only process sampler and the single
115
+ interval that invokes shared periodic metric collectors.
109
116
  - `src/nextjs-config.ts`: build-time constants only; no runtime imports.
110
117
  - `src/nextjs.ts`: enabled runtime helper.
111
118
  - `src/browser/*`: browser RUM. `config.ts` (shared, env-free contract) and
package/README.md CHANGED
@@ -285,6 +285,50 @@ implementation, where you can decide what a dropped report means.
285
285
  `attributes`, so putting it in both `tags` and `extra`, as the snippet above does
286
286
  with `tags`, is your choice rather than an accident of the payload.
287
287
 
288
+ ### Built-in runtime metrics
289
+
290
+ An active provider starts one process-wide 10-second sampler. Disabled/noop
291
+ telemetry starts no interval or event-loop monitor. The sampler also invokes
292
+ shared metric collectors, which lets the primary PostgreSQL pool report state
293
+ without making `@open-mercato/shared` depend on this package.
294
+
295
+ | Metric | Kind | Unit | Labels |
296
+ | --- | --- | --- | --- |
297
+ | `om.db.pool.connections` | gauge | `{connection}` | `pool=primary`, `state=idle\|used` |
298
+ | `om.db.pool.pending_requests` | gauge | `{request}` | `pool=primary` |
299
+ | `om.db.pool.max` | gauge | `{connection}` | `pool=primary` |
300
+ | `om.db.pool.wait_time` | histogram | `s` | `pool=primary` |
301
+ | `nodejs.eventloop.utilization` | gauge | `1` | none |
302
+ | `nodejs.eventloop.delay.p50` / `p90` / `p99` | gauge | `s` | none |
303
+ | `process.memory.usage` | gauge | `By` | none |
304
+ | `v8js.memory.heap.used` | gauge | `By` | `v8js.heap.space.name` |
305
+
306
+ Pool acquisition wait is recorded per `pool.connect` call, including failed
307
+ promise and callback acquisitions, and includes opening a new connection when
308
+ the pool grows. Connection-state values are sampled; sum `idle` and `used` for
309
+ the current open-connection total.
310
+
311
+ The pool metrics use the `om.db.pool.*` namespace on purpose. Under an OTLP
312
+ backend `@opentelemetry/instrumentation-pg` already emits the semantic-convention
313
+ `db.client.connection.count` and `db.client.connection.pending_requests` as
314
+ UpDownCounters labelled `db.client.connection.state` /
315
+ `db.client.connection.pool.name`. Reusing those names would export two streams
316
+ per name with conflicting types and label keys. Query `om.db.pool.*` for the
317
+ sampled view, which is also available under the `console` backend.
318
+
319
+ The event-loop delay percentiles come from `monitorEventLoopDelay` with a 20 ms
320
+ resolution, which records the whole timer interval rather than the overshoot.
321
+ An idle process therefore reports about 0.020 s, not 0. Subtract 0.020 s before
322
+ you compare against a real-delay budget: an alert at 0.050 s fires at about
323
+ 30 ms of actual blocking.
324
+
325
+ Under an OTLP backend, every histogram recorded in seconds
326
+ (`om.db.pool.wait_time`, `http.server.request.duration`,
327
+ `om.enricher.duration`) is exported with explicit boundaries of 1 ms, 5 ms, 10 ms, 25 ms, 50 ms, 75 ms, 100 ms, 250 ms,
328
+ 500 ms, 750 ms, 1 s, 2.5 s, 5 s, 7.5 s and 10 s, so backends can compute
329
+ percentiles. The SDK default is millisecond-scaled and would put every
330
+ realistic latency in its first bucket.
331
+
288
332
  ### Long-lived jobs: root spans
289
333
 
290
334
  Trace context propagates from the request that triggered a job through the queue
package/dist/init.js CHANGED
@@ -15,10 +15,13 @@ import { withSpan } from "./facade/tracer.js";
15
15
  import { recordHttpDuration } from "./facade/http.js";
16
16
  import { histogram } from "./facade/meter.js";
17
17
  import { reportError } from "./facade/report-error.js";
18
+ import { startRuntimeMetrics } from "./runtime-metrics.js";
18
19
  let initialized = false;
20
+ let initializing;
19
21
  let activeProvider;
20
22
  let disposeLoggerExtension;
21
23
  let disposeRuntime;
24
+ let disposeRuntimeMetrics;
22
25
  const logger = createLogger("telemetry");
23
26
  const OTLP_DEPENDENCY_UNAVAILABLE = /* @__PURE__ */ Symbol.for("open-mercato.telemetry.otlp-dependency-unavailable");
24
27
  class OtlpDependencyUnavailableError extends Error {
@@ -33,6 +36,15 @@ class OtlpDependencyUnavailableError extends Error {
33
36
  }
34
37
  async function initTelemetry() {
35
38
  if (initialized) return;
39
+ if (initializing) return initializing;
40
+ let pending;
41
+ pending = initializeTelemetry().finally(() => {
42
+ if (initializing === pending) initializing = void 0;
43
+ });
44
+ initializing = pending;
45
+ return pending;
46
+ }
47
+ async function initializeTelemetry() {
36
48
  resetTelemetryEnvCache();
37
49
  const env = readTelemetryEnv();
38
50
  const configuredBackend = process.env.TELEMETRY_BACKEND?.trim().toLowerCase() ?? "";
@@ -50,6 +62,7 @@ async function initTelemetry() {
50
62
  activeProvider = provider;
51
63
  disposeLoggerExtension = registerTelemetryLogger(provider);
52
64
  disposeRuntime = registerTelemetryRuntime(createRuntime(provider));
65
+ disposeRuntimeMetrics = startRuntimeMetrics();
53
66
  initialized = true;
54
67
  logger.info("Telemetry initialized", {
55
68
  backend: provider.name,
@@ -73,11 +86,27 @@ async function loadOtlpProvider(backend) {
73
86
  return new mod.OtlpProvider({}, backend);
74
87
  }
75
88
  async function shutdownTelemetry() {
89
+ if (initializing) {
90
+ try {
91
+ await initializing;
92
+ } catch (err) {
93
+ try {
94
+ reportError(err, {
95
+ module: "telemetry",
96
+ code: "telemetry.initialization_failed"
97
+ });
98
+ } catch {
99
+ }
100
+ logger.warn("Telemetry initialization failed before shutdown", { err });
101
+ }
102
+ }
76
103
  const provider = activeProvider;
77
104
  activeProvider = void 0;
78
105
  initialized = false;
79
106
  disposeLoggerExtension?.();
80
107
  disposeLoggerExtension = void 0;
108
+ disposeRuntimeMetrics?.();
109
+ disposeRuntimeMetrics = void 0;
81
110
  disposeRuntime?.();
82
111
  disposeRuntime = void 0;
83
112
  clearActiveProvider();
@@ -89,6 +118,7 @@ function createRuntime(provider) {
89
118
  captureTraceContext,
90
119
  continueTrace: (carrier, name, fn, options) => continueTrace(carrier, name, () => fn(), options),
91
120
  withSpan: (name, fn, options) => withSpan(name, fn, options),
121
+ recordMetric: (point) => provider.recordMetric(point),
92
122
  recordHistogram: histogram,
93
123
  recordHttpDuration,
94
124
  reportError,
@@ -97,9 +127,12 @@ function createRuntime(provider) {
97
127
  }
98
128
  function resetTelemetryInit() {
99
129
  initialized = false;
130
+ initializing = void 0;
100
131
  activeProvider = void 0;
101
132
  disposeLoggerExtension?.();
102
133
  disposeLoggerExtension = void 0;
134
+ disposeRuntimeMetrics?.();
135
+ disposeRuntimeMetrics = void 0;
103
136
  disposeRuntime?.();
104
137
  disposeRuntime = void 0;
105
138
  }
package/dist/init.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/init.ts"],
4
- "sourcesContent": ["import type { TelemetryBackendName, TelemetryProvider } from './types'\nimport { isOtlpBackend, readTelemetryEnv, resetTelemetryEnvCache } from './env'\nimport { ConsoleProvider } from './provider/console-provider'\nimport {\n getRegisteredProvider,\n clearActiveProvider,\n setActiveProvider,\n} from './provider/registry'\nimport {\n registerTelemetryRuntime,\n type TelemetryRuntime,\n} from '@open-mercato/shared/lib/telemetry/runtime'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { registerTelemetryLogger } from './facade/logger-bridge'\nimport { captureTraceContext, continueTrace } from './facade/propagation'\nimport { withSpan } from './facade/tracer'\nimport { recordHttpDuration } from './facade/http'\nimport { histogram } from './facade/meter'\nimport { reportError } from './facade/report-error'\n\nlet initialized = false\nlet activeProvider: TelemetryProvider | undefined\nlet disposeLoggerExtension: (() => void) | undefined\nlet disposeRuntime: (() => void) | undefined\n\nconst logger = createLogger('telemetry')\nconst OTLP_DEPENDENCY_UNAVAILABLE = Symbol.for('open-mercato.telemetry.otlp-dependency-unavailable')\n\nclass OtlpDependencyUnavailableError extends Error {\n constructor(backend: TelemetryBackendName, cause: unknown) {\n super(\n `[internal] OTLP telemetry backend \"${backend}\" cannot start because its OpenTelemetry runtime dependencies are unavailable. Install optional dependencies, set TELEMETRY_BACKEND=console, or unset TELEMETRY_BACKEND to disable telemetry.`,\n { cause },\n )\n this.name = 'OtlpDependencyUnavailableError'\n Object.defineProperty(this, OTLP_DEPENDENCY_UNAVAILABLE, { value: true })\n }\n}\n\n/**\n * One-shot bootstrap, invoked from `apps/mercato/instrumentation.ts` (web) and,\n * for cross-boundary propagation, from the queue/worker bootstrap. Resolves the\n * backend from `TELEMETRY_BACKEND`, starts it, and installs it as the active\n * provider. Idempotent and safe to call when telemetry is off (defaults to\n * no-op).\n */\nexport async function initTelemetry(): Promise<void> {\n if (initialized) return\n\n // Drop any env snapshot memoized BEFORE the host loaded its `.env` (the CLI\n // binary imports this package before dotenv runs) so init resolves the backend\n // from the actual, fully-loaded environment.\n resetTelemetryEnvCache()\n const env = readTelemetryEnv()\n const configuredBackend = process.env.TELEMETRY_BACKEND?.trim().toLowerCase() ?? ''\n const registeredProvider = configuredBackend && configuredBackend !== 'noop'\n ? getRegisteredProvider(configuredBackend)\n : undefined\n // Explicit off is absolute: do not resolve providers, register hooks, or\n // install process-wide bridges when the backend is unset/noop/unregistered.\n if (!env.enabled && !registeredProvider) return\n\n const provider = registeredProvider ?? await resolveProvider(env.backend)\n if (!provider) {\n logger.warn('Telemetry backend is unsupported; telemetry remains disabled', {\n backend: configuredBackend || env.backend,\n })\n return\n }\n\n // Only flag initialized AFTER start() succeeds \u2014 a throw here must leave\n // telemetry re-initializable, not stuck flagged-on with an unstarted provider.\n await provider.start()\n setActiveProvider(provider)\n activeProvider = provider\n disposeLoggerExtension = registerTelemetryLogger(provider)\n disposeRuntime = registerTelemetryRuntime(createRuntime(provider))\n initialized = true\n\n logger.info('Telemetry initialized', {\n backend: provider.name,\n samplingRatio: env.samplingRatio,\n })\n}\n\nasync function resolveProvider(backend: TelemetryBackendName): Promise<TelemetryProvider | undefined> {\n const registered = getRegisteredProvider(backend)\n if (registered) return registered\n\n if (backend === 'console') return new ConsoleProvider()\n // signoz | newrelic | otlp \u2014 same OTLP provider, vendor differs only by endpoint.\n if (isOtlpBackend(backend)) return await loadOtlpProvider(backend)\n return undefined\n}\n\n/**\n * Dynamically load the OTLP provider so `@opentelemetry/*` (optionalDependencies)\n * is imported only when an OTLP backend is selected. An explicit OTLP selection\n * must fail at startup when those packages are unavailable instead of silently\n * switching telemetry semantics.\n */\nasync function loadOtlpProvider(backend: TelemetryBackendName): Promise<TelemetryProvider> {\n let mod: typeof import('./provider/otlp-provider')\n try {\n mod = await import('./provider/otlp-provider')\n } catch (error) {\n throw new OtlpDependencyUnavailableError(backend, error)\n }\n return new mod.OtlpProvider({}, backend)\n}\n\n/** Flush + tear down the active backend (shutdown hook / `after()`). */\nexport async function shutdownTelemetry(): Promise<void> {\n const provider = activeProvider\n activeProvider = undefined\n initialized = false\n disposeLoggerExtension?.()\n disposeLoggerExtension = undefined\n disposeRuntime?.()\n disposeRuntime = undefined\n clearActiveProvider()\n if (provider) await provider.shutdown()\n}\n\nfunction createRuntime(provider: TelemetryProvider): TelemetryRuntime {\n return {\n canUseGlobalTracePropagation: () =>\n isOtlpBackend(provider.name as TelemetryBackendName)\n && readTelemetryEnv().trustInboundTrace,\n captureTraceContext,\n continueTrace: (carrier, name, fn, options) =>\n continueTrace(carrier, name, () => fn(), options),\n withSpan: (name, fn, options) => withSpan(name, fn, options),\n recordHistogram: histogram,\n recordHttpDuration,\n reportError,\n shutdown: shutdownTelemetry,\n }\n}\n\n/** Test-only: allow re-init in jest. */\nexport function resetTelemetryInit(): void {\n initialized = false\n activeProvider = undefined\n disposeLoggerExtension?.()\n disposeLoggerExtension = undefined\n disposeRuntime?.()\n disposeRuntime = undefined\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { TelemetryBackendName, TelemetryProvider } from './types'\nimport { isOtlpBackend, readTelemetryEnv, resetTelemetryEnvCache } from './env'\nimport { ConsoleProvider } from './provider/console-provider'\nimport {\n getRegisteredProvider,\n clearActiveProvider,\n setActiveProvider,\n} from './provider/registry'\nimport {\n registerTelemetryRuntime,\n type TelemetryRuntime,\n} from '@open-mercato/shared/lib/telemetry/runtime'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { registerTelemetryLogger } from './facade/logger-bridge'\nimport { captureTraceContext, continueTrace } from './facade/propagation'\nimport { withSpan } from './facade/tracer'\nimport { recordHttpDuration } from './facade/http'\nimport { histogram } from './facade/meter'\nimport { reportError } from './facade/report-error'\nimport { startRuntimeMetrics } from './runtime-metrics'\n\nlet initialized = false\nlet initializing: Promise<void> | undefined\nlet activeProvider: TelemetryProvider | undefined\nlet disposeLoggerExtension: (() => void) | undefined\nlet disposeRuntime: (() => void) | undefined\nlet disposeRuntimeMetrics: (() => void) | undefined\n\nconst logger = createLogger('telemetry')\nconst OTLP_DEPENDENCY_UNAVAILABLE = Symbol.for('open-mercato.telemetry.otlp-dependency-unavailable')\n\nclass OtlpDependencyUnavailableError extends Error {\n constructor(backend: TelemetryBackendName, cause: unknown) {\n super(\n `[internal] OTLP telemetry backend \"${backend}\" cannot start because its OpenTelemetry runtime dependencies are unavailable. Install optional dependencies, set TELEMETRY_BACKEND=console, or unset TELEMETRY_BACKEND to disable telemetry.`,\n { cause },\n )\n this.name = 'OtlpDependencyUnavailableError'\n Object.defineProperty(this, OTLP_DEPENDENCY_UNAVAILABLE, { value: true })\n }\n}\n\n/**\n * One-shot bootstrap, invoked from `apps/mercato/instrumentation.ts` (web) and,\n * for cross-boundary propagation, from the queue/worker bootstrap. Resolves the\n * backend from `TELEMETRY_BACKEND`, starts it, and installs it as the active\n * provider. Idempotent and safe to call when telemetry is off (defaults to\n * no-op).\n */\nexport async function initTelemetry(): Promise<void> {\n if (initialized) return\n if (initializing) return initializing\n\n let pending: Promise<void>\n pending = initializeTelemetry().finally(() => {\n if (initializing === pending) initializing = undefined\n })\n initializing = pending\n return pending\n}\n\nasync function initializeTelemetry(): Promise<void> {\n // Drop any env snapshot memoized BEFORE the host loaded its `.env` (the CLI\n // binary imports this package before dotenv runs) so init resolves the backend\n // from the actual, fully-loaded environment.\n resetTelemetryEnvCache()\n const env = readTelemetryEnv()\n const configuredBackend = process.env.TELEMETRY_BACKEND?.trim().toLowerCase() ?? ''\n const registeredProvider = configuredBackend && configuredBackend !== 'noop'\n ? getRegisteredProvider(configuredBackend)\n : undefined\n // Explicit off is absolute: do not resolve providers, register hooks, or\n // install process-wide bridges when the backend is unset/noop/unregistered.\n if (!env.enabled && !registeredProvider) return\n\n const provider = registeredProvider ?? await resolveProvider(env.backend)\n if (!provider) {\n logger.warn('Telemetry backend is unsupported; telemetry remains disabled', {\n backend: configuredBackend || env.backend,\n })\n return\n }\n\n // Only flag initialized AFTER start() succeeds \u2014 a throw here must leave\n // telemetry re-initializable, not stuck flagged-on with an unstarted provider.\n await provider.start()\n setActiveProvider(provider)\n activeProvider = provider\n disposeLoggerExtension = registerTelemetryLogger(provider)\n disposeRuntime = registerTelemetryRuntime(createRuntime(provider))\n disposeRuntimeMetrics = startRuntimeMetrics()\n initialized = true\n\n logger.info('Telemetry initialized', {\n backend: provider.name,\n samplingRatio: env.samplingRatio,\n })\n}\n\nasync function resolveProvider(backend: TelemetryBackendName): Promise<TelemetryProvider | undefined> {\n const registered = getRegisteredProvider(backend)\n if (registered) return registered\n\n if (backend === 'console') return new ConsoleProvider()\n // signoz | newrelic | otlp \u2014 same OTLP provider, vendor differs only by endpoint.\n if (isOtlpBackend(backend)) return await loadOtlpProvider(backend)\n return undefined\n}\n\n/**\n * Dynamically load the OTLP provider so `@opentelemetry/*` (optionalDependencies)\n * is imported only when an OTLP backend is selected. An explicit OTLP selection\n * must fail at startup when those packages are unavailable instead of silently\n * switching telemetry semantics.\n */\nasync function loadOtlpProvider(backend: TelemetryBackendName): Promise<TelemetryProvider> {\n let mod: typeof import('./provider/otlp-provider')\n try {\n mod = await import('./provider/otlp-provider')\n } catch (error) {\n throw new OtlpDependencyUnavailableError(backend, error)\n }\n return new mod.OtlpProvider({}, backend)\n}\n\n/** Flush + tear down the active backend (shutdown hook / `after()`). */\nexport async function shutdownTelemetry(): Promise<void> {\n if (initializing) {\n try {\n await initializing\n } catch (err) {\n try {\n reportError(err, {\n module: 'telemetry',\n code: 'telemetry.initialization_failed',\n })\n } catch {}\n logger.warn('Telemetry initialization failed before shutdown', { err })\n }\n }\n const provider = activeProvider\n activeProvider = undefined\n initialized = false\n disposeLoggerExtension?.()\n disposeLoggerExtension = undefined\n disposeRuntimeMetrics?.()\n disposeRuntimeMetrics = undefined\n disposeRuntime?.()\n disposeRuntime = undefined\n clearActiveProvider()\n if (provider) await provider.shutdown()\n}\n\nfunction createRuntime(provider: TelemetryProvider): TelemetryRuntime {\n return {\n canUseGlobalTracePropagation: () =>\n isOtlpBackend(provider.name as TelemetryBackendName)\n && readTelemetryEnv().trustInboundTrace,\n captureTraceContext,\n continueTrace: (carrier, name, fn, options) =>\n continueTrace(carrier, name, () => fn(), options),\n withSpan: (name, fn, options) => withSpan(name, fn, options),\n recordMetric: (point) => provider.recordMetric(point),\n recordHistogram: histogram,\n recordHttpDuration,\n reportError,\n shutdown: shutdownTelemetry,\n }\n}\n\n/** Test-only: allow re-init in jest. */\nexport function resetTelemetryInit(): void {\n initialized = false\n initializing = undefined\n activeProvider = undefined\n disposeLoggerExtension?.()\n disposeLoggerExtension = undefined\n disposeRuntimeMetrics?.()\n disposeRuntimeMetrics = undefined\n disposeRuntime?.()\n disposeRuntime = undefined\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -125,6 +125,27 @@ class OtelSpan {
125
125
  }
126
126
  }
127
127
  const PG_INSTRUMENTATION_OPTIONS = { enhancedDatabaseReporting: false };
128
+ const SECONDS_HISTOGRAM_BUCKETS = [
129
+ 1e-3,
130
+ 5e-3,
131
+ 0.01,
132
+ 0.025,
133
+ 0.05,
134
+ 0.075,
135
+ 0.1,
136
+ 0.25,
137
+ 0.5,
138
+ 0.75,
139
+ 1,
140
+ 2.5,
141
+ 5,
142
+ 7.5,
143
+ 10
144
+ ];
145
+ function histogramOptions(unit) {
146
+ if (unit !== "s") return unit ? { unit } : void 0;
147
+ return { unit, advice: { explicitBucketBoundaries: [...SECONDS_HISTOGRAM_BUCKETS] } };
148
+ }
128
149
  class OtlpProvider {
129
150
  constructor(options = {}, name = "otlp") {
130
151
  this.options = options;
@@ -221,7 +242,7 @@ class OtlpProvider {
221
242
  if (!instrument) {
222
243
  const meter = metrics.getMeter(TRACER_NAME);
223
244
  const opts = point.unit ? { unit: point.unit } : void 0;
224
- instrument = point.kind === "counter" ? meter.createCounter(point.name, opts) : point.kind === "histogram" ? meter.createHistogram(point.name, opts) : meter.createGauge(point.name, opts);
245
+ instrument = point.kind === "counter" ? meter.createCounter(point.name, opts) : point.kind === "histogram" ? meter.createHistogram(point.name, histogramOptions(point.unit)) : meter.createGauge(point.name, opts);
225
246
  this.instruments.set(key, instrument);
226
247
  }
227
248
  return instrument;
@@ -229,6 +250,7 @@ class OtlpProvider {
229
250
  }
230
251
  export {
231
252
  OtlpProvider,
232
- PG_INSTRUMENTATION_OPTIONS
253
+ PG_INSTRUMENTATION_OPTIONS,
254
+ SECONDS_HISTOGRAM_BUCKETS
233
255
  };
234
256
  //# sourceMappingURL=otlp-provider.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/provider/otlp-provider.ts"],
4
- "sourcesContent": ["/**\n * The ONLY file in this package that imports `@opentelemetry/*`. Loaded via\n * dynamic `import()` from `init.ts` (so the heavy SDK never loads when telemetry\n * is off), and the OTEL packages live in `optionalDependencies`.\n *\n * Serves any OTLP backend \u2014 the endpoint + headers come from the standard\n * `OTEL_EXPORTER_OTLP_*` env vars, read by the exporters directly.\n */\nimport {\n trace,\n metrics,\n context,\n isSpanContextValid,\n defaultTextMapGetter,\n defaultTextMapSetter,\n ROOT_CONTEXT,\n SpanStatusCode,\n SpanKind as OtelSpanKind,\n type Link,\n type Span as OtelApiSpan,\n type SpanOptions as OtelSpanOptions,\n type TextMapPropagator,\n type Counter,\n type Histogram,\n type Gauge,\n} from '@opentelemetry/api'\nimport { W3CTraceContextPropagator } from '@opentelemetry/core'\nimport { logs, SeverityNumber } from '@opentelemetry/api-logs'\nimport { NodeSDK } from '@opentelemetry/sdk-node'\nimport type { Instrumentation } from '@opentelemetry/instrumentation'\nimport { resourceFromAttributes } from '@opentelemetry/resources'\nimport { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'\nimport {\n BatchSpanProcessor,\n ParentBasedSampler,\n TraceIdRatioBasedSampler,\n type SpanProcessor,\n} from '@opentelemetry/sdk-trace-node'\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'\nimport { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'\nimport { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'\nimport { BatchLogRecordProcessor, type LogRecordProcessor } from '@opentelemetry/sdk-logs'\nimport { PeriodicExportingMetricReader, type IMetricReader } from '@opentelemetry/sdk-metrics'\nimport { PgInstrumentation } from '@opentelemetry/instrumentation-pg'\nimport { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'\n\nimport type {\n Attributes,\n AttributeValue,\n LogLevel,\n LogRecord,\n MetricPoint,\n Span,\n SpanKind,\n SpanOptions,\n TelemetryProvider,\n TelemetrySignal,\n TraceCarrier,\n TraceContext,\n} from '../types'\nimport { readTelemetryEnv } from '../env'\nimport { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../trace-headers'\nimport { redactAttributes, redactPii } from '../facade/redact'\nimport { serializeError } from '../facade/serialize'\nimport { runSpan } from './run-span'\n\nconst TRACER_NAME = 'open-mercato'\n\n/**\n * Queue/event trace propagation rides a dedicated W3C propagator on the payload\n * `_trace` carrier \u2014 deliberately NOT the global propagator below.\n */\nconst queuePropagator = new W3CTraceContextPropagator()\n\nconst W3C_TRACEPARENT = 'traceparent'\nconst W3C_TRACESTATE = 'tracestate'\n// Backup headers that mirror the W3C context but a proxy/LB won't rewrite.\nconst BACKUP_TRACEPARENT = BACKUP_TRACEPARENT_HEADER\nconst BACKUP_TRACESTATE = BACKUP_TRACESTATE_HEADER\n\n/**\n * Global propagator: standard W3C plus a backup copy\n * (`x-original-traceparent`), with explicit inbound trust.\n *\n * The problem: a load balancer (e.g. GCP's) reads the inbound `traceparent`,\n * creates its own span, and **rewrites** `traceparent` to point at that span \u2014\n * which our backend never sees. With plain W3C extraction every request becomes a\n * child of that unexported span: no request is a root, and root-span / trace-group\n * views come up empty.\n *\n * The fix (the industry \"backup header\" pattern): on inject we also write\n * `x-original-traceparent`, which the LB leaves untouched. A backup header is\n * still caller-controlled at an HTTP boundary, so its mere\n * presence is not proof that one of our services created it. Extraction ignores\n * both standard and backup headers by default.\n *\n * `TELEMETRY_TRUST_INBOUND_TRACE=true` enables both extraction paths for a\n * deployment behind a trusted upstream. Our dedicated queue/event carrier is\n * unaffected \u2014 it uses `queuePropagator` directly, not this global one.\n */\nconst backupHeaderPropagator: TextMapPropagator = {\n inject(ctx, carrier, setter) {\n // Inject into a plain temp carrier so we can mirror the values regardless of\n // the real carrier's setter shape, then copy both standard + backup headers.\n const tmp: Record<string, string> = {}\n queuePropagator.inject(ctx, tmp, defaultTextMapSetter)\n for (const [key, value] of Object.entries(tmp)) setter.set(carrier, key, value)\n if (tmp[W3C_TRACEPARENT]) setter.set(carrier, BACKUP_TRACEPARENT, tmp[W3C_TRACEPARENT])\n if (tmp[W3C_TRACESTATE]) setter.set(carrier, BACKUP_TRACESTATE, tmp[W3C_TRACESTATE])\n },\n extract(ctx, carrier, getter) {\n // Both the standard and backup headers are caller-controlled at an HTTP\n // boundary. Trust neither unless the deployment explicitly opts in.\n if (!readTelemetryEnv().trustInboundTrace) return ctx\n const first = (value: string | string[] | undefined): string | undefined =>\n Array.isArray(value) ? value[0] : value\n const backupTraceparent = first(getter.get(carrier, BACKUP_TRACEPARENT))\n if (backupTraceparent) {\n const backupCarrier: Record<string, string> = { [W3C_TRACEPARENT]: backupTraceparent }\n const backupTracestate = first(getter.get(carrier, BACKUP_TRACESTATE))\n if (backupTracestate) backupCarrier[W3C_TRACESTATE] = backupTracestate\n return queuePropagator.extract(ctx, backupCarrier, defaultTextMapGetter)\n }\n return queuePropagator.extract(ctx, carrier, getter)\n },\n fields: () => [...queuePropagator.fields(), BACKUP_TRACEPARENT, BACKUP_TRACESTATE],\n}\n\nfunction cleanAttributes(attributes?: Attributes): Record<string, AttributeValue> {\n const out: Record<string, AttributeValue> = {}\n if (!attributes) return out\n for (const [key, value] of Object.entries(redactAttributes(attributes))) {\n if (value !== undefined) out[key] = value\n }\n return out\n}\n\nconst SPAN_KIND: Record<SpanKind, OtelSpanKind> = {\n internal: OtelSpanKind.INTERNAL,\n server: OtelSpanKind.SERVER,\n client: OtelSpanKind.CLIENT,\n producer: OtelSpanKind.PRODUCER,\n consumer: OtelSpanKind.CONSUMER,\n}\n\n/**\n * Resolve `links` carriers to span contexts. Extraction runs against\n * `ROOT_CONTEXT`, never the active one \u2014 otherwise an empty carrier would\n * silently link a span to its own ambient parent instead of yielding no link.\n */\nfunction spanLinks(carriers?: TraceCarrier[]): Link[] | undefined {\n if (!carriers?.length) return undefined\n const links: Link[] = []\n for (const carrier of carriers) {\n const linked = trace.getSpanContext(queuePropagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter))\n if (linked && isSpanContextValid(linked)) links.push({ context: linked })\n }\n return links.length ? links : undefined\n}\n\n/**\n * Translate the vendor-neutral options to OTEL's. `root: true` makes OTEL drop\n * the parent from the context it samples against, so the span starts a new trace\n * and the `ParentBasedSampler` falls through to its ratio-based root sampler.\n */\nfunction toOtelSpanOptions(options: SpanOptions): OtelSpanOptions {\n return {\n kind: options.kind ? SPAN_KIND[options.kind] : undefined,\n attributes: cleanAttributes(options.attributes),\n root: options.root,\n links: spanLinks(options.links),\n }\n}\n\nconst SEVERITY: Record<LogLevel, SeverityNumber> = {\n debug: SeverityNumber.DEBUG,\n info: SeverityNumber.INFO,\n warn: SeverityNumber.WARN,\n error: SeverityNumber.ERROR,\n}\n\n/** Adapts an OTEL span to the vendor-neutral `Span` interface. */\nclass OtelSpan implements Span {\n constructor(private readonly span: OtelApiSpan) {}\n setAttribute(key: string, value: AttributeValue): void {\n const redacted = cleanAttributes({ [key]: value })[key]\n if (redacted !== undefined) this.span.setAttribute(key, redacted)\n }\n setAttributes(attributes: Attributes): void {\n this.span.setAttributes(cleanAttributes(attributes))\n }\n updateName(name: string): void {\n this.span.updateName(name)\n }\n recordException(error: unknown): void {\n // Redact message + stack before they leave the process (Privacy): the auto\n // record-on-throw path (run-span) and reportError both pass raw errors here.\n this.span.recordException(serializeError(error))\n }\n setStatus(status: 'ok' | 'error', message?: string): void {\n this.span.setStatus({\n code: status === 'error' ? SpanStatusCode.ERROR : SpanStatusCode.OK,\n message: message ? redactPii(message) : undefined,\n })\n }\n end(): void {\n this.span.end()\n }\n}\n\n/**\n * Test seam: override the SDK's exporters/instrumentations so tests can use\n * in-memory exporters (deterministic, no network) instead of the OTLP defaults.\n * Production passes nothing \u2014 the OTLP defaults apply.\n */\nexport type OtlpProviderOptions = {\n spanProcessors?: SpanProcessor[]\n logRecordProcessors?: LogRecordProcessor[]\n metricReaders?: IMetricReader[]\n instrumentations?: Instrumentation[]\n}\n\n/**\n * PII guard (telemetry spec Privacy / R12). The single source of truth for pg\n * instrumentation config: `enhancedDatabaseReporting: false` means bound SQL\n * parameter VALUES are NOT captured \u2014 only the statement shape ($1, $2). User\n * data lives in those params, so this must never be flipped on. Asserted by a\n * regression test.\n */\nexport const PG_INSTRUMENTATION_OPTIONS = { enhancedDatabaseReporting: false } as const\n\nexport class OtlpProvider implements TelemetryProvider {\n /** The configured backend name (signoz | newrelic | otlp) \u2014 all OTLP, vendor differs only by endpoint. */\n readonly name: string\n readonly supports: readonly TelemetrySignal[] = ['traces', 'metrics', 'logs', 'errors']\n\n private sdk: NodeSDK | undefined\n private readonly instruments = new Map<string, Counter | Histogram | Gauge>()\n\n constructor(private readonly options: OtlpProviderOptions = {}, name = 'otlp') {\n this.name = name\n }\n\n async start(): Promise<void> {\n if (this.sdk) return\n const env = readTelemetryEnv()\n\n this.sdk = new NodeSDK({\n resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: env.serviceName }),\n // Global propagation roots requests by default. Both standard and backup\n // inbound headers are honored only when TELEMETRY_TRUST_INBOUND_TRACE is\n // explicitly enabled behind trusted infrastructure.\n textMapPropagator: backupHeaderPropagator,\n // Parent-based so child spans follow the trace's sampling decision; root\n // spans sample at the configured ratio.\n sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(env.samplingRatio) }),\n spanProcessors: this.options.spanProcessors ?? [new BatchSpanProcessor(new OTLPTraceExporter())],\n logRecordProcessors: this.options.logRecordProcessors ?? [\n new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() }),\n ],\n metricReaders: this.options.metricReaders ?? [\n new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),\n ],\n instrumentations: this.options.instrumentations ?? [\n new PgInstrumentation(PG_INSTRUMENTATION_OPTIONS),\n new UndiciInstrumentation(),\n ],\n })\n this.sdk.start()\n }\n\n async shutdown(): Promise<void> {\n if (!this.sdk) return\n await this.sdk.shutdown()\n this.sdk = undefined\n }\n\n runInSpan<T>(name: string, options: SpanOptions, fn: (span: Span) => T): T {\n const tracer = trace.getTracer(TRACER_NAME)\n return tracer.startActiveSpan(name, toOtelSpanOptions(options), (otelSpan) =>\n runSpan(new OtelSpan(otelSpan), fn),\n )\n }\n\n activeSpan(): Span | undefined {\n const active = trace.getActiveSpan()\n return active ? new OtelSpan(active) : undefined\n }\n\n activeTraceContext(): TraceContext | undefined {\n const ctx = trace.getActiveSpan()?.spanContext()\n if (!ctx || !isSpanContextValid(ctx)) return undefined\n return { traceId: ctx.traceId, spanId: ctx.spanId }\n }\n\n inject(carrier: TraceCarrier): void {\n queuePropagator.inject(context.active(), carrier, defaultTextMapSetter)\n }\n\n runInRemoteSpan<T>(carrier: TraceCarrier, name: string, options: SpanOptions, fn: (span: Span) => T): T {\n const parent = queuePropagator.extract(context.active(), carrier, defaultTextMapGetter)\n const tracer = trace.getTracer(TRACER_NAME)\n return context.with(parent, () =>\n tracer.startActiveSpan(name, toOtelSpanOptions(options), (otelSpan) =>\n runSpan(new OtelSpan(otelSpan), fn),\n ),\n )\n }\n\n emitLog(record: LogRecord): void {\n const attributes: Record<string, AttributeValue> = cleanAttributes(record.attributes)\n if (record.error) {\n attributes['exception.type'] = redactPii(record.error.name)\n attributes['exception.message'] = redactPii(record.error.message)\n if (record.error.stack) attributes['exception.stacktrace'] = redactPii(record.error.stack)\n }\n logs.getLogger(TRACER_NAME).emit({\n severityNumber: SEVERITY[record.level],\n severityText: record.level,\n body: redactPii(record.message),\n attributes,\n })\n }\n\n recordMetric(point: MetricPoint): void {\n const labels = cleanAttributes(point.labels)\n const instrument = this.getInstrument(point)\n if (point.kind === 'counter') (instrument as Counter).add(point.value, labels)\n else (instrument as Histogram | Gauge).record(point.value, labels)\n }\n\n private getInstrument(point: MetricPoint): Counter | Histogram | Gauge {\n const key = `${point.kind}:${point.name}`\n let instrument = this.instruments.get(key)\n if (!instrument) {\n const meter = metrics.getMeter(TRACER_NAME)\n const opts = point.unit ? { unit: point.unit } : undefined\n instrument =\n point.kind === 'counter'\n ? meter.createCounter(point.name, opts)\n : point.kind === 'histogram'\n ? meter.createHistogram(point.name, opts)\n : meter.createGauge(point.name, opts)\n this.instruments.set(key, instrument)\n }\n return instrument\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * The ONLY file in this package that imports `@opentelemetry/*`. Loaded via\n * dynamic `import()` from `init.ts` (so the heavy SDK never loads when telemetry\n * is off), and the OTEL packages live in `optionalDependencies`.\n *\n * Serves any OTLP backend \u2014 the endpoint + headers come from the standard\n * `OTEL_EXPORTER_OTLP_*` env vars, read by the exporters directly.\n */\nimport {\n trace,\n metrics,\n context,\n isSpanContextValid,\n defaultTextMapGetter,\n defaultTextMapSetter,\n ROOT_CONTEXT,\n SpanStatusCode,\n SpanKind as OtelSpanKind,\n type Link,\n type Span as OtelApiSpan,\n type SpanOptions as OtelSpanOptions,\n type TextMapPropagator,\n type Counter,\n type Histogram,\n type Gauge,\n type MetricOptions,\n} from '@opentelemetry/api'\nimport { W3CTraceContextPropagator } from '@opentelemetry/core'\nimport { logs, SeverityNumber } from '@opentelemetry/api-logs'\nimport { NodeSDK } from '@opentelemetry/sdk-node'\nimport type { Instrumentation } from '@opentelemetry/instrumentation'\nimport { resourceFromAttributes } from '@opentelemetry/resources'\nimport { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'\nimport {\n BatchSpanProcessor,\n ParentBasedSampler,\n TraceIdRatioBasedSampler,\n type SpanProcessor,\n} from '@opentelemetry/sdk-trace-node'\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'\nimport { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'\nimport { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'\nimport { BatchLogRecordProcessor, type LogRecordProcessor } from '@opentelemetry/sdk-logs'\nimport { PeriodicExportingMetricReader, type IMetricReader } from '@opentelemetry/sdk-metrics'\nimport { PgInstrumentation } from '@opentelemetry/instrumentation-pg'\nimport { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'\n\nimport type {\n Attributes,\n AttributeValue,\n LogLevel,\n LogRecord,\n MetricPoint,\n Span,\n SpanKind,\n SpanOptions,\n TelemetryProvider,\n TelemetrySignal,\n TraceCarrier,\n TraceContext,\n} from '../types'\nimport { readTelemetryEnv } from '../env'\nimport { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../trace-headers'\nimport { redactAttributes, redactPii } from '../facade/redact'\nimport { serializeError } from '../facade/serialize'\nimport { runSpan } from './run-span'\n\nconst TRACER_NAME = 'open-mercato'\n\n/**\n * Queue/event trace propagation rides a dedicated W3C propagator on the payload\n * `_trace` carrier \u2014 deliberately NOT the global propagator below.\n */\nconst queuePropagator = new W3CTraceContextPropagator()\n\nconst W3C_TRACEPARENT = 'traceparent'\nconst W3C_TRACESTATE = 'tracestate'\n// Backup headers that mirror the W3C context but a proxy/LB won't rewrite.\nconst BACKUP_TRACEPARENT = BACKUP_TRACEPARENT_HEADER\nconst BACKUP_TRACESTATE = BACKUP_TRACESTATE_HEADER\n\n/**\n * Global propagator: standard W3C plus a backup copy\n * (`x-original-traceparent`), with explicit inbound trust.\n *\n * The problem: a load balancer (e.g. GCP's) reads the inbound `traceparent`,\n * creates its own span, and **rewrites** `traceparent` to point at that span \u2014\n * which our backend never sees. With plain W3C extraction every request becomes a\n * child of that unexported span: no request is a root, and root-span / trace-group\n * views come up empty.\n *\n * The fix (the industry \"backup header\" pattern): on inject we also write\n * `x-original-traceparent`, which the LB leaves untouched. A backup header is\n * still caller-controlled at an HTTP boundary, so its mere\n * presence is not proof that one of our services created it. Extraction ignores\n * both standard and backup headers by default.\n *\n * `TELEMETRY_TRUST_INBOUND_TRACE=true` enables both extraction paths for a\n * deployment behind a trusted upstream. Our dedicated queue/event carrier is\n * unaffected \u2014 it uses `queuePropagator` directly, not this global one.\n */\nconst backupHeaderPropagator: TextMapPropagator = {\n inject(ctx, carrier, setter) {\n // Inject into a plain temp carrier so we can mirror the values regardless of\n // the real carrier's setter shape, then copy both standard + backup headers.\n const tmp: Record<string, string> = {}\n queuePropagator.inject(ctx, tmp, defaultTextMapSetter)\n for (const [key, value] of Object.entries(tmp)) setter.set(carrier, key, value)\n if (tmp[W3C_TRACEPARENT]) setter.set(carrier, BACKUP_TRACEPARENT, tmp[W3C_TRACEPARENT])\n if (tmp[W3C_TRACESTATE]) setter.set(carrier, BACKUP_TRACESTATE, tmp[W3C_TRACESTATE])\n },\n extract(ctx, carrier, getter) {\n // Both the standard and backup headers are caller-controlled at an HTTP\n // boundary. Trust neither unless the deployment explicitly opts in.\n if (!readTelemetryEnv().trustInboundTrace) return ctx\n const first = (value: string | string[] | undefined): string | undefined =>\n Array.isArray(value) ? value[0] : value\n const backupTraceparent = first(getter.get(carrier, BACKUP_TRACEPARENT))\n if (backupTraceparent) {\n const backupCarrier: Record<string, string> = { [W3C_TRACEPARENT]: backupTraceparent }\n const backupTracestate = first(getter.get(carrier, BACKUP_TRACESTATE))\n if (backupTracestate) backupCarrier[W3C_TRACESTATE] = backupTracestate\n return queuePropagator.extract(ctx, backupCarrier, defaultTextMapGetter)\n }\n return queuePropagator.extract(ctx, carrier, getter)\n },\n fields: () => [...queuePropagator.fields(), BACKUP_TRACEPARENT, BACKUP_TRACESTATE],\n}\n\nfunction cleanAttributes(attributes?: Attributes): Record<string, AttributeValue> {\n const out: Record<string, AttributeValue> = {}\n if (!attributes) return out\n for (const [key, value] of Object.entries(redactAttributes(attributes))) {\n if (value !== undefined) out[key] = value\n }\n return out\n}\n\nconst SPAN_KIND: Record<SpanKind, OtelSpanKind> = {\n internal: OtelSpanKind.INTERNAL,\n server: OtelSpanKind.SERVER,\n client: OtelSpanKind.CLIENT,\n producer: OtelSpanKind.PRODUCER,\n consumer: OtelSpanKind.CONSUMER,\n}\n\n/**\n * Resolve `links` carriers to span contexts. Extraction runs against\n * `ROOT_CONTEXT`, never the active one \u2014 otherwise an empty carrier would\n * silently link a span to its own ambient parent instead of yielding no link.\n */\nfunction spanLinks(carriers?: TraceCarrier[]): Link[] | undefined {\n if (!carriers?.length) return undefined\n const links: Link[] = []\n for (const carrier of carriers) {\n const linked = trace.getSpanContext(queuePropagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter))\n if (linked && isSpanContextValid(linked)) links.push({ context: linked })\n }\n return links.length ? links : undefined\n}\n\n/**\n * Translate the vendor-neutral options to OTEL's. `root: true` makes OTEL drop\n * the parent from the context it samples against, so the span starts a new trace\n * and the `ParentBasedSampler` falls through to its ratio-based root sampler.\n */\nfunction toOtelSpanOptions(options: SpanOptions): OtelSpanOptions {\n return {\n kind: options.kind ? SPAN_KIND[options.kind] : undefined,\n attributes: cleanAttributes(options.attributes),\n root: options.root,\n links: spanLinks(options.links),\n }\n}\n\nconst SEVERITY: Record<LogLevel, SeverityNumber> = {\n debug: SeverityNumber.DEBUG,\n info: SeverityNumber.INFO,\n warn: SeverityNumber.WARN,\n error: SeverityNumber.ERROR,\n}\n\n/** Adapts an OTEL span to the vendor-neutral `Span` interface. */\nclass OtelSpan implements Span {\n constructor(private readonly span: OtelApiSpan) {}\n setAttribute(key: string, value: AttributeValue): void {\n const redacted = cleanAttributes({ [key]: value })[key]\n if (redacted !== undefined) this.span.setAttribute(key, redacted)\n }\n setAttributes(attributes: Attributes): void {\n this.span.setAttributes(cleanAttributes(attributes))\n }\n updateName(name: string): void {\n this.span.updateName(name)\n }\n recordException(error: unknown): void {\n // Redact message + stack before they leave the process (Privacy): the auto\n // record-on-throw path (run-span) and reportError both pass raw errors here.\n this.span.recordException(serializeError(error))\n }\n setStatus(status: 'ok' | 'error', message?: string): void {\n this.span.setStatus({\n code: status === 'error' ? SpanStatusCode.ERROR : SpanStatusCode.OK,\n message: message ? redactPii(message) : undefined,\n })\n }\n end(): void {\n this.span.end()\n }\n}\n\n/**\n * Test seam: override the SDK's exporters/instrumentations so tests can use\n * in-memory exporters (deterministic, no network) instead of the OTLP defaults.\n * Production passes nothing \u2014 the OTLP defaults apply.\n */\nexport type OtlpProviderOptions = {\n spanProcessors?: SpanProcessor[]\n logRecordProcessors?: LogRecordProcessor[]\n metricReaders?: IMetricReader[]\n instrumentations?: Instrumentation[]\n}\n\n/**\n * PII guard (telemetry spec Privacy / R12). The single source of truth for pg\n * instrumentation config: `enhancedDatabaseReporting: false` means bound SQL\n * parameter VALUES are NOT captured \u2014 only the statement shape ($1, $2). User\n * data lives in those params, so this must never be flipped on. Asserted by a\n * regression test.\n */\nexport const PG_INSTRUMENTATION_OPTIONS = { enhancedDatabaseReporting: false } as const\n\n/**\n * Bucket boundaries for every histogram recorded in seconds. Without explicit\n * advice the SDK falls back to millisecond-scaled defaults (0, 5, 10 \u2026 10000),\n * which puts every realistic latency in the first bucket and leaves percentiles\n * uncomputable. A superset of the semconv HTTP-duration advice, plus a 1 ms\n * bucket for sub-millisecond pool acquisitions.\n */\nexport const SECONDS_HISTOGRAM_BUCKETS: readonly number[] = [\n 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,\n]\n\nfunction histogramOptions(unit: string | undefined): MetricOptions | undefined {\n if (unit !== 's') return unit ? { unit } : undefined\n return { unit, advice: { explicitBucketBoundaries: [...SECONDS_HISTOGRAM_BUCKETS] } }\n}\n\nexport class OtlpProvider implements TelemetryProvider {\n /** The configured backend name (signoz | newrelic | otlp) \u2014 all OTLP, vendor differs only by endpoint. */\n readonly name: string\n readonly supports: readonly TelemetrySignal[] = ['traces', 'metrics', 'logs', 'errors']\n\n private sdk: NodeSDK | undefined\n private readonly instruments = new Map<string, Counter | Histogram | Gauge>()\n\n constructor(private readonly options: OtlpProviderOptions = {}, name = 'otlp') {\n this.name = name\n }\n\n async start(): Promise<void> {\n if (this.sdk) return\n const env = readTelemetryEnv()\n\n this.sdk = new NodeSDK({\n resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: env.serviceName }),\n // Global propagation roots requests by default. Both standard and backup\n // inbound headers are honored only when TELEMETRY_TRUST_INBOUND_TRACE is\n // explicitly enabled behind trusted infrastructure.\n textMapPropagator: backupHeaderPropagator,\n // Parent-based so child spans follow the trace's sampling decision; root\n // spans sample at the configured ratio.\n sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(env.samplingRatio) }),\n spanProcessors: this.options.spanProcessors ?? [new BatchSpanProcessor(new OTLPTraceExporter())],\n logRecordProcessors: this.options.logRecordProcessors ?? [\n new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() }),\n ],\n metricReaders: this.options.metricReaders ?? [\n new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),\n ],\n instrumentations: this.options.instrumentations ?? [\n new PgInstrumentation(PG_INSTRUMENTATION_OPTIONS),\n new UndiciInstrumentation(),\n ],\n })\n this.sdk.start()\n }\n\n async shutdown(): Promise<void> {\n if (!this.sdk) return\n await this.sdk.shutdown()\n this.sdk = undefined\n }\n\n runInSpan<T>(name: string, options: SpanOptions, fn: (span: Span) => T): T {\n const tracer = trace.getTracer(TRACER_NAME)\n return tracer.startActiveSpan(name, toOtelSpanOptions(options), (otelSpan) =>\n runSpan(new OtelSpan(otelSpan), fn),\n )\n }\n\n activeSpan(): Span | undefined {\n const active = trace.getActiveSpan()\n return active ? new OtelSpan(active) : undefined\n }\n\n activeTraceContext(): TraceContext | undefined {\n const ctx = trace.getActiveSpan()?.spanContext()\n if (!ctx || !isSpanContextValid(ctx)) return undefined\n return { traceId: ctx.traceId, spanId: ctx.spanId }\n }\n\n inject(carrier: TraceCarrier): void {\n queuePropagator.inject(context.active(), carrier, defaultTextMapSetter)\n }\n\n runInRemoteSpan<T>(carrier: TraceCarrier, name: string, options: SpanOptions, fn: (span: Span) => T): T {\n const parent = queuePropagator.extract(context.active(), carrier, defaultTextMapGetter)\n const tracer = trace.getTracer(TRACER_NAME)\n return context.with(parent, () =>\n tracer.startActiveSpan(name, toOtelSpanOptions(options), (otelSpan) =>\n runSpan(new OtelSpan(otelSpan), fn),\n ),\n )\n }\n\n emitLog(record: LogRecord): void {\n const attributes: Record<string, AttributeValue> = cleanAttributes(record.attributes)\n if (record.error) {\n attributes['exception.type'] = redactPii(record.error.name)\n attributes['exception.message'] = redactPii(record.error.message)\n if (record.error.stack) attributes['exception.stacktrace'] = redactPii(record.error.stack)\n }\n logs.getLogger(TRACER_NAME).emit({\n severityNumber: SEVERITY[record.level],\n severityText: record.level,\n body: redactPii(record.message),\n attributes,\n })\n }\n\n recordMetric(point: MetricPoint): void {\n const labels = cleanAttributes(point.labels)\n const instrument = this.getInstrument(point)\n if (point.kind === 'counter') (instrument as Counter).add(point.value, labels)\n else (instrument as Histogram | Gauge).record(point.value, labels)\n }\n\n private getInstrument(point: MetricPoint): Counter | Histogram | Gauge {\n const key = `${point.kind}:${point.name}`\n let instrument = this.instruments.get(key)\n if (!instrument) {\n const meter = metrics.getMeter(TRACER_NAME)\n const opts = point.unit ? { unit: point.unit } : undefined\n instrument =\n point.kind === 'counter'\n ? meter.createCounter(point.name, opts)\n : point.kind === 'histogram'\n ? meter.createHistogram(point.name, histogramOptions(point.unit))\n : meter.createGauge(point.name, opts)\n this.instruments.set(key, instrument)\n }\n return instrument\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,132 @@
1
+ import { monitorEventLoopDelay, performance } from "node:perf_hooks";
2
+ import { getHeapSpaceStatistics } from "node:v8";
3
+ import {
4
+ collectTelemetryMetrics,
5
+ getTelemetryRuntime,
6
+ recordTelemetryMetric
7
+ } from "@open-mercato/shared/lib/telemetry/runtime";
8
+ import { createLogger } from "@open-mercato/shared/lib/logger";
9
+ const SAMPLE_INTERVAL_MS = 1e4;
10
+ const DELAY_RESOLUTION_MS = 20;
11
+ const NANOSECONDS_PER_SECOND = 1e9;
12
+ const GLOBAL_KEY = /* @__PURE__ */ Symbol.for("@open-mercato/telemetry.runtimeMetrics");
13
+ const logger = createLogger("telemetry").child({ component: "runtime-metrics" });
14
+ const defaultSources = {
15
+ createEventLoopDelayMonitor: () => monitorEventLoopDelay({ resolution: DELAY_RESOLUTION_MS }),
16
+ eventLoopUtilization: (first, second) => performance.eventLoopUtilization(first, second),
17
+ memoryUsage: () => process.memoryUsage(),
18
+ heapSpaceStatistics: () => getHeapSpaceStatistics(),
19
+ setInterval: (handler, intervalMs) => setInterval(handler, intervalMs),
20
+ clearInterval: (interval) => clearInterval(interval)
21
+ };
22
+ function store() {
23
+ const globalStore = globalThis;
24
+ let current = globalStore[GLOBAL_KEY];
25
+ if (!current) {
26
+ current = {};
27
+ globalStore[GLOBAL_KEY] = current;
28
+ }
29
+ return current;
30
+ }
31
+ function secondsFromNanoseconds(value) {
32
+ return Number.isFinite(value) ? Math.max(0, value) / NANOSECONDS_PER_SECOND : 0;
33
+ }
34
+ function createRuntimeMetricsSampler(sources = defaultSources) {
35
+ const delay = sources.createEventLoopDelayMonitor();
36
+ let previousUtilization = sources.eventLoopUtilization();
37
+ delay.enable();
38
+ const sample = () => {
39
+ try {
40
+ const currentUtilization = sources.eventLoopUtilization();
41
+ const intervalUtilization = sources.eventLoopUtilization(
42
+ currentUtilization,
43
+ previousUtilization
44
+ );
45
+ previousUtilization = currentUtilization;
46
+ recordTelemetryMetric({
47
+ kind: "gauge",
48
+ name: "nodejs.eventloop.utilization",
49
+ value: intervalUtilization.utilization,
50
+ unit: "1"
51
+ });
52
+ for (const percentile of [50, 90, 99]) {
53
+ recordTelemetryMetric({
54
+ kind: "gauge",
55
+ name: `nodejs.eventloop.delay.p${percentile}`,
56
+ value: secondsFromNanoseconds(delay.percentile(percentile)),
57
+ unit: "s"
58
+ });
59
+ }
60
+ recordTelemetryMetric({
61
+ kind: "gauge",
62
+ name: "process.memory.usage",
63
+ value: sources.memoryUsage().rss,
64
+ unit: "By"
65
+ });
66
+ for (const heapSpace of sources.heapSpaceStatistics()) {
67
+ recordTelemetryMetric({
68
+ kind: "gauge",
69
+ name: "v8js.memory.heap.used",
70
+ value: heapSpace.space_used_size,
71
+ labels: { "v8js.heap.space.name": heapSpace.space_name },
72
+ unit: "By"
73
+ });
74
+ }
75
+ collectTelemetryMetrics((err) => {
76
+ logger.warn("Runtime metric collector failed", { err });
77
+ });
78
+ } catch (err) {
79
+ try {
80
+ getTelemetryRuntime()?.reportError(err, {
81
+ module: "telemetry",
82
+ code: "telemetry.runtime_sampling_failed"
83
+ });
84
+ } catch {
85
+ }
86
+ logger.warn("Runtime metric sampling failed", { err });
87
+ } finally {
88
+ delay.reset();
89
+ }
90
+ };
91
+ const interval = sources.setInterval(sample, SAMPLE_INTERVAL_MS);
92
+ interval.unref?.();
93
+ let disposed = false;
94
+ return () => {
95
+ if (disposed) return;
96
+ disposed = true;
97
+ sources.clearInterval(interval);
98
+ delay.disable();
99
+ };
100
+ }
101
+ function startRuntimeMetrics(sources = defaultSources) {
102
+ const current = store();
103
+ if (!current.active) {
104
+ current.active = {
105
+ references: 0,
106
+ dispose: createRuntimeMetricsSampler(sources)
107
+ };
108
+ }
109
+ const active = current.active;
110
+ active.references += 1;
111
+ let released = false;
112
+ return () => {
113
+ if (released) return;
114
+ released = true;
115
+ active.references -= 1;
116
+ if (active.references > 0) return;
117
+ active.dispose();
118
+ const latest = store();
119
+ if (latest.active === active) latest.active = void 0;
120
+ };
121
+ }
122
+ function resetRuntimeMetrics() {
123
+ const current = store();
124
+ current.active?.dispose();
125
+ current.active = void 0;
126
+ }
127
+ export {
128
+ createRuntimeMetricsSampler,
129
+ resetRuntimeMetrics,
130
+ startRuntimeMetrics
131
+ };
132
+ //# sourceMappingURL=runtime-metrics.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/runtime-metrics.ts"],
4
+ "sourcesContent": ["import { monitorEventLoopDelay, performance, type EventLoopUtilization } from 'node:perf_hooks'\nimport { getHeapSpaceStatistics } from 'node:v8'\nimport {\n collectTelemetryMetrics,\n getTelemetryRuntime,\n recordTelemetryMetric,\n} from '@open-mercato/shared/lib/telemetry/runtime'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst SAMPLE_INTERVAL_MS = 10_000\nconst DELAY_RESOLUTION_MS = 20\nconst NANOSECONDS_PER_SECOND = 1_000_000_000\nconst GLOBAL_KEY = Symbol.for('@open-mercato/telemetry.runtimeMetrics')\n\nexport type EventLoopDelayMonitor = {\n enable(): void\n disable(): void\n percentile(percentile: number): number\n reset(): void\n}\n\nexport type HeapSpaceSample = {\n space_name: string\n space_used_size: number\n}\n\nexport type RuntimeMetricSources = {\n createEventLoopDelayMonitor(): EventLoopDelayMonitor\n eventLoopUtilization(\n first?: EventLoopUtilization,\n second?: EventLoopUtilization,\n ): EventLoopUtilization\n memoryUsage(): { rss: number }\n heapSpaceStatistics(): HeapSpaceSample[]\n setInterval(handler: () => void, intervalMs: number): ReturnType<typeof setInterval>\n clearInterval(interval: ReturnType<typeof setInterval>): void\n}\n\ntype ActiveRuntimeSampler = {\n references: number\n dispose(): void\n}\n\ntype RuntimeMetricsStore = {\n active?: ActiveRuntimeSampler\n}\n\nconst logger = createLogger('telemetry').child({ component: 'runtime-metrics' })\n\nconst defaultSources: RuntimeMetricSources = {\n createEventLoopDelayMonitor: () => monitorEventLoopDelay({ resolution: DELAY_RESOLUTION_MS }),\n eventLoopUtilization: (first, second) => performance.eventLoopUtilization(first, second),\n memoryUsage: () => process.memoryUsage(),\n heapSpaceStatistics: () => getHeapSpaceStatistics(),\n setInterval: (handler, intervalMs) => setInterval(handler, intervalMs),\n clearInterval: (interval) => clearInterval(interval),\n}\n\nfunction store(): RuntimeMetricsStore {\n const globalStore = globalThis as unknown as Record<symbol, RuntimeMetricsStore | undefined>\n let current = globalStore[GLOBAL_KEY]\n if (!current) {\n current = {}\n globalStore[GLOBAL_KEY] = current\n }\n return current\n}\n\nfunction secondsFromNanoseconds(value: number): number {\n return Number.isFinite(value) ? Math.max(0, value) / NANOSECONDS_PER_SECOND : 0\n}\n\nexport function createRuntimeMetricsSampler(\n sources: RuntimeMetricSources = defaultSources,\n): () => void {\n const delay = sources.createEventLoopDelayMonitor()\n let previousUtilization = sources.eventLoopUtilization()\n delay.enable()\n\n const sample = () => {\n try {\n const currentUtilization = sources.eventLoopUtilization()\n const intervalUtilization = sources.eventLoopUtilization(\n currentUtilization,\n previousUtilization,\n )\n previousUtilization = currentUtilization\n\n recordTelemetryMetric({\n kind: 'gauge',\n name: 'nodejs.eventloop.utilization',\n value: intervalUtilization.utilization,\n unit: '1',\n })\n for (const percentile of [50, 90, 99]) {\n recordTelemetryMetric({\n kind: 'gauge',\n name: `nodejs.eventloop.delay.p${percentile}`,\n value: secondsFromNanoseconds(delay.percentile(percentile)),\n unit: 's',\n })\n }\n\n recordTelemetryMetric({\n kind: 'gauge',\n name: 'process.memory.usage',\n value: sources.memoryUsage().rss,\n unit: 'By',\n })\n for (const heapSpace of sources.heapSpaceStatistics()) {\n recordTelemetryMetric({\n kind: 'gauge',\n name: 'v8js.memory.heap.used',\n value: heapSpace.space_used_size,\n labels: { 'v8js.heap.space.name': heapSpace.space_name },\n unit: 'By',\n })\n }\n\n collectTelemetryMetrics((err) => {\n logger.warn('Runtime metric collector failed', { err })\n })\n } catch (err) {\n try {\n getTelemetryRuntime()?.reportError(err, {\n module: 'telemetry',\n code: 'telemetry.runtime_sampling_failed',\n })\n } catch {}\n logger.warn('Runtime metric sampling failed', { err })\n } finally {\n delay.reset()\n }\n }\n\n const interval = sources.setInterval(sample, SAMPLE_INTERVAL_MS)\n interval.unref?.()\n\n let disposed = false\n return () => {\n if (disposed) return\n disposed = true\n sources.clearInterval(interval)\n delay.disable()\n }\n}\n\nexport function startRuntimeMetrics(\n sources: RuntimeMetricSources = defaultSources,\n): () => void {\n const current = store()\n if (!current.active) {\n current.active = {\n references: 0,\n dispose: createRuntimeMetricsSampler(sources),\n }\n }\n\n const active = current.active\n active.references += 1\n let released = false\n\n return () => {\n if (released) return\n released = true\n active.references -= 1\n if (active.references > 0) return\n active.dispose()\n const latest = store()\n if (latest.active === active) latest.active = undefined\n }\n}\n\n/** Test-only: tear down the process-wide sampler regardless of reference count. */\nexport function resetRuntimeMetrics(): void {\n const current = store()\n current.active?.dispose()\n current.active = undefined\n}\n"],
5
+ "mappings": "AAAA,SAAS,uBAAuB,mBAA8C;AAC9E,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;AAC5B,MAAM,yBAAyB;AAC/B,MAAM,aAAa,uBAAO,IAAI,wCAAwC;AAmCtE,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,kBAAkB,CAAC;AAE/E,MAAM,iBAAuC;AAAA,EAC3C,6BAA6B,MAAM,sBAAsB,EAAE,YAAY,oBAAoB,CAAC;AAAA,EAC5F,sBAAsB,CAAC,OAAO,WAAW,YAAY,qBAAqB,OAAO,MAAM;AAAA,EACvF,aAAa,MAAM,QAAQ,YAAY;AAAA,EACvC,qBAAqB,MAAM,uBAAuB;AAAA,EAClD,aAAa,CAAC,SAAS,eAAe,YAAY,SAAS,UAAU;AAAA,EACrE,eAAe,CAAC,aAAa,cAAc,QAAQ;AACrD;AAEA,SAAS,QAA6B;AACpC,QAAM,cAAc;AACpB,MAAI,UAAU,YAAY,UAAU;AACpC,MAAI,CAAC,SAAS;AACZ,cAAU,CAAC;AACX,gBAAY,UAAU,IAAI;AAAA,EAC5B;AACA,SAAO;AACT;AAEA,SAAS,uBAAuB,OAAuB;AACrD,SAAO,OAAO,SAAS,KAAK,IAAI,KAAK,IAAI,GAAG,KAAK,IAAI,yBAAyB;AAChF;AAEO,SAAS,4BACd,UAAgC,gBACpB;AACZ,QAAM,QAAQ,QAAQ,4BAA4B;AAClD,MAAI,sBAAsB,QAAQ,qBAAqB;AACvD,QAAM,OAAO;AAEb,QAAM,SAAS,MAAM;AACnB,QAAI;AACF,YAAM,qBAAqB,QAAQ,qBAAqB;AACxD,YAAM,sBAAsB,QAAQ;AAAA,QAClC;AAAA,QACA;AAAA,MACF;AACA,4BAAsB;AAEtB,4BAAsB;AAAA,QACpB,MAAM;AAAA,QACN,MAAM;AAAA,QACN,OAAO,oBAAoB;AAAA,QAC3B,MAAM;AAAA,MACR,CAAC;AACD,iBAAW,cAAc,CAAC,IAAI,IAAI,EAAE,GAAG;AACrC,8BAAsB;AAAA,UACpB,MAAM;AAAA,UACN,MAAM,2BAA2B,UAAU;AAAA,UAC3C,OAAO,uBAAuB,MAAM,WAAW,UAAU,CAAC;AAAA,UAC1D,MAAM;AAAA,QACR,CAAC;AAAA,MACH;AAEA,4BAAsB;AAAA,QACpB,MAAM;AAAA,QACN,MAAM;AAAA,QACN,OAAO,QAAQ,YAAY,EAAE;AAAA,QAC7B,MAAM;AAAA,MACR,CAAC;AACD,iBAAW,aAAa,QAAQ,oBAAoB,GAAG;AACrD,8BAAsB;AAAA,UACpB,MAAM;AAAA,UACN,MAAM;AAAA,UACN,OAAO,UAAU;AAAA,UACjB,QAAQ,EAAE,wBAAwB,UAAU,WAAW;AAAA,UACvD,MAAM;AAAA,QACR,CAAC;AAAA,MACH;AAEA,8BAAwB,CAAC,QAAQ;AAC/B,eAAO,KAAK,mCAAmC,EAAE,IAAI,CAAC;AAAA,MACxD,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,UAAI;AACF,4BAAoB,GAAG,YAAY,KAAK;AAAA,UACtC,QAAQ;AAAA,UACR,MAAM;AAAA,QACR,CAAC;AAAA,MACH,QAAQ;AAAA,MAAC;AACT,aAAO,KAAK,kCAAkC,EAAE,IAAI,CAAC;AAAA,IACvD,UAAE;AACA,YAAM,MAAM;AAAA,IACd;AAAA,EACF;AAEA,QAAM,WAAW,QAAQ,YAAY,QAAQ,kBAAkB;AAC/D,WAAS,QAAQ;AAEjB,MAAI,WAAW;AACf,SAAO,MAAM;AACX,QAAI,SAAU;AACd,eAAW;AACX,YAAQ,cAAc,QAAQ;AAC9B,UAAM,QAAQ;AAAA,EAChB;AACF;AAEO,SAAS,oBACd,UAAgC,gBACpB;AACZ,QAAM,UAAU,MAAM;AACtB,MAAI,CAAC,QAAQ,QAAQ;AACnB,YAAQ,SAAS;AAAA,MACf,YAAY;AAAA,MACZ,SAAS,4BAA4B,OAAO;AAAA,IAC9C;AAAA,EACF;AAEA,QAAM,SAAS,QAAQ;AACvB,SAAO,cAAc;AACrB,MAAI,WAAW;AAEf,SAAO,MAAM;AACX,QAAI,SAAU;AACd,eAAW;AACX,WAAO,cAAc;AACrB,QAAI,OAAO,aAAa,EAAG;AAC3B,WAAO,QAAQ;AACf,UAAM,SAAS,MAAM;AACrB,QAAI,OAAO,WAAW,OAAQ,QAAO,SAAS;AAAA,EAChD;AACF;AAGO,SAAS,sBAA4B;AAC1C,QAAM,UAAU,MAAM;AACtB,UAAQ,QAAQ,QAAQ;AACxB,UAAQ,SAAS;AACnB;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/telemetry",
3
- "version": "0.10.1-develop.7833.1.d6cbf0e3ab",
3
+ "version": "0.10.1-develop.7841.1.af87ac614d",
4
4
  "license": "MIT",
5
5
  "description": "Vendor-neutral spans, metrics, and error reporting backed by a pluggable OTLP provider. Off by default; the telemetry runtime is loaded only when explicitly enabled.",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  }
39
39
  },
40
40
  "dependencies": {
41
- "@open-mercato/shared": "0.10.1-develop.7833.1.d6cbf0e3ab"
41
+ "@open-mercato/shared": "0.10.1-develop.7841.1.af87ac614d"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "react": ">=18.0.0"
@@ -3,7 +3,7 @@ import {
3
3
  getTelemetryRuntime,
4
4
  resetTelemetryRuntime,
5
5
  } from '@open-mercato/shared/lib/telemetry/runtime'
6
- import { initTelemetry, resetTelemetryInit } from '../init'
6
+ import { initTelemetry, resetTelemetryInit, shutdownTelemetry } from '../init'
7
7
  import {
8
8
  getActiveProvider,
9
9
  registerProvider,
@@ -12,6 +12,7 @@ import {
12
12
  import { resetTelemetryEnvCache } from '../env'
13
13
  import type { TelemetryProvider } from '../types'
14
14
  import { NOOP_SPAN } from '../provider/noop-provider'
15
+ import * as errorReporting from '../facade/report-error'
15
16
 
16
17
  function provider(name = 'noop'): TelemetryProvider {
17
18
  return {
@@ -31,6 +32,7 @@ function provider(name = 'noop'): TelemetryProvider {
31
32
 
32
33
  describe('telemetry explicit opt-in boundary', () => {
33
34
  beforeEach(() => {
35
+ jest.useFakeTimers()
34
36
  delete process.env.TELEMETRY_BACKEND
35
37
  resetTelemetryEnvCache()
36
38
  resetTelemetryInit()
@@ -39,6 +41,12 @@ describe('telemetry explicit opt-in boundary', () => {
39
41
  resetTelemetryRuntime()
40
42
  })
41
43
 
44
+ afterEach(() => {
45
+ jest.restoreAllMocks()
46
+ resetTelemetryInit()
47
+ jest.useRealTimers()
48
+ })
49
+
42
50
  it('does not resolve a custom provider or register runtime hooks while off', async () => {
43
51
  const customNoop = provider()
44
52
  registerProvider(customNoop)
@@ -49,6 +57,7 @@ describe('telemetry explicit opt-in boundary', () => {
49
57
  expect(getActiveProvider().name).toBe('noop')
50
58
  expect(getLoggerExtension()).toBeUndefined()
51
59
  expect(getTelemetryRuntime()).toBeUndefined()
60
+ expect(jest.getTimerCount()).toBe(0)
52
61
  })
53
62
 
54
63
  it('can initialize after dotenv sets an enabled backend following an off call', async () => {
@@ -63,6 +72,7 @@ describe('telemetry explicit opt-in boundary', () => {
63
72
  expect(getActiveProvider()).toBe(customConsole)
64
73
  expect(getLoggerExtension()).toBeDefined()
65
74
  expect(getTelemetryRuntime()).toBeDefined()
75
+ expect(jest.getTimerCount()).toBe(1)
66
76
  })
67
77
 
68
78
  it('initializes an explicitly registered custom backend', async () => {
@@ -78,6 +88,69 @@ describe('telemetry explicit opt-in boundary', () => {
78
88
  expect(getTelemetryRuntime()).toBeDefined()
79
89
  })
80
90
 
91
+ it('shares concurrent initialization and releases the sampler on shutdown', async () => {
92
+ const customConsole = provider('console')
93
+ registerProvider(customConsole)
94
+ process.env.TELEMETRY_BACKEND = 'console'
95
+
96
+ await Promise.all([initTelemetry(), initTelemetry()])
97
+
98
+ expect(customConsole.start).toHaveBeenCalledTimes(1)
99
+ expect(jest.getTimerCount()).toBe(1)
100
+
101
+ await shutdownTelemetry()
102
+
103
+ expect(customConsole.shutdown).toHaveBeenCalledTimes(1)
104
+ expect(getTelemetryRuntime()).toBeUndefined()
105
+ expect(jest.getTimerCount()).toBe(0)
106
+ })
107
+
108
+ it.each([false, true])('reports rejected initialization during shutdown and permits recovery (reporting throws: %s)', async (reportingThrows) => {
109
+ const failure = new Error('[internal] telemetry provider startup failed')
110
+ let rejectStart!: (reason: unknown) => void
111
+ const startup = new Promise<void>((_resolve, reject) => { rejectStart = reject })
112
+ const customConsole = provider('console')
113
+ customConsole.start = jest.fn(() => startup)
114
+ registerProvider(customConsole)
115
+ process.env.TELEMETRY_BACKEND = 'console'
116
+ const reportError = jest.spyOn(errorReporting, 'reportError')
117
+ if (reportingThrows) {
118
+ reportError.mockImplementation(() => { throw new Error('[internal] reporting unavailable') })
119
+ }
120
+
121
+ const initialization = initTelemetry()
122
+ const initializationFailure = expect(initialization).rejects.toBe(failure)
123
+ const shutdown = shutdownTelemetry()
124
+ expect(customConsole.start).toHaveBeenCalledTimes(1)
125
+ expect(reportError).not.toHaveBeenCalled()
126
+
127
+ rejectStart(failure)
128
+ await initializationFailure
129
+ await expect(shutdown).resolves.toBeUndefined()
130
+
131
+ expect(reportError).toHaveBeenCalledTimes(1)
132
+ expect(reportError).toHaveBeenCalledWith(failure, {
133
+ module: 'telemetry',
134
+ code: 'telemetry.initialization_failed',
135
+ })
136
+ expect(customConsole.shutdown).not.toHaveBeenCalled()
137
+ expect(getActiveProvider().name).toBe('noop')
138
+ expect(getLoggerExtension()).toBeUndefined()
139
+ expect(getTelemetryRuntime()).toBeUndefined()
140
+ expect(jest.getTimerCount()).toBe(0)
141
+
142
+ const recoveredProvider = provider('console')
143
+ registerProvider(recoveredProvider)
144
+ await initTelemetry()
145
+ expect(recoveredProvider.start).toHaveBeenCalledTimes(1)
146
+ expect(jest.getTimerCount()).toBe(1)
147
+ await shutdownTelemetry()
148
+ expect(recoveredProvider.shutdown).toHaveBeenCalledTimes(1)
149
+ expect(getLoggerExtension()).toBeUndefined()
150
+ expect(getTelemetryRuntime()).toBeUndefined()
151
+ expect(jest.getTimerCount()).toBe(0)
152
+ })
153
+
81
154
  it('wires shared runtime histograms to the active provider', async () => {
82
155
  const customConsole = provider('console')
83
156
  registerProvider(customConsole)
@@ -8,9 +8,15 @@ import { SpanStatusCode, propagation, trace, context, defaultTextMapSetter, ROOT
8
8
  import { W3CTraceContextPropagator } from '@opentelemetry/core'
9
9
  import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'
10
10
  import { InMemoryLogRecordExporter, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs'
11
- import { InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from '@opentelemetry/sdk-metrics'
12
-
13
- import { OtlpProvider } from '../provider/otlp-provider'
11
+ import {
12
+ InMemoryMetricExporter,
13
+ PeriodicExportingMetricReader,
14
+ AggregationTemporality,
15
+ DataPointType,
16
+ type Histogram,
17
+ } from '@opentelemetry/sdk-metrics'
18
+
19
+ import { OtlpProvider, SECONDS_HISTOGRAM_BUCKETS } from '../provider/otlp-provider'
14
20
  import { createBackupHeaderPropagator } from '../browser/propagator'
15
21
  import {
16
22
  createLogger,
@@ -20,7 +26,7 @@ import {
20
26
  import { setActiveProvider, resetActiveProvider } from '../provider/registry'
21
27
  import { resetTelemetryEnvCache } from '../env'
22
28
  import { registerTelemetryLogger } from '../facade/logger-bridge'
23
- import { withSpan, captureTraceContext, continueTrace, reportError, counter } from '../index'
29
+ import { withSpan, captureTraceContext, continueTrace, reportError, counter, histogram } from '../index'
24
30
 
25
31
  const spanExporter = new InMemorySpanExporter()
26
32
  const logExporter = new InMemoryLogRecordExporter()
@@ -357,4 +363,28 @@ describe('OtlpProvider (in-memory exporters)', () => {
357
363
  .flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics.map((m) => m.descriptor.name)))
358
364
  expect(metricNames).toContain('om.errors')
359
365
  })
366
+
367
+ it('aggregates second-unit histograms with second-scaled buckets', async () => {
368
+ histogram('om.test.wait_time', 0.003, { pool: 'primary' }, 's')
369
+ histogram('om.test.wait_time', 0.0002, { pool: 'primary' }, 's')
370
+ histogram('om.test.payload_size', 3, undefined, 'By')
371
+ await metricReader.forceFlush()
372
+
373
+ const histogramPoint = (name: string) => {
374
+ const metric = metricExporter
375
+ .getMetrics()
376
+ .flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics))
377
+ .find((candidate) => candidate.descriptor.name === name)
378
+ expect(metric?.dataPointType).toBe(DataPointType.HISTOGRAM)
379
+ return metric?.dataPoints[0]?.value as Histogram | undefined
380
+ }
381
+
382
+ const seconds = histogramPoint('om.test.wait_time')
383
+ expect(seconds?.buckets.boundaries).toEqual([...SECONDS_HISTOGRAM_BUCKETS])
384
+ expect(seconds?.buckets.counts.slice(0, 3)).toEqual([1, 1, 0])
385
+ expect(seconds?.count).toBe(2)
386
+
387
+ const bytes = histogramPoint('om.test.payload_size')
388
+ expect(bytes?.buckets.boundaries).not.toEqual([...SECONDS_HISTOGRAM_BUCKETS])
389
+ })
360
390
  })
@@ -0,0 +1,239 @@
1
+ import {
2
+ registerTelemetryMetricCollector,
3
+ registerTelemetryRuntime,
4
+ resetTelemetryMetricCollectors,
5
+ resetTelemetryRuntime,
6
+ type TelemetryMetricPoint,
7
+ type TelemetryRuntime,
8
+ } from '@open-mercato/shared/lib/telemetry/runtime'
9
+ import {
10
+ createRuntimeMetricsSampler,
11
+ resetRuntimeMetrics,
12
+ startRuntimeMetrics,
13
+ type EventLoopDelayMonitor,
14
+ type RuntimeMetricSources,
15
+ } from '../runtime-metrics'
16
+ import type { EventLoopUtilization } from 'node:perf_hooks'
17
+
18
+ function createRuntime(recordMetric: (point: TelemetryMetricPoint) => void): TelemetryRuntime {
19
+ return {
20
+ canUseGlobalTracePropagation: () => false,
21
+ captureTraceContext: () => ({}),
22
+ continueTrace: (_carrier, _name, fn) => fn(),
23
+ recordMetric,
24
+ recordHttpDuration: () => {},
25
+ reportError: () => {},
26
+ shutdown: async () => {},
27
+ }
28
+ }
29
+
30
+ describe('runtime metrics sampler', () => {
31
+ afterEach(() => {
32
+ resetRuntimeMetrics()
33
+ resetTelemetryMetricCollectors()
34
+ resetTelemetryRuntime()
35
+ })
36
+
37
+ it('records interval runtime values and invokes shared metric collectors', () => {
38
+ const points: TelemetryMetricPoint[] = []
39
+ registerTelemetryRuntime(createRuntime((point) => points.push(point)))
40
+ const collectPool = jest.fn()
41
+ registerTelemetryMetricCollector(collectPool)
42
+
43
+ const delay: EventLoopDelayMonitor = {
44
+ enable: jest.fn(),
45
+ disable: jest.fn(),
46
+ percentile: jest.fn((percentile) => percentile * 1_000_000),
47
+ reset: jest.fn(),
48
+ }
49
+ const utilization: EventLoopUtilization[] = [
50
+ { idle: 10, active: 10, utilization: 0.5 },
51
+ { idle: 15, active: 25, utilization: 0.625 },
52
+ { idle: 5, active: 15, utilization: 0.75 },
53
+ ]
54
+ let sample = () => {}
55
+ const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
56
+ const sources: RuntimeMetricSources = {
57
+ createEventLoopDelayMonitor: () => delay,
58
+ eventLoopUtilization: jest.fn(() => utilization.shift() ?? {
59
+ idle: 0,
60
+ active: 0,
61
+ utilization: 0,
62
+ }),
63
+ memoryUsage: () => ({ rss: 123_456 }),
64
+ heapSpaceStatistics: () => [{
65
+ space_name: 'old_space',
66
+ space_used_size: 78_900,
67
+ }],
68
+ setInterval: (handler, intervalMs) => {
69
+ expect(intervalMs).toBe(10_000)
70
+ sample = handler
71
+ return interval
72
+ },
73
+ clearInterval: jest.fn(),
74
+ }
75
+
76
+ const dispose = createRuntimeMetricsSampler(sources)
77
+ sample()
78
+
79
+ expect(delay.enable).toHaveBeenCalledTimes(1)
80
+ expect(interval.unref).toHaveBeenCalledTimes(1)
81
+ expect(points).toEqual([
82
+ {
83
+ kind: 'gauge',
84
+ name: 'nodejs.eventloop.utilization',
85
+ value: 0.75,
86
+ unit: '1',
87
+ },
88
+ {
89
+ kind: 'gauge',
90
+ name: 'nodejs.eventloop.delay.p50',
91
+ value: 0.05,
92
+ unit: 's',
93
+ },
94
+ {
95
+ kind: 'gauge',
96
+ name: 'nodejs.eventloop.delay.p90',
97
+ value: 0.09,
98
+ unit: 's',
99
+ },
100
+ {
101
+ kind: 'gauge',
102
+ name: 'nodejs.eventloop.delay.p99',
103
+ value: 0.099,
104
+ unit: 's',
105
+ },
106
+ {
107
+ kind: 'gauge',
108
+ name: 'process.memory.usage',
109
+ value: 123_456,
110
+ unit: 'By',
111
+ },
112
+ {
113
+ kind: 'gauge',
114
+ name: 'v8js.memory.heap.used',
115
+ value: 78_900,
116
+ labels: { 'v8js.heap.space.name': 'old_space' },
117
+ unit: 'By',
118
+ },
119
+ ])
120
+ expect(collectPool).toHaveBeenCalledTimes(1)
121
+ expect(delay.reset).toHaveBeenCalledTimes(1)
122
+
123
+ dispose()
124
+
125
+ expect(sources.clearInterval).toHaveBeenCalledWith(interval)
126
+ expect(delay.disable).toHaveBeenCalledTimes(1)
127
+ })
128
+
129
+ it('normalizes non-finite delay percentiles to zero', () => {
130
+ const points: TelemetryMetricPoint[] = []
131
+ registerTelemetryRuntime(createRuntime((point) => points.push(point)))
132
+ let sample = () => {}
133
+ const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
134
+ const delay: EventLoopDelayMonitor = {
135
+ enable: jest.fn(),
136
+ disable: jest.fn(),
137
+ percentile: () => Number.NaN,
138
+ reset: jest.fn(),
139
+ }
140
+ const sources: RuntimeMetricSources = {
141
+ createEventLoopDelayMonitor: () => delay,
142
+ eventLoopUtilization: () => ({ idle: 0, active: 0, utilization: 0 }),
143
+ memoryUsage: () => ({ rss: 0 }),
144
+ heapSpaceStatistics: () => [],
145
+ setInterval: (handler) => {
146
+ sample = handler
147
+ return interval
148
+ },
149
+ clearInterval: jest.fn(),
150
+ }
151
+
152
+ const dispose = createRuntimeMetricsSampler(sources)
153
+ sample()
154
+ dispose()
155
+
156
+ expect(points.filter((point) => point.name.startsWith('nodejs.eventloop.delay.')))
157
+ .toEqual([
158
+ { kind: 'gauge', name: 'nodejs.eventloop.delay.p50', value: 0, unit: 's' },
159
+ { kind: 'gauge', name: 'nodejs.eventloop.delay.p90', value: 0, unit: 's' },
160
+ { kind: 'gauge', name: 'nodejs.eventloop.delay.p99', value: 0, unit: 's' },
161
+ ])
162
+ })
163
+
164
+ it.each([false, true])('reports sampling failures, recovers, and disposes once (reporting throws: %s)', (reportingThrows) => {
165
+ const failure = new Error('[internal] memory sample failed')
166
+ const reportError = jest.fn(() => {
167
+ if (reportingThrows) throw new Error('[internal] reporting sink failed')
168
+ })
169
+ const points: TelemetryMetricPoint[] = []
170
+ registerTelemetryRuntime({ ...createRuntime((point) => points.push(point)), reportError })
171
+ let sample = () => {}
172
+ const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
173
+ const delay: EventLoopDelayMonitor = {
174
+ enable: jest.fn(),
175
+ disable: jest.fn(),
176
+ percentile: () => 0,
177
+ reset: jest.fn(),
178
+ }
179
+ const sources: RuntimeMetricSources = {
180
+ createEventLoopDelayMonitor: () => delay,
181
+ eventLoopUtilization: () => ({ idle: 0, active: 0, utilization: 0 }),
182
+ memoryUsage: jest.fn().mockImplementationOnce(() => { throw failure }).mockReturnValue({ rss: 123 }),
183
+ heapSpaceStatistics: () => [],
184
+ setInterval: (handler) => {
185
+ sample = handler
186
+ return interval
187
+ },
188
+ clearInterval: jest.fn(),
189
+ }
190
+
191
+ const dispose = createRuntimeMetricsSampler(sources)
192
+ expect(sample).not.toThrow()
193
+ expect(sample).not.toThrow()
194
+ dispose()
195
+ dispose()
196
+
197
+ expect(reportError).toHaveBeenCalledWith(failure, {
198
+ module: 'telemetry',
199
+ code: 'telemetry.runtime_sampling_failed',
200
+ })
201
+ expect(points).toContainEqual({ kind: 'gauge', name: 'process.memory.usage', value: 123, unit: 'By' })
202
+ expect(delay.reset).toHaveBeenCalledTimes(2)
203
+ expect(delay.disable).toHaveBeenCalledTimes(1)
204
+ expect(sources.clearInterval).toHaveBeenCalledTimes(1)
205
+ })
206
+
207
+ it('shares one process sampler until every owner releases it', () => {
208
+ const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
209
+ const delay: EventLoopDelayMonitor = {
210
+ enable: jest.fn(),
211
+ disable: jest.fn(),
212
+ percentile: () => 0,
213
+ reset: jest.fn(),
214
+ }
215
+ const sources: RuntimeMetricSources = {
216
+ createEventLoopDelayMonitor: jest.fn(() => delay),
217
+ eventLoopUtilization: () => ({ idle: 0, active: 0, utilization: 0 }),
218
+ memoryUsage: () => ({ rss: 0 }),
219
+ heapSpaceStatistics: () => [],
220
+ setInterval: jest.fn(() => interval),
221
+ clearInterval: jest.fn(),
222
+ }
223
+
224
+ const releaseFirst = startRuntimeMetrics(sources)
225
+ const releaseSecond = startRuntimeMetrics(sources)
226
+
227
+ expect(sources.createEventLoopDelayMonitor).toHaveBeenCalledTimes(1)
228
+ expect(sources.setInterval).toHaveBeenCalledTimes(1)
229
+
230
+ releaseFirst()
231
+ expect(sources.clearInterval).not.toHaveBeenCalled()
232
+
233
+ releaseSecond()
234
+ releaseSecond()
235
+
236
+ expect(sources.clearInterval).toHaveBeenCalledTimes(1)
237
+ expect(delay.disable).toHaveBeenCalledTimes(1)
238
+ })
239
+ })
package/src/init.ts CHANGED
@@ -17,11 +17,14 @@ import { withSpan } from './facade/tracer'
17
17
  import { recordHttpDuration } from './facade/http'
18
18
  import { histogram } from './facade/meter'
19
19
  import { reportError } from './facade/report-error'
20
+ import { startRuntimeMetrics } from './runtime-metrics'
20
21
 
21
22
  let initialized = false
23
+ let initializing: Promise<void> | undefined
22
24
  let activeProvider: TelemetryProvider | undefined
23
25
  let disposeLoggerExtension: (() => void) | undefined
24
26
  let disposeRuntime: (() => void) | undefined
27
+ let disposeRuntimeMetrics: (() => void) | undefined
25
28
 
26
29
  const logger = createLogger('telemetry')
27
30
  const OTLP_DEPENDENCY_UNAVAILABLE = Symbol.for('open-mercato.telemetry.otlp-dependency-unavailable')
@@ -46,7 +49,17 @@ class OtlpDependencyUnavailableError extends Error {
46
49
  */
47
50
  export async function initTelemetry(): Promise<void> {
48
51
  if (initialized) return
52
+ if (initializing) return initializing
49
53
 
54
+ let pending: Promise<void>
55
+ pending = initializeTelemetry().finally(() => {
56
+ if (initializing === pending) initializing = undefined
57
+ })
58
+ initializing = pending
59
+ return pending
60
+ }
61
+
62
+ async function initializeTelemetry(): Promise<void> {
50
63
  // Drop any env snapshot memoized BEFORE the host loaded its `.env` (the CLI
51
64
  // binary imports this package before dotenv runs) so init resolves the backend
52
65
  // from the actual, fully-loaded environment.
@@ -75,6 +88,7 @@ export async function initTelemetry(): Promise<void> {
75
88
  activeProvider = provider
76
89
  disposeLoggerExtension = registerTelemetryLogger(provider)
77
90
  disposeRuntime = registerTelemetryRuntime(createRuntime(provider))
91
+ disposeRuntimeMetrics = startRuntimeMetrics()
78
92
  initialized = true
79
93
 
80
94
  logger.info('Telemetry initialized', {
@@ -111,11 +125,26 @@ async function loadOtlpProvider(backend: TelemetryBackendName): Promise<Telemetr
111
125
 
112
126
  /** Flush + tear down the active backend (shutdown hook / `after()`). */
113
127
  export async function shutdownTelemetry(): Promise<void> {
128
+ if (initializing) {
129
+ try {
130
+ await initializing
131
+ } catch (err) {
132
+ try {
133
+ reportError(err, {
134
+ module: 'telemetry',
135
+ code: 'telemetry.initialization_failed',
136
+ })
137
+ } catch {}
138
+ logger.warn('Telemetry initialization failed before shutdown', { err })
139
+ }
140
+ }
114
141
  const provider = activeProvider
115
142
  activeProvider = undefined
116
143
  initialized = false
117
144
  disposeLoggerExtension?.()
118
145
  disposeLoggerExtension = undefined
146
+ disposeRuntimeMetrics?.()
147
+ disposeRuntimeMetrics = undefined
119
148
  disposeRuntime?.()
120
149
  disposeRuntime = undefined
121
150
  clearActiveProvider()
@@ -131,6 +160,7 @@ function createRuntime(provider: TelemetryProvider): TelemetryRuntime {
131
160
  continueTrace: (carrier, name, fn, options) =>
132
161
  continueTrace(carrier, name, () => fn(), options),
133
162
  withSpan: (name, fn, options) => withSpan(name, fn, options),
163
+ recordMetric: (point) => provider.recordMetric(point),
134
164
  recordHistogram: histogram,
135
165
  recordHttpDuration,
136
166
  reportError,
@@ -141,9 +171,12 @@ function createRuntime(provider: TelemetryProvider): TelemetryRuntime {
141
171
  /** Test-only: allow re-init in jest. */
142
172
  export function resetTelemetryInit(): void {
143
173
  initialized = false
174
+ initializing = undefined
144
175
  activeProvider = undefined
145
176
  disposeLoggerExtension?.()
146
177
  disposeLoggerExtension = undefined
178
+ disposeRuntimeMetrics?.()
179
+ disposeRuntimeMetrics = undefined
147
180
  disposeRuntime?.()
148
181
  disposeRuntime = undefined
149
182
  }
@@ -23,6 +23,7 @@ import {
23
23
  type Counter,
24
24
  type Histogram,
25
25
  type Gauge,
26
+ type MetricOptions,
26
27
  } from '@opentelemetry/api'
27
28
  import { W3CTraceContextPropagator } from '@opentelemetry/core'
28
29
  import { logs, SeverityNumber } from '@opentelemetry/api-logs'
@@ -229,6 +230,22 @@ export type OtlpProviderOptions = {
229
230
  */
230
231
  export const PG_INSTRUMENTATION_OPTIONS = { enhancedDatabaseReporting: false } as const
231
232
 
233
+ /**
234
+ * Bucket boundaries for every histogram recorded in seconds. Without explicit
235
+ * advice the SDK falls back to millisecond-scaled defaults (0, 5, 10 … 10000),
236
+ * which puts every realistic latency in the first bucket and leaves percentiles
237
+ * uncomputable. A superset of the semconv HTTP-duration advice, plus a 1 ms
238
+ * bucket for sub-millisecond pool acquisitions.
239
+ */
240
+ export const SECONDS_HISTOGRAM_BUCKETS: readonly number[] = [
241
+ 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
242
+ ]
243
+
244
+ function histogramOptions(unit: string | undefined): MetricOptions | undefined {
245
+ if (unit !== 's') return unit ? { unit } : undefined
246
+ return { unit, advice: { explicitBucketBoundaries: [...SECONDS_HISTOGRAM_BUCKETS] } }
247
+ }
248
+
232
249
  export class OtlpProvider implements TelemetryProvider {
233
250
  /** The configured backend name (signoz | newrelic | otlp) — all OTLP, vendor differs only by endpoint. */
234
251
  readonly name: string
@@ -339,7 +356,7 @@ export class OtlpProvider implements TelemetryProvider {
339
356
  point.kind === 'counter'
340
357
  ? meter.createCounter(point.name, opts)
341
358
  : point.kind === 'histogram'
342
- ? meter.createHistogram(point.name, opts)
359
+ ? meter.createHistogram(point.name, histogramOptions(point.unit))
343
360
  : meter.createGauge(point.name, opts)
344
361
  this.instruments.set(key, instrument)
345
362
  }
@@ -0,0 +1,179 @@
1
+ import { monitorEventLoopDelay, performance, type EventLoopUtilization } from 'node:perf_hooks'
2
+ import { getHeapSpaceStatistics } from 'node:v8'
3
+ import {
4
+ collectTelemetryMetrics,
5
+ getTelemetryRuntime,
6
+ recordTelemetryMetric,
7
+ } from '@open-mercato/shared/lib/telemetry/runtime'
8
+ import { createLogger } from '@open-mercato/shared/lib/logger'
9
+
10
+ const SAMPLE_INTERVAL_MS = 10_000
11
+ const DELAY_RESOLUTION_MS = 20
12
+ const NANOSECONDS_PER_SECOND = 1_000_000_000
13
+ const GLOBAL_KEY = Symbol.for('@open-mercato/telemetry.runtimeMetrics')
14
+
15
+ export type EventLoopDelayMonitor = {
16
+ enable(): void
17
+ disable(): void
18
+ percentile(percentile: number): number
19
+ reset(): void
20
+ }
21
+
22
+ export type HeapSpaceSample = {
23
+ space_name: string
24
+ space_used_size: number
25
+ }
26
+
27
+ export type RuntimeMetricSources = {
28
+ createEventLoopDelayMonitor(): EventLoopDelayMonitor
29
+ eventLoopUtilization(
30
+ first?: EventLoopUtilization,
31
+ second?: EventLoopUtilization,
32
+ ): EventLoopUtilization
33
+ memoryUsage(): { rss: number }
34
+ heapSpaceStatistics(): HeapSpaceSample[]
35
+ setInterval(handler: () => void, intervalMs: number): ReturnType<typeof setInterval>
36
+ clearInterval(interval: ReturnType<typeof setInterval>): void
37
+ }
38
+
39
+ type ActiveRuntimeSampler = {
40
+ references: number
41
+ dispose(): void
42
+ }
43
+
44
+ type RuntimeMetricsStore = {
45
+ active?: ActiveRuntimeSampler
46
+ }
47
+
48
+ const logger = createLogger('telemetry').child({ component: 'runtime-metrics' })
49
+
50
+ const defaultSources: RuntimeMetricSources = {
51
+ createEventLoopDelayMonitor: () => monitorEventLoopDelay({ resolution: DELAY_RESOLUTION_MS }),
52
+ eventLoopUtilization: (first, second) => performance.eventLoopUtilization(first, second),
53
+ memoryUsage: () => process.memoryUsage(),
54
+ heapSpaceStatistics: () => getHeapSpaceStatistics(),
55
+ setInterval: (handler, intervalMs) => setInterval(handler, intervalMs),
56
+ clearInterval: (interval) => clearInterval(interval),
57
+ }
58
+
59
+ function store(): RuntimeMetricsStore {
60
+ const globalStore = globalThis as unknown as Record<symbol, RuntimeMetricsStore | undefined>
61
+ let current = globalStore[GLOBAL_KEY]
62
+ if (!current) {
63
+ current = {}
64
+ globalStore[GLOBAL_KEY] = current
65
+ }
66
+ return current
67
+ }
68
+
69
+ function secondsFromNanoseconds(value: number): number {
70
+ return Number.isFinite(value) ? Math.max(0, value) / NANOSECONDS_PER_SECOND : 0
71
+ }
72
+
73
+ export function createRuntimeMetricsSampler(
74
+ sources: RuntimeMetricSources = defaultSources,
75
+ ): () => void {
76
+ const delay = sources.createEventLoopDelayMonitor()
77
+ let previousUtilization = sources.eventLoopUtilization()
78
+ delay.enable()
79
+
80
+ const sample = () => {
81
+ try {
82
+ const currentUtilization = sources.eventLoopUtilization()
83
+ const intervalUtilization = sources.eventLoopUtilization(
84
+ currentUtilization,
85
+ previousUtilization,
86
+ )
87
+ previousUtilization = currentUtilization
88
+
89
+ recordTelemetryMetric({
90
+ kind: 'gauge',
91
+ name: 'nodejs.eventloop.utilization',
92
+ value: intervalUtilization.utilization,
93
+ unit: '1',
94
+ })
95
+ for (const percentile of [50, 90, 99]) {
96
+ recordTelemetryMetric({
97
+ kind: 'gauge',
98
+ name: `nodejs.eventloop.delay.p${percentile}`,
99
+ value: secondsFromNanoseconds(delay.percentile(percentile)),
100
+ unit: 's',
101
+ })
102
+ }
103
+
104
+ recordTelemetryMetric({
105
+ kind: 'gauge',
106
+ name: 'process.memory.usage',
107
+ value: sources.memoryUsage().rss,
108
+ unit: 'By',
109
+ })
110
+ for (const heapSpace of sources.heapSpaceStatistics()) {
111
+ recordTelemetryMetric({
112
+ kind: 'gauge',
113
+ name: 'v8js.memory.heap.used',
114
+ value: heapSpace.space_used_size,
115
+ labels: { 'v8js.heap.space.name': heapSpace.space_name },
116
+ unit: 'By',
117
+ })
118
+ }
119
+
120
+ collectTelemetryMetrics((err) => {
121
+ logger.warn('Runtime metric collector failed', { err })
122
+ })
123
+ } catch (err) {
124
+ try {
125
+ getTelemetryRuntime()?.reportError(err, {
126
+ module: 'telemetry',
127
+ code: 'telemetry.runtime_sampling_failed',
128
+ })
129
+ } catch {}
130
+ logger.warn('Runtime metric sampling failed', { err })
131
+ } finally {
132
+ delay.reset()
133
+ }
134
+ }
135
+
136
+ const interval = sources.setInterval(sample, SAMPLE_INTERVAL_MS)
137
+ interval.unref?.()
138
+
139
+ let disposed = false
140
+ return () => {
141
+ if (disposed) return
142
+ disposed = true
143
+ sources.clearInterval(interval)
144
+ delay.disable()
145
+ }
146
+ }
147
+
148
+ export function startRuntimeMetrics(
149
+ sources: RuntimeMetricSources = defaultSources,
150
+ ): () => void {
151
+ const current = store()
152
+ if (!current.active) {
153
+ current.active = {
154
+ references: 0,
155
+ dispose: createRuntimeMetricsSampler(sources),
156
+ }
157
+ }
158
+
159
+ const active = current.active
160
+ active.references += 1
161
+ let released = false
162
+
163
+ return () => {
164
+ if (released) return
165
+ released = true
166
+ active.references -= 1
167
+ if (active.references > 0) return
168
+ active.dispose()
169
+ const latest = store()
170
+ if (latest.active === active) latest.active = undefined
171
+ }
172
+ }
173
+
174
+ /** Test-only: tear down the process-wide sampler regardless of reference count. */
175
+ export function resetRuntimeMetrics(): void {
176
+ const current = store()
177
+ current.active?.dispose()
178
+ current.active = undefined
179
+ }