@open-mercato/telemetry 0.6.7-develop.6834.1.e76f4b8cbc

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.
Files changed (82) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +71 -0
  3. package/README.md +164 -0
  4. package/build.mjs +10 -0
  5. package/dist/env.js +44 -0
  6. package/dist/env.js.map +7 -0
  7. package/dist/facade/context.js +14 -0
  8. package/dist/facade/context.js.map +7 -0
  9. package/dist/facade/http.js +18 -0
  10. package/dist/facade/http.js.map +7 -0
  11. package/dist/facade/logger-bridge.js +40 -0
  12. package/dist/facade/logger-bridge.js.map +7 -0
  13. package/dist/facade/meter.js +16 -0
  14. package/dist/facade/meter.js.map +7 -0
  15. package/dist/facade/propagation.js +20 -0
  16. package/dist/facade/propagation.js.map +7 -0
  17. package/dist/facade/redact.js +21 -0
  18. package/dist/facade/redact.js.map +7 -0
  19. package/dist/facade/report-error.js +26 -0
  20. package/dist/facade/report-error.js.map +7 -0
  21. package/dist/facade/serialize.js +35 -0
  22. package/dist/facade/serialize.js.map +7 -0
  23. package/dist/facade/tracer.js +21 -0
  24. package/dist/facade/tracer.js.map +7 -0
  25. package/dist/index.js +21 -0
  26. package/dist/index.js.map +7 -0
  27. package/dist/init.js +98 -0
  28. package/dist/init.js.map +7 -0
  29. package/dist/nextjs-config.js +21 -0
  30. package/dist/nextjs-config.js.map +7 -0
  31. package/dist/nextjs.js +30 -0
  32. package/dist/nextjs.js.map +7 -0
  33. package/dist/provider/console-provider.js +70 -0
  34. package/dist/provider/console-provider.js.map +7 -0
  35. package/dist/provider/noop-provider.js +45 -0
  36. package/dist/provider/noop-provider.js.map +7 -0
  37. package/dist/provider/otlp-provider.js +212 -0
  38. package/dist/provider/otlp-provider.js.map +7 -0
  39. package/dist/provider/registry.js +40 -0
  40. package/dist/provider/registry.js.map +7 -0
  41. package/dist/provider/run-span.js +34 -0
  42. package/dist/provider/run-span.js.map +7 -0
  43. package/dist/types.js +1 -0
  44. package/dist/types.js.map +7 -0
  45. package/jest.config.cjs +27 -0
  46. package/package.json +68 -0
  47. package/src/__tests__/default-unloaded.test.ts +58 -0
  48. package/src/__tests__/env-load-order.test.ts +80 -0
  49. package/src/__tests__/env.test.ts +72 -0
  50. package/src/__tests__/logger-level.test.ts +64 -0
  51. package/src/__tests__/nextjs-shutdown.test.ts +71 -0
  52. package/src/__tests__/nextjs.test.ts +122 -0
  53. package/src/__tests__/otlp-integration.test.ts +264 -0
  54. package/src/__tests__/pg-instrumentation.test.ts +88 -0
  55. package/src/__tests__/pg-pii-config.test.ts +14 -0
  56. package/src/__tests__/redact.test.ts +92 -0
  57. package/src/__tests__/registry.test.ts +77 -0
  58. package/src/__tests__/run-span.test.ts +56 -0
  59. package/src/__tests__/serialize.test.ts +47 -0
  60. package/src/__tests__/telemetry.test.ts +172 -0
  61. package/src/env.ts +71 -0
  62. package/src/facade/context.ts +17 -0
  63. package/src/facade/http.ts +25 -0
  64. package/src/facade/logger-bridge.ts +50 -0
  65. package/src/facade/meter.ts +20 -0
  66. package/src/facade/propagation.ts +31 -0
  67. package/src/facade/redact.ts +54 -0
  68. package/src/facade/report-error.ts +41 -0
  69. package/src/facade/serialize.ts +46 -0
  70. package/src/facade/tracer.ts +25 -0
  71. package/src/index.ts +30 -0
  72. package/src/init.ts +134 -0
  73. package/src/nextjs-config.ts +22 -0
  74. package/src/nextjs.ts +47 -0
  75. package/src/provider/console-provider.ts +87 -0
  76. package/src/provider/noop-provider.ts +53 -0
  77. package/src/provider/otlp-provider.ts +316 -0
  78. package/src/provider/registry.ts +60 -0
  79. package/src/provider/run-span.ts +45 -0
  80. package/src/types.ts +108 -0
  81. package/tsconfig.json +9 -0
  82. package/watch.mjs +6 -0
package/src/init.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { TelemetryBackendName, TelemetryProvider } from './types'
2
+ import { isOtlpBackend, readTelemetryEnv, resetTelemetryEnvCache } from './env'
3
+ import { ConsoleProvider } from './provider/console-provider'
4
+ import {
5
+ getRegisteredProvider,
6
+ clearActiveProvider,
7
+ setActiveProvider,
8
+ } from './provider/registry'
9
+ import {
10
+ registerTelemetryRuntime,
11
+ type TelemetryRuntime,
12
+ } from '@open-mercato/shared/lib/telemetry/runtime'
13
+ import { createLogger } from '@open-mercato/shared/lib/logger'
14
+ import { registerTelemetryLogger } from './facade/logger-bridge'
15
+ import { captureTraceContext, continueTrace } from './facade/propagation'
16
+ import { recordHttpDuration } from './facade/http'
17
+ import { reportError } from './facade/report-error'
18
+
19
+ let initialized = false
20
+ let activeProvider: TelemetryProvider | undefined
21
+ let disposeLoggerExtension: (() => void) | undefined
22
+ let disposeRuntime: (() => void) | undefined
23
+
24
+ const logger = createLogger('telemetry')
25
+
26
+ /**
27
+ * One-shot bootstrap, invoked from `apps/mercato/instrumentation.ts` (web) and,
28
+ * for cross-boundary propagation, from the queue/worker bootstrap. Resolves the
29
+ * backend from `TELEMETRY_BACKEND`, starts it, and installs it as the active
30
+ * provider. Idempotent and safe to call when telemetry is off (defaults to
31
+ * no-op).
32
+ */
33
+ export async function initTelemetry(): Promise<void> {
34
+ if (initialized) return
35
+
36
+ // Drop any env snapshot memoized BEFORE the host loaded its `.env` (the CLI
37
+ // binary imports this package before dotenv runs) so init resolves the backend
38
+ // from the actual, fully-loaded environment.
39
+ resetTelemetryEnvCache()
40
+ const env = readTelemetryEnv()
41
+ const configuredBackend = process.env.TELEMETRY_BACKEND?.trim().toLowerCase() ?? ''
42
+ const registeredProvider = configuredBackend && configuredBackend !== 'noop'
43
+ ? getRegisteredProvider(configuredBackend)
44
+ : undefined
45
+ // Explicit off is absolute: do not resolve providers, register hooks, or
46
+ // install process-wide bridges when the backend is unset/noop/unregistered.
47
+ if (!env.enabled && !registeredProvider) return
48
+
49
+ const provider = registeredProvider ?? await resolveProvider(env.backend)
50
+ if (!provider) {
51
+ logger.warn('Telemetry backend is unsupported; telemetry remains disabled', {
52
+ backend: configuredBackend || env.backend,
53
+ })
54
+ return
55
+ }
56
+
57
+ // Only flag initialized AFTER start() succeeds — a throw here must leave
58
+ // telemetry re-initializable, not stuck flagged-on with an unstarted provider.
59
+ await provider.start()
60
+ setActiveProvider(provider)
61
+ activeProvider = provider
62
+ disposeLoggerExtension = registerTelemetryLogger(provider)
63
+ disposeRuntime = registerTelemetryRuntime(createRuntime(provider))
64
+ initialized = true
65
+
66
+ logger.info('Telemetry initialized', {
67
+ backend: provider.name,
68
+ samplingRatio: env.samplingRatio,
69
+ })
70
+ }
71
+
72
+ async function resolveProvider(backend: TelemetryBackendName): Promise<TelemetryProvider | undefined> {
73
+ const registered = getRegisteredProvider(backend)
74
+ if (registered) return registered
75
+
76
+ if (backend === 'console') return new ConsoleProvider()
77
+ // signoz | newrelic | otlp — same OTLP provider, vendor differs only by endpoint.
78
+ if (isOtlpBackend(backend)) return await loadOtlpProvider(backend)
79
+ return undefined
80
+ }
81
+
82
+ /**
83
+ * Dynamically load the OTLP provider so `@opentelemetry/*` (optionalDependencies)
84
+ * is imported only when an OTLP backend is selected. Falls back to console if
85
+ * the OTEL packages are absent rather than crashing the app.
86
+ */
87
+ async function loadOtlpProvider(backend: TelemetryBackendName): Promise<TelemetryProvider> {
88
+ try {
89
+ const mod = await import('./provider/otlp-provider')
90
+ return new mod.OtlpProvider({}, backend)
91
+ } catch (err) {
92
+ logger.warn('OTLP provider unavailable; falling back to console', {
93
+ reason: err instanceof Error ? err.message : String(err),
94
+ })
95
+ return new ConsoleProvider()
96
+ }
97
+ }
98
+
99
+ /** Flush + tear down the active backend (shutdown hook / `after()`). */
100
+ export async function shutdownTelemetry(): Promise<void> {
101
+ const provider = activeProvider
102
+ activeProvider = undefined
103
+ initialized = false
104
+ disposeLoggerExtension?.()
105
+ disposeLoggerExtension = undefined
106
+ disposeRuntime?.()
107
+ disposeRuntime = undefined
108
+ clearActiveProvider()
109
+ if (provider) await provider.shutdown()
110
+ }
111
+
112
+ function createRuntime(provider: TelemetryProvider): TelemetryRuntime {
113
+ return {
114
+ canUseGlobalTracePropagation: () =>
115
+ isOtlpBackend(provider.name as TelemetryBackendName)
116
+ && readTelemetryEnv().trustInboundTrace,
117
+ captureTraceContext,
118
+ continueTrace: (carrier, name, fn, options) =>
119
+ continueTrace(carrier, name, () => fn(), options),
120
+ recordHttpDuration,
121
+ reportError,
122
+ shutdown: shutdownTelemetry,
123
+ }
124
+ }
125
+
126
+ /** Test-only: allow re-init in jest. */
127
+ export function resetTelemetryInit(): void {
128
+ initialized = false
129
+ activeProvider = undefined
130
+ disposeLoggerExtension?.()
131
+ disposeLoggerExtension = undefined
132
+ disposeRuntime?.()
133
+ disposeRuntime = undefined
134
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * OpenTelemetry packages that must remain real Node modules so runtime
3
+ * instrumentations can patch their targets. This config-only entrypoint has no
4
+ * telemetry runtime imports and is safe to evaluate from `next.config.ts`.
5
+ */
6
+ export const telemetryServerExternalPackages = [
7
+ '@opentelemetry/api',
8
+ '@opentelemetry/api-logs',
9
+ '@opentelemetry/core',
10
+ '@opentelemetry/sdk-node',
11
+ '@opentelemetry/sdk-trace-node',
12
+ '@opentelemetry/sdk-logs',
13
+ '@opentelemetry/sdk-metrics',
14
+ '@opentelemetry/resources',
15
+ '@opentelemetry/semantic-conventions',
16
+ '@opentelemetry/instrumentation',
17
+ '@opentelemetry/instrumentation-pg',
18
+ '@opentelemetry/instrumentation-undici',
19
+ '@opentelemetry/exporter-trace-otlp-http',
20
+ '@opentelemetry/exporter-logs-otlp-http',
21
+ '@opentelemetry/exporter-metrics-otlp-http',
22
+ ] as const
package/src/nextjs.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Next.js wiring helpers for `@open-mercato/telemetry`.
3
+ *
4
+ * Runtime helpers for an enabled Next.js telemetry integration. Build-time
5
+ * config belongs in `@open-mercato/telemetry/nextjs-config`, which has no
6
+ * runtime imports.
7
+ */
8
+ import { isTelemetryBackendEnabled } from '@open-mercato/shared/lib/telemetry/runtime'
9
+ import { createLogger } from '@open-mercato/shared/lib/logger'
10
+ export { telemetryServerExternalPackages } from './nextjs-config'
11
+ export { recordHttpDuration } from './facade/http'
12
+
13
+ const logger = createLogger('telemetry')
14
+
15
+ /**
16
+ * One-line telemetry bootstrap for a Next.js `instrumentation.ts`. Initializes
17
+ * the active backend (no-op unless `TELEMETRY_BACKEND` is set), degrades to no
18
+ * telemetry on init failure (never bubbles a rejection out of Next's
19
+ * `register()`), and registers a best-effort flush on `SIGTERM`/`SIGINT`. Skips
20
+ * the edge runtime — the OTEL NodeSDK is Node-only — so callers may import it
21
+ * unconditionally, though gating the dynamic import on
22
+ * `NEXT_RUNTIME === 'nodejs'` also keeps the SDK off the edge bundle.
23
+ */
24
+ export async function registerTelemetryForNextjs(): Promise<void> {
25
+ if (process.env.NEXT_RUNTIME && process.env.NEXT_RUNTIME !== 'nodejs') return
26
+ if (!isTelemetryBackendEnabled()) return
27
+ const { initTelemetry, shutdownTelemetry } = await import('./init')
28
+ try {
29
+ await initTelemetry()
30
+ } catch (error) {
31
+ logger.warn('Init from Next.js instrumentation failed', { err: error })
32
+ return
33
+ }
34
+ // A signal listener suppresses Node's default termination, so after the
35
+ // best-effort flush the handler re-raises the signal. `once` has already
36
+ // removed this listener by then, so the re-raise falls through to the default
37
+ // terminate (or to another component's own handler, e.g. Next's).
38
+ const flush = (signal: NodeJS.Signals) => {
39
+ void shutdownTelemetry()
40
+ .catch(() => {})
41
+ .finally(() => {
42
+ process.kill(process.pid, signal)
43
+ })
44
+ }
45
+ process.once('SIGTERM', flush)
46
+ process.once('SIGINT', flush)
47
+ }
@@ -0,0 +1,87 @@
1
+ import type {
2
+ LogRecord,
3
+ MetricPoint,
4
+ Span,
5
+ SpanOptions,
6
+ TelemetryProvider,
7
+ TelemetrySignal,
8
+ TraceCarrier,
9
+ TraceContext,
10
+ } from '../types'
11
+ import { createLogger } from '@open-mercato/shared/lib/logger'
12
+ import { serializeError } from '../facade/serialize'
13
+ import { runSpan } from './run-span'
14
+
15
+ const logger = createLogger('telemetry')
16
+
17
+ /**
18
+ * Dev backend: prints span timings and metric points to stdout (via the shared
19
+ * logger). It does NOT handle logs — the facade's logger already writes those to
20
+ * stdout, so `emitLog` is a no-op here to avoid duplication.
21
+ */
22
+ class ConsoleSpan implements Span {
23
+ private readonly attributes: Record<string, unknown> = {}
24
+ private status: 'ok' | 'error' = 'ok'
25
+ private readonly startedAt = Date.now()
26
+
27
+ constructor(private readonly name: string) {}
28
+
29
+ setAttribute(key: string, value: string | number | boolean): void {
30
+ this.attributes[key] = value
31
+ }
32
+ setAttributes(attributes: Record<string, string | number | boolean | undefined>): void {
33
+ Object.assign(this.attributes, attributes)
34
+ }
35
+ recordException(error: unknown): void {
36
+ this.status = 'error'
37
+ this.attributes.exception = serializeError(error).message
38
+ }
39
+ setStatus(status: 'ok' | 'error'): void {
40
+ this.status = status
41
+ }
42
+ end(): void {
43
+ logger[this.status === 'error' ? 'error' : 'debug'](`Span ${this.name}`, {
44
+ span: this.name,
45
+ durationMs: Date.now() - this.startedAt,
46
+ status: this.status,
47
+ })
48
+ }
49
+ }
50
+
51
+ export class ConsoleProvider implements TelemetryProvider {
52
+ readonly name = 'console'
53
+ readonly supports: readonly TelemetrySignal[] = ['traces', 'metrics']
54
+
55
+ async start(): Promise<void> {}
56
+ async shutdown(): Promise<void> {}
57
+
58
+ runInSpan<T>(name: string, _options: SpanOptions, fn: (span: Span) => T): T {
59
+ return runSpan(new ConsoleSpan(name), fn)
60
+ }
61
+
62
+ activeSpan(): Span | undefined {
63
+ return undefined
64
+ }
65
+
66
+ activeTraceContext(): TraceContext | undefined {
67
+ // ConsoleSpan carries no real trace ids (dev-only timing print).
68
+ return undefined
69
+ }
70
+
71
+ inject(_carrier: TraceCarrier): void {}
72
+
73
+ runInRemoteSpan<T>(_carrier: TraceCarrier, name: string, _options: SpanOptions, fn: (span: Span) => T): T {
74
+ return runSpan(new ConsoleSpan(name), fn)
75
+ }
76
+
77
+ emitLog(_record: LogRecord): void {}
78
+
79
+ recordMetric(point: MetricPoint): void {
80
+ logger.debug(`Metric ${point.name}`, {
81
+ metric: point.name,
82
+ kind: point.kind,
83
+ value: point.value,
84
+ ...point.labels,
85
+ })
86
+ }
87
+ }
@@ -0,0 +1,53 @@
1
+ import type {
2
+ LogRecord,
3
+ MetricPoint,
4
+ Span,
5
+ SpanOptions,
6
+ TelemetryProvider,
7
+ TelemetrySignal,
8
+ TraceCarrier,
9
+ TraceContext,
10
+ } from '../types'
11
+
12
+ /** A span that does nothing — used when telemetry is off. */
13
+ export const NOOP_SPAN: Span = {
14
+ setAttribute() {},
15
+ setAttributes() {},
16
+ recordException() {},
17
+ setStatus() {},
18
+ end() {},
19
+ }
20
+
21
+ /**
22
+ * The default backend: a hard no-op. `runInSpan` still runs `fn` (so app logic
23
+ * is unaffected); everything else is a cheap return. The heavy OTEL SDK is
24
+ * never imported on this path.
25
+ */
26
+ export class NoopProvider implements TelemetryProvider {
27
+ readonly name = 'noop'
28
+ readonly supports: readonly TelemetrySignal[] = []
29
+
30
+ async start(): Promise<void> {}
31
+ async shutdown(): Promise<void> {}
32
+
33
+ runInSpan<T>(_name: string, _options: SpanOptions, fn: (span: Span) => T): T {
34
+ return fn(NOOP_SPAN)
35
+ }
36
+
37
+ activeSpan(): Span | undefined {
38
+ return undefined
39
+ }
40
+
41
+ activeTraceContext(): TraceContext | undefined {
42
+ return undefined
43
+ }
44
+
45
+ inject(_carrier: TraceCarrier): void {}
46
+
47
+ runInRemoteSpan<T>(_carrier: TraceCarrier, _name: string, _options: SpanOptions, fn: (span: Span) => T): T {
48
+ return fn(NOOP_SPAN)
49
+ }
50
+
51
+ emitLog(_record: LogRecord): void {}
52
+ recordMetric(_point: MetricPoint): void {}
53
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The ONLY file in this package that imports `@opentelemetry/*`. Loaded via
3
+ * dynamic `import()` from `init.ts` (so the heavy SDK never loads when telemetry
4
+ * is off), and the OTEL packages live in `optionalDependencies`.
5
+ *
6
+ * Serves any OTLP backend — the endpoint + headers come from the standard
7
+ * `OTEL_EXPORTER_OTLP_*` env vars, read by the exporters directly.
8
+ */
9
+ import {
10
+ trace,
11
+ metrics,
12
+ context,
13
+ isSpanContextValid,
14
+ defaultTextMapGetter,
15
+ defaultTextMapSetter,
16
+ SpanStatusCode,
17
+ SpanKind as OtelSpanKind,
18
+ type Span as OtelApiSpan,
19
+ type TextMapPropagator,
20
+ type Counter,
21
+ type Histogram,
22
+ type Gauge,
23
+ } from '@opentelemetry/api'
24
+ import { W3CTraceContextPropagator } from '@opentelemetry/core'
25
+ import { logs, SeverityNumber } from '@opentelemetry/api-logs'
26
+ import { NodeSDK } from '@opentelemetry/sdk-node'
27
+ import type { Instrumentation } from '@opentelemetry/instrumentation'
28
+ import { resourceFromAttributes } from '@opentelemetry/resources'
29
+ import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
30
+ import {
31
+ BatchSpanProcessor,
32
+ ParentBasedSampler,
33
+ TraceIdRatioBasedSampler,
34
+ type SpanProcessor,
35
+ } from '@opentelemetry/sdk-trace-node'
36
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
37
+ import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
38
+ import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
39
+ import { BatchLogRecordProcessor, type LogRecordProcessor } from '@opentelemetry/sdk-logs'
40
+ import { PeriodicExportingMetricReader, type IMetricReader } from '@opentelemetry/sdk-metrics'
41
+ import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'
42
+ import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'
43
+
44
+ import type {
45
+ Attributes,
46
+ AttributeValue,
47
+ LogLevel,
48
+ LogRecord,
49
+ MetricPoint,
50
+ Span,
51
+ SpanKind,
52
+ SpanOptions,
53
+ TelemetryProvider,
54
+ TelemetrySignal,
55
+ TraceCarrier,
56
+ TraceContext,
57
+ } from '../types'
58
+ import { readTelemetryEnv } from '../env'
59
+ import { redactAttributes, redactPii } from '../facade/redact'
60
+ import { serializeError } from '../facade/serialize'
61
+ import { runSpan } from './run-span'
62
+
63
+ const TRACER_NAME = 'open-mercato'
64
+
65
+ /**
66
+ * Queue/event trace propagation rides a dedicated W3C propagator on the payload
67
+ * `_trace` carrier — deliberately NOT the global propagator below.
68
+ */
69
+ const queuePropagator = new W3CTraceContextPropagator()
70
+
71
+ const W3C_TRACEPARENT = 'traceparent'
72
+ const W3C_TRACESTATE = 'tracestate'
73
+ // Backup headers that mirror the W3C context but a proxy/LB won't rewrite.
74
+ const BACKUP_TRACEPARENT = 'x-original-traceparent'
75
+ const BACKUP_TRACESTATE = 'x-original-tracestate'
76
+
77
+ /**
78
+ * Global propagator: standard W3C plus a backup copy
79
+ * (`x-original-traceparent`), with explicit inbound trust.
80
+ *
81
+ * The problem: a load balancer (e.g. GCP's) reads the inbound `traceparent`,
82
+ * creates its own span, and **rewrites** `traceparent` to point at that span —
83
+ * which our backend never sees. With plain W3C extraction every request becomes a
84
+ * child of that unexported span: no request is a root, and root-span / trace-group
85
+ * views come up empty.
86
+ *
87
+ * The fix (the industry "backup header" pattern): on inject we also write
88
+ * `x-original-traceparent`, which the LB leaves untouched. A backup header is
89
+ * still caller-controlled at an HTTP boundary, so its mere
90
+ * presence is not proof that one of our services created it. Extraction ignores
91
+ * both standard and backup headers by default.
92
+ *
93
+ * `TELEMETRY_TRUST_INBOUND_TRACE=true` enables both extraction paths for a
94
+ * deployment behind a trusted upstream. Our dedicated queue/event carrier is
95
+ * unaffected — it uses `queuePropagator` directly, not this global one.
96
+ */
97
+ const backupHeaderPropagator: TextMapPropagator = {
98
+ inject(ctx, carrier, setter) {
99
+ // Inject into a plain temp carrier so we can mirror the values regardless of
100
+ // the real carrier's setter shape, then copy both standard + backup headers.
101
+ const tmp: Record<string, string> = {}
102
+ queuePropagator.inject(ctx, tmp, defaultTextMapSetter)
103
+ for (const [key, value] of Object.entries(tmp)) setter.set(carrier, key, value)
104
+ if (tmp[W3C_TRACEPARENT]) setter.set(carrier, BACKUP_TRACEPARENT, tmp[W3C_TRACEPARENT])
105
+ if (tmp[W3C_TRACESTATE]) setter.set(carrier, BACKUP_TRACESTATE, tmp[W3C_TRACESTATE])
106
+ },
107
+ extract(ctx, carrier, getter) {
108
+ // Both the standard and backup headers are caller-controlled at an HTTP
109
+ // boundary. Trust neither unless the deployment explicitly opts in.
110
+ if (!readTelemetryEnv().trustInboundTrace) return ctx
111
+ const first = (value: string | string[] | undefined): string | undefined =>
112
+ Array.isArray(value) ? value[0] : value
113
+ const backupTraceparent = first(getter.get(carrier, BACKUP_TRACEPARENT))
114
+ if (backupTraceparent) {
115
+ const backupCarrier: Record<string, string> = { [W3C_TRACEPARENT]: backupTraceparent }
116
+ const backupTracestate = first(getter.get(carrier, BACKUP_TRACESTATE))
117
+ if (backupTracestate) backupCarrier[W3C_TRACESTATE] = backupTracestate
118
+ return queuePropagator.extract(ctx, backupCarrier, defaultTextMapGetter)
119
+ }
120
+ return queuePropagator.extract(ctx, carrier, getter)
121
+ },
122
+ fields: () => [...queuePropagator.fields(), BACKUP_TRACEPARENT, BACKUP_TRACESTATE],
123
+ }
124
+
125
+ function cleanAttributes(attributes?: Attributes): Record<string, AttributeValue> {
126
+ const out: Record<string, AttributeValue> = {}
127
+ if (!attributes) return out
128
+ for (const [key, value] of Object.entries(redactAttributes(attributes))) {
129
+ if (value !== undefined) out[key] = value
130
+ }
131
+ return out
132
+ }
133
+
134
+ const SPAN_KIND: Record<SpanKind, OtelSpanKind> = {
135
+ internal: OtelSpanKind.INTERNAL,
136
+ server: OtelSpanKind.SERVER,
137
+ client: OtelSpanKind.CLIENT,
138
+ producer: OtelSpanKind.PRODUCER,
139
+ consumer: OtelSpanKind.CONSUMER,
140
+ }
141
+
142
+ const SEVERITY: Record<LogLevel, SeverityNumber> = {
143
+ debug: SeverityNumber.DEBUG,
144
+ info: SeverityNumber.INFO,
145
+ warn: SeverityNumber.WARN,
146
+ error: SeverityNumber.ERROR,
147
+ }
148
+
149
+ /** Adapts an OTEL span to the vendor-neutral `Span` interface. */
150
+ class OtelSpan implements Span {
151
+ constructor(private readonly span: OtelApiSpan) {}
152
+ setAttribute(key: string, value: AttributeValue): void {
153
+ const redacted = cleanAttributes({ [key]: value })[key]
154
+ if (redacted !== undefined) this.span.setAttribute(key, redacted)
155
+ }
156
+ setAttributes(attributes: Attributes): void {
157
+ this.span.setAttributes(cleanAttributes(attributes))
158
+ }
159
+ recordException(error: unknown): void {
160
+ // Redact message + stack before they leave the process (Privacy): the auto
161
+ // record-on-throw path (run-span) and reportError both pass raw errors here.
162
+ this.span.recordException(serializeError(error))
163
+ }
164
+ setStatus(status: 'ok' | 'error', message?: string): void {
165
+ this.span.setStatus({
166
+ code: status === 'error' ? SpanStatusCode.ERROR : SpanStatusCode.OK,
167
+ message: message ? redactPii(message) : undefined,
168
+ })
169
+ }
170
+ end(): void {
171
+ this.span.end()
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Test seam: override the SDK's exporters/instrumentations so tests can use
177
+ * in-memory exporters (deterministic, no network) instead of the OTLP defaults.
178
+ * Production passes nothing — the OTLP defaults apply.
179
+ */
180
+ export type OtlpProviderOptions = {
181
+ spanProcessors?: SpanProcessor[]
182
+ logRecordProcessors?: LogRecordProcessor[]
183
+ metricReaders?: IMetricReader[]
184
+ instrumentations?: Instrumentation[]
185
+ }
186
+
187
+ /**
188
+ * PII guard (telemetry spec Privacy / R12). The single source of truth for pg
189
+ * instrumentation config: `enhancedDatabaseReporting: false` means bound SQL
190
+ * parameter VALUES are NOT captured — only the statement shape ($1, $2). User
191
+ * data lives in those params, so this must never be flipped on. Asserted by a
192
+ * regression test.
193
+ */
194
+ export const PG_INSTRUMENTATION_OPTIONS = { enhancedDatabaseReporting: false } as const
195
+
196
+ export class OtlpProvider implements TelemetryProvider {
197
+ /** The configured backend name (signoz | newrelic | otlp) — all OTLP, vendor differs only by endpoint. */
198
+ readonly name: string
199
+ readonly supports: readonly TelemetrySignal[] = ['traces', 'metrics', 'logs', 'errors']
200
+
201
+ private sdk: NodeSDK | undefined
202
+ private readonly instruments = new Map<string, Counter | Histogram | Gauge>()
203
+
204
+ constructor(private readonly options: OtlpProviderOptions = {}, name = 'otlp') {
205
+ this.name = name
206
+ }
207
+
208
+ async start(): Promise<void> {
209
+ if (this.sdk) return
210
+ const env = readTelemetryEnv()
211
+
212
+ this.sdk = new NodeSDK({
213
+ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: env.serviceName }),
214
+ // Global propagation roots requests by default. Both standard and backup
215
+ // inbound headers are honored only when TELEMETRY_TRUST_INBOUND_TRACE is
216
+ // explicitly enabled behind trusted infrastructure.
217
+ textMapPropagator: backupHeaderPropagator,
218
+ // Parent-based so child spans follow the trace's sampling decision; root
219
+ // spans sample at the configured ratio.
220
+ sampler: new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(env.samplingRatio) }),
221
+ spanProcessors: this.options.spanProcessors ?? [new BatchSpanProcessor(new OTLPTraceExporter())],
222
+ logRecordProcessors: this.options.logRecordProcessors ?? [
223
+ new BatchLogRecordProcessor({ exporter: new OTLPLogExporter() }),
224
+ ],
225
+ metricReaders: this.options.metricReaders ?? [
226
+ new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),
227
+ ],
228
+ instrumentations: this.options.instrumentations ?? [
229
+ new PgInstrumentation(PG_INSTRUMENTATION_OPTIONS),
230
+ new UndiciInstrumentation(),
231
+ ],
232
+ })
233
+ this.sdk.start()
234
+ }
235
+
236
+ async shutdown(): Promise<void> {
237
+ if (!this.sdk) return
238
+ await this.sdk.shutdown()
239
+ this.sdk = undefined
240
+ }
241
+
242
+ runInSpan<T>(name: string, options: SpanOptions, fn: (span: Span) => T): T {
243
+ const tracer = trace.getTracer(TRACER_NAME)
244
+ return tracer.startActiveSpan(
245
+ name,
246
+ { kind: options.kind ? SPAN_KIND[options.kind] : undefined, attributes: cleanAttributes(options.attributes) },
247
+ (otelSpan) => runSpan(new OtelSpan(otelSpan), fn),
248
+ )
249
+ }
250
+
251
+ activeSpan(): Span | undefined {
252
+ const active = trace.getActiveSpan()
253
+ return active ? new OtelSpan(active) : undefined
254
+ }
255
+
256
+ activeTraceContext(): TraceContext | undefined {
257
+ const ctx = trace.getActiveSpan()?.spanContext()
258
+ if (!ctx || !isSpanContextValid(ctx)) return undefined
259
+ return { traceId: ctx.traceId, spanId: ctx.spanId }
260
+ }
261
+
262
+ inject(carrier: TraceCarrier): void {
263
+ queuePropagator.inject(context.active(), carrier, defaultTextMapSetter)
264
+ }
265
+
266
+ runInRemoteSpan<T>(carrier: TraceCarrier, name: string, options: SpanOptions, fn: (span: Span) => T): T {
267
+ const parent = queuePropagator.extract(context.active(), carrier, defaultTextMapGetter)
268
+ const tracer = trace.getTracer(TRACER_NAME)
269
+ return context.with(parent, () =>
270
+ tracer.startActiveSpan(
271
+ name,
272
+ { kind: options.kind ? SPAN_KIND[options.kind] : undefined, attributes: cleanAttributes(options.attributes) },
273
+ (otelSpan) => runSpan(new OtelSpan(otelSpan), fn),
274
+ ),
275
+ )
276
+ }
277
+
278
+ emitLog(record: LogRecord): void {
279
+ const attributes: Record<string, AttributeValue> = cleanAttributes(record.attributes)
280
+ if (record.error) {
281
+ attributes['exception.type'] = redactPii(record.error.name)
282
+ attributes['exception.message'] = redactPii(record.error.message)
283
+ if (record.error.stack) attributes['exception.stacktrace'] = redactPii(record.error.stack)
284
+ }
285
+ logs.getLogger(TRACER_NAME).emit({
286
+ severityNumber: SEVERITY[record.level],
287
+ severityText: record.level,
288
+ body: redactPii(record.message),
289
+ attributes,
290
+ })
291
+ }
292
+
293
+ recordMetric(point: MetricPoint): void {
294
+ const labels = cleanAttributes(point.labels)
295
+ const instrument = this.getInstrument(point)
296
+ if (point.kind === 'counter') (instrument as Counter).add(point.value, labels)
297
+ else (instrument as Histogram | Gauge).record(point.value, labels)
298
+ }
299
+
300
+ private getInstrument(point: MetricPoint): Counter | Histogram | Gauge {
301
+ const key = `${point.kind}:${point.name}`
302
+ let instrument = this.instruments.get(key)
303
+ if (!instrument) {
304
+ const meter = metrics.getMeter(TRACER_NAME)
305
+ const opts = point.unit ? { unit: point.unit } : undefined
306
+ instrument =
307
+ point.kind === 'counter'
308
+ ? meter.createCounter(point.name, opts)
309
+ : point.kind === 'histogram'
310
+ ? meter.createHistogram(point.name, opts)
311
+ : meter.createGauge(point.name, opts)
312
+ this.instruments.set(key, instrument)
313
+ }
314
+ return instrument
315
+ }
316
+ }