@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +71 -0
- package/README.md +164 -0
- package/build.mjs +10 -0
- package/dist/env.js +44 -0
- package/dist/env.js.map +7 -0
- package/dist/facade/context.js +14 -0
- package/dist/facade/context.js.map +7 -0
- package/dist/facade/http.js +18 -0
- package/dist/facade/http.js.map +7 -0
- package/dist/facade/logger-bridge.js +40 -0
- package/dist/facade/logger-bridge.js.map +7 -0
- package/dist/facade/meter.js +16 -0
- package/dist/facade/meter.js.map +7 -0
- package/dist/facade/propagation.js +20 -0
- package/dist/facade/propagation.js.map +7 -0
- package/dist/facade/redact.js +21 -0
- package/dist/facade/redact.js.map +7 -0
- package/dist/facade/report-error.js +26 -0
- package/dist/facade/report-error.js.map +7 -0
- package/dist/facade/serialize.js +35 -0
- package/dist/facade/serialize.js.map +7 -0
- package/dist/facade/tracer.js +21 -0
- package/dist/facade/tracer.js.map +7 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +7 -0
- package/dist/init.js +98 -0
- package/dist/init.js.map +7 -0
- package/dist/nextjs-config.js +21 -0
- package/dist/nextjs-config.js.map +7 -0
- package/dist/nextjs.js +30 -0
- package/dist/nextjs.js.map +7 -0
- package/dist/provider/console-provider.js +70 -0
- package/dist/provider/console-provider.js.map +7 -0
- package/dist/provider/noop-provider.js +45 -0
- package/dist/provider/noop-provider.js.map +7 -0
- package/dist/provider/otlp-provider.js +212 -0
- package/dist/provider/otlp-provider.js.map +7 -0
- package/dist/provider/registry.js +40 -0
- package/dist/provider/registry.js.map +7 -0
- package/dist/provider/run-span.js +34 -0
- package/dist/provider/run-span.js.map +7 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +7 -0
- package/jest.config.cjs +27 -0
- package/package.json +68 -0
- package/src/__tests__/default-unloaded.test.ts +58 -0
- package/src/__tests__/env-load-order.test.ts +80 -0
- package/src/__tests__/env.test.ts +72 -0
- package/src/__tests__/logger-level.test.ts +64 -0
- package/src/__tests__/nextjs-shutdown.test.ts +71 -0
- package/src/__tests__/nextjs.test.ts +122 -0
- package/src/__tests__/otlp-integration.test.ts +264 -0
- package/src/__tests__/pg-instrumentation.test.ts +88 -0
- package/src/__tests__/pg-pii-config.test.ts +14 -0
- package/src/__tests__/redact.test.ts +92 -0
- package/src/__tests__/registry.test.ts +77 -0
- package/src/__tests__/run-span.test.ts +56 -0
- package/src/__tests__/serialize.test.ts +47 -0
- package/src/__tests__/telemetry.test.ts +172 -0
- package/src/env.ts +71 -0
- package/src/facade/context.ts +17 -0
- package/src/facade/http.ts +25 -0
- package/src/facade/logger-bridge.ts +50 -0
- package/src/facade/meter.ts +20 -0
- package/src/facade/propagation.ts +31 -0
- package/src/facade/redact.ts +54 -0
- package/src/facade/report-error.ts +41 -0
- package/src/facade/serialize.ts +46 -0
- package/src/facade/tracer.ts +25 -0
- package/src/index.ts +30 -0
- package/src/init.ts +134 -0
- package/src/nextjs-config.ts +22 -0
- package/src/nextjs.ts +47 -0
- package/src/provider/console-provider.ts +87 -0
- package/src/provider/noop-provider.ts +53 -0
- package/src/provider/otlp-provider.ts +316 -0
- package/src/provider/registry.ts +60 -0
- package/src/provider/run-span.ts +45 -0
- package/src/types.ts +108 -0
- package/tsconfig.json +9 -0
- 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'
|