@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,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real OtlpProvider wired to in-memory OTEL exporters (no network,
|
|
3
|
+
* deterministic). Proves the OTEL-specific behaviour our design relies on:
|
|
4
|
+
* span shape, the delegation/nesting model, cross-boundary trace propagation
|
|
5
|
+
* (same traceId), root-per-request, log correlation, and metrics.
|
|
6
|
+
*/
|
|
7
|
+
import { SpanStatusCode, propagation, trace, context, ROOT_CONTEXT } from '@opentelemetry/api'
|
|
8
|
+
import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'
|
|
9
|
+
import { InMemoryLogRecordExporter, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs'
|
|
10
|
+
import { InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from '@opentelemetry/sdk-metrics'
|
|
11
|
+
|
|
12
|
+
import { OtlpProvider } from '../provider/otlp-provider'
|
|
13
|
+
import {
|
|
14
|
+
createLogger,
|
|
15
|
+
resetLoggerExtension,
|
|
16
|
+
resetLoggerRegistry,
|
|
17
|
+
} from '@open-mercato/shared/lib/logger'
|
|
18
|
+
import { setActiveProvider, resetActiveProvider } from '../provider/registry'
|
|
19
|
+
import { resetTelemetryEnvCache } from '../env'
|
|
20
|
+
import { registerTelemetryLogger } from '../facade/logger-bridge'
|
|
21
|
+
import { withSpan, captureTraceContext, continueTrace, reportError, counter } from '../index'
|
|
22
|
+
|
|
23
|
+
const spanExporter = new InMemorySpanExporter()
|
|
24
|
+
const logExporter = new InMemoryLogRecordExporter()
|
|
25
|
+
const metricExporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE)
|
|
26
|
+
const metricReader = new PeriodicExportingMetricReader({ exporter: metricExporter })
|
|
27
|
+
|
|
28
|
+
let provider: OtlpProvider
|
|
29
|
+
let disposeLogger: (() => void) | undefined
|
|
30
|
+
const logger = createLogger('telemetry-test')
|
|
31
|
+
|
|
32
|
+
function parentSpanId(span: ReadableSpan): string | undefined {
|
|
33
|
+
const s = span as { parentSpanContext?: { spanId?: string }; parentSpanId?: string }
|
|
34
|
+
return s.parentSpanContext?.spanId ?? s.parentSpanId
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
process.env.TELEMETRY_BACKEND = 'otlp'
|
|
39
|
+
resetTelemetryEnvCache()
|
|
40
|
+
provider = new OtlpProvider({
|
|
41
|
+
spanProcessors: [new SimpleSpanProcessor(spanExporter)],
|
|
42
|
+
logRecordProcessors: [new SimpleLogRecordProcessor({ exporter: logExporter })],
|
|
43
|
+
metricReaders: [metricReader],
|
|
44
|
+
instrumentations: [], // pure facade behaviour — pg/undici patching is OTEL's own code
|
|
45
|
+
})
|
|
46
|
+
await provider.start()
|
|
47
|
+
setActiveProvider(provider)
|
|
48
|
+
disposeLogger = registerTelemetryLogger(provider)
|
|
49
|
+
// The first span after NodeSDK.start() can be dropped while the global tracer
|
|
50
|
+
// provider finishes installing — warm it up, settle a tick, then reset.
|
|
51
|
+
withSpan('warmup', () => undefined)
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
53
|
+
spanExporter.reset()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
spanExporter.reset()
|
|
58
|
+
logExporter.reset()
|
|
59
|
+
metricExporter.reset()
|
|
60
|
+
delete process.env.TELEMETRY_TRUST_INBOUND_TRACE
|
|
61
|
+
resetTelemetryEnvCache()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
await provider.shutdown()
|
|
66
|
+
disposeLogger?.()
|
|
67
|
+
resetLoggerExtension()
|
|
68
|
+
resetLoggerRegistry()
|
|
69
|
+
resetActiveProvider()
|
|
70
|
+
delete process.env.TELEMETRY_BACKEND
|
|
71
|
+
resetTelemetryEnvCache()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('OtlpProvider (in-memory exporters)', () => {
|
|
75
|
+
it('records span name, attributes (dropping undefined), and ok status', () => {
|
|
76
|
+
withSpan('op', (s) => s.setAttribute('x', 1), { attributes: { a: 'b', skip: undefined } })
|
|
77
|
+
const span = spanExporter.getFinishedSpans().find((s) => s.name === 'op')
|
|
78
|
+
expect(span).toBeDefined()
|
|
79
|
+
expect(span?.attributes.a).toBe('b')
|
|
80
|
+
expect(span?.attributes.x).toBe(1)
|
|
81
|
+
expect(span?.attributes).not.toHaveProperty('skip')
|
|
82
|
+
expect(span?.status.code).toBe(SpanStatusCode.UNSET)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('records exceptions + error status on a span', () => {
|
|
86
|
+
withSpan('boom', (s) => {
|
|
87
|
+
s.recordException(new Error('kaboom'))
|
|
88
|
+
s.setStatus('error', 'failed')
|
|
89
|
+
})
|
|
90
|
+
const span = spanExporter.getFinishedSpans().find((s) => s.name === 'boom')
|
|
91
|
+
expect(span?.status.code).toBe(SpanStatusCode.ERROR)
|
|
92
|
+
expect(span?.events.some((e) => e.name === 'exception')).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('nests child spans under the parent (delegation model)', () => {
|
|
96
|
+
withSpan('parent', () => withSpan('child', () => undefined))
|
|
97
|
+
const spans = spanExporter.getFinishedSpans()
|
|
98
|
+
const parent = spans.find((s) => s.name === 'parent')
|
|
99
|
+
const child = spans.find((s) => s.name === 'child')
|
|
100
|
+
expect(parent && child).toBeTruthy()
|
|
101
|
+
expect(child?.spanContext().traceId).toBe(parent?.spanContext().traceId)
|
|
102
|
+
expect(parentSpanId(child as ReadableSpan)).toBe(parent?.spanContext().spanId)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('inject() writes a valid W3C traceparent inside an active span', () => {
|
|
106
|
+
let carrier: Record<string, string> = {}
|
|
107
|
+
withSpan('producer', () => {
|
|
108
|
+
carrier = captureTraceContext()
|
|
109
|
+
})
|
|
110
|
+
expect(carrier.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('propagates trace context across a boundary: consumer shares the producer traceId', () => {
|
|
114
|
+
let carrier: Record<string, string> = {}
|
|
115
|
+
withSpan('producer', () => {
|
|
116
|
+
carrier = captureTraceContext()
|
|
117
|
+
})
|
|
118
|
+
const producer = spanExporter.getFinishedSpans().find((s) => s.name === 'producer')
|
|
119
|
+
const producerTraceId = producer?.spanContext().traceId ?? ''
|
|
120
|
+
const producerSpanId = producer?.spanContext().spanId ?? ''
|
|
121
|
+
|
|
122
|
+
continueTrace(carrier, 'consumer', () => undefined)
|
|
123
|
+
const consumer = spanExporter.getFinishedSpans().find((s) => s.name === 'consumer')
|
|
124
|
+
|
|
125
|
+
expect(producerTraceId).toMatch(/^[0-9a-f]{32}$/)
|
|
126
|
+
expect(consumer?.spanContext().traceId).toBe(producerTraceId)
|
|
127
|
+
expect(parentSpanId(consumer as ReadableSpan)).toBe(producerSpanId)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('roots per request: the global propagator drops inbound HTTP context while the queue carrier still propagates', () => {
|
|
131
|
+
// A carrier as if a load balancer injected an upstream traceparent.
|
|
132
|
+
let carrier: Record<string, string> = {}
|
|
133
|
+
withSpan('upstream', () => {
|
|
134
|
+
carrier = captureTraceContext()
|
|
135
|
+
})
|
|
136
|
+
const upstreamTraceId = carrier.traceparent.split('-')[1]
|
|
137
|
+
|
|
138
|
+
// Incoming HTTP uses the GLOBAL propagator — it must IGNORE the inbound
|
|
139
|
+
// context so the request span becomes a fresh root (not a child of the LB).
|
|
140
|
+
const extracted = propagation.extract(ROOT_CONTEXT, carrier)
|
|
141
|
+
expect(trace.getSpanContext(extracted)).toBeUndefined()
|
|
142
|
+
|
|
143
|
+
// Queue path uses the dedicated propagator — it must STILL honor the carrier.
|
|
144
|
+
continueTrace(carrier, 'consumer', () => undefined)
|
|
145
|
+
const consumer = spanExporter.getFinishedSpans().find((s) => s.name === 'consumer')
|
|
146
|
+
expect(consumer?.spanContext().traceId).toBe(upstreamTraceId)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('global inject writes a backup x-original-traceparent that mirrors traceparent', () => {
|
|
150
|
+
const carrier: Record<string, string> = {}
|
|
151
|
+
withSpan('producer', () => {
|
|
152
|
+
propagation.inject(context.active(), carrier)
|
|
153
|
+
})
|
|
154
|
+
expect(carrier.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/)
|
|
155
|
+
expect(carrier['x-original-traceparent']).toBe(carrier.traceparent)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('global extract rejects a caller-supplied backup header by default', () => {
|
|
159
|
+
const carrier: Record<string, string> = {}
|
|
160
|
+
withSpan('producer', () => {
|
|
161
|
+
propagation.inject(context.active(), carrier)
|
|
162
|
+
})
|
|
163
|
+
// Simulate the load balancer rewriting `traceparent` to its own (unexported) span.
|
|
164
|
+
carrier.traceparent = `00-${'a'.repeat(32)}-${'b'.repeat(16)}-01`
|
|
165
|
+
|
|
166
|
+
const extracted = propagation.extract(ROOT_CONTEXT, carrier)
|
|
167
|
+
expect(trace.getSpanContext(extracted)).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('global extract trusts the backup header only after explicit opt-in', () => {
|
|
171
|
+
const carrier: Record<string, string> = {}
|
|
172
|
+
withSpan('producer', () => {
|
|
173
|
+
propagation.inject(context.active(), carrier)
|
|
174
|
+
})
|
|
175
|
+
const realTraceId = carrier['x-original-traceparent'].split('-')[1]
|
|
176
|
+
carrier.traceparent = `00-${'a'.repeat(32)}-${'b'.repeat(16)}-01`
|
|
177
|
+
process.env.TELEMETRY_TRUST_INBOUND_TRACE = 'true'
|
|
178
|
+
resetTelemetryEnvCache()
|
|
179
|
+
|
|
180
|
+
const extracted = propagation.extract(ROOT_CONTEXT, carrier)
|
|
181
|
+
expect(trace.getSpanContext(extracted)?.traceId).toBe(realTraceId)
|
|
182
|
+
expect(trace.getSpanContext(extracted)?.traceId).not.toBe('a'.repeat(32))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('global extract roots on a bare inbound traceparent with no backup (LB on entry)', () => {
|
|
186
|
+
const carrier = { traceparent: `00-${'a'.repeat(32)}-${'b'.repeat(16)}-01` }
|
|
187
|
+
const extracted = propagation.extract(ROOT_CONTEXT, carrier)
|
|
188
|
+
expect(trace.getSpanContext(extracted)).toBeUndefined()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('redacts a leaked email from a span exception (PII backstop at the OTEL boundary)', () => {
|
|
192
|
+
withSpan('pii-span', (s) => {
|
|
193
|
+
s.recordException(new Error('no account for jan.kowalski@example.com'))
|
|
194
|
+
})
|
|
195
|
+
const span = spanExporter.getFinishedSpans().find((s) => s.name === 'pii-span')
|
|
196
|
+
const exception = span?.events.find((e) => e.name === 'exception')
|
|
197
|
+
const message = String(exception?.attributes?.['exception.message'] ?? '')
|
|
198
|
+
expect(message).toContain('[redacted-email]')
|
|
199
|
+
expect(message).not.toContain('jan.kowalski@example.com')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('redacts secret span attributes at the OTEL provider boundary', () => {
|
|
203
|
+
withSpan('secret-span', (span) => {
|
|
204
|
+
span.setAttributes({
|
|
205
|
+
token: 'secret-token',
|
|
206
|
+
note: 'contact jane@example.com',
|
|
207
|
+
token_count: 12,
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
const span = spanExporter.getFinishedSpans().find((item) => item.name === 'secret-span')
|
|
211
|
+
expect(span?.attributes.token).toBe('[redacted]')
|
|
212
|
+
expect(span?.attributes.note).toBe('contact [redacted-email]')
|
|
213
|
+
expect(span?.attributes.token_count).toBe(12)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('emits structured logs and error exceptions through reportError', () => {
|
|
217
|
+
logger.info('hello', { k: 1 })
|
|
218
|
+
reportError(new Error('explode'), { module: 'orders' })
|
|
219
|
+
const records = logExporter.getFinishedLogRecords()
|
|
220
|
+
const info = records.find((r) => r.body === 'hello')
|
|
221
|
+
expect(info?.attributes?.k).toBe(1)
|
|
222
|
+
const err = records.find((r) => r.severityText === 'error')
|
|
223
|
+
expect(err?.attributes?.['exception.message']).toBe('explode')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('redacts direct provider logs at the final export boundary', () => {
|
|
227
|
+
provider.emitLog({
|
|
228
|
+
level: 'warn',
|
|
229
|
+
message: 'upstream rejected Bearer raw-token',
|
|
230
|
+
attributes: {
|
|
231
|
+
token: 'raw-token',
|
|
232
|
+
note: 'owner@example.com',
|
|
233
|
+
token_count: 3,
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
const record = logExporter.getFinishedLogRecords().find((item) => item.severityText === 'warn')
|
|
237
|
+
expect(record?.body).toBe('upstream rejected Bearer [redacted]')
|
|
238
|
+
expect(record?.attributes?.token).toBe('[redacted]')
|
|
239
|
+
expect(record?.attributes?.note).toBe('[redacted-email]')
|
|
240
|
+
expect(record?.attributes?.token_count).toBe(3)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('exposes the active trace context for log correlation (undefined when none active)', () => {
|
|
244
|
+
expect(provider.activeTraceContext()).toBeUndefined()
|
|
245
|
+
let seen: { traceId: string; spanId: string } | undefined
|
|
246
|
+
withSpan('logged-op', () => {
|
|
247
|
+
seen = provider.activeTraceContext()
|
|
248
|
+
})
|
|
249
|
+
const span = spanExporter.getFinishedSpans().find((s) => s.name === 'logged-op')
|
|
250
|
+
expect(seen?.traceId).toMatch(/^[0-9a-f]{32}$/)
|
|
251
|
+
expect(seen?.spanId).toMatch(/^[0-9a-f]{16}$/)
|
|
252
|
+
expect(seen?.traceId).toBe(span?.spanContext().traceId)
|
|
253
|
+
expect(seen?.spanId).toBe(span?.spanContext().spanId)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('records metrics through the OTEL meter', async () => {
|
|
257
|
+
counter('om.errors', 1, { module: 'orders' })
|
|
258
|
+
await metricReader.forceFlush()
|
|
259
|
+
const metricNames = metricExporter
|
|
260
|
+
.getMetrics()
|
|
261
|
+
.flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics.map((m) => m.descriptor.name)))
|
|
262
|
+
expect(metricNames).toContain('om.errors')
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Guards that an OTLP-backed telemetry provider actually instruments `pg`, so a
|
|
7
|
+
* database query emits a span. This is the capability that makes worker/scheduler
|
|
8
|
+
* DB work visible (the `queue worker` bootstrap in `@open-mercato/cli` initializes
|
|
9
|
+
* telemetry BEFORE it loads MikroORM's Postgres driver precisely so these spans
|
|
10
|
+
* are captured — see packages/cli/src/bin.ts).
|
|
11
|
+
*
|
|
12
|
+
* It runs in a real child Node process rather than in-process: OpenTelemetry's
|
|
13
|
+
* pg instrumentation patches `pg` via require-in-the-middle, which jest's module
|
|
14
|
+
* system does not exercise faithfully (the sibling otlp-integration.test.ts wires
|
|
15
|
+
* `instrumentations: []` for that reason). No database is needed — a connection to
|
|
16
|
+
* a dead port still drives the query path, and the instrumentation records the
|
|
17
|
+
* span before the underlying call fails.
|
|
18
|
+
*/
|
|
19
|
+
const distProvider = path.resolve(__dirname, '../../dist/provider/otlp-provider.js')
|
|
20
|
+
const pkgRoot = path.resolve(__dirname, '../..')
|
|
21
|
+
|
|
22
|
+
const CHILD_SCRIPT = `
|
|
23
|
+
const { OtlpProvider } = await import('@open-mercato/telemetry/provider/otlp-provider')
|
|
24
|
+
const { setActiveProvider } = await import('@open-mercato/telemetry/provider/registry')
|
|
25
|
+
const { InMemorySpanExporter, SimpleSpanProcessor } = await import('@opentelemetry/sdk-trace-base')
|
|
26
|
+
|
|
27
|
+
// No 'instrumentations' override: the provider's DEFAULT list must include the
|
|
28
|
+
// pg instrumentation (with PII-safe options), or this probe records no span.
|
|
29
|
+
const exporter = new InMemorySpanExporter()
|
|
30
|
+
const provider = new OtlpProvider({
|
|
31
|
+
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
|
32
|
+
})
|
|
33
|
+
// start() BEFORE pg is imported — the ordering the CLI bootstrap guarantees.
|
|
34
|
+
await provider.start()
|
|
35
|
+
setActiveProvider(provider)
|
|
36
|
+
|
|
37
|
+
const pgModule = await import('pg')
|
|
38
|
+
const Client = (pgModule.default ?? pgModule).Client
|
|
39
|
+
const client = new Client({ host: '127.0.0.1', port: 1, connectionTimeoutMillis: 200 })
|
|
40
|
+
try { await client.connect() } catch {}
|
|
41
|
+
try { await client.query('SELECT $1::text', ['pii-sentinel-value']) } catch {}
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
43
|
+
const spans = exporter.getFinishedSpans().map((s) => ({ name: s.name, attributes: s.attributes }))
|
|
44
|
+
console.log('SPANS=' + JSON.stringify(spans))
|
|
45
|
+
process.exit(0)
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
type ProbeSpan = { name: string; attributes: Record<string, unknown> }
|
|
49
|
+
|
|
50
|
+
function runChild(): ProbeSpan[] {
|
|
51
|
+
// The script must live inside the package tree: ESM resolves bare specifiers
|
|
52
|
+
// (`@open-mercato/telemetry`, `@opentelemetry/*`, `pg`) relative to the file's
|
|
53
|
+
// location, not the process cwd.
|
|
54
|
+
const scriptFile = path.join(pkgRoot, `.pg-instrumentation-probe.${process.pid}.mjs`)
|
|
55
|
+
fs.writeFileSync(scriptFile, CHILD_SCRIPT)
|
|
56
|
+
try {
|
|
57
|
+
const result = spawnSync(process.execPath, [scriptFile], {
|
|
58
|
+
cwd: pkgRoot,
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
env: {
|
|
61
|
+
...process.env,
|
|
62
|
+
TELEMETRY_BACKEND: 'otlp',
|
|
63
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://127.0.0.1:1',
|
|
64
|
+
OM_LOG_LEVEL: 'error',
|
|
65
|
+
},
|
|
66
|
+
timeout: 30_000,
|
|
67
|
+
})
|
|
68
|
+
const line = (result.stdout || '').split('\n').find((l) => l.startsWith('SPANS='))
|
|
69
|
+
if (!line) throw new Error(`child produced no SPANS output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`)
|
|
70
|
+
return JSON.parse(line.slice('SPANS='.length)) as ProbeSpan[]
|
|
71
|
+
} finally {
|
|
72
|
+
fs.rmSync(scriptFile, { force: true })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The build output must exist (the child imports the compiled provider). Runs in
|
|
77
|
+
// CI after build:packages; skip locally when the package hasn't been built yet.
|
|
78
|
+
const maybe = fs.existsSync(distProvider) ? describe : describe.skip
|
|
79
|
+
|
|
80
|
+
maybe('OTLP provider pg instrumentation', () => {
|
|
81
|
+
it('emits a pg query span (default instrumentations) without leaking bound parameter values', () => {
|
|
82
|
+
const spans = runChild()
|
|
83
|
+
expect(spans.some((span) => span.name.startsWith('pg.query'))).toBe(true)
|
|
84
|
+
// enhancedDatabaseReporting must stay off in the provider's default wiring:
|
|
85
|
+
// the statement text may appear, bound values must not.
|
|
86
|
+
expect(JSON.stringify(spans)).not.toContain('pii-sentinel-value')
|
|
87
|
+
}, 35_000)
|
|
88
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PG_INSTRUMENTATION_OPTIONS } from '../provider/otlp-provider'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression guard for the PII promise (telemetry spec Privacy / R12): the pg
|
|
5
|
+
* instrumentation must never capture bound SQL parameter VALUES. Flipping this
|
|
6
|
+
* to `true` would leak user data into `db.statement` spans. The runtime
|
|
7
|
+
* behaviour itself is OpenTelemetry's (tested upstream); this locks OUR
|
|
8
|
+
* configuration so a refactor can't silently enable it.
|
|
9
|
+
*/
|
|
10
|
+
describe('pg instrumentation PII config', () => {
|
|
11
|
+
it('never enables enhancedDatabaseReporting (no SQL parameter values in spans)', () => {
|
|
12
|
+
expect(PG_INSTRUMENTATION_OPTIONS.enhancedDatabaseReporting).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { redactPii, redactAttributes } from '../facade/redact'
|
|
2
|
+
|
|
3
|
+
describe('redactPii', () => {
|
|
4
|
+
it('masks email addresses', () => {
|
|
5
|
+
expect(redactPii('no user for jan.kowalski@example.com found')).toBe(
|
|
6
|
+
'no user for [redacted-email] found',
|
|
7
|
+
)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('masks every email in the text', () => {
|
|
11
|
+
expect(redactPii('a@b.co and c.d+tag@sub.example.org')).toBe(
|
|
12
|
+
'[redacted-email] and [redacted-email]',
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('handles hostile runs of email punctuation without pathological matching', () => {
|
|
17
|
+
const hostile = `${'%'.repeat(100_000)} not-an-email`
|
|
18
|
+
|
|
19
|
+
expect(redactPii(hostile)).toBe(hostile)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('masks an Authorization header dump (scheme + token) in one pass', () => {
|
|
23
|
+
expect(redactPii('request failed: Authorization: Bearer eyJhbGci.abc-123_x')).toBe(
|
|
24
|
+
'request failed: Authorization: [redacted]',
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('masks a standalone Bearer/Basic/ApiKey token in text', () => {
|
|
29
|
+
expect(redactPii('used Basic dXNlcjpwYXNz to auth')).toBe('used Basic [redacted] to auth')
|
|
30
|
+
expect(redactPii('sent ApiKey sk_live_abc123 upstream')).toBe('sent ApiKey [redacted] upstream')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('masks a cookie header dump but keeps surrounding structure', () => {
|
|
34
|
+
expect(redactPii("headers { cookie: 'session=abc123'; x-id: 5 }")).toBe(
|
|
35
|
+
"headers { cookie: [redacted]; x-id: 5 }",
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('leaves opaque ids/UUIDs untouched (we keep those)', () => {
|
|
40
|
+
const uuid = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
|
|
41
|
+
expect(redactPii(`tenant ${uuid}`)).toBe(`tenant ${uuid}`)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('is a no-op for text with no PII', () => {
|
|
45
|
+
expect(redactPii('connection refused')).toBe('connection refused')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('redactAttributes', () => {
|
|
50
|
+
it('masks values under secret-looking keys', () => {
|
|
51
|
+
expect(
|
|
52
|
+
redactAttributes({
|
|
53
|
+
authorization: 'Bearer secret-token',
|
|
54
|
+
'set-cookie': 'session=abc',
|
|
55
|
+
client_secret: 'sk_live_123',
|
|
56
|
+
'x-api-key': 'key_123',
|
|
57
|
+
access_token: 'at_123',
|
|
58
|
+
token: 'generic-token',
|
|
59
|
+
}),
|
|
60
|
+
).toEqual({
|
|
61
|
+
authorization: '[redacted]',
|
|
62
|
+
'set-cookie': '[redacted]',
|
|
63
|
+
client_secret: '[redacted]',
|
|
64
|
+
'x-api-key': '[redacted]',
|
|
65
|
+
access_token: '[redacted]',
|
|
66
|
+
token: '[redacted]',
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps opaque ids and benign fields, and does not clobber token_count', () => {
|
|
71
|
+
expect(
|
|
72
|
+
redactAttributes({
|
|
73
|
+
'om.tenant_id': '3f2504e0-4f89-41d3-9a0c-0305e82c3301',
|
|
74
|
+
'http.route': '/api/orders',
|
|
75
|
+
token_count: 42,
|
|
76
|
+
'http.response.status_code': 500,
|
|
77
|
+
}),
|
|
78
|
+
).toEqual({
|
|
79
|
+
'om.tenant_id': '3f2504e0-4f89-41d3-9a0c-0305e82c3301',
|
|
80
|
+
'http.route': '/api/orders',
|
|
81
|
+
token_count: 42,
|
|
82
|
+
'http.response.status_code': 500,
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('runs redactPii over non-secret string values (inline email)', () => {
|
|
87
|
+
expect(redactAttributes({ note: 'contact jan@example.com', module: 'orders' })).toEqual({
|
|
88
|
+
note: 'contact [redacted-email]',
|
|
89
|
+
module: 'orders',
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getActiveProvider,
|
|
3
|
+
setActiveProvider,
|
|
4
|
+
resetActiveProvider,
|
|
5
|
+
registerProvider,
|
|
6
|
+
getRegisteredProvider,
|
|
7
|
+
} from '../provider/registry'
|
|
8
|
+
import type { Span, TelemetryProvider } from '../types'
|
|
9
|
+
|
|
10
|
+
// The provider registry stores state on this global symbol (see registry.ts).
|
|
11
|
+
// It is load-bearing: the worker bootstrap esbuild-bundles a PRIVATE copy of the
|
|
12
|
+
// telemetry module while job handlers load the SOURCE copy, and only a global
|
|
13
|
+
// (not module-local) singleton lets those copies share one active provider.
|
|
14
|
+
const GLOBAL_KEY = Symbol.for('@open-mercato/telemetry.providerRegistry')
|
|
15
|
+
|
|
16
|
+
function fakeProvider(name: string): TelemetryProvider {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
supports: [],
|
|
20
|
+
async start() {},
|
|
21
|
+
async shutdown() {},
|
|
22
|
+
runInSpan: (_n, _o, fn) => fn({} as Span),
|
|
23
|
+
activeSpan: () => undefined,
|
|
24
|
+
activeTraceContext: () => undefined,
|
|
25
|
+
inject: () => {},
|
|
26
|
+
runInRemoteSpan: (_c, _n, _o, fn) => fn({} as Span),
|
|
27
|
+
emitLog: () => {},
|
|
28
|
+
recordMetric: () => {},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
resetActiveProvider()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('provider registry (global singleton)', () => {
|
|
37
|
+
it('defaults to a noop provider', () => {
|
|
38
|
+
expect(getActiveProvider().name).toBe('noop')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('stores the active provider on globalThis, not in module-local state', () => {
|
|
42
|
+
const p = fakeProvider('otlp')
|
|
43
|
+
setActiveProvider(p)
|
|
44
|
+
const store = (globalThis as Record<symbol, { active: TelemetryProvider } | undefined>)[GLOBAL_KEY]
|
|
45
|
+
expect(store?.active).toBe(p)
|
|
46
|
+
expect(getActiveProvider()).toBe(p)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('shares the active provider across a separately-loaded module copy (dual-copy guard)', () => {
|
|
50
|
+
const p = fakeProvider('otlp')
|
|
51
|
+
setActiveProvider(p)
|
|
52
|
+
// A second, isolated copy of the registry module — its own module closure,
|
|
53
|
+
// mimicking the worker's bundled-DI vs source-handler copies. With a global
|
|
54
|
+
// singleton it resolves the SAME provider; with module-local state it would
|
|
55
|
+
// see its own fresh noop default and this would fail.
|
|
56
|
+
let copyActive: TelemetryProvider | undefined
|
|
57
|
+
jest.isolateModules(() => {
|
|
58
|
+
copyActive = (require('../provider/registry') as typeof import('../provider/registry')).getActiveProvider()
|
|
59
|
+
})
|
|
60
|
+
expect(copyActive).toBe(p)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('registerProvider / getRegisteredProvider round-trips by name', () => {
|
|
64
|
+
const p = fakeProvider('otlp')
|
|
65
|
+
registerProvider(p)
|
|
66
|
+
expect(getRegisteredProvider('otlp')).toBe(p)
|
|
67
|
+
expect(getRegisteredProvider('nope')).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('resetActiveProvider restores the noop default and clears registrations', () => {
|
|
71
|
+
setActiveProvider(fakeProvider('otlp'))
|
|
72
|
+
registerProvider(fakeProvider('newrelic'))
|
|
73
|
+
resetActiveProvider()
|
|
74
|
+
expect(getActiveProvider().name).toBe('noop')
|
|
75
|
+
expect(getRegisteredProvider('newrelic')).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { runSpan } from '../provider/run-span'
|
|
2
|
+
import type { Span } from '../types'
|
|
3
|
+
|
|
4
|
+
function fakeSpan() {
|
|
5
|
+
const calls = { exceptions: [] as unknown[], status: '' as string, ended: 0 }
|
|
6
|
+
const span: Span = {
|
|
7
|
+
setAttribute() {},
|
|
8
|
+
setAttributes() {},
|
|
9
|
+
recordException: (e) => calls.exceptions.push(e),
|
|
10
|
+
setStatus: (s) => {
|
|
11
|
+
calls.status = s
|
|
12
|
+
},
|
|
13
|
+
end: () => {
|
|
14
|
+
calls.ended += 1
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
return { span, calls }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('runSpan (sync + async lifecycle)', () => {
|
|
21
|
+
it('ends the span and returns the value for a sync function', () => {
|
|
22
|
+
const { span, calls } = fakeSpan()
|
|
23
|
+
expect(runSpan(span, () => 5)).toBe(5)
|
|
24
|
+
expect(calls.ended).toBe(1)
|
|
25
|
+
expect(calls.status).toBe('')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('records the exception, ends, and rethrows on a sync throw', () => {
|
|
29
|
+
const { span, calls } = fakeSpan()
|
|
30
|
+
const err = new Error('sync')
|
|
31
|
+
expect(() => runSpan(span, () => { throw err })).toThrow('sync')
|
|
32
|
+
expect(calls.exceptions).toEqual([err])
|
|
33
|
+
expect(calls.status).toBe('error')
|
|
34
|
+
expect(calls.ended).toBe(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('ends the span only after an async function resolves', async () => {
|
|
38
|
+
const { span, calls } = fakeSpan()
|
|
39
|
+
const p = runSpan(span, async () => {
|
|
40
|
+
expect(calls.ended).toBe(0)
|
|
41
|
+
return 'ok'
|
|
42
|
+
})
|
|
43
|
+
expect(calls.ended).toBe(0) // not ended synchronously
|
|
44
|
+
await expect(p).resolves.toBe('ok')
|
|
45
|
+
expect(calls.ended).toBe(1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('records the exception, ends, and rejects on an async rejection', async () => {
|
|
49
|
+
const { span, calls } = fakeSpan()
|
|
50
|
+
const err = new Error('async')
|
|
51
|
+
await expect(runSpan(span, async () => { throw err })).rejects.toThrow('async')
|
|
52
|
+
expect(calls.exceptions).toEqual([err])
|
|
53
|
+
expect(calls.status).toBe('error')
|
|
54
|
+
expect(calls.ended).toBe(1)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { serializeError } from '../facade/serialize'
|
|
2
|
+
|
|
3
|
+
describe('serializeError (PII-safe)', () => {
|
|
4
|
+
it('keeps name, message, and stack for an Error', () => {
|
|
5
|
+
const err = new TypeError('bad thing')
|
|
6
|
+
const out = serializeError(err)
|
|
7
|
+
expect(out.name).toBe('TypeError')
|
|
8
|
+
expect(out.message).toBe('bad thing')
|
|
9
|
+
expect(out.stack).toContain('bad thing')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('folds the cause chain into the message (names/messages only)', () => {
|
|
13
|
+
const root = new Error('db down')
|
|
14
|
+
const wrapped = new Error('save failed', { cause: root })
|
|
15
|
+
const out = serializeError(wrapped)
|
|
16
|
+
expect(out.message).toBe('save failed — caused by Error: db down')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('does not leak arbitrary error properties (e.g. request bodies)', () => {
|
|
20
|
+
const err = Object.assign(new Error('boom'), { requestBody: { secret: 'SECRET' } })
|
|
21
|
+
const out = serializeError(err)
|
|
22
|
+
expect(JSON.stringify(out)).not.toContain('SECRET')
|
|
23
|
+
expect(out).not.toHaveProperty('requestBody')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('handles circular causes without infinite recursion', () => {
|
|
27
|
+
const a = new Error('a') as Error & { cause?: unknown }
|
|
28
|
+
const b = new Error('b', { cause: a })
|
|
29
|
+
a.cause = b
|
|
30
|
+
expect(() => serializeError(b)).not.toThrow()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles non-Error values', () => {
|
|
34
|
+
expect(serializeError('plain string')).toMatchObject({ name: 'NonError', message: 'plain string' })
|
|
35
|
+
expect(serializeError({ password: 'SECRET', body: { email: 'person@example.com' } })).toEqual({
|
|
36
|
+
name: 'NonError',
|
|
37
|
+
message: '[non-error object]',
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('redacts a leaked email from message and stack (Privacy backstop)', () => {
|
|
42
|
+
const err = new Error('no account for jan.kowalski@example.com')
|
|
43
|
+
const out = serializeError(err)
|
|
44
|
+
expect(out.message).toBe('no account for [redacted-email]')
|
|
45
|
+
expect(out.stack).not.toContain('jan.kowalski@example.com')
|
|
46
|
+
})
|
|
47
|
+
})
|