@open-mercato/telemetry 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7295.1.d0e0e33014

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +22 -2
  3. package/README.md +76 -1
  4. package/build.mjs +1 -1
  5. package/dist/browser/BrowserTelemetry.js +110 -0
  6. package/dist/browser/BrowserTelemetry.js.map +7 -0
  7. package/dist/browser/config.js +5 -0
  8. package/dist/browser/config.js.map +7 -0
  9. package/dist/browser/index.js +10 -0
  10. package/dist/browser/index.js.map +7 -0
  11. package/dist/browser/propagator.js +19 -0
  12. package/dist/browser/propagator.js.map +7 -0
  13. package/dist/browser/server.js +95 -0
  14. package/dist/browser/server.js.map +7 -0
  15. package/dist/modules/telemetry/__integration__/TC-TELEMETRY-001.spec.js +34 -0
  16. package/dist/modules/telemetry/__integration__/TC-TELEMETRY-001.spec.js.map +7 -0
  17. package/dist/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.js +118 -0
  18. package/dist/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.js.map +7 -0
  19. package/dist/modules/telemetry/api/browser-traces/route.js +141 -0
  20. package/dist/modules/telemetry/api/browser-traces/route.js.map +7 -0
  21. package/dist/modules/telemetry/index.js +11 -0
  22. package/dist/modules/telemetry/index.js.map +7 -0
  23. package/dist/provider/otlp-provider.js +3 -2
  24. package/dist/provider/otlp-provider.js.map +2 -2
  25. package/dist/trace-headers.js +7 -0
  26. package/dist/trace-headers.js.map +7 -0
  27. package/package.json +29 -3
  28. package/src/__tests__/browser-server-config.test.ts +265 -0
  29. package/src/__tests__/browser-traces-route.test.ts +234 -0
  30. package/src/__tests__/otlp-integration.test.ts +54 -1
  31. package/src/browser/BrowserTelemetry.tsx +194 -0
  32. package/src/browser/__tests__/browser-telemetry.test.tsx +325 -0
  33. package/src/browser/config.ts +21 -0
  34. package/src/browser/index.ts +15 -0
  35. package/src/browser/propagator.ts +40 -0
  36. package/src/browser/server.ts +170 -0
  37. package/src/modules/telemetry/__integration__/TC-TELEMETRY-001.spec.ts +50 -0
  38. package/src/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.ts +179 -0
  39. package/src/modules/telemetry/api/browser-traces/route.ts +206 -0
  40. package/src/modules/telemetry/index.ts +15 -0
  41. package/src/provider/otlp-provider.ts +3 -2
  42. package/src/trace-headers.ts +16 -0
@@ -0,0 +1,194 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Browser RUM. Boots the OpenTelemetry web SDK once per page load and exports
5
+ * spans through the same-origin proxy (`/api/telemetry/browser-traces`).
6
+ *
7
+ * Why this exists: server traces can only prove where the time *isn't*. A page
8
+ * can render an API answer in 50 ms yet leave the user waiting seconds before
9
+ * the request is even sent — bundle download, hydration, or a click handler
10
+ * stuck behind a re-render are all invisible to every server span.
11
+ *
12
+ * Two rules this file lives by:
13
+ * - **The SDK is `import()`ed, never statically imported.** RUM often
14
+ * diagnoses routes suspected to be bundle-bound; shipping the SDK in the
15
+ * critical path would be self-defeating. Document-load timings survive the
16
+ * late start because they are read back from the PerformanceTimeline.
17
+ * - **It can never break the app.** Every failure is swallowed. No
18
+ * user-facing surface, no retry.
19
+ *
20
+ * This file, `config.ts`, and `propagator.ts` are the only parts of
21
+ * `@open-mercato/telemetry` a client bundle may reach (via the `/browser` entry).
22
+ * None of them may import the server facade (`node:async_hooks`) or the
23
+ * env-reading `browser/server.ts`; the two wirings are independent and meet only
24
+ * at the collector.
25
+ */
26
+
27
+ import * as React from 'react'
28
+ import type { BrowserTelemetryConfig } from './config'
29
+ import { createBackupHeaderPropagator } from './propagator'
30
+
31
+ /** Module-level so React StrictMode's double-invoke (and any remount) cannot start a second SDK. */
32
+ let bootstrapped = false
33
+
34
+ /**
35
+ * Test seam. Production has exactly one bootstrap per page load, so nothing else
36
+ * may call this — a second bootstrap would double every span.
37
+ */
38
+ export function resetBrowserTelemetryBootstrap(): void {
39
+ bootstrapped = false
40
+ }
41
+
42
+ async function startBrowserTelemetry(config: BrowserTelemetryConfig): Promise<void> {
43
+ const [
44
+ { WebTracerProvider },
45
+ { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler },
46
+ { OTLPTraceExporter },
47
+ { resourceFromAttributes },
48
+ { registerInstrumentations },
49
+ { DocumentLoadInstrumentation },
50
+ { FetchInstrumentation },
51
+ { W3CTraceContextPropagator },
52
+ { defaultTextMapSetter },
53
+ ] = await Promise.all([
54
+ import('@opentelemetry/sdk-trace-web'),
55
+ import('@opentelemetry/sdk-trace-base'),
56
+ import('@opentelemetry/exporter-trace-otlp-http'),
57
+ import('@opentelemetry/resources'),
58
+ import('@opentelemetry/instrumentation'),
59
+ import('@opentelemetry/instrumentation-document-load'),
60
+ import('@opentelemetry/instrumentation-fetch'),
61
+ import('@opentelemetry/core'),
62
+ import('@opentelemetry/api'),
63
+ ])
64
+
65
+ const provider = new WebTracerProvider({
66
+ resource: resourceFromAttributes({
67
+ 'service.name': config.serviceName,
68
+ // Plain attribute name (not the semconv `deployment.environment.name`) so browser spans sort
69
+ // alongside the server spans, which use OTEL_RESOURCE_ATTRIBUTES.
70
+ ...(config.environment ? { 'deployment.environment': config.environment } : {}),
71
+ 'user_agent.original': navigator.userAgent,
72
+ }),
73
+ sampler: new ParentBasedSampler({
74
+ root: new TraceIdRatioBasedSampler(config.samplingRatio),
75
+ }),
76
+ spanProcessors: [
77
+ new BatchSpanProcessor(
78
+ new OTLPTraceExporter({ url: config.endpoint }),
79
+ // The interesting spans arrive in bursts around a navigation; a short delay keeps them
80
+ // close together in the trace view without one request per span.
81
+ { scheduledDelayMillis: 3_000, maxQueueSize: 512 },
82
+ ),
83
+ ],
84
+ })
85
+
86
+ // Standard `traceparent` plus the backup copy the server continues once
87
+ // TELEMETRY_TRUST_INBOUND_TRACE is on — see `propagator.ts` for why that flag is the supported
88
+ // way to get one end-to-end trace, and what happens without it.
89
+ provider.register({
90
+ propagator: createBackupHeaderPropagator(new W3CTraceContextPropagator(), defaultTextMapSetter),
91
+ })
92
+
93
+ // ── The fetch-patch dance (do not simplify) ────────────────────────────────────────────────
94
+ // `@open-mercato/ui`'s api utils install a global wrapper at module load so that stray
95
+ // `fetch()` calls still get the 401 session-refresh / 403 banner behaviour:
96
+ // w.__omOriginalFetch = window.fetch // pristine native fetch
97
+ // window.fetch = (i, x) => apiFetch(i, x)
98
+ // and `apiFetch` then calls `__omOriginalFetch` to avoid recursing into itself. Since the whole
99
+ // backoffice (DataTable, CrudForm, react-query queryFns…) imports `apiFetch` DIRECTLY, those
100
+ // calls never touch `window.fetch` — so instrumenting `window.fetch` alone yields document-load
101
+ // spans and almost no API spans, which is worse than none at all: it looks like it works.
102
+ //
103
+ // Fix: let the instrumentation patch the *native* fetch, then hand the patched version back as
104
+ // the stash. Both paths end up instrumented exactly once:
105
+ // apiFetch → __omOriginalFetch = OTel(native) ✓
106
+ // window.fetch → apiFetch wrapper → OTel(native) ✓
107
+ // Assigning `__omOriginalFetch = window.fetch` instead would loop forever
108
+ // (apiFetch → wrapper → apiFetch → …). The swap is synchronous — `registerInstrumentations`
109
+ // calls `enable()` inline — so no caller can observe the intermediate state.
110
+ const patchTarget = window as Window & { __omOriginalFetch?: typeof window.fetch }
111
+ const frameworkFetch = patchTarget.__omOriginalFetch ? window.fetch : null
112
+ if (frameworkFetch && patchTarget.__omOriginalFetch) {
113
+ window.fetch = patchTarget.__omOriginalFetch
114
+ }
115
+
116
+ // `finally`, not a plain sequence: the instrumentation constructors below are evaluated inside
117
+ // this window, and a throw from any of them (SDK version skew, a browser missing an API an
118
+ // instrumentation touches) would otherwise leave `window.fetch` as the raw native fetch stashed
119
+ // above. `apiFetch` would keep working — it reads `__omOriginalFetch` — but every stray
120
+ // `window.fetch(...)` would silently bypass the framework wrapper's 401 session-refresh and 403
121
+ // banner behaviour for the rest of the page's life. This is the one path where the file's
122
+ // "every failure is swallowed" rule would leave global state degraded.
123
+ try {
124
+ registerInstrumentations({
125
+ tracerProvider: provider,
126
+ instrumentations: [
127
+ // Navigation + resource timings: chunk download vs. parse vs. hydrate.
128
+ new DocumentLoadInstrumentation(),
129
+ // The client-side START of each API call — the number the server can never see.
130
+ new FetchInstrumentation({
131
+ // Never instrument our own export, or each batch would generate the next one.
132
+ ignoreUrls: [new RegExp(escapeRegExp(config.endpoint))],
133
+ clearTimingResources: true,
134
+ }),
135
+ // No `UserInteractionInstrumentation` here, deliberately. It works by patching
136
+ // `addEventListener` when `enable()` runs, but React attaches a SINGLE delegated `click`
137
+ // listener to the root container during `createRoot`/hydration — and this SDK boots from
138
+ // `useEffect`, which React runs strictly after hydration. The patch would land after the
139
+ // only listener that will ever see a backoffice click, so it produces no spans at all
140
+ // (confirmed in manual QA). Booting early enough to beat hydration would mean putting the
141
+ // SDK back on the critical path, which is the one thing this file refuses to do. An
142
+ // instrumentation that silently produces nothing is worse than an absent one: an empty
143
+ // interaction view reads as "nobody clicks" rather than "this is broken".
144
+ ],
145
+ })
146
+ } finally {
147
+ if (frameworkFetch) {
148
+ // On the success path `window.fetch` is OTel's patched native fetch; on the throw path it is
149
+ // the pristine native one. Either way this restores the framework wrapper on top of whatever
150
+ // the stash should now hold, so the pre-swap contract holds in both.
151
+ patchTarget.__omOriginalFetch = window.fetch
152
+ window.fetch = frameworkFetch
153
+ }
154
+ }
155
+
156
+ // BatchSpanProcessor would otherwise drop whatever is still queued when the user navigates away
157
+ // — which is exactly when a slow page gets abandoned, i.e. the spans we most want.
158
+ //
159
+ // `forceFlush` exports through the OTLP fetch transport, which sets `keepalive: true` on the
160
+ // request whenever the payload and the number of in-flight requests are within the browser's
161
+ // limits — so the batch survives the page going away. That is the durable path here;
162
+ // `sendBeacon` would be the other candidate, but upstream deprecated its export delegate in
163
+ // favour of the fetch one, so reaching for it would trade a supported API for a doomed one.
164
+ //
165
+ // Both events, because neither alone covers every exit: `visibilitychange` misses some bfcache
166
+ // entries, and `pagehide` does not fire when a tab is merely backgrounded. A second flush with
167
+ // an empty queue is a no-op, so the overlap costs nothing.
168
+ const flush = () => void provider.forceFlush().catch(() => {})
169
+ document.addEventListener('visibilitychange', () => {
170
+ if (document.visibilityState === 'hidden') flush()
171
+ })
172
+ window.addEventListener('pagehide', flush)
173
+ }
174
+
175
+ function escapeRegExp(value: string): string {
176
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
177
+ }
178
+
179
+ /**
180
+ * Renders nothing. `config` is resolved server-side (`resolveBrowserTelemetryConfig()`) and is
181
+ * `null` whenever browser telemetry is disabled for the environment — which is the default, and
182
+ * keeps the SDK chunk from ever being requested.
183
+ */
184
+ export function BrowserTelemetry({ config }: { config: BrowserTelemetryConfig | null }) {
185
+ React.useEffect(() => {
186
+ if (!config || bootstrapped) return
187
+ bootstrapped = true
188
+ void startBrowserTelemetry(config).catch(() => {
189
+ // Telemetry must never degrade the app it measures: stay silent and stay down (no retry).
190
+ })
191
+ }, [config])
192
+
193
+ return null
194
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * The browser runtime itself: what actually loads the SDK, what patches global
7
+ * fetch, and what must never happen while RUM is off. The OTel web SDK is mocked
8
+ * — the point here is our wiring, not OpenTelemetry's. Trace continuity against
9
+ * the real server extractor is covered in `__tests__/otlp-integration.test.ts`.
10
+ */
11
+ import * as React from 'react'
12
+ import { act, render } from '@testing-library/react'
13
+ import { BrowserTelemetry, resetBrowserTelemetryBootstrap } from '../BrowserTelemetry'
14
+ import { BROWSER_TRACES_PATH, type BrowserTelemetryConfig } from '../config'
15
+ import { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../../trace-headers'
16
+ import type { TextMapPropagator } from '@opentelemetry/api'
17
+
18
+ /** Which SDK modules were dynamically imported — the "zero client cost while off" claim. */
19
+ const mockLoadedModules: string[] = []
20
+ const mockProviderInstances: Array<{
21
+ options: Record<string, unknown>
22
+ register: jest.Mock
23
+ forceFlush: jest.Mock
24
+ }> = []
25
+ const mockFetchInstrumentationConfigs: Array<{ ignoreUrls?: RegExp[] }> = []
26
+ /**
27
+ * Arms the next `new FetchInstrumentation(...)` to throw. The constructors are evaluated as
28
+ * arguments to `registerInstrumentations`, i.e. while the native fetch is swapped in — so this is
29
+ * the seam that reproduces a throw landing mid-swap (SDK version skew, a missing browser API).
30
+ */
31
+ const mockFetchInstrumentationFailure: { next: Error | null } = { next: null }
32
+ const mockExporterConfigs: Array<{ url?: string }> = []
33
+ const mockRegisterInstrumentations = jest.fn()
34
+ /** Stands in for the fetch OTel's FetchInstrumentation.enable() installs, synchronously. */
35
+ const mockOtelPatchedFetch = jest.fn() as unknown as typeof window.fetch
36
+
37
+ function record<T>(moduleName: string, exports: T): T {
38
+ mockLoadedModules.push(moduleName)
39
+ return exports
40
+ }
41
+
42
+ jest.mock('@opentelemetry/sdk-trace-web', () =>
43
+ record('@opentelemetry/sdk-trace-web', {
44
+ WebTracerProvider: class {
45
+ register = jest.fn()
46
+ forceFlush = jest.fn(async () => {})
47
+ constructor(options: Record<string, unknown>) {
48
+ mockProviderInstances.push({ options, register: this.register, forceFlush: this.forceFlush })
49
+ }
50
+ },
51
+ }),
52
+ )
53
+
54
+ jest.mock('@opentelemetry/sdk-trace-base', () =>
55
+ record('@opentelemetry/sdk-trace-base', {
56
+ BatchSpanProcessor: class {},
57
+ ParentBasedSampler: class {},
58
+ TraceIdRatioBasedSampler: class {},
59
+ }),
60
+ )
61
+
62
+ jest.mock('@opentelemetry/exporter-trace-otlp-http', () =>
63
+ record('@opentelemetry/exporter-trace-otlp-http', {
64
+ OTLPTraceExporter: class {
65
+ constructor(config: { url?: string }) {
66
+ mockExporterConfigs.push(config)
67
+ }
68
+ },
69
+ }),
70
+ )
71
+
72
+ jest.mock('@opentelemetry/resources', () =>
73
+ record('@opentelemetry/resources', {
74
+ resourceFromAttributes: (attributes: Record<string, unknown>) => attributes,
75
+ }),
76
+ )
77
+
78
+ jest.mock('@opentelemetry/instrumentation', () =>
79
+ record('@opentelemetry/instrumentation', {
80
+ registerInstrumentations: (...args: unknown[]) => {
81
+ // The real call enables each instrumentation inline, and FetchInstrumentation's enable()
82
+ // replaces whatever `window.fetch` is at that moment. Reproducing that here is the whole
83
+ // point of the fetch-swap assertions below.
84
+ window.fetch = mockOtelPatchedFetch
85
+ mockRegisterInstrumentations(...args)
86
+ },
87
+ }),
88
+ )
89
+
90
+ jest.mock('@opentelemetry/instrumentation-document-load', () =>
91
+ record('@opentelemetry/instrumentation-document-load', { DocumentLoadInstrumentation: class {} }),
92
+ )
93
+
94
+ jest.mock('@opentelemetry/instrumentation-fetch', () =>
95
+ record('@opentelemetry/instrumentation-fetch', {
96
+ FetchInstrumentation: class {
97
+ constructor(config: { ignoreUrls?: RegExp[] }) {
98
+ if (mockFetchInstrumentationFailure.next) {
99
+ const failure = mockFetchInstrumentationFailure.next
100
+ mockFetchInstrumentationFailure.next = null
101
+ throw failure
102
+ }
103
+ mockFetchInstrumentationConfigs.push(config)
104
+ }
105
+ },
106
+ }),
107
+ )
108
+
109
+ /**
110
+ * Nothing imports this any more — the mock stays so that a re-introduction shows up in
111
+ * `mockLoadedModules` (and is caught by the regression test below) instead of failing as a missing
112
+ * dependency with the reason lost. `virtual` because the package is no longer installed.
113
+ */
114
+ jest.mock(
115
+ '@opentelemetry/instrumentation-user-interaction',
116
+ () => record('@opentelemetry/instrumentation-user-interaction', { UserInteractionInstrumentation: class {} }),
117
+ { virtual: true },
118
+ )
119
+
120
+ jest.mock('@opentelemetry/core', () =>
121
+ record('@opentelemetry/core', {
122
+ W3CTraceContextPropagator: class {
123
+ inject(_ctx: unknown, carrier: Record<string, string>, setter: { set(c: unknown, k: string, v: string): void }) {
124
+ setter.set(carrier, 'traceparent', '00-11111111111111111111111111111111-2222222222222222-01')
125
+ setter.set(carrier, 'tracestate', 'om=1')
126
+ }
127
+ fields() {
128
+ return ['traceparent', 'tracestate']
129
+ }
130
+ },
131
+ }),
132
+ )
133
+
134
+ jest.mock('@opentelemetry/api', () =>
135
+ record('@opentelemetry/api', {
136
+ defaultTextMapSetter: {
137
+ set: (carrier: Record<string, string>, key: string, value: string) => {
138
+ carrier[key] = value
139
+ },
140
+ },
141
+ }),
142
+ )
143
+
144
+ const CONFIG: BrowserTelemetryConfig = {
145
+ endpoint: BROWSER_TRACES_PATH,
146
+ serviceName: 'acme-oms-browser',
147
+ environment: 'production',
148
+ samplingRatio: 0.25,
149
+ }
150
+
151
+ /** The effect chains a dynamic import; one macrotask turn is enough to settle it. */
152
+ async function renderAndSettle(element: React.ReactElement): Promise<void> {
153
+ render(element)
154
+ await act(async () => {
155
+ await new Promise((resolve) => setTimeout(resolve, 0))
156
+ })
157
+ }
158
+
159
+ describe('BrowserTelemetry', () => {
160
+ const nativeFetch = window.fetch
161
+
162
+ beforeEach(() => {
163
+ resetBrowserTelemetryBootstrap()
164
+ mockLoadedModules.length = 0
165
+ mockProviderInstances.length = 0
166
+ mockFetchInstrumentationConfigs.length = 0
167
+ mockExporterConfigs.length = 0
168
+ mockRegisterInstrumentations.mockClear()
169
+ mockFetchInstrumentationFailure.next = null
170
+ window.fetch = nativeFetch
171
+ delete (window as { __omOriginalFetch?: unknown }).__omOriginalFetch
172
+ })
173
+
174
+ it('loads nothing at all while telemetry is disabled — RUM must cost a disabled app zero bytes', async () => {
175
+ await renderAndSettle(<BrowserTelemetry config={null} />)
176
+
177
+ expect(mockLoadedModules).toEqual([])
178
+ expect(mockProviderInstances).toHaveLength(0)
179
+ })
180
+
181
+ it('boots the web SDK exactly once under StrictMode, which double-invokes every effect', async () => {
182
+ await renderAndSettle(
183
+ <React.StrictMode>
184
+ <BrowserTelemetry config={CONFIG} />
185
+ </React.StrictMode>,
186
+ )
187
+
188
+ // A second provider would double every span and every export.
189
+ expect(mockProviderInstances).toHaveLength(1)
190
+ expect(mockRegisterInstrumentations).toHaveBeenCalledTimes(1)
191
+ })
192
+
193
+ it('stays down after a remount, because a page load gets one SDK', async () => {
194
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
195
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
196
+
197
+ expect(mockProviderInstances).toHaveLength(1)
198
+ })
199
+
200
+ it('exports through the same-origin proxy and never instruments that export', async () => {
201
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
202
+
203
+ expect(mockExporterConfigs[0]?.url).toBe(BROWSER_TRACES_PATH)
204
+ const ignoreUrls = mockFetchInstrumentationConfigs[0]?.ignoreUrls ?? []
205
+ // Without this the exporter's own POST produces a span, which produces the next batch, forever.
206
+ expect(ignoreUrls.some((pattern) => pattern.test(`http://localhost${BROWSER_TRACES_PATH}`))).toBe(true)
207
+ expect(ignoreUrls.some((pattern) => pattern.test('http://localhost/api/customers'))).toBe(false)
208
+ })
209
+
210
+ it('never loads the user-interaction instrumentation — it cannot see a React click and would emit nothing', async () => {
211
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
212
+
213
+ // React attaches its single delegated `click` listener to the root during hydration, which is
214
+ // strictly before this SDK boots from `useEffect`. The instrumentation patches
215
+ // `addEventListener` on `enable()`, so it would land too late to ever see a backoffice click
216
+ // and would ship an interaction view that reads "nobody clicks" instead of "this is broken".
217
+ expect(mockLoadedModules).not.toContain('@opentelemetry/instrumentation-user-interaction')
218
+ const registered = mockRegisterInstrumentations.mock.calls[0][0] as { instrumentations: unknown[] }
219
+ expect(registered.instrumentations).toHaveLength(2)
220
+ })
221
+
222
+ it('carries the resolved service identity onto the resource', async () => {
223
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
224
+
225
+ expect(mockProviderInstances[0]?.options.resource).toMatchObject({
226
+ 'service.name': 'acme-oms-browser',
227
+ 'deployment.environment': 'production',
228
+ })
229
+ })
230
+
231
+ it('injects the backup trace headers alongside the standard ones', async () => {
232
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
233
+
234
+ const registerArg = mockProviderInstances[0]?.register.mock.calls[0][0] as {
235
+ propagator: TextMapPropagator
236
+ }
237
+ const carrier: Record<string, string> = {}
238
+ registerArg.propagator.inject({} as never, carrier, {
239
+ set: (target: Record<string, string>, key: string, value: string) => {
240
+ target[key] = value
241
+ },
242
+ })
243
+
244
+ // The backup copy is what survives a load balancer rewriting `traceparent`.
245
+ expect(carrier[BACKUP_TRACEPARENT_HEADER]).toBe(carrier.traceparent)
246
+ expect(carrier[BACKUP_TRACESTATE_HEADER]).toBe(carrier.tracestate)
247
+ expect(registerArg.propagator.fields()).toEqual(
248
+ expect.arrayContaining([BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER]),
249
+ )
250
+ })
251
+
252
+ describe('global fetch instrumentation', () => {
253
+ it('instruments the native fetch behind the framework wrapper, without recursing', async () => {
254
+ // What `@open-mercato/ui`'s api utils install at module load: the pristine fetch stashed
255
+ // aside, and a wrapper in its place. (jsdom ships no `fetch`, hence the explicit stub.)
256
+ const pristineFetch = jest.fn() as unknown as typeof window.fetch
257
+ const frameworkFetch = jest.fn() as unknown as typeof window.fetch
258
+ ;(window as { __omOriginalFetch?: typeof window.fetch }).__omOriginalFetch = pristineFetch
259
+ window.fetch = frameworkFetch
260
+
261
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
262
+
263
+ // The framework wrapper stays the public entry point, so 401/403 handling is untouched…
264
+ expect(window.fetch).toBe(frameworkFetch)
265
+ // …and the fetch it delegates to is now the instrumented one, so direct apiFetch callers
266
+ // (DataTable, CrudForm, react-query) produce spans too.
267
+ expect((window as { __omOriginalFetch?: typeof window.fetch }).__omOriginalFetch).toBe(mockOtelPatchedFetch)
268
+ // Handing the wrapper back to itself would loop forever on the first request.
269
+ expect((window as { __omOriginalFetch?: typeof window.fetch }).__omOriginalFetch).not.toBe(frameworkFetch)
270
+ })
271
+
272
+ it('instruments window.fetch directly when the framework wrapper is absent', async () => {
273
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
274
+
275
+ expect(window.fetch).toBe(mockOtelPatchedFetch)
276
+ expect((window as { __omOriginalFetch?: typeof window.fetch }).__omOriginalFetch).toBeUndefined()
277
+ })
278
+
279
+ it('puts the framework wrapper back when instrumentation setup throws mid-swap', async () => {
280
+ const pristineFetch = jest.fn() as unknown as typeof window.fetch
281
+ const frameworkFetch = jest.fn() as unknown as typeof window.fetch
282
+ ;(window as { __omOriginalFetch?: typeof window.fetch }).__omOriginalFetch = pristineFetch
283
+ window.fetch = frameworkFetch
284
+ mockFetchInstrumentationFailure.next = new Error('SDK version skew')
285
+
286
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
287
+
288
+ // Without the `finally`, `window.fetch` would stay the raw native fetch for the rest of the
289
+ // page's life and every stray caller would silently lose the 401 session-refresh and 403
290
+ // banner behaviour the framework wrapper exists for. Nothing logs, so it would be invisible.
291
+ expect(window.fetch).toBe(frameworkFetch)
292
+ expect((window as { __omOriginalFetch?: typeof window.fetch }).__omOriginalFetch).toBe(pristineFetch)
293
+ expect(mockRegisterInstrumentations).not.toHaveBeenCalled()
294
+ })
295
+ })
296
+
297
+ it('flushes queued spans when the tab is hidden — an abandoned slow page is the one worth keeping', async () => {
298
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
299
+ const { forceFlush } = mockProviderInstances[0]
300
+
301
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true })
302
+ document.dispatchEvent(new Event('visibilitychange'))
303
+ expect(forceFlush).not.toHaveBeenCalled()
304
+
305
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true })
306
+ document.dispatchEvent(new Event('visibilitychange'))
307
+ expect(forceFlush).toHaveBeenCalledTimes(1)
308
+ })
309
+
310
+ it('also flushes on pagehide, which is the exit visibilitychange can miss', async () => {
311
+ await renderAndSettle(<BrowserTelemetry config={CONFIG} />)
312
+ const { forceFlush } = mockProviderInstances[0]
313
+
314
+ window.dispatchEvent(new Event('pagehide'))
315
+ expect(forceFlush).toHaveBeenCalledTimes(1)
316
+ })
317
+
318
+ it('stays silent when the SDK fails to boot — telemetry may never degrade the app it measures', async () => {
319
+ mockRegisterInstrumentations.mockImplementationOnce(() => {
320
+ throw new Error('SDK exploded')
321
+ })
322
+
323
+ await expect(renderAndSettle(<BrowserTelemetry config={CONFIG} />)).resolves.toBeUndefined()
324
+ })
325
+ })
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The browser-RUM contract shared by both halves of the feature: the shape the
3
+ * server resolves (`browser/server.ts`) and the shape the client component
4
+ * consumes (`browser/BrowserTelemetry.tsx`).
5
+ *
6
+ * Deliberately dependency-free and env-free so it can sit in a client bundle.
7
+ * Everything that reads `process.env` lives in `browser/server.ts`, which is a
8
+ * server-only module and is never reachable from the `/browser` entry.
9
+ */
10
+
11
+ /** Same-origin path the browser exporter posts to; served by the `telemetry` module's API route. */
12
+ export const BROWSER_TRACES_PATH = '/api/telemetry/browser-traces'
13
+
14
+ export type BrowserTelemetryConfig = {
15
+ /** Same-origin OTLP endpoint (the proxy route). */
16
+ endpoint: string
17
+ serviceName: string
18
+ /** `deployment.environment` — kept as a plain attribute name to match existing server spans. */
19
+ environment: string | null
20
+ samplingRatio: number
21
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Browser RUM entry — the ONLY part of `@open-mercato/telemetry` a client
3
+ * bundle may reach. Import from `@open-mercato/telemetry/browser`.
4
+ *
5
+ * Everything here is env-free. The server half — `resolveBrowserTelemetryConfig()`
6
+ * plus the collector endpoint/credential readers — lives behind
7
+ * `@open-mercato/telemetry/browser/server`, which refuses to load in a browser.
8
+ * A server component resolves the config there and passes it to
9
+ * `<BrowserTelemetry />`, a `'use client'` boundary that dynamically loads the
10
+ * OTel web SDK only when the config is non-null.
11
+ */
12
+ export { BrowserTelemetry } from './BrowserTelemetry'
13
+ export { BROWSER_TRACES_PATH } from './config'
14
+ export type { BrowserTelemetryConfig } from './config'
15
+ export { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../trace-headers'
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The browser's outbound trace propagator: standard W3C plus the backup copy
3
+ * (`x-original-traceparent`) that a load balancer will not rewrite — the mirror
4
+ * image of `provider/otlp-provider.ts`'s `backupHeaderPropagator`.
5
+ *
6
+ * Injecting these headers is necessary but NOT sufficient for one end-to-end
7
+ * trace. The server ignores every inbound trace header — standard and backup
8
+ * alike — unless `TELEMETRY_TRUST_INBOUND_TRACE=true`, because at an HTTP
9
+ * boundary they are caller-controlled. That flag is therefore the supported way
10
+ * to stitch a browser span to its server span; the backup header is what
11
+ * survives a proxy in between. Without the flag RUM still works, it just yields
12
+ * two disconnected traces per page (the server warns about that once).
13
+ *
14
+ * The W3C propagator is passed in rather than imported: the web SDK is only ever
15
+ * loaded through a dynamic `import()` inside `BrowserTelemetry.tsx`. Keeping the
16
+ * logic here — free of any runtime OTel import — is also what lets a test drive
17
+ * the real injector against the real server extractor.
18
+ */
19
+ import type { TextMapPropagator, TextMapSetter } from '@opentelemetry/api'
20
+ import { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../trace-headers'
21
+
22
+ export function createBackupHeaderPropagator(
23
+ w3c: TextMapPropagator,
24
+ defaultTextMapSetter: TextMapSetter<Record<string, string>>,
25
+ ): TextMapPropagator {
26
+ return {
27
+ inject(ctx, carrier, setter) {
28
+ w3c.inject(ctx, carrier, setter)
29
+ // Inject into a plain temp carrier so the values can be mirrored regardless
30
+ // of the real carrier's setter shape.
31
+ const mirror: Record<string, string> = {}
32
+ w3c.inject(ctx, mirror, defaultTextMapSetter)
33
+ if (mirror.traceparent) setter.set(carrier, BACKUP_TRACEPARENT_HEADER, mirror.traceparent)
34
+ if (mirror.tracestate) setter.set(carrier, BACKUP_TRACESTATE_HEADER, mirror.tracestate)
35
+ },
36
+ // A browser is always the root of its own trace — nothing inbound to extract.
37
+ extract: (ctx) => ctx,
38
+ fields: () => [...w3c.fields(), BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER],
39
+ }
40
+ }