@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +22 -2
- package/README.md +76 -1
- package/build.mjs +1 -1
- package/dist/browser/BrowserTelemetry.js +110 -0
- package/dist/browser/BrowserTelemetry.js.map +7 -0
- package/dist/browser/config.js +5 -0
- package/dist/browser/config.js.map +7 -0
- package/dist/browser/index.js +10 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/propagator.js +19 -0
- package/dist/browser/propagator.js.map +7 -0
- package/dist/browser/server.js +95 -0
- package/dist/browser/server.js.map +7 -0
- package/dist/modules/telemetry/__integration__/TC-TELEMETRY-001.spec.js +34 -0
- package/dist/modules/telemetry/__integration__/TC-TELEMETRY-001.spec.js.map +7 -0
- package/dist/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.js +118 -0
- package/dist/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.js.map +7 -0
- package/dist/modules/telemetry/api/browser-traces/route.js +141 -0
- package/dist/modules/telemetry/api/browser-traces/route.js.map +7 -0
- package/dist/modules/telemetry/index.js +11 -0
- package/dist/modules/telemetry/index.js.map +7 -0
- package/dist/provider/otlp-provider.js +3 -2
- package/dist/provider/otlp-provider.js.map +2 -2
- package/dist/trace-headers.js +7 -0
- package/dist/trace-headers.js.map +7 -0
- package/package.json +29 -3
- package/src/__tests__/browser-server-config.test.ts +265 -0
- package/src/__tests__/browser-traces-route.test.ts +234 -0
- package/src/__tests__/otlp-integration.test.ts +54 -1
- package/src/browser/BrowserTelemetry.tsx +194 -0
- package/src/browser/__tests__/browser-telemetry.test.tsx +325 -0
- package/src/browser/config.ts +21 -0
- package/src/browser/index.ts +15 -0
- package/src/browser/propagator.ts +40 -0
- package/src/browser/server.ts +170 -0
- package/src/modules/telemetry/__integration__/TC-TELEMETRY-001.spec.ts +50 -0
- package/src/modules/telemetry/__integration__/TC-TELEMETRY-002.spec.ts +179 -0
- package/src/modules/telemetry/api/browser-traces/route.ts +206 -0
- package/src/modules/telemetry/index.ts +15 -0
- package/src/provider/otlp-provider.ts +3 -2
- package/src/trace-headers.ts +16 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerLoggerExtension,
|
|
3
|
+
resetLoggerExtension,
|
|
4
|
+
type LoggerExtensionRecord,
|
|
5
|
+
} from '@open-mercato/shared/lib/logger'
|
|
6
|
+
import { BROWSER_TRACES_PATH } from '../browser/config'
|
|
7
|
+
import {
|
|
8
|
+
BROWSER_TELEMETRY_TEST_COOKIE,
|
|
9
|
+
resetBrowserTelemetryWarnings,
|
|
10
|
+
resolveBrowserTelemetryConfig,
|
|
11
|
+
resolveCollectorHeaders,
|
|
12
|
+
resolveCollectorTracesUrl,
|
|
13
|
+
} from '../browser/server'
|
|
14
|
+
import { resetTelemetryEnvCache } from '../env'
|
|
15
|
+
|
|
16
|
+
// The env keys this module reads. Cleared before each case so a leaked value from the developer's
|
|
17
|
+
// own .env cannot make a test pass for the wrong reason.
|
|
18
|
+
const TELEMETRY_ENV_KEYS = [
|
|
19
|
+
'TELEMETRY_BROWSER_ENABLED',
|
|
20
|
+
'TELEMETRY_BROWSER_SAMPLING_RATIO',
|
|
21
|
+
'TELEMETRY_BROWSER_SERVICE_NAME',
|
|
22
|
+
'TELEMETRY_BACKEND',
|
|
23
|
+
'TELEMETRY_TRUST_INBOUND_TRACE',
|
|
24
|
+
'OTEL_EXPORTER_OTLP_ENDPOINT',
|
|
25
|
+
'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT',
|
|
26
|
+
'OTEL_EXPORTER_OTLP_HEADERS',
|
|
27
|
+
'OTEL_SERVICE_NAME',
|
|
28
|
+
'OTEL_RESOURCE_ATTRIBUTES',
|
|
29
|
+
'OM_TEST_MODE',
|
|
30
|
+
'OM_TEST_BROWSER_TELEMETRY_MODE',
|
|
31
|
+
] as const
|
|
32
|
+
|
|
33
|
+
describe('browser telemetry config', () => {
|
|
34
|
+
const original: Record<string, string | undefined> = {}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
for (const key of TELEMETRY_ENV_KEYS) {
|
|
38
|
+
original[key] = process.env[key]
|
|
39
|
+
delete process.env[key]
|
|
40
|
+
}
|
|
41
|
+
resetTelemetryEnvCache()
|
|
42
|
+
resetBrowserTelemetryWarnings()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
for (const key of TELEMETRY_ENV_KEYS) {
|
|
47
|
+
if (original[key] === undefined) delete process.env[key]
|
|
48
|
+
else process.env[key] = original[key]
|
|
49
|
+
}
|
|
50
|
+
resetTelemetryEnvCache()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('resolveBrowserTelemetryConfig', () => {
|
|
54
|
+
it('is off unless explicitly enabled — the SDK chunk must never load by accident', () => {
|
|
55
|
+
process.env.TELEMETRY_BACKEND = 'signoz'
|
|
56
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
57
|
+
|
|
58
|
+
expect(resolveBrowserTelemetryConfig()).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('stays off when the server telemetry backend is absent or a no-op', () => {
|
|
62
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
63
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
64
|
+
|
|
65
|
+
expect(resolveBrowserTelemetryConfig()).toBeNull()
|
|
66
|
+
|
|
67
|
+
process.env.TELEMETRY_BACKEND = 'noop'
|
|
68
|
+
expect(resolveBrowserTelemetryConfig()).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('stays off with a backend but no collector endpoint — RUM with nowhere to export is pure client overhead', () => {
|
|
72
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
73
|
+
process.env.TELEMETRY_BACKEND = 'signoz'
|
|
74
|
+
|
|
75
|
+
expect(resolveBrowserTelemetryConfig()).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('derives service name and deployment.environment from the existing server telemetry env', () => {
|
|
79
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
80
|
+
process.env.TELEMETRY_BACKEND = 'signoz'
|
|
81
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
82
|
+
process.env.OTEL_SERVICE_NAME = 'acme-oms'
|
|
83
|
+
process.env.OTEL_RESOURCE_ATTRIBUTES = 'deployment.environment=production,foo=bar'
|
|
84
|
+
|
|
85
|
+
const config = resolveBrowserTelemetryConfig()
|
|
86
|
+
|
|
87
|
+
// Separate service so RUM latency does not distort the server service's charts.
|
|
88
|
+
expect(config?.serviceName).toBe('acme-oms-browser')
|
|
89
|
+
expect(config?.environment).toBe('production')
|
|
90
|
+
// Same-origin proxy — never the collector itself.
|
|
91
|
+
expect(config?.endpoint).toBe(BROWSER_TRACES_PATH)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('honors an explicit browser service name override', () => {
|
|
95
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
96
|
+
process.env.TELEMETRY_BACKEND = 'signoz'
|
|
97
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
98
|
+
process.env.TELEMETRY_BROWSER_SERVICE_NAME = 'storefront-rum'
|
|
99
|
+
|
|
100
|
+
expect(resolveBrowserTelemetryConfig()?.serviceName).toBe('storefront-rum')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('tolerates OTEL_RESOURCE_ATTRIBUTES without deployment.environment', () => {
|
|
104
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
105
|
+
process.env.TELEMETRY_BACKEND = 'signoz'
|
|
106
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
107
|
+
process.env.OTEL_RESOURCE_ATTRIBUTES = 'foo=bar'
|
|
108
|
+
|
|
109
|
+
expect(resolveBrowserTelemetryConfig()?.environment).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('defaults sampling to 1.0 and clamps out-of-range or garbage values', () => {
|
|
113
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
114
|
+
process.env.TELEMETRY_BACKEND = 'signoz'
|
|
115
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
116
|
+
|
|
117
|
+
expect(resolveBrowserTelemetryConfig()?.samplingRatio).toBe(1)
|
|
118
|
+
|
|
119
|
+
process.env.TELEMETRY_BROWSER_SAMPLING_RATIO = '0.25'
|
|
120
|
+
expect(resolveBrowserTelemetryConfig()?.samplingRatio).toBe(0.25)
|
|
121
|
+
|
|
122
|
+
process.env.TELEMETRY_BROWSER_SAMPLING_RATIO = '7'
|
|
123
|
+
expect(resolveBrowserTelemetryConfig()?.samplingRatio).toBe(1)
|
|
124
|
+
|
|
125
|
+
process.env.TELEMETRY_BROWSER_SAMPLING_RATIO = '-1'
|
|
126
|
+
expect(resolveBrowserTelemetryConfig()?.samplingRatio).toBe(0)
|
|
127
|
+
|
|
128
|
+
process.env.TELEMETRY_BROWSER_SAMPLING_RATIO = 'garbage'
|
|
129
|
+
expect(resolveBrowserTelemetryConfig()?.samplingRatio).toBe(1)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('trace-continuity warning', () => {
|
|
134
|
+
function enableRum(): void {
|
|
135
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
136
|
+
process.env.TELEMETRY_BACKEND = 'signoz'
|
|
137
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function captureWarnings(): { records: LoggerExtensionRecord[]; dispose: () => void } {
|
|
141
|
+
const records: LoggerExtensionRecord[] = []
|
|
142
|
+
const dispose = registerLoggerExtension({ emit: (record) => records.push(record) })
|
|
143
|
+
return { records, dispose }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
resetLoggerExtension()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('warns once when RUM is enabled without TELEMETRY_TRUST_INBOUND_TRACE — the spans would not stitch', () => {
|
|
151
|
+
enableRum()
|
|
152
|
+
const { records } = captureWarnings()
|
|
153
|
+
|
|
154
|
+
resolveBrowserTelemetryConfig()
|
|
155
|
+
resolveBrowserTelemetryConfig()
|
|
156
|
+
|
|
157
|
+
const warnings = records.filter((record) => record.message === 'telemetry.browser.trace_continuity_disabled')
|
|
158
|
+
// Once per process, not once per request: the layout resolves this on every page render.
|
|
159
|
+
expect(warnings).toHaveLength(1)
|
|
160
|
+
expect(warnings[0].level).toBe('warn')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('stays quiet once the deployment opts into continuing the inbound trace', () => {
|
|
164
|
+
enableRum()
|
|
165
|
+
process.env.TELEMETRY_TRUST_INBOUND_TRACE = 'true'
|
|
166
|
+
resetTelemetryEnvCache()
|
|
167
|
+
const { records } = captureWarnings()
|
|
168
|
+
|
|
169
|
+
expect(resolveBrowserTelemetryConfig()).not.toBeNull()
|
|
170
|
+
expect(records.filter((record) => record.message === 'telemetry.browser.trace_continuity_disabled')).toHaveLength(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('stays quiet while RUM is off — a disabled feature has nothing to misconfigure', () => {
|
|
174
|
+
const { records } = captureWarnings()
|
|
175
|
+
|
|
176
|
+
expect(resolveBrowserTelemetryConfig()).toBeNull()
|
|
177
|
+
expect(records).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('resolveCollectorTracesUrl', () => {
|
|
182
|
+
it('appends /v1/traces to the generic endpoint, tolerating a trailing slash', () => {
|
|
183
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'https://collector.example.com'
|
|
184
|
+
expect(resolveCollectorTracesUrl()).toBe('https://collector.example.com/v1/traces')
|
|
185
|
+
|
|
186
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'https://collector.example.com/'
|
|
187
|
+
expect(resolveCollectorTracesUrl()).toBe('https://collector.example.com/v1/traces')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('uses a signal-specific endpoint verbatim, as the OTLP spec requires', () => {
|
|
191
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'https://collector.example.com'
|
|
192
|
+
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'https://collector.example.com/custom/traces'
|
|
193
|
+
|
|
194
|
+
expect(resolveCollectorTracesUrl()).toBe('https://collector.example.com/custom/traces')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('returns null when nothing is configured', () => {
|
|
198
|
+
expect(resolveCollectorTracesUrl()).toBeNull()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('integration-test cookie opt-in', () => {
|
|
203
|
+
const ON = `${BROWSER_TELEMETRY_TEST_COOKIE}=on`
|
|
204
|
+
|
|
205
|
+
function armTestMode(): void {
|
|
206
|
+
process.env.OM_TEST_MODE = '1'
|
|
207
|
+
process.env.OM_TEST_BROWSER_TELEMETRY_MODE = 'opt-in'
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
it('boots RUM for a request carrying the cookie, with no telemetry env set at all', () => {
|
|
211
|
+
armTestMode()
|
|
212
|
+
|
|
213
|
+
// The point of the escape hatch: a Playwright spec can cover the enabled path for its own
|
|
214
|
+
// page without turning the web SDK on for every other spec in the environment.
|
|
215
|
+
expect(resolveBrowserTelemetryConfig({ cookieHeader: `foo=1; ${ON}; bar=2` })).toEqual({
|
|
216
|
+
endpoint: BROWSER_TRACES_PATH,
|
|
217
|
+
serviceName: 'open-mercato-browser',
|
|
218
|
+
environment: null,
|
|
219
|
+
samplingRatio: 1,
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('ignores the cookie in production, where neither test-mode variable is set', () => {
|
|
224
|
+
expect(resolveBrowserTelemetryConfig({ cookieHeader: ON })).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('ignores the cookie when OM_TEST_MODE is set but the feature was not explicitly opted in', () => {
|
|
228
|
+
process.env.OM_TEST_MODE = '1'
|
|
229
|
+
|
|
230
|
+
// Two gates, not one: OM_TEST_MODE is on across the whole integration environment.
|
|
231
|
+
expect(resolveBrowserTelemetryConfig({ cookieHeader: ON })).toBeNull()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('ignores a request without the cookie even with both gates armed', () => {
|
|
235
|
+
armTestMode()
|
|
236
|
+
|
|
237
|
+
expect(resolveBrowserTelemetryConfig({ cookieHeader: 'session=abc' })).toBeNull()
|
|
238
|
+
expect(resolveBrowserTelemetryConfig()).toBeNull()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('does not match a cookie whose name merely ends with the opt-in name', () => {
|
|
242
|
+
armTestMode()
|
|
243
|
+
|
|
244
|
+
expect(resolveBrowserTelemetryConfig({ cookieHeader: `not_${BROWSER_TELEMETRY_TEST_COOKIE}=on` })).toBeNull()
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('resolveCollectorHeaders', () => {
|
|
249
|
+
it('splits on the FIRST = so base64 padding in a BasicAuth credential survives', () => {
|
|
250
|
+
process.env.OTEL_EXPORTER_OTLP_HEADERS = 'Authorization=Basic b3RlbDpzZWNyZXQ='
|
|
251
|
+
|
|
252
|
+
expect(resolveCollectorHeaders()).toEqual({ Authorization: 'Basic b3RlbDpzZWNyZXQ=' })
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('parses multiple comma-separated pairs and ignores malformed ones', () => {
|
|
256
|
+
process.env.OTEL_EXPORTER_OTLP_HEADERS = 'api-key=abc123, x-tenant = t1 ,broken,=novalue'
|
|
257
|
+
|
|
258
|
+
expect(resolveCollectorHeaders()).toEqual({ 'api-key': 'abc123', 'x-tenant': 't1' })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('returns an empty record when unset — an in-cluster collector may need no credential', () => {
|
|
262
|
+
expect(resolveCollectorHeaders()).toEqual({})
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
})
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { POST } from '../modules/telemetry/api/browser-traces/route'
|
|
2
|
+
|
|
3
|
+
/** Stands in for the DI-registered `rateLimiterService`; `null` means none is registered. */
|
|
4
|
+
const mockConsume = jest.fn(async () => ({ allowed: true, remainingPoints: 399, msBeforeNext: 0, consumedPoints: 1 }))
|
|
5
|
+
let mockRateLimiter: { consume: typeof mockConsume } | null = null
|
|
6
|
+
|
|
7
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
8
|
+
createRequestContainer: async () => ({
|
|
9
|
+
resolve: (name: string) => {
|
|
10
|
+
if (name === 'rateLimiterService' && mockRateLimiter) return mockRateLimiter
|
|
11
|
+
throw new Error(`[internal] not registered: ${name}`)
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
/** The dispatcher hands the handler the resolved auth context; `sub` is the budget key. */
|
|
17
|
+
const AUTHED = { auth: { sub: 'user-1' } } as Parameters<typeof POST>[1]
|
|
18
|
+
|
|
19
|
+
const ENV_KEYS = [
|
|
20
|
+
'TELEMETRY_BROWSER_ENABLED',
|
|
21
|
+
'TELEMETRY_BACKEND',
|
|
22
|
+
'OTEL_EXPORTER_OTLP_ENDPOINT',
|
|
23
|
+
'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT',
|
|
24
|
+
'OTEL_EXPORTER_OTLP_HEADERS',
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
function enableBrowserTelemetry(): void {
|
|
28
|
+
process.env.TELEMETRY_BROWSER_ENABLED = 'true'
|
|
29
|
+
process.env.TELEMETRY_BACKEND = 'otlp'
|
|
30
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://collector:4318'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The statuses `@opentelemetry/otlp-exporter-base/is-export-retryable` treats as retryable. Any of
|
|
35
|
+
* them on a failure path turns a collector outage into a retry storm from every open tab, which is
|
|
36
|
+
* exactly what the route's fire-and-forget contract exists to prevent.
|
|
37
|
+
*/
|
|
38
|
+
const EXPORTER_RETRYABLE_STATUSES = [429, 502, 503, 504]
|
|
39
|
+
|
|
40
|
+
function traceRequest(init?: RequestInit & { headers?: Record<string, string> }): Request {
|
|
41
|
+
return new Request('http://localhost/api/telemetry/browser-traces', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
body: '{"resourceSpans":[]}',
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
...init,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('browser-traces proxy route', () => {
|
|
50
|
+
const original: Record<string, string | undefined> = {}
|
|
51
|
+
const realFetch = global.fetch
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
for (const key of ENV_KEYS) {
|
|
55
|
+
original[key] = process.env[key]
|
|
56
|
+
delete process.env[key]
|
|
57
|
+
}
|
|
58
|
+
mockConsume.mockClear()
|
|
59
|
+
mockRateLimiter = { consume: mockConsume }
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
for (const key of ENV_KEYS) {
|
|
64
|
+
if (original[key] === undefined) delete process.env[key]
|
|
65
|
+
else process.env[key] = original[key]
|
|
66
|
+
}
|
|
67
|
+
global.fetch = realFetch
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('accepts and drops with 204 when browser telemetry is disabled — a stale client must not retry', async () => {
|
|
71
|
+
const fetchSpy = jest.fn()
|
|
72
|
+
global.fetch = fetchSpy as typeof fetch
|
|
73
|
+
|
|
74
|
+
const res = await POST(traceRequest())
|
|
75
|
+
|
|
76
|
+
expect(res.status).toBe(204)
|
|
77
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('forwards the payload to the collector with server-side credentials and returns 202', async () => {
|
|
81
|
+
enableBrowserTelemetry()
|
|
82
|
+
process.env.OTEL_EXPORTER_OTLP_HEADERS = 'api-key=secret123'
|
|
83
|
+
const fetchSpy = jest.fn(async () => new Response(null, { status: 200 }))
|
|
84
|
+
global.fetch = fetchSpy as unknown as typeof fetch
|
|
85
|
+
|
|
86
|
+
const res = await POST(traceRequest())
|
|
87
|
+
|
|
88
|
+
expect(res.status).toBe(202)
|
|
89
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
90
|
+
const [target, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit]
|
|
91
|
+
expect(target).toBe('http://collector:4318/v1/traces')
|
|
92
|
+
expect((init.headers as Record<string, string>)['api-key']).toBe('secret123')
|
|
93
|
+
expect((init.headers as Record<string, string>)['content-type']).toBe('application/json')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('drops the batch with a non-retryable 202 when the collector rejects it, without throwing', async () => {
|
|
97
|
+
enableBrowserTelemetry()
|
|
98
|
+
global.fetch = jest.fn(async () => new Response(null, { status: 500 })) as unknown as typeof fetch
|
|
99
|
+
|
|
100
|
+
const res = await POST(traceRequest())
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(202)
|
|
103
|
+
expect(EXPORTER_RETRYABLE_STATUSES).not.toContain(res.status)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('drops the batch with a non-retryable 202 when the collector is unreachable, without throwing', async () => {
|
|
107
|
+
enableBrowserTelemetry()
|
|
108
|
+
global.fetch = jest.fn(async () => {
|
|
109
|
+
throw new Error('connect ECONNREFUSED')
|
|
110
|
+
}) as unknown as typeof fetch
|
|
111
|
+
|
|
112
|
+
const res = await POST(traceRequest())
|
|
113
|
+
|
|
114
|
+
expect(res.status).toBe(202)
|
|
115
|
+
expect(EXPORTER_RETRYABLE_STATUSES).not.toContain(res.status)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('refuses an oversized declared content-length before buffering', async () => {
|
|
119
|
+
enableBrowserTelemetry()
|
|
120
|
+
const fetchSpy = jest.fn()
|
|
121
|
+
global.fetch = fetchSpy as typeof fetch
|
|
122
|
+
|
|
123
|
+
const res = await POST(
|
|
124
|
+
traceRequest({ headers: { 'content-type': 'application/json', 'content-length': '2000000' } }),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(res.status).toBe(413)
|
|
128
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('keeps the declared-size cap even when telemetry is disabled — the cap is an unconditional contract', async () => {
|
|
132
|
+
const res = await POST(
|
|
133
|
+
traceRequest({ headers: { 'content-type': 'application/json', 'content-length': '2000000' } }),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(res.status).toBe(413)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('refuses a body that exceeds the cap even when content-length lies', async () => {
|
|
140
|
+
enableBrowserTelemetry()
|
|
141
|
+
const fetchSpy = jest.fn()
|
|
142
|
+
global.fetch = fetchSpy as typeof fetch
|
|
143
|
+
|
|
144
|
+
const oversized = 'x'.repeat(1_000_001)
|
|
145
|
+
const res = await POST(traceRequest({ body: oversized }))
|
|
146
|
+
|
|
147
|
+
expect(res.status).toBe(413)
|
|
148
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('acknowledges an empty batch with 202 without calling the collector', async () => {
|
|
152
|
+
enableBrowserTelemetry()
|
|
153
|
+
const fetchSpy = jest.fn()
|
|
154
|
+
global.fetch = fetchSpy as typeof fetch
|
|
155
|
+
|
|
156
|
+
const res = await POST(traceRequest({ body: null }))
|
|
157
|
+
|
|
158
|
+
expect(res.status).toBe(202)
|
|
159
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('lets operator collector headers be overridden by the content headers the browser actually sent', async () => {
|
|
163
|
+
enableBrowserTelemetry()
|
|
164
|
+
// An operator whose OTEL_EXPORTER_OTLP_HEADERS happens to carry a content-type must not silently
|
|
165
|
+
// relabel a gzipped protobuf batch as JSON.
|
|
166
|
+
process.env.OTEL_EXPORTER_OTLP_HEADERS = 'api-key=secret123,content-type=text/plain'
|
|
167
|
+
const fetchSpy = jest.fn(async () => new Response(null, { status: 200 }))
|
|
168
|
+
global.fetch = fetchSpy as unknown as typeof fetch
|
|
169
|
+
|
|
170
|
+
await POST(traceRequest({ headers: { 'content-type': 'application/x-protobuf' } }))
|
|
171
|
+
|
|
172
|
+
const [, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit]
|
|
173
|
+
expect((init.headers as Record<string, string>)['content-type']).toBe('application/x-protobuf')
|
|
174
|
+
expect((init.headers as Record<string, string>)['api-key']).toBe('secret123')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('per-user ingest budget', () => {
|
|
178
|
+
it('charges the budget to the authenticated user, not to a shared bucket', async () => {
|
|
179
|
+
enableBrowserTelemetry()
|
|
180
|
+
global.fetch = jest.fn(async () => new Response(null, { status: 200 })) as unknown as typeof fetch
|
|
181
|
+
|
|
182
|
+
await POST(traceRequest(), AUTHED)
|
|
183
|
+
|
|
184
|
+
// An IP-keyed limiter collapses to one 'global' bucket at the default trust-proxy depth, so
|
|
185
|
+
// one looping tab would starve telemetry for the whole deployment.
|
|
186
|
+
expect(mockConsume).toHaveBeenCalledWith('user-1', expect.objectContaining({ points: 400, duration: 60 }))
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('drops an over-budget batch with 204 — never a status the exporter would retry', async () => {
|
|
190
|
+
enableBrowserTelemetry()
|
|
191
|
+
const fetchSpy = jest.fn()
|
|
192
|
+
global.fetch = fetchSpy as typeof fetch
|
|
193
|
+
mockConsume.mockResolvedValueOnce({ allowed: false, remainingPoints: 0, msBeforeNext: 1_000, consumedPoints: 401 })
|
|
194
|
+
|
|
195
|
+
const res = await POST(traceRequest(), AUTHED)
|
|
196
|
+
|
|
197
|
+
expect(res.status).toBe(204)
|
|
198
|
+
// 429 is in the exporter's retryable set, so throttling with it would produce exactly the
|
|
199
|
+
// backoff storm this route's status contract exists to prevent.
|
|
200
|
+
expect(EXPORTER_RETRYABLE_STATUSES).not.toContain(res.status)
|
|
201
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('fails open when the limiter is degraded — an unenforced budget beats refusing traffic', async () => {
|
|
205
|
+
enableBrowserTelemetry()
|
|
206
|
+
const fetchSpy = jest.fn(async () => new Response(null, { status: 200 }))
|
|
207
|
+
global.fetch = fetchSpy as unknown as typeof fetch
|
|
208
|
+
mockConsume.mockResolvedValueOnce({
|
|
209
|
+
allowed: false,
|
|
210
|
+
degraded: true,
|
|
211
|
+
remainingPoints: 400,
|
|
212
|
+
msBeforeNext: 0,
|
|
213
|
+
consumedPoints: 0,
|
|
214
|
+
} as Awaited<ReturnType<typeof mockConsume>>)
|
|
215
|
+
|
|
216
|
+
const res = await POST(traceRequest(), AUTHED)
|
|
217
|
+
|
|
218
|
+
expect(res.status).toBe(202)
|
|
219
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('fails open when no limiter is registered at all', async () => {
|
|
223
|
+
enableBrowserTelemetry()
|
|
224
|
+
mockRateLimiter = null
|
|
225
|
+
const fetchSpy = jest.fn(async () => new Response(null, { status: 200 }))
|
|
226
|
+
global.fetch = fetchSpy as unknown as typeof fetch
|
|
227
|
+
|
|
228
|
+
const res = await POST(traceRequest(), AUTHED)
|
|
229
|
+
|
|
230
|
+
expect(res.status).toBe(202)
|
|
231
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* span shape, the delegation/nesting model, cross-boundary trace propagation
|
|
5
5
|
* (same traceId), root-per-request, log correlation, and metrics.
|
|
6
6
|
*/
|
|
7
|
-
import { SpanStatusCode, propagation, trace, context, ROOT_CONTEXT } from '@opentelemetry/api'
|
|
7
|
+
import { SpanStatusCode, propagation, trace, context, defaultTextMapSetter, ROOT_CONTEXT } from '@opentelemetry/api'
|
|
8
|
+
import { W3CTraceContextPropagator } from '@opentelemetry/core'
|
|
8
9
|
import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'
|
|
9
10
|
import { InMemoryLogRecordExporter, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs'
|
|
10
11
|
import { InMemoryMetricExporter, PeriodicExportingMetricReader, AggregationTemporality } from '@opentelemetry/sdk-metrics'
|
|
11
12
|
|
|
12
13
|
import { OtlpProvider } from '../provider/otlp-provider'
|
|
14
|
+
import { createBackupHeaderPropagator } from '../browser/propagator'
|
|
13
15
|
import {
|
|
14
16
|
createLogger,
|
|
15
17
|
resetLoggerExtension,
|
|
@@ -231,6 +233,57 @@ describe('OtlpProvider (in-memory exporters)', () => {
|
|
|
231
233
|
expect(trace.getSpanContext(extracted)).toBeUndefined()
|
|
232
234
|
})
|
|
233
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Browser RUM's end-to-end promise: a span the browser started and the server span it caused
|
|
238
|
+
* share one trace. Drives the REAL browser injector (`createBackupHeaderPropagator`, the same
|
|
239
|
+
* factory `BrowserTelemetry.tsx` registers) against the REAL server extractor, with a proxy
|
|
240
|
+
* rewriting `traceparent` in between — the case the backup header exists for.
|
|
241
|
+
*/
|
|
242
|
+
describe('browser RUM trace continuity', () => {
|
|
243
|
+
const browserPropagator = createBackupHeaderPropagator(
|
|
244
|
+
new W3CTraceContextPropagator(),
|
|
245
|
+
defaultTextMapSetter,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
/** Headers as the browser exporter's fetch instrumentation would send them, then an LB rewrite. */
|
|
249
|
+
function browserRequestHeaders(): { headers: Record<string, string>; browserTraceId: string } {
|
|
250
|
+
const headers: Record<string, string> = {}
|
|
251
|
+
let browserTraceId = ''
|
|
252
|
+
withSpan('browser-fetch', () => {
|
|
253
|
+
browserPropagator.inject(context.active(), headers, defaultTextMapSetter)
|
|
254
|
+
browserTraceId = trace.getActiveSpan()?.spanContext().traceId ?? ''
|
|
255
|
+
})
|
|
256
|
+
// The proxy in front of the app points `traceparent` at its own unexported span.
|
|
257
|
+
headers.traceparent = `00-${'a'.repeat(32)}-${'b'.repeat(16)}-01`
|
|
258
|
+
return { headers, browserTraceId }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
it('puts the server span in the browser span trace once the deployment trusts inbound context', () => {
|
|
262
|
+
const { headers, browserTraceId } = browserRequestHeaders()
|
|
263
|
+
process.env.TELEMETRY_TRUST_INBOUND_TRACE = 'true'
|
|
264
|
+
resetTelemetryEnvCache()
|
|
265
|
+
|
|
266
|
+
const inbound = propagation.extract(ROOT_CONTEXT, headers)
|
|
267
|
+
context.with(inbound, () => withSpan('server-request', () => undefined))
|
|
268
|
+
|
|
269
|
+
const serverSpan = spanExporter.getFinishedSpans().find((s) => s.name === 'server-request')
|
|
270
|
+
expect(browserTraceId).toMatch(/^[0-9a-f]{32}$/)
|
|
271
|
+
expect(serverSpan?.spanContext().traceId).toBe(browserTraceId)
|
|
272
|
+
// Not the proxy's rewritten id — that is exactly what the backup header defends against.
|
|
273
|
+
expect(serverSpan?.spanContext().traceId).not.toBe('a'.repeat(32))
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('leaves the server span in its own trace while the default trust boundary holds', () => {
|
|
277
|
+
const { headers, browserTraceId } = browserRequestHeaders()
|
|
278
|
+
|
|
279
|
+
const inbound = propagation.extract(ROOT_CONTEXT, headers)
|
|
280
|
+
context.with(inbound, () => withSpan('server-request', () => undefined))
|
|
281
|
+
|
|
282
|
+
const serverSpan = spanExporter.getFinishedSpans().find((s) => s.name === 'server-request')
|
|
283
|
+
expect(serverSpan?.spanContext().traceId).not.toBe(browserTraceId)
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
234
287
|
it('redacts a leaked email from a span exception (PII backstop at the OTEL boundary)', () => {
|
|
235
288
|
withSpan('pii-span', (s) => {
|
|
236
289
|
s.recordException(new Error('no account for jan.kowalski@example.com'))
|