@open-mercato/telemetry 0.10.1-develop.7833.1.d6cbf0e3ab → 0.10.1-develop.7836.1.0ef0f453c6
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +8 -1
- package/README.md +44 -0
- package/dist/init.js +33 -0
- package/dist/init.js.map +2 -2
- package/dist/provider/otlp-provider.js +24 -2
- package/dist/provider/otlp-provider.js.map +2 -2
- package/dist/runtime-metrics.js +132 -0
- package/dist/runtime-metrics.js.map +7 -0
- package/package.json +2 -2
- package/src/__tests__/env-load-order.test.ts +74 -1
- package/src/__tests__/otlp-integration.test.ts +34 -4
- package/src/__tests__/runtime-metrics.test.ts +239 -0
- package/src/init.ts +33 -0
- package/src/provider/otlp-provider.ts +18 -1
- package/src/runtime-metrics.ts +179 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:telemetry] found
|
|
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": "AACA,SAAS,eAAe,kBAAkB,8BAA8B;AACxE,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,OAEK;AACP,SAAS,oBAAoB;AAC7B,SAAS,+BAA+B;AACxC,SAAS,qBAAqB,qBAAqB;AACnD,SAAS,gBAAgB;AACzB,SAAS,0BAA0B;AACnC,SAAS,iBAAiB;AAC1B,SAAS,mBAAmB;
|
|
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,
|
|
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": "AAQA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,
|
|
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": "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",
|
|
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.
|
|
3
|
+
"version": "0.10.1-develop.7836.1.0ef0f453c6",
|
|
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.
|
|
41
|
+
"@open-mercato/shared": "0.10.1-develop.7836.1.0ef0f453c6"
|
|
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 {
|
|
12
|
-
|
|
13
|
-
|
|
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,
|
|
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
|
+
}
|