@open-mercato/telemetry 0.10.1-develop.7827.1.5c631908e4 → 0.10.1-develop.7836.1.0ef0f453c6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +8 -1
- package/README.md +44 -0
- package/dist/init.js +33 -0
- package/dist/init.js.map +2 -2
- package/dist/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.js +4 -0
- package/dist/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.js.map +2 -2
- package/dist/provider/otlp-provider.js +24 -2
- package/dist/provider/otlp-provider.js.map +2 -2
- package/dist/runtime-metrics.js +132 -0
- package/dist/runtime-metrics.js.map +7 -0
- package/package.json +2 -2
- package/src/__tests__/env-load-order.test.ts +74 -1
- package/src/__tests__/otlp-integration.test.ts +34 -4
- package/src/__tests__/runtime-metrics.test.ts +239 -0
- package/src/init.ts +33 -0
- package/src/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.ts +6 -0
- package/src/provider/otlp-provider.ts +18 -1
- package/src/runtime-metrics.ts +179 -0
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
getTelemetryRuntime,
|
|
4
4
|
resetTelemetryRuntime,
|
|
5
5
|
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
6
|
-
import { initTelemetry, resetTelemetryInit } from '../init'
|
|
6
|
+
import { initTelemetry, resetTelemetryInit, shutdownTelemetry } from '../init'
|
|
7
7
|
import {
|
|
8
8
|
getActiveProvider,
|
|
9
9
|
registerProvider,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { resetTelemetryEnvCache } from '../env'
|
|
13
13
|
import type { TelemetryProvider } from '../types'
|
|
14
14
|
import { NOOP_SPAN } from '../provider/noop-provider'
|
|
15
|
+
import * as errorReporting from '../facade/report-error'
|
|
15
16
|
|
|
16
17
|
function provider(name = 'noop'): TelemetryProvider {
|
|
17
18
|
return {
|
|
@@ -31,6 +32,7 @@ function provider(name = 'noop'): TelemetryProvider {
|
|
|
31
32
|
|
|
32
33
|
describe('telemetry explicit opt-in boundary', () => {
|
|
33
34
|
beforeEach(() => {
|
|
35
|
+
jest.useFakeTimers()
|
|
34
36
|
delete process.env.TELEMETRY_BACKEND
|
|
35
37
|
resetTelemetryEnvCache()
|
|
36
38
|
resetTelemetryInit()
|
|
@@ -39,6 +41,12 @@ describe('telemetry explicit opt-in boundary', () => {
|
|
|
39
41
|
resetTelemetryRuntime()
|
|
40
42
|
})
|
|
41
43
|
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
jest.restoreAllMocks()
|
|
46
|
+
resetTelemetryInit()
|
|
47
|
+
jest.useRealTimers()
|
|
48
|
+
})
|
|
49
|
+
|
|
42
50
|
it('does not resolve a custom provider or register runtime hooks while off', async () => {
|
|
43
51
|
const customNoop = provider()
|
|
44
52
|
registerProvider(customNoop)
|
|
@@ -49,6 +57,7 @@ describe('telemetry explicit opt-in boundary', () => {
|
|
|
49
57
|
expect(getActiveProvider().name).toBe('noop')
|
|
50
58
|
expect(getLoggerExtension()).toBeUndefined()
|
|
51
59
|
expect(getTelemetryRuntime()).toBeUndefined()
|
|
60
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
52
61
|
})
|
|
53
62
|
|
|
54
63
|
it('can initialize after dotenv sets an enabled backend following an off call', async () => {
|
|
@@ -63,6 +72,7 @@ describe('telemetry explicit opt-in boundary', () => {
|
|
|
63
72
|
expect(getActiveProvider()).toBe(customConsole)
|
|
64
73
|
expect(getLoggerExtension()).toBeDefined()
|
|
65
74
|
expect(getTelemetryRuntime()).toBeDefined()
|
|
75
|
+
expect(jest.getTimerCount()).toBe(1)
|
|
66
76
|
})
|
|
67
77
|
|
|
68
78
|
it('initializes an explicitly registered custom backend', async () => {
|
|
@@ -78,6 +88,69 @@ describe('telemetry explicit opt-in boundary', () => {
|
|
|
78
88
|
expect(getTelemetryRuntime()).toBeDefined()
|
|
79
89
|
})
|
|
80
90
|
|
|
91
|
+
it('shares concurrent initialization and releases the sampler on shutdown', async () => {
|
|
92
|
+
const customConsole = provider('console')
|
|
93
|
+
registerProvider(customConsole)
|
|
94
|
+
process.env.TELEMETRY_BACKEND = 'console'
|
|
95
|
+
|
|
96
|
+
await Promise.all([initTelemetry(), initTelemetry()])
|
|
97
|
+
|
|
98
|
+
expect(customConsole.start).toHaveBeenCalledTimes(1)
|
|
99
|
+
expect(jest.getTimerCount()).toBe(1)
|
|
100
|
+
|
|
101
|
+
await shutdownTelemetry()
|
|
102
|
+
|
|
103
|
+
expect(customConsole.shutdown).toHaveBeenCalledTimes(1)
|
|
104
|
+
expect(getTelemetryRuntime()).toBeUndefined()
|
|
105
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it.each([false, true])('reports rejected initialization during shutdown and permits recovery (reporting throws: %s)', async (reportingThrows) => {
|
|
109
|
+
const failure = new Error('[internal] telemetry provider startup failed')
|
|
110
|
+
let rejectStart!: (reason: unknown) => void
|
|
111
|
+
const startup = new Promise<void>((_resolve, reject) => { rejectStart = reject })
|
|
112
|
+
const customConsole = provider('console')
|
|
113
|
+
customConsole.start = jest.fn(() => startup)
|
|
114
|
+
registerProvider(customConsole)
|
|
115
|
+
process.env.TELEMETRY_BACKEND = 'console'
|
|
116
|
+
const reportError = jest.spyOn(errorReporting, 'reportError')
|
|
117
|
+
if (reportingThrows) {
|
|
118
|
+
reportError.mockImplementation(() => { throw new Error('[internal] reporting unavailable') })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const initialization = initTelemetry()
|
|
122
|
+
const initializationFailure = expect(initialization).rejects.toBe(failure)
|
|
123
|
+
const shutdown = shutdownTelemetry()
|
|
124
|
+
expect(customConsole.start).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(reportError).not.toHaveBeenCalled()
|
|
126
|
+
|
|
127
|
+
rejectStart(failure)
|
|
128
|
+
await initializationFailure
|
|
129
|
+
await expect(shutdown).resolves.toBeUndefined()
|
|
130
|
+
|
|
131
|
+
expect(reportError).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(reportError).toHaveBeenCalledWith(failure, {
|
|
133
|
+
module: 'telemetry',
|
|
134
|
+
code: 'telemetry.initialization_failed',
|
|
135
|
+
})
|
|
136
|
+
expect(customConsole.shutdown).not.toHaveBeenCalled()
|
|
137
|
+
expect(getActiveProvider().name).toBe('noop')
|
|
138
|
+
expect(getLoggerExtension()).toBeUndefined()
|
|
139
|
+
expect(getTelemetryRuntime()).toBeUndefined()
|
|
140
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
141
|
+
|
|
142
|
+
const recoveredProvider = provider('console')
|
|
143
|
+
registerProvider(recoveredProvider)
|
|
144
|
+
await initTelemetry()
|
|
145
|
+
expect(recoveredProvider.start).toHaveBeenCalledTimes(1)
|
|
146
|
+
expect(jest.getTimerCount()).toBe(1)
|
|
147
|
+
await shutdownTelemetry()
|
|
148
|
+
expect(recoveredProvider.shutdown).toHaveBeenCalledTimes(1)
|
|
149
|
+
expect(getLoggerExtension()).toBeUndefined()
|
|
150
|
+
expect(getTelemetryRuntime()).toBeUndefined()
|
|
151
|
+
expect(jest.getTimerCount()).toBe(0)
|
|
152
|
+
})
|
|
153
|
+
|
|
81
154
|
it('wires shared runtime histograms to the active provider', async () => {
|
|
82
155
|
const customConsole = provider('console')
|
|
83
156
|
registerProvider(customConsole)
|
|
@@ -8,9 +8,15 @@ import { SpanStatusCode, propagation, trace, context, defaultTextMapSetter, ROOT
|
|
|
8
8
|
import { W3CTraceContextPropagator } from '@opentelemetry/core'
|
|
9
9
|
import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'
|
|
10
10
|
import { InMemoryLogRecordExporter, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs'
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
import {
|
|
12
|
+
InMemoryMetricExporter,
|
|
13
|
+
PeriodicExportingMetricReader,
|
|
14
|
+
AggregationTemporality,
|
|
15
|
+
DataPointType,
|
|
16
|
+
type Histogram,
|
|
17
|
+
} from '@opentelemetry/sdk-metrics'
|
|
18
|
+
|
|
19
|
+
import { OtlpProvider, SECONDS_HISTOGRAM_BUCKETS } from '../provider/otlp-provider'
|
|
14
20
|
import { createBackupHeaderPropagator } from '../browser/propagator'
|
|
15
21
|
import {
|
|
16
22
|
createLogger,
|
|
@@ -20,7 +26,7 @@ import {
|
|
|
20
26
|
import { setActiveProvider, resetActiveProvider } from '../provider/registry'
|
|
21
27
|
import { resetTelemetryEnvCache } from '../env'
|
|
22
28
|
import { registerTelemetryLogger } from '../facade/logger-bridge'
|
|
23
|
-
import { withSpan, captureTraceContext, continueTrace, reportError, counter } from '../index'
|
|
29
|
+
import { withSpan, captureTraceContext, continueTrace, reportError, counter, histogram } from '../index'
|
|
24
30
|
|
|
25
31
|
const spanExporter = new InMemorySpanExporter()
|
|
26
32
|
const logExporter = new InMemoryLogRecordExporter()
|
|
@@ -357,4 +363,28 @@ describe('OtlpProvider (in-memory exporters)', () => {
|
|
|
357
363
|
.flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics.map((m) => m.descriptor.name)))
|
|
358
364
|
expect(metricNames).toContain('om.errors')
|
|
359
365
|
})
|
|
366
|
+
|
|
367
|
+
it('aggregates second-unit histograms with second-scaled buckets', async () => {
|
|
368
|
+
histogram('om.test.wait_time', 0.003, { pool: 'primary' }, 's')
|
|
369
|
+
histogram('om.test.wait_time', 0.0002, { pool: 'primary' }, 's')
|
|
370
|
+
histogram('om.test.payload_size', 3, undefined, 'By')
|
|
371
|
+
await metricReader.forceFlush()
|
|
372
|
+
|
|
373
|
+
const histogramPoint = (name: string) => {
|
|
374
|
+
const metric = metricExporter
|
|
375
|
+
.getMetrics()
|
|
376
|
+
.flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics))
|
|
377
|
+
.find((candidate) => candidate.descriptor.name === name)
|
|
378
|
+
expect(metric?.dataPointType).toBe(DataPointType.HISTOGRAM)
|
|
379
|
+
return metric?.dataPoints[0]?.value as Histogram | undefined
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const seconds = histogramPoint('om.test.wait_time')
|
|
383
|
+
expect(seconds?.buckets.boundaries).toEqual([...SECONDS_HISTOGRAM_BUCKETS])
|
|
384
|
+
expect(seconds?.buckets.counts.slice(0, 3)).toEqual([1, 1, 0])
|
|
385
|
+
expect(seconds?.count).toBe(2)
|
|
386
|
+
|
|
387
|
+
const bytes = histogramPoint('om.test.payload_size')
|
|
388
|
+
expect(bytes?.buckets.boundaries).not.toEqual([...SECONDS_HISTOGRAM_BUCKETS])
|
|
389
|
+
})
|
|
360
390
|
})
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerTelemetryMetricCollector,
|
|
3
|
+
registerTelemetryRuntime,
|
|
4
|
+
resetTelemetryMetricCollectors,
|
|
5
|
+
resetTelemetryRuntime,
|
|
6
|
+
type TelemetryMetricPoint,
|
|
7
|
+
type TelemetryRuntime,
|
|
8
|
+
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
9
|
+
import {
|
|
10
|
+
createRuntimeMetricsSampler,
|
|
11
|
+
resetRuntimeMetrics,
|
|
12
|
+
startRuntimeMetrics,
|
|
13
|
+
type EventLoopDelayMonitor,
|
|
14
|
+
type RuntimeMetricSources,
|
|
15
|
+
} from '../runtime-metrics'
|
|
16
|
+
import type { EventLoopUtilization } from 'node:perf_hooks'
|
|
17
|
+
|
|
18
|
+
function createRuntime(recordMetric: (point: TelemetryMetricPoint) => void): TelemetryRuntime {
|
|
19
|
+
return {
|
|
20
|
+
canUseGlobalTracePropagation: () => false,
|
|
21
|
+
captureTraceContext: () => ({}),
|
|
22
|
+
continueTrace: (_carrier, _name, fn) => fn(),
|
|
23
|
+
recordMetric,
|
|
24
|
+
recordHttpDuration: () => {},
|
|
25
|
+
reportError: () => {},
|
|
26
|
+
shutdown: async () => {},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('runtime metrics sampler', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
resetRuntimeMetrics()
|
|
33
|
+
resetTelemetryMetricCollectors()
|
|
34
|
+
resetTelemetryRuntime()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('records interval runtime values and invokes shared metric collectors', () => {
|
|
38
|
+
const points: TelemetryMetricPoint[] = []
|
|
39
|
+
registerTelemetryRuntime(createRuntime((point) => points.push(point)))
|
|
40
|
+
const collectPool = jest.fn()
|
|
41
|
+
registerTelemetryMetricCollector(collectPool)
|
|
42
|
+
|
|
43
|
+
const delay: EventLoopDelayMonitor = {
|
|
44
|
+
enable: jest.fn(),
|
|
45
|
+
disable: jest.fn(),
|
|
46
|
+
percentile: jest.fn((percentile) => percentile * 1_000_000),
|
|
47
|
+
reset: jest.fn(),
|
|
48
|
+
}
|
|
49
|
+
const utilization: EventLoopUtilization[] = [
|
|
50
|
+
{ idle: 10, active: 10, utilization: 0.5 },
|
|
51
|
+
{ idle: 15, active: 25, utilization: 0.625 },
|
|
52
|
+
{ idle: 5, active: 15, utilization: 0.75 },
|
|
53
|
+
]
|
|
54
|
+
let sample = () => {}
|
|
55
|
+
const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
|
|
56
|
+
const sources: RuntimeMetricSources = {
|
|
57
|
+
createEventLoopDelayMonitor: () => delay,
|
|
58
|
+
eventLoopUtilization: jest.fn(() => utilization.shift() ?? {
|
|
59
|
+
idle: 0,
|
|
60
|
+
active: 0,
|
|
61
|
+
utilization: 0,
|
|
62
|
+
}),
|
|
63
|
+
memoryUsage: () => ({ rss: 123_456 }),
|
|
64
|
+
heapSpaceStatistics: () => [{
|
|
65
|
+
space_name: 'old_space',
|
|
66
|
+
space_used_size: 78_900,
|
|
67
|
+
}],
|
|
68
|
+
setInterval: (handler, intervalMs) => {
|
|
69
|
+
expect(intervalMs).toBe(10_000)
|
|
70
|
+
sample = handler
|
|
71
|
+
return interval
|
|
72
|
+
},
|
|
73
|
+
clearInterval: jest.fn(),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const dispose = createRuntimeMetricsSampler(sources)
|
|
77
|
+
sample()
|
|
78
|
+
|
|
79
|
+
expect(delay.enable).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(interval.unref).toHaveBeenCalledTimes(1)
|
|
81
|
+
expect(points).toEqual([
|
|
82
|
+
{
|
|
83
|
+
kind: 'gauge',
|
|
84
|
+
name: 'nodejs.eventloop.utilization',
|
|
85
|
+
value: 0.75,
|
|
86
|
+
unit: '1',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
kind: 'gauge',
|
|
90
|
+
name: 'nodejs.eventloop.delay.p50',
|
|
91
|
+
value: 0.05,
|
|
92
|
+
unit: 's',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
kind: 'gauge',
|
|
96
|
+
name: 'nodejs.eventloop.delay.p90',
|
|
97
|
+
value: 0.09,
|
|
98
|
+
unit: 's',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
kind: 'gauge',
|
|
102
|
+
name: 'nodejs.eventloop.delay.p99',
|
|
103
|
+
value: 0.099,
|
|
104
|
+
unit: 's',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
kind: 'gauge',
|
|
108
|
+
name: 'process.memory.usage',
|
|
109
|
+
value: 123_456,
|
|
110
|
+
unit: 'By',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
kind: 'gauge',
|
|
114
|
+
name: 'v8js.memory.heap.used',
|
|
115
|
+
value: 78_900,
|
|
116
|
+
labels: { 'v8js.heap.space.name': 'old_space' },
|
|
117
|
+
unit: 'By',
|
|
118
|
+
},
|
|
119
|
+
])
|
|
120
|
+
expect(collectPool).toHaveBeenCalledTimes(1)
|
|
121
|
+
expect(delay.reset).toHaveBeenCalledTimes(1)
|
|
122
|
+
|
|
123
|
+
dispose()
|
|
124
|
+
|
|
125
|
+
expect(sources.clearInterval).toHaveBeenCalledWith(interval)
|
|
126
|
+
expect(delay.disable).toHaveBeenCalledTimes(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('normalizes non-finite delay percentiles to zero', () => {
|
|
130
|
+
const points: TelemetryMetricPoint[] = []
|
|
131
|
+
registerTelemetryRuntime(createRuntime((point) => points.push(point)))
|
|
132
|
+
let sample = () => {}
|
|
133
|
+
const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
|
|
134
|
+
const delay: EventLoopDelayMonitor = {
|
|
135
|
+
enable: jest.fn(),
|
|
136
|
+
disable: jest.fn(),
|
|
137
|
+
percentile: () => Number.NaN,
|
|
138
|
+
reset: jest.fn(),
|
|
139
|
+
}
|
|
140
|
+
const sources: RuntimeMetricSources = {
|
|
141
|
+
createEventLoopDelayMonitor: () => delay,
|
|
142
|
+
eventLoopUtilization: () => ({ idle: 0, active: 0, utilization: 0 }),
|
|
143
|
+
memoryUsage: () => ({ rss: 0 }),
|
|
144
|
+
heapSpaceStatistics: () => [],
|
|
145
|
+
setInterval: (handler) => {
|
|
146
|
+
sample = handler
|
|
147
|
+
return interval
|
|
148
|
+
},
|
|
149
|
+
clearInterval: jest.fn(),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dispose = createRuntimeMetricsSampler(sources)
|
|
153
|
+
sample()
|
|
154
|
+
dispose()
|
|
155
|
+
|
|
156
|
+
expect(points.filter((point) => point.name.startsWith('nodejs.eventloop.delay.')))
|
|
157
|
+
.toEqual([
|
|
158
|
+
{ kind: 'gauge', name: 'nodejs.eventloop.delay.p50', value: 0, unit: 's' },
|
|
159
|
+
{ kind: 'gauge', name: 'nodejs.eventloop.delay.p90', value: 0, unit: 's' },
|
|
160
|
+
{ kind: 'gauge', name: 'nodejs.eventloop.delay.p99', value: 0, unit: 's' },
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it.each([false, true])('reports sampling failures, recovers, and disposes once (reporting throws: %s)', (reportingThrows) => {
|
|
165
|
+
const failure = new Error('[internal] memory sample failed')
|
|
166
|
+
const reportError = jest.fn(() => {
|
|
167
|
+
if (reportingThrows) throw new Error('[internal] reporting sink failed')
|
|
168
|
+
})
|
|
169
|
+
const points: TelemetryMetricPoint[] = []
|
|
170
|
+
registerTelemetryRuntime({ ...createRuntime((point) => points.push(point)), reportError })
|
|
171
|
+
let sample = () => {}
|
|
172
|
+
const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
|
|
173
|
+
const delay: EventLoopDelayMonitor = {
|
|
174
|
+
enable: jest.fn(),
|
|
175
|
+
disable: jest.fn(),
|
|
176
|
+
percentile: () => 0,
|
|
177
|
+
reset: jest.fn(),
|
|
178
|
+
}
|
|
179
|
+
const sources: RuntimeMetricSources = {
|
|
180
|
+
createEventLoopDelayMonitor: () => delay,
|
|
181
|
+
eventLoopUtilization: () => ({ idle: 0, active: 0, utilization: 0 }),
|
|
182
|
+
memoryUsage: jest.fn().mockImplementationOnce(() => { throw failure }).mockReturnValue({ rss: 123 }),
|
|
183
|
+
heapSpaceStatistics: () => [],
|
|
184
|
+
setInterval: (handler) => {
|
|
185
|
+
sample = handler
|
|
186
|
+
return interval
|
|
187
|
+
},
|
|
188
|
+
clearInterval: jest.fn(),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const dispose = createRuntimeMetricsSampler(sources)
|
|
192
|
+
expect(sample).not.toThrow()
|
|
193
|
+
expect(sample).not.toThrow()
|
|
194
|
+
dispose()
|
|
195
|
+
dispose()
|
|
196
|
+
|
|
197
|
+
expect(reportError).toHaveBeenCalledWith(failure, {
|
|
198
|
+
module: 'telemetry',
|
|
199
|
+
code: 'telemetry.runtime_sampling_failed',
|
|
200
|
+
})
|
|
201
|
+
expect(points).toContainEqual({ kind: 'gauge', name: 'process.memory.usage', value: 123, unit: 'By' })
|
|
202
|
+
expect(delay.reset).toHaveBeenCalledTimes(2)
|
|
203
|
+
expect(delay.disable).toHaveBeenCalledTimes(1)
|
|
204
|
+
expect(sources.clearInterval).toHaveBeenCalledTimes(1)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('shares one process sampler until every owner releases it', () => {
|
|
208
|
+
const interval = { unref: jest.fn() } as unknown as ReturnType<typeof setInterval>
|
|
209
|
+
const delay: EventLoopDelayMonitor = {
|
|
210
|
+
enable: jest.fn(),
|
|
211
|
+
disable: jest.fn(),
|
|
212
|
+
percentile: () => 0,
|
|
213
|
+
reset: jest.fn(),
|
|
214
|
+
}
|
|
215
|
+
const sources: RuntimeMetricSources = {
|
|
216
|
+
createEventLoopDelayMonitor: jest.fn(() => delay),
|
|
217
|
+
eventLoopUtilization: () => ({ idle: 0, active: 0, utilization: 0 }),
|
|
218
|
+
memoryUsage: () => ({ rss: 0 }),
|
|
219
|
+
heapSpaceStatistics: () => [],
|
|
220
|
+
setInterval: jest.fn(() => interval),
|
|
221
|
+
clearInterval: jest.fn(),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const releaseFirst = startRuntimeMetrics(sources)
|
|
225
|
+
const releaseSecond = startRuntimeMetrics(sources)
|
|
226
|
+
|
|
227
|
+
expect(sources.createEventLoopDelayMonitor).toHaveBeenCalledTimes(1)
|
|
228
|
+
expect(sources.setInterval).toHaveBeenCalledTimes(1)
|
|
229
|
+
|
|
230
|
+
releaseFirst()
|
|
231
|
+
expect(sources.clearInterval).not.toHaveBeenCalled()
|
|
232
|
+
|
|
233
|
+
releaseSecond()
|
|
234
|
+
releaseSecond()
|
|
235
|
+
|
|
236
|
+
expect(sources.clearInterval).toHaveBeenCalledTimes(1)
|
|
237
|
+
expect(delay.disable).toHaveBeenCalledTimes(1)
|
|
238
|
+
})
|
|
239
|
+
})
|
package/src/init.ts
CHANGED
|
@@ -17,11 +17,14 @@ import { withSpan } from './facade/tracer'
|
|
|
17
17
|
import { recordHttpDuration } from './facade/http'
|
|
18
18
|
import { histogram } from './facade/meter'
|
|
19
19
|
import { reportError } from './facade/report-error'
|
|
20
|
+
import { startRuntimeMetrics } from './runtime-metrics'
|
|
20
21
|
|
|
21
22
|
let initialized = false
|
|
23
|
+
let initializing: Promise<void> | undefined
|
|
22
24
|
let activeProvider: TelemetryProvider | undefined
|
|
23
25
|
let disposeLoggerExtension: (() => void) | undefined
|
|
24
26
|
let disposeRuntime: (() => void) | undefined
|
|
27
|
+
let disposeRuntimeMetrics: (() => void) | undefined
|
|
25
28
|
|
|
26
29
|
const logger = createLogger('telemetry')
|
|
27
30
|
const OTLP_DEPENDENCY_UNAVAILABLE = Symbol.for('open-mercato.telemetry.otlp-dependency-unavailable')
|
|
@@ -46,7 +49,17 @@ class OtlpDependencyUnavailableError extends Error {
|
|
|
46
49
|
*/
|
|
47
50
|
export async function initTelemetry(): Promise<void> {
|
|
48
51
|
if (initialized) return
|
|
52
|
+
if (initializing) return initializing
|
|
49
53
|
|
|
54
|
+
let pending: Promise<void>
|
|
55
|
+
pending = initializeTelemetry().finally(() => {
|
|
56
|
+
if (initializing === pending) initializing = undefined
|
|
57
|
+
})
|
|
58
|
+
initializing = pending
|
|
59
|
+
return pending
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function initializeTelemetry(): Promise<void> {
|
|
50
63
|
// Drop any env snapshot memoized BEFORE the host loaded its `.env` (the CLI
|
|
51
64
|
// binary imports this package before dotenv runs) so init resolves the backend
|
|
52
65
|
// from the actual, fully-loaded environment.
|
|
@@ -75,6 +88,7 @@ export async function initTelemetry(): Promise<void> {
|
|
|
75
88
|
activeProvider = provider
|
|
76
89
|
disposeLoggerExtension = registerTelemetryLogger(provider)
|
|
77
90
|
disposeRuntime = registerTelemetryRuntime(createRuntime(provider))
|
|
91
|
+
disposeRuntimeMetrics = startRuntimeMetrics()
|
|
78
92
|
initialized = true
|
|
79
93
|
|
|
80
94
|
logger.info('Telemetry initialized', {
|
|
@@ -111,11 +125,26 @@ async function loadOtlpProvider(backend: TelemetryBackendName): Promise<Telemetr
|
|
|
111
125
|
|
|
112
126
|
/** Flush + tear down the active backend (shutdown hook / `after()`). */
|
|
113
127
|
export async function shutdownTelemetry(): Promise<void> {
|
|
128
|
+
if (initializing) {
|
|
129
|
+
try {
|
|
130
|
+
await initializing
|
|
131
|
+
} catch (err) {
|
|
132
|
+
try {
|
|
133
|
+
reportError(err, {
|
|
134
|
+
module: 'telemetry',
|
|
135
|
+
code: 'telemetry.initialization_failed',
|
|
136
|
+
})
|
|
137
|
+
} catch {}
|
|
138
|
+
logger.warn('Telemetry initialization failed before shutdown', { err })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
114
141
|
const provider = activeProvider
|
|
115
142
|
activeProvider = undefined
|
|
116
143
|
initialized = false
|
|
117
144
|
disposeLoggerExtension?.()
|
|
118
145
|
disposeLoggerExtension = undefined
|
|
146
|
+
disposeRuntimeMetrics?.()
|
|
147
|
+
disposeRuntimeMetrics = undefined
|
|
119
148
|
disposeRuntime?.()
|
|
120
149
|
disposeRuntime = undefined
|
|
121
150
|
clearActiveProvider()
|
|
@@ -131,6 +160,7 @@ function createRuntime(provider: TelemetryProvider): TelemetryRuntime {
|
|
|
131
160
|
continueTrace: (carrier, name, fn, options) =>
|
|
132
161
|
continueTrace(carrier, name, () => fn(), options),
|
|
133
162
|
withSpan: (name, fn, options) => withSpan(name, fn, options),
|
|
163
|
+
recordMetric: (point) => provider.recordMetric(point),
|
|
134
164
|
recordHistogram: histogram,
|
|
135
165
|
recordHttpDuration,
|
|
136
166
|
reportError,
|
|
@@ -141,9 +171,12 @@ function createRuntime(provider: TelemetryProvider): TelemetryRuntime {
|
|
|
141
171
|
/** Test-only: allow re-init in jest. */
|
|
142
172
|
export function resetTelemetryInit(): void {
|
|
143
173
|
initialized = false
|
|
174
|
+
initializing = undefined
|
|
144
175
|
activeProvider = undefined
|
|
145
176
|
disposeLoggerExtension?.()
|
|
146
177
|
disposeLoggerExtension = undefined
|
|
178
|
+
disposeRuntimeMetrics?.()
|
|
179
|
+
disposeRuntimeMetrics = undefined
|
|
147
180
|
disposeRuntime?.()
|
|
148
181
|
disposeRuntime = undefined
|
|
149
182
|
}
|
|
@@ -68,6 +68,12 @@ async function otelApiIsRegistered(page: Page): Promise<boolean> {
|
|
|
68
68
|
|
|
69
69
|
test.describe('TC-TELEMETRY-002: browser RUM runtime', () => {
|
|
70
70
|
test('exports document-load and fetch spans without breaking either fetch path', async ({ page, baseURL }) => {
|
|
71
|
+
// The server ignores the opt-in cookie unless the app runs with this variable; lanes that
|
|
72
|
+
// start the app from their own env (the standalone app) do not set it, so there is no SDK to boot.
|
|
73
|
+
test.skip(
|
|
74
|
+
process.env.OM_TEST_BROWSER_TELEMETRY_MODE !== 'opt-in',
|
|
75
|
+
'OM_TEST_BROWSER_TELEMETRY_MODE=opt-in is not configured for this lane.',
|
|
76
|
+
)
|
|
71
77
|
const origin = new URL(baseURL ?? 'http://localhost:3000').origin
|
|
72
78
|
await page.context().addCookies([{ name: OPT_IN_COOKIE, value: 'on', url: origin }])
|
|
73
79
|
const ingest = recordIngest(page)
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type Counter,
|
|
24
24
|
type Histogram,
|
|
25
25
|
type Gauge,
|
|
26
|
+
type MetricOptions,
|
|
26
27
|
} from '@opentelemetry/api'
|
|
27
28
|
import { W3CTraceContextPropagator } from '@opentelemetry/core'
|
|
28
29
|
import { logs, SeverityNumber } from '@opentelemetry/api-logs'
|
|
@@ -229,6 +230,22 @@ export type OtlpProviderOptions = {
|
|
|
229
230
|
*/
|
|
230
231
|
export const PG_INSTRUMENTATION_OPTIONS = { enhancedDatabaseReporting: false } as const
|
|
231
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Bucket boundaries for every histogram recorded in seconds. Without explicit
|
|
235
|
+
* advice the SDK falls back to millisecond-scaled defaults (0, 5, 10 … 10000),
|
|
236
|
+
* which puts every realistic latency in the first bucket and leaves percentiles
|
|
237
|
+
* uncomputable. A superset of the semconv HTTP-duration advice, plus a 1 ms
|
|
238
|
+
* bucket for sub-millisecond pool acquisitions.
|
|
239
|
+
*/
|
|
240
|
+
export const SECONDS_HISTOGRAM_BUCKETS: readonly number[] = [
|
|
241
|
+
0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
function histogramOptions(unit: string | undefined): MetricOptions | undefined {
|
|
245
|
+
if (unit !== 's') return unit ? { unit } : undefined
|
|
246
|
+
return { unit, advice: { explicitBucketBoundaries: [...SECONDS_HISTOGRAM_BUCKETS] } }
|
|
247
|
+
}
|
|
248
|
+
|
|
232
249
|
export class OtlpProvider implements TelemetryProvider {
|
|
233
250
|
/** The configured backend name (signoz | newrelic | otlp) — all OTLP, vendor differs only by endpoint. */
|
|
234
251
|
readonly name: string
|
|
@@ -339,7 +356,7 @@ export class OtlpProvider implements TelemetryProvider {
|
|
|
339
356
|
point.kind === 'counter'
|
|
340
357
|
? meter.createCounter(point.name, opts)
|
|
341
358
|
: point.kind === 'histogram'
|
|
342
|
-
? meter.createHistogram(point.name,
|
|
359
|
+
? meter.createHistogram(point.name, histogramOptions(point.unit))
|
|
343
360
|
: meter.createGauge(point.name, opts)
|
|
344
361
|
this.instruments.set(key, instrument)
|
|
345
362
|
}
|