@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.
@@ -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 { InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from '@opentelemetry/sdk-metrics'
12
-
13
- import { OtlpProvider } from '../provider/otlp-provider'
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, opts)
359
+ ? meter.createHistogram(point.name, histogramOptions(point.unit))
343
360
  : meter.createGauge(point.name, opts)
344
361
  this.instruments.set(key, instrument)
345
362
  }