@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
@@ -0,0 +1,172 @@
1
+ import { withSpan, currentSpan, counter, reportError, captureTraceContext, continueTrace } from '../index'
2
+ import {
3
+ createLogger,
4
+ resetLoggerExtension,
5
+ resetLoggerRegistry,
6
+ } from '@open-mercato/shared/lib/logger'
7
+ import { resetTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
8
+ import { initTelemetry, resetTelemetryInit } from '../init'
9
+ import { registerProvider, resetActiveProvider } from '../provider/registry'
10
+ import { resetTelemetryEnvCache } from '../env'
11
+ import { runSpan } from '../provider/run-span'
12
+ import type { LogRecord, MetricPoint, Span, SpanOptions, TelemetryProvider, TraceCarrier } from '../types'
13
+
14
+ class RecordingSpan implements Span {
15
+ attributes: Record<string, unknown> = {}
16
+ exceptions: unknown[] = []
17
+ status: 'ok' | 'error' = 'ok'
18
+ ended = false
19
+ setAttribute(key: string, value: string | number | boolean): void {
20
+ this.attributes[key] = value
21
+ }
22
+ setAttributes(attributes: Record<string, string | number | boolean | undefined>): void {
23
+ Object.assign(this.attributes, attributes)
24
+ }
25
+ recordException(error: unknown): void {
26
+ this.exceptions.push(error)
27
+ }
28
+ setStatus(status: 'ok' | 'error'): void {
29
+ this.status = status
30
+ }
31
+ end(): void {
32
+ this.ended = true
33
+ }
34
+ }
35
+
36
+ function recordingProvider() {
37
+ const span = new RecordingSpan()
38
+ const logs: LogRecord[] = []
39
+ const metrics: MetricPoint[] = []
40
+ const spanNames: string[] = []
41
+ const remoteCarriers: TraceCarrier[] = []
42
+ const provider: TelemetryProvider = {
43
+ name: 'console',
44
+ supports: ['traces', 'metrics', 'logs', 'errors'],
45
+ async start() {},
46
+ async shutdown() {},
47
+ runInSpan<T>(name: string, _options: SpanOptions, fn: (s: Span) => T): T {
48
+ spanNames.push(name)
49
+ return runSpan(span, fn)
50
+ },
51
+ activeSpan: () => span,
52
+ activeTraceContext: () => ({ traceId: 'a'.repeat(32), spanId: 'b'.repeat(16) }),
53
+ inject: (carrier) => {
54
+ carrier.traceparent = 'test-traceparent'
55
+ },
56
+ runInRemoteSpan<T>(carrier: TraceCarrier, name: string, _options: SpanOptions, fn: (s: Span) => T): T {
57
+ remoteCarriers.push(carrier)
58
+ spanNames.push(name)
59
+ return runSpan(span, fn)
60
+ },
61
+ emitLog: (record) => logs.push(record),
62
+ recordMetric: (point) => metrics.push(point),
63
+ }
64
+ return { provider, span, logs, metrics, spanNames, remoteCarriers }
65
+ }
66
+
67
+ describe('telemetry facade', () => {
68
+ beforeEach(() => {
69
+ resetActiveProvider()
70
+ resetTelemetryInit()
71
+ resetTelemetryEnvCache()
72
+ resetLoggerExtension()
73
+ resetLoggerRegistry()
74
+ resetTelemetryRuntime()
75
+ delete process.env.TELEMETRY_BACKEND
76
+ })
77
+
78
+ it('is a safe no-op when telemetry is off (default)', () => {
79
+ expect(withSpan('work', () => 42)).toBe(42)
80
+ expect(currentSpan()).toBeUndefined()
81
+ expect(() => {
82
+ createLogger('test').info('hello')
83
+ counter('demo')
84
+ reportError(new Error('boom'))
85
+ }).not.toThrow()
86
+ // Propagation helpers are safe with no backend: empty carrier, fn still runs.
87
+ expect(captureTraceContext()).toEqual({})
88
+ expect(continueTrace({}, 'job', () => 5)).toBe(5)
89
+ })
90
+
91
+ it('runs the function and returns its value inside a span', () => {
92
+ const seen: Span[] = []
93
+ const result = withSpan('compute', (span) => {
94
+ seen.push(span)
95
+ return 7
96
+ })
97
+ expect(result).toBe(7)
98
+ expect(seen).toHaveLength(1)
99
+ })
100
+
101
+ it('awaits async work before the span ends', async () => {
102
+ const { provider, span } = recordingProvider()
103
+ process.env.TELEMETRY_BACKEND = 'console'
104
+ registerProvider(provider)
105
+ await initTelemetry()
106
+
107
+ let asyncWorkDone = false
108
+ const pending = withSpan('tutor.turn', async (s) => {
109
+ s.setAttributes({ subject: 'math' })
110
+ await new Promise((resolve) => setTimeout(resolve, 5))
111
+ asyncWorkDone = true
112
+ })
113
+ expect(span.ended).toBe(false)
114
+ await pending
115
+ expect(asyncWorkDone).toBe(true)
116
+ expect(span.ended).toBe(true)
117
+ expect(span.attributes.subject).toBe('math')
118
+ })
119
+
120
+ it('records a span, exception, error status, error log, and om.errors metric', async () => {
121
+ const { provider, span, logs, metrics, spanNames } = recordingProvider()
122
+ process.env.TELEMETRY_BACKEND = 'console'
123
+ registerProvider(provider)
124
+ await initTelemetry()
125
+
126
+ withSpan('work.unit', (s) => s.setAttributes({ subject: 'math' }))
127
+ reportError(new Error('kaboom'), { module: 'orders' })
128
+
129
+ expect(spanNames).toContain('work.unit')
130
+ expect(span.attributes.subject).toBe('math')
131
+ expect(span.exceptions).toHaveLength(1)
132
+ expect(span.status).toBe('error')
133
+ expect(metrics.find((m) => m.name === 'om.errors')?.labels).toEqual({ module: 'orders' })
134
+ expect(logs.some((l) => l.level === 'error' && l.error?.message === 'kaboom')).toBe(true)
135
+ })
136
+
137
+ it('propagates trace context: captureTraceContext injects, continueTrace consumes it', async () => {
138
+ const { provider, remoteCarriers, spanNames } = recordingProvider()
139
+ process.env.TELEMETRY_BACKEND = 'console'
140
+ registerProvider(provider)
141
+ await initTelemetry()
142
+
143
+ // Producer side (e.g. enqueue): capture the active context into a carrier.
144
+ const carrier = captureTraceContext()
145
+ expect(carrier.traceparent).toBe('test-traceparent')
146
+
147
+ // Consumer side (e.g. worker): continue the trace under the carrier's parent.
148
+ const result = await continueTrace(carrier, 'queue.orders-process', async () => 'done')
149
+ expect(result).toBe('done')
150
+ expect(spanNames).toContain('queue.orders-process')
151
+ expect(remoteCarriers[0]?.traceparent).toBe('test-traceparent')
152
+ })
153
+
154
+ it('redacts secret-keyed attributes in the reported error context before they ship', async () => {
155
+ const { provider, logs } = recordingProvider()
156
+ process.env.TELEMETRY_BACKEND = 'console'
157
+ registerProvider(provider)
158
+ await initTelemetry()
159
+
160
+ reportError(new Error('request failed'), {
161
+ module: 'integrations',
162
+ attributes: { authorization: 'Bearer sk_live_abc', 'http.route': '/api/sync', 'x-api-key': 'key_123' },
163
+ })
164
+
165
+ const record = logs.find((l) => l.level === 'error')
166
+ expect(record?.attributes?.authorization).toBe('[redacted]')
167
+ expect(record?.attributes?.['x-api-key']).toBe('[redacted]')
168
+ // low-cardinality, non-secret attributes are preserved
169
+ expect(record?.attributes?.['http.route']).toBe('/api/sync')
170
+ expect(record?.attributes?.module).toBe('integrations')
171
+ })
172
+ })
package/src/env.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { TelemetryBackendName } from './types'
2
+ import { isTelemetryBackendEnabled } from '@open-mercato/shared/lib/telemetry/runtime'
3
+
4
+ /** Backend names that resolve to the OTLP provider (vendor differs only by endpoint). */
5
+ const OTLP_BACKENDS: readonly TelemetryBackendName[] = ['signoz', 'newrelic', 'otlp']
6
+ export function isOtlpBackend(name: TelemetryBackendName): boolean {
7
+ return (OTLP_BACKENDS as readonly string[]).includes(name)
8
+ }
9
+
10
+ export type TelemetryEnv = {
11
+ /** Active backend. Unset/unknown → 'noop' (off). */
12
+ backend: TelemetryBackendName
13
+ /** True unless backend is 'noop'. */
14
+ enabled: boolean
15
+ /** 0.0–1.0 trace sampling ratio. */
16
+ samplingRatio: number
17
+ /**
18
+ * Continue the inbound HTTP trace (standard W3C extract) instead of rooting
19
+ * per request. Default false. Set true only when embedded behind a trusted
20
+ * upstream whose trace should continue.
21
+ */
22
+ trustInboundTrace: boolean
23
+ serviceName: string
24
+ /** OTLP endpoint (provider reads standard OTEL vars; surfaced here for diagnostics). */
25
+ otlpEndpoint?: string
26
+ }
27
+
28
+ function parseBackend(raw: string | undefined): TelemetryBackendName {
29
+ const v = (raw ?? '').trim().toLowerCase()
30
+ return isTelemetryBackendEnabled(v) ? (v as TelemetryBackendName) : 'noop'
31
+ }
32
+
33
+ function parseSampling(raw: string | undefined, isProd: boolean): number {
34
+ const n = Number.parseFloat((raw ?? '').trim())
35
+ if (Number.isFinite(n) && n >= 0 && n <= 1) return n
36
+ return isProd ? 0.1 : 1.0
37
+ }
38
+
39
+ /** Parse a boolean env flag; unset/unknown → `fallback`. */
40
+ function parseBool(raw: string | undefined, fallback: boolean): boolean {
41
+ const v = (raw ?? '').trim().toLowerCase()
42
+ if (v === 'true' || v === '1') return true
43
+ if (v === 'false' || v === '0') return false
44
+ return fallback
45
+ }
46
+
47
+ let cached: TelemetryEnv | undefined
48
+
49
+ export function readTelemetryEnv(): TelemetryEnv {
50
+ if (cached) return cached
51
+ const isProd = process.env.NODE_ENV === 'production'
52
+ const backend = parseBackend(process.env.TELEMETRY_BACKEND)
53
+ cached = {
54
+ backend,
55
+ enabled: backend !== 'noop',
56
+ samplingRatio: parseSampling(process.env.TELEMETRY_SAMPLING_RATIO, isProd),
57
+ trustInboundTrace: parseBool(process.env.TELEMETRY_TRUST_INBOUND_TRACE, false),
58
+ serviceName: process.env.OTEL_SERVICE_NAME?.trim() || 'open-mercato',
59
+ otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() || undefined,
60
+ }
61
+ return cached
62
+ }
63
+
64
+ /**
65
+ * Reset the memoized env snapshot. Called by `initTelemetry()` so init always
66
+ * resolves from the fully-loaded environment (a host may import this package
67
+ * before its `.env` is loaded — the CLI binary does), and by jest between tests.
68
+ */
69
+ export function resetTelemetryEnvCache(): void {
70
+ cached = undefined
71
+ }
@@ -0,0 +1,17 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { Span } from '../types'
3
+
4
+ /**
5
+ * Carries the facade's active span across `await` boundaries for spans created
6
+ * via `withSpan`. (OTEL auto-instrumentation spans live in OTEL's own context;
7
+ * the OTLP provider's `activeSpan()` bridges to those — see `currentSpan`.)
8
+ */
9
+ export const spanStore = new AsyncLocalStorage<Span>()
10
+
11
+ export function runWithSpan<T>(span: Span, fn: () => T): T {
12
+ return spanStore.run(span, fn)
13
+ }
14
+
15
+ export function spanFromStore(): Span | undefined {
16
+ return spanStore.getStore()
17
+ }
@@ -0,0 +1,25 @@
1
+ import { histogram } from './meter'
2
+
3
+ /**
4
+ * Emit the OpenTelemetry-standard HTTP server metric
5
+ * `http.server.request.duration` (histogram, seconds) with semconv attributes.
6
+ * `route` MUST be a low-cardinality route template, never a resolved pathname.
7
+ */
8
+ export function recordHttpDuration(
9
+ method: string,
10
+ route: string,
11
+ status: number,
12
+ startedAt: number,
13
+ ): void {
14
+ histogram(
15
+ 'http.server.request.duration',
16
+ (Date.now() - startedAt) / 1000,
17
+ {
18
+ 'http.request.method': method,
19
+ 'http.route': route,
20
+ 'http.response.status_code': status,
21
+ 'error.type': status >= 500 ? String(status) : undefined,
22
+ },
23
+ 's',
24
+ )
25
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ registerLoggerExtension,
3
+ type LoggerExtensionRecord,
4
+ } from '@open-mercato/shared/lib/logger'
5
+ import type { Attributes, LogRecord, TelemetryProvider } from '../types'
6
+ import { redactAttributes } from './redact'
7
+ import { serializeError } from './serialize'
8
+
9
+ function toAttributes(record: LoggerExtensionRecord): Attributes {
10
+ const attributes: Attributes = {
11
+ 'logger.name': record.namespace,
12
+ }
13
+ for (const [key, value] of Object.entries(record.fields)) {
14
+ if (key === 'err' || value === undefined) continue
15
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
16
+ attributes[key] = value
17
+ }
18
+ }
19
+ return redactAttributes(attributes)
20
+ }
21
+
22
+ function toLogRecord(record: LoggerExtensionRecord): LogRecord {
23
+ const error = record.fields.err === undefined
24
+ ? undefined
25
+ : serializeError(record.fields.err)
26
+ return {
27
+ level: record.level,
28
+ message: record.message,
29
+ attributes: toAttributes(record),
30
+ error,
31
+ time: record.time,
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Extend the canonical shared logger with trace correlation + remote export.
37
+ * The shared logger remains the sole application-facing logger and sole local
38
+ * output path; telemetry observes the already-normalized record exactly once.
39
+ */
40
+ export function registerTelemetryLogger(provider: TelemetryProvider): () => void {
41
+ return registerLoggerExtension({
42
+ enrich: () => {
43
+ const context = provider.activeTraceContext()
44
+ return context
45
+ ? { trace_id: context.traceId, span_id: context.spanId }
46
+ : undefined
47
+ },
48
+ emit: (record) => provider.emitLog(toLogRecord(record)),
49
+ })
50
+ }
@@ -0,0 +1,20 @@
1
+ import type { Attributes } from '../types'
2
+ import { getActiveProvider } from '../provider/registry'
3
+
4
+ /**
5
+ * Metric helpers. Labels MUST be low-cardinality (never tenant/org/user IDs —
6
+ * those belong on span attributes, per the telemetry spec's R4). The active
7
+ * provider caches the underlying instrument by name; these are no-ops when
8
+ * metrics are off.
9
+ */
10
+ export function counter(name: string, value = 1, labels?: Attributes, unit?: string): void {
11
+ getActiveProvider().recordMetric({ kind: 'counter', name, value, labels, unit })
12
+ }
13
+
14
+ export function histogram(name: string, value: number, labels?: Attributes, unit?: string): void {
15
+ getActiveProvider().recordMetric({ kind: 'histogram', name, value, labels, unit })
16
+ }
17
+
18
+ export function gauge(name: string, value: number, labels?: Attributes, unit?: string): void {
19
+ getActiveProvider().recordMetric({ kind: 'gauge', name, value, labels, unit })
20
+ }
@@ -0,0 +1,31 @@
1
+ import type { Span, SpanOptions, TraceCarrier } from '../types'
2
+ import { getActiveProvider } from '../provider/registry'
3
+ import { runWithSpan } from './context'
4
+
5
+ /**
6
+ * Capture the active trace context into a fresh carrier, to embed in a queue job
7
+ * or event payload (the queue/event bus expose no metadata channel — see the
8
+ * telemetry spec's S5). Returns `{}` when telemetry is off.
9
+ */
10
+ export function captureTraceContext(): TraceCarrier {
11
+ const carrier: TraceCarrier = {}
12
+ getActiveProvider().inject(carrier)
13
+ return carrier
14
+ }
15
+
16
+ /**
17
+ * Continue a trace on the consumer side (worker / subscriber): run `fn` inside a
18
+ * new active span named `name`, parented to the context in `carrier`. The job's
19
+ * spans (pg, etc.) then nest under the producer's trace as one waterfall. If
20
+ * `carrier` is empty/undefined the span simply starts a fresh root.
21
+ */
22
+ export function continueTrace<T>(
23
+ carrier: TraceCarrier | undefined,
24
+ name: string,
25
+ fn: (span: Span) => T,
26
+ options?: SpanOptions,
27
+ ): T {
28
+ return getActiveProvider().runInRemoteSpan(carrier ?? {}, name, options ?? {}, (span) =>
29
+ runWithSpan(span, () => fn(span)),
30
+ )
31
+ }
@@ -0,0 +1,54 @@
1
+ import type { Attributes } from '../types'
2
+
3
+ /**
4
+ * Active redaction backstop for telemetry (Privacy section of the telemetry spec).
5
+ *
6
+ * Defense-in-depth over the don't-emit posture: callers are expected not to put
7
+ * PII or secrets into errors/logs, but error messages and stack frames can still
8
+ * pick them up a layer down (e.g. `Error: no user for jan.kowalski@example.com`,
9
+ * or a dumped `Authorization: Bearer …` header). This scrubs the highest-signal
10
+ * identifiers — email addresses and auth tokens — from any text that ships to the
11
+ * backend, without touching opaque UUIDs/ids (which we keep).
12
+ *
13
+ * Deliberately conservative (emails + auth tokens) to preserve debuggability;
14
+ * extend the pattern set here if a new leak vector is found.
15
+ */
16
+ // Bound every component both to match the practical limits from RFC 5321 and
17
+ // to keep scanning time linear for hostile strings with long runs of email
18
+ // punctuation (CodeQL js/polynomial-redos).
19
+ const EMAIL_RE = /[A-Z0-9._%+-]{1,64}@[A-Z0-9.-]{1,253}\.[A-Z]{2,63}/gi
20
+ // `authorization: <value>` / `cookie: <value>` header dumps embedded in free text.
21
+ // The value runs to the next separator (`;`/`,`/`}`) or newline, so it swallows a
22
+ // `Bearer <token>` value in one pass (before the standalone-scheme rule below).
23
+ const SECRET_HEADER_RE = /\b(authorization|proxy-authorization|cookie|set-cookie)(\s*[:=]\s*)([^\n;,}]+)/gi
24
+ // `Bearer <token>` / `Basic <base64>` / `ApiKey <key>` standing alone in free text (message or stack).
25
+ const AUTH_SCHEME_RE = /\b(Bearer|Basic|ApiKey)\s+[A-Za-z0-9._~+/=-]+/gi
26
+
27
+ export function redactPii(text: string): string {
28
+ return text
29
+ .replace(EMAIL_RE, '[redacted-email]')
30
+ .replace(SECRET_HEADER_RE, (_match, name: string, separator: string) => `${name}${separator}[redacted]`)
31
+ .replace(AUTH_SCHEME_RE, (_match, scheme: string) => `${scheme} [redacted]`)
32
+ }
33
+
34
+ // Attribute KEYS whose value is a secret regardless of content. Intentionally
35
+ // specific enough not to clobber benign fields like `token_count`, while
36
+ // still masking the exact key `token`.
37
+ const SECRET_KEY_RE =
38
+ /(authorization|cookie|password|passwd|\bpwd\b|secret|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|auth[-_]?token|^token$|\bbearer\b|private[-_]?key|credential)/i
39
+
40
+ /**
41
+ * Redact a telemetry attribute bag before it ships: a value under a secret-looking
42
+ * KEY (`authorization`, `set-cookie`, `client_secret`, `x-api-key`, …) is masked
43
+ * wholesale; other string values still pass through `redactPii` so an inline email
44
+ * or auth token is caught too. Non-string values are left as-is.
45
+ */
46
+ export function redactAttributes(attributes: Attributes): Attributes {
47
+ const result: Attributes = {}
48
+ for (const [key, value] of Object.entries(attributes)) {
49
+ if (SECRET_KEY_RE.test(key)) result[key] = '[redacted]'
50
+ else if (typeof value === 'string') result[key] = redactPii(value)
51
+ else result[key] = value
52
+ }
53
+ return result
54
+ }
@@ -0,0 +1,41 @@
1
+ import type { Attributes } from '../types'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import { currentSpan } from './tracer'
4
+ import { serializeError } from './serialize'
5
+ import { counter } from './meter'
6
+ import { redactAttributes } from './redact'
7
+
8
+ const logger = createLogger('telemetry')
9
+
10
+ export type ReportErrorContext = {
11
+ /** Owning module, e.g. 'orders'. Used as the only metric label. */
12
+ module?: string
13
+ /** Low-cardinality, NO-PII attributes (ids ok, never names/content). */
14
+ attributes?: Attributes
15
+ }
16
+
17
+ /**
18
+ * The error funnel. Additive — existing `console.error` calls stay valid; this
19
+ * is the path that reaches the active backend. It:
20
+ * 1. records the exception on the active span (errors-as-span-events),
21
+ * 2. emits a structured error log (stack only — no PII payloads),
22
+ * 3. increments the `om.errors` counter, labeled by `module` only.
23
+ */
24
+ export function reportError(error: unknown, ctx?: ReportErrorContext): void {
25
+ const span = currentSpan()
26
+ if (span) {
27
+ span.recordException(error)
28
+ span.setStatus('error')
29
+ }
30
+
31
+ const serialized = serializeError(error)
32
+ const attributes: Attributes = { ...(ctx?.attributes ?? {}) }
33
+ if (ctx?.module) attributes.module = ctx.module
34
+ const safeAttributes = redactAttributes(attributes)
35
+
36
+ const safeError = new Error(serialized.message)
37
+ safeError.name = serialized.name
38
+ safeError.stack = serialized.stack
39
+ logger.error('Application error reported', { ...safeAttributes, err: safeError })
40
+ counter('om.errors', 1, ctx?.module ? { module: ctx.module } : undefined)
41
+ }
@@ -0,0 +1,46 @@
1
+ import type { LogRecord } from '../types'
2
+ import { redactPii } from './redact'
3
+
4
+ /**
5
+ * Serialize an error for telemetry: name, message, and stack only.
6
+ *
7
+ * Deliberately does NOT include arbitrary error properties (request bodies,
8
+ * `cause` payloads, query parameters) — those can carry PII. The cause CHAIN is
9
+ * folded into the message text (names/messages only), never its data. Message +
10
+ * stack are run through `redactPii` so a leaked email never reaches the backend
11
+ * (active-redaction backstop over the don't-emit posture).
12
+ */
13
+ export function serializeError(error: unknown): NonNullable<LogRecord['error']> {
14
+ if (error instanceof Error) {
15
+ return {
16
+ name: redactPii(error.name || 'Error'),
17
+ message: redactPii(foldCause(error)),
18
+ stack: error.stack ? redactPii(error.stack) : undefined,
19
+ }
20
+ }
21
+ return { name: 'NonError', message: redactPii(safeString(error)) }
22
+ }
23
+
24
+ function foldCause(error: Error, seen = new WeakSet<object>()): string {
25
+ if (seen.has(error)) return error.message
26
+ seen.add(error)
27
+ const cause = (error as { cause?: unknown }).cause
28
+ if (cause instanceof Error) {
29
+ return `${error.message} — caused by ${cause.name || 'Error'}: ${foldCause(cause, seen)}`
30
+ }
31
+ return error.message
32
+ }
33
+
34
+ function safeString(value: unknown): string {
35
+ if (typeof value === 'string') return value
36
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
37
+ return String(value)
38
+ }
39
+ if (typeof value === 'symbol') return value.description ? `Symbol(${value.description})` : 'Symbol'
40
+ if (typeof value === 'function') return '[function]'
41
+ if (value === null) return 'null'
42
+ if (value === undefined) return 'undefined'
43
+ // Never stringify arbitrary objects: request bodies, provider errors, and
44
+ // thrown payloads commonly contain credentials or personal data.
45
+ return '[non-error object]'
46
+ }
@@ -0,0 +1,25 @@
1
+ import type { Attributes, Span, SpanOptions } from '../types'
2
+ import { getActiveProvider } from '../provider/registry'
3
+ import { runWithSpan, spanFromStore } from './context'
4
+
5
+ /**
6
+ * Run `fn` inside a new span named `name`. The active provider owns span
7
+ * creation + context, so any OTEL auto-instrumentation (pg/http) inside `fn`
8
+ * nests under this span in the same trace. Exceptions and duration are recorded
9
+ * automatically by the provider. No-op-cheap when telemetry is off.
10
+ */
11
+ export function withSpan<T>(name: string, fn: (span: Span) => T, options?: SpanOptions): T {
12
+ return getActiveProvider().runInSpan(name, options ?? {}, (span) =>
13
+ runWithSpan(span, () => fn(span)),
14
+ )
15
+ }
16
+
17
+ /** The active span (facade-created or OTEL auto-instrumented), if any. */
18
+ export function currentSpan(): Span | undefined {
19
+ return spanFromStore() ?? getActiveProvider().activeSpan()
20
+ }
21
+
22
+ /** Set attributes on the active span, if any. */
23
+ export function setAttributes(attributes: Attributes): void {
24
+ currentSpan()?.setAttributes(attributes)
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Public facade for the telemetry package. Import from `@open-mercato/telemetry`.
3
+ *
4
+ * Vendor-neutral by construction — nothing here imports `@opentelemetry/*`;
5
+ * only `provider/otlp-provider.ts` does, and it is loaded dynamically.
6
+ */
7
+ export { withSpan, currentSpan, setAttributes } from './facade/tracer'
8
+ export { counter, histogram, gauge } from './facade/meter'
9
+ export { reportError } from './facade/report-error'
10
+ export type { ReportErrorContext } from './facade/report-error'
11
+ export { captureTraceContext, continueTrace } from './facade/propagation'
12
+ export { initTelemetry, shutdownTelemetry } from './init'
13
+ export { registerProvider } from './provider/registry'
14
+
15
+ export type {
16
+ Span,
17
+ SpanKind,
18
+ SpanOptions,
19
+ TelemetryProvider,
20
+ TelemetrySignal,
21
+ TelemetryBackendName,
22
+ LogRecord,
23
+ LogLevel,
24
+ MetricKind,
25
+ MetricPoint,
26
+ Attributes,
27
+ AttributeValue,
28
+ TraceCarrier,
29
+ TraceContext,
30
+ } from './types'