@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only half of browser RUM: resolves the client configuration and the
|
|
3
|
+
* collector coordinates from the telemetry env the host already sets
|
|
4
|
+
* (`TELEMETRY_BACKEND`, `OTEL_EXPORTER_OTLP_*`), so enabling RUM is one flag and
|
|
5
|
+
* no new endpoint plumbing.
|
|
6
|
+
*
|
|
7
|
+
* Import from `@open-mercato/telemetry/browser/server` — NOT from
|
|
8
|
+
* `@open-mercato/telemetry/browser`, which is the client-safe entry. The split is
|
|
9
|
+
* the point: `resolveCollectorHeaders()` returns the collector credential, which
|
|
10
|
+
* must never be reachable from a client bundle. The guard below turns a wrong
|
|
11
|
+
* import into an immediate, loud failure instead of a silent leak.
|
|
12
|
+
*
|
|
13
|
+
* Resolved at request time in the backend layout (`force-dynamic`) and passed to
|
|
14
|
+
* the client as props — deliberately NOT `NEXT_PUBLIC_*`, which would bake the
|
|
15
|
+
* values into the build and require a rebuild to toggle.
|
|
16
|
+
*/
|
|
17
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
18
|
+
import { isTelemetryBackendEnabled } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
19
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
20
|
+
import { readTelemetryEnv } from '../env'
|
|
21
|
+
import { BROWSER_TRACES_PATH, type BrowserTelemetryConfig } from './config'
|
|
22
|
+
|
|
23
|
+
if (typeof window !== 'undefined') {
|
|
24
|
+
throw new Error('[internal] @open-mercato/telemetry/browser/server is server-only; import @open-mercato/telemetry/browser instead')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const logger = createLogger('telemetry').child({ component: 'browser-config' })
|
|
28
|
+
|
|
29
|
+
/** `deployment.environment=dev,foo=bar` → `dev`. */
|
|
30
|
+
function readDeploymentEnvironment(raw: string | undefined): string | null {
|
|
31
|
+
if (!raw) return null
|
|
32
|
+
for (const pair of raw.split(',')) {
|
|
33
|
+
const idx = pair.indexOf('=')
|
|
34
|
+
if (idx === -1) continue
|
|
35
|
+
if (pair.slice(0, idx).trim() !== 'deployment.environment') continue
|
|
36
|
+
const value = pair.slice(idx + 1).trim()
|
|
37
|
+
if (value) return value
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readSamplingRatio(raw: string | undefined): number {
|
|
43
|
+
if (!raw) return 1
|
|
44
|
+
const parsed = Number(raw)
|
|
45
|
+
if (!Number.isFinite(parsed)) return 1
|
|
46
|
+
return Math.min(1, Math.max(0, parsed))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let warnedAboutTraceContinuity = false
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Browser spans stitch to their server spans only when the server continues the
|
|
53
|
+
* inbound trace, which is exactly what `TELEMETRY_TRUST_INBOUND_TRACE` gates. RUM
|
|
54
|
+
* without it still works — it just yields two disconnected traces per page, which
|
|
55
|
+
* looks like a bug and is the single most likely misconfiguration, so say it once
|
|
56
|
+
* per process rather than let an operator discover it in the trace view.
|
|
57
|
+
*/
|
|
58
|
+
function warnWhenTracesCannotStitch(): void {
|
|
59
|
+
if (warnedAboutTraceContinuity) return
|
|
60
|
+
// Same reader the provider's propagator consults, so the warning can never
|
|
61
|
+
// disagree with the behaviour it describes.
|
|
62
|
+
if (readTelemetryEnv().trustInboundTrace) return
|
|
63
|
+
warnedAboutTraceContinuity = true
|
|
64
|
+
logger.warn('telemetry.browser.trace_continuity_disabled', {
|
|
65
|
+
detail:
|
|
66
|
+
'Browser RUM is enabled but TELEMETRY_TRUST_INBOUND_TRACE is not true, so browser spans and their server spans will land in separate traces. Set it to true when the app is only reachable through trusted infrastructure.',
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Test seam: the warn-once latch is process-wide, which a second test case would otherwise miss. */
|
|
71
|
+
export function resetBrowserTelemetryWarnings(): void {
|
|
72
|
+
warnedAboutTraceContinuity = false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Cookie an integration spec sets to boot RUM for its own page only. See
|
|
77
|
+
* `resolveTestOptIn` for why a per-request switch is the only way to cover the
|
|
78
|
+
* enabled path.
|
|
79
|
+
*/
|
|
80
|
+
export const BROWSER_TELEMETRY_TEST_COOKIE = 'om_test_browser_telemetry'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Browser RUM is an environment-wide switch read from `process.env`, so a Playwright spec cannot
|
|
84
|
+
* turn it on for one page — and turning it on for the whole integration environment would boot the
|
|
85
|
+
* web SDK on every page of every other spec. This is the same targeted escape hatch the auth and
|
|
86
|
+
* AI-chat rate limiters use (`OM_TEST_MODE=1` plus a second, explicit `*_MODE=opt-in` variable):
|
|
87
|
+
* both are absent in production, and only then is the request's cookie consulted at all.
|
|
88
|
+
*
|
|
89
|
+
* The synthetic config it returns is deliberately self-contained — no `TELEMETRY_BACKEND`, no
|
|
90
|
+
* collector endpoint — so enabling it costs the integration environment nothing but the one page
|
|
91
|
+
* that asked for it. With no collector configured the proxy answers `204`, which is exactly the
|
|
92
|
+
* accept-and-drop contract the route documents, and the spec asserts on the batch the browser
|
|
93
|
+
* posted rather than on anything the collector received.
|
|
94
|
+
*/
|
|
95
|
+
function resolveTestOptIn(cookieHeader: string | null | undefined): BrowserTelemetryConfig | null {
|
|
96
|
+
if (process.env.OM_TEST_MODE !== '1') return null
|
|
97
|
+
if (process.env.OM_TEST_BROWSER_TELEMETRY_MODE !== 'opt-in') return null
|
|
98
|
+
if (!cookieHeader) return null
|
|
99
|
+
const pattern = new RegExp(`(?:^|;)\\s*${BROWSER_TELEMETRY_TEST_COOKIE}=on(?:;|$)`)
|
|
100
|
+
if (!pattern.test(cookieHeader)) return null
|
|
101
|
+
return {
|
|
102
|
+
endpoint: BROWSER_TRACES_PATH,
|
|
103
|
+
serviceName: `${process.env.OTEL_SERVICE_NAME?.trim() || 'open-mercato'}-browser`,
|
|
104
|
+
environment: readDeploymentEnvironment(process.env.OTEL_RESOURCE_ATTRIBUTES),
|
|
105
|
+
samplingRatio: 1,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns `null` when browser telemetry is off — which is the default
|
|
111
|
+
* everywhere, including local. Requires an active server telemetry backend
|
|
112
|
+
* *and* an explicit opt-in: RUM without a reachable collector is pure overhead
|
|
113
|
+
* in the client bundle.
|
|
114
|
+
*
|
|
115
|
+
* `options.cookieHeader` is the request's raw `Cookie` header, and is read only by the
|
|
116
|
+
* integration-test opt-in above; production resolution is env-only and never consults it.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveBrowserTelemetryConfig(
|
|
119
|
+
options: { cookieHeader?: string | null } = {},
|
|
120
|
+
): BrowserTelemetryConfig | null {
|
|
121
|
+
const testOptIn = resolveTestOptIn(options.cookieHeader)
|
|
122
|
+
if (testOptIn) return testOptIn
|
|
123
|
+
|
|
124
|
+
if (!parseBooleanWithDefault(process.env.TELEMETRY_BROWSER_ENABLED, false)) return null
|
|
125
|
+
if (!isTelemetryBackendEnabled()) return null
|
|
126
|
+
if (!resolveCollectorTracesUrl()) return null
|
|
127
|
+
|
|
128
|
+
warnWhenTracesCannotStitch()
|
|
129
|
+
|
|
130
|
+
const serviceName =
|
|
131
|
+
process.env.TELEMETRY_BROWSER_SERVICE_NAME?.trim() ||
|
|
132
|
+
`${process.env.OTEL_SERVICE_NAME?.trim() || 'open-mercato'}-browser`
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
endpoint: BROWSER_TRACES_PATH,
|
|
136
|
+
serviceName,
|
|
137
|
+
environment: readDeploymentEnvironment(process.env.OTEL_RESOURCE_ATTRIBUTES),
|
|
138
|
+
samplingRatio: readSamplingRatio(process.env.TELEMETRY_BROWSER_SAMPLING_RATIO),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Where the proxy forwards to. Per the OTLP spec a signal-specific endpoint is
|
|
144
|
+
* used verbatim, while the generic one gets `/v1/traces` appended.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveCollectorTracesUrl(): string | null {
|
|
147
|
+
const signal = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim()
|
|
148
|
+
if (signal) return signal
|
|
149
|
+
const generic = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()
|
|
150
|
+
if (!generic) return null
|
|
151
|
+
return `${generic.replace(/\/+$/, '')}/v1/traces`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <b64>,key=value` → header record.
|
|
156
|
+
* Split on the FIRST `=` only: base64 credentials routinely end in `=` padding.
|
|
157
|
+
*/
|
|
158
|
+
export function resolveCollectorHeaders(): Record<string, string> {
|
|
159
|
+
const raw = process.env.OTEL_EXPORTER_OTLP_HEADERS?.trim()
|
|
160
|
+
if (!raw) return {}
|
|
161
|
+
const headers: Record<string, string> = {}
|
|
162
|
+
for (const pair of raw.split(',')) {
|
|
163
|
+
const idx = pair.indexOf('=')
|
|
164
|
+
if (idx <= 0) continue
|
|
165
|
+
const key = pair.slice(0, idx).trim()
|
|
166
|
+
const value = pair.slice(idx + 1).trim()
|
|
167
|
+
if (key && value) headers[key] = value
|
|
168
|
+
}
|
|
169
|
+
return headers
|
|
170
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-TELEMETRY-001: browser RUM span-ingest proxy contract.
|
|
6
|
+
*
|
|
7
|
+
* Route: POST /api/telemetry/browser-traces (requireAuth). The proxy accepts
|
|
8
|
+
* OTLP/HTTP batches from authenticated backoffice sessions and forwards them
|
|
9
|
+
* to the configured collector. It is deliberately fire-and-forget: when browser
|
|
10
|
+
* telemetry is disabled for the environment (the default, and the state of the
|
|
11
|
+
* integration test env) it accepts and drops with 204 so a stale client never
|
|
12
|
+
* retries. Oversized payloads are refused with 413 before buffering.
|
|
13
|
+
*/
|
|
14
|
+
test.describe('TC-TELEMETRY-001: browser traces proxy', () => {
|
|
15
|
+
let token = ''
|
|
16
|
+
|
|
17
|
+
test.beforeAll(async ({ request }) => {
|
|
18
|
+
token = await getAuthToken(request, 'admin')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('unauthenticated span ingest is rejected', async ({ request }) => {
|
|
22
|
+
const res = await request.post('/api/telemetry/browser-traces', {
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
data: { resourceSpans: [] },
|
|
25
|
+
})
|
|
26
|
+
expect(res.status(), 'span ingest must not be an unauthenticated endpoint').toBe(401)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('authenticated batch is accepted', async ({ request }) => {
|
|
30
|
+
const res = await apiRequest(request, 'POST', '/api/telemetry/browser-traces', {
|
|
31
|
+
token,
|
|
32
|
+
data: { resourceSpans: [] },
|
|
33
|
+
})
|
|
34
|
+
// 204 = telemetry disabled for the environment (accept-and-drop, the
|
|
35
|
+
// default here); 202 = forwarded to a configured collector.
|
|
36
|
+
expect([204, 202], 'an authenticated batch is always accepted').toContain(res.status())
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('an oversized payload is refused with 413', async ({ request }) => {
|
|
40
|
+
const oversized = `{"resourceSpans":["${'x'.repeat(1_100_000)}"]}`
|
|
41
|
+
const res = await request.post('/api/telemetry/browser-traces', {
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${token}`,
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
},
|
|
46
|
+
data: oversized,
|
|
47
|
+
})
|
|
48
|
+
expect(res.status(), 'payloads above the 1 MB cap are refused').toBe(413)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { expect, test, type Page, type Request as PlaywrightRequest } from '@playwright/test'
|
|
2
|
+
import { login } from '@open-mercato/core/helpers/integration/auth'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-TELEMETRY-002: browser RUM in a real browser.
|
|
6
|
+
*
|
|
7
|
+
* TC-TELEMETRY-001 drives the proxy route with `request.post` — a useful API contract test that
|
|
8
|
+
* never opens a page. This spec covers what only a browser can: that the SDK actually boots, that
|
|
9
|
+
* the `__omOriginalFetch` patch dance leaves both fetch paths working (a recursion here hangs every
|
|
10
|
+
* API call in the backoffice), that batches reach the proxy, and — the mirror image — that a
|
|
11
|
+
* default environment ships none of it.
|
|
12
|
+
*
|
|
13
|
+
* Browser RUM is normally an environment-wide env switch, which a spec cannot flip for one page.
|
|
14
|
+
* The integration environment therefore sets `OM_TEST_BROWSER_TELEMETRY_MODE=opt-in` on top of
|
|
15
|
+
* `OM_TEST_MODE=1`, and only then does the server honour an `om_test_browser_telemetry=on` cookie.
|
|
16
|
+
* Both variables are absent in production. Same escape hatch the auth rate limiter already uses.
|
|
17
|
+
*
|
|
18
|
+
* No collector is configured here, so the proxy answers 204 (accept-and-drop) rather than 202 —
|
|
19
|
+
* which is why the span assertions read the batch the *browser posted* instead of anything a sink
|
|
20
|
+
* received. That is the stronger assertion anyway: it is the browser's output under test.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TRACES_PATH = '/api/telemetry/browser-traces'
|
|
24
|
+
const OPT_IN_COOKIE = 'om_test_browser_telemetry'
|
|
25
|
+
|
|
26
|
+
type IngestRecord = { status: number; body: string }
|
|
27
|
+
|
|
28
|
+
/** Records every proxy call the page makes, with the payload the exporter actually produced. */
|
|
29
|
+
function recordIngest(page: Page): IngestRecord[] {
|
|
30
|
+
const records: IngestRecord[] = []
|
|
31
|
+
page.on('response', async (response) => {
|
|
32
|
+
if (!response.url().includes(TRACES_PATH)) return
|
|
33
|
+
const request: PlaywrightRequest = response.request()
|
|
34
|
+
if (request.method() !== 'POST') return
|
|
35
|
+
records.push({ status: response.status(), body: request.postData() ?? '' })
|
|
36
|
+
})
|
|
37
|
+
return records
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Records the same-origin API GETs the backoffice itself made and that answered 200. Replaying a
|
|
42
|
+
* real one beats naming an endpoint here: the set of routes is not this spec's business, and a
|
|
43
|
+
* path that quietly stops existing would fail the test for the wrong reason.
|
|
44
|
+
*/
|
|
45
|
+
function recordApiGets(page: Page): string[] {
|
|
46
|
+
const urls: string[] = []
|
|
47
|
+
page.on('response', (response) => {
|
|
48
|
+
const request = response.request()
|
|
49
|
+
if (request.method() !== 'GET') return
|
|
50
|
+
if (response.status() !== 200) return
|
|
51
|
+
try {
|
|
52
|
+
const { pathname } = new URL(response.url())
|
|
53
|
+
if (!pathname.startsWith('/api/') || pathname.startsWith(TRACES_PATH)) return
|
|
54
|
+
urls.push(response.url())
|
|
55
|
+
} catch {
|
|
56
|
+
// A non-absolute or exotic URL is simply not a candidate for replay.
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
return urls
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The web SDK registers itself on this global symbol; its presence is the "SDK booted" signal. */
|
|
63
|
+
async function otelApiIsRegistered(page: Page): Promise<boolean> {
|
|
64
|
+
return page.evaluate(() =>
|
|
65
|
+
Object.getOwnPropertySymbols(globalThis).some((symbol) => symbol.toString().includes('opentelemetry.js.api')),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test.describe('TC-TELEMETRY-002: browser RUM runtime', () => {
|
|
70
|
+
test('exports document-load and fetch spans without breaking either fetch path', async ({ page, baseURL }) => {
|
|
71
|
+
const origin = new URL(baseURL ?? 'http://localhost:3000').origin
|
|
72
|
+
await page.context().addCookies([{ name: OPT_IN_COOKIE, value: 'on', url: origin }])
|
|
73
|
+
const ingest = recordIngest(page)
|
|
74
|
+
const apiGets = recordApiGets(page)
|
|
75
|
+
|
|
76
|
+
await login(page, 'admin')
|
|
77
|
+
await page.goto('/backend')
|
|
78
|
+
await expect.poll(() => otelApiIsRegistered(page), {
|
|
79
|
+
message: 'the web SDK must boot once the server hands the layout a non-null config',
|
|
80
|
+
timeout: 15_000,
|
|
81
|
+
}).toBe(true)
|
|
82
|
+
|
|
83
|
+
// The patch dance, asserted where it actually runs. `@open-mercato/ui`'s api utils stash the
|
|
84
|
+
// pristine fetch on `__omOriginalFetch` and put a wrapper on `window.fetch`; the SDK has to
|
|
85
|
+
// instrument the stash rather than the wrapper, or direct `apiFetch` callers (DataTable,
|
|
86
|
+
// CrudForm, react-query) produce no spans at all.
|
|
87
|
+
const patchState = await page.evaluate(() => {
|
|
88
|
+
const w = window as Window & { __omOriginalFetch?: typeof window.fetch }
|
|
89
|
+
return {
|
|
90
|
+
hasStash: typeof w.__omOriginalFetch === 'function',
|
|
91
|
+
// Identical would mean the wrapper was handed back to itself — an infinite recursion on
|
|
92
|
+
// the first request through `apiFetch`.
|
|
93
|
+
stashIsWrapper: w.__omOriginalFetch === window.fetch,
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
expect(patchState.hasStash, '@open-mercato/ui installs the fetch wrapper on every backoffice page').toBe(true)
|
|
97
|
+
expect(patchState.stashIsWrapper, 'instrumenting the wrapper instead of the native fetch recurses forever').toBe(false)
|
|
98
|
+
|
|
99
|
+
// A DataTable page drives `apiFetch` (which reads the instrumented stash) end to end. Waiting
|
|
100
|
+
// on the API call it makes, rather than on a DOM role, keeps this assertion about the thing
|
|
101
|
+
// under test: that the swap left `apiFetch` able to reach the API at all.
|
|
102
|
+
await expect.poll(() => apiGets.length, {
|
|
103
|
+
message: 'the backoffice must still reach its own API through the patched fetch',
|
|
104
|
+
timeout: 30_000,
|
|
105
|
+
}).toBeGreaterThan(0)
|
|
106
|
+
await page.goto('/backend/customers/people')
|
|
107
|
+
await expect(page.locator('table, [role="grid"]').first()).toBeVisible({ timeout: 30_000 })
|
|
108
|
+
|
|
109
|
+
// Replay one of the page's OWN successful GETs down both paths. A hang here IS the recursion
|
|
110
|
+
// bug, hence the tight budget; a non-200 means the swap broke the wrapper's auth handling.
|
|
111
|
+
const replayUrl = apiGets[apiGets.length - 1]
|
|
112
|
+
const replay = await page.evaluate(async (url: string) => {
|
|
113
|
+
const w = window as Window & { __omOriginalFetch?: typeof window.fetch }
|
|
114
|
+
const withTimeout = async (call: Promise<Response>) => {
|
|
115
|
+
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 10_000))
|
|
116
|
+
return (await Promise.race([call, timeout])).status
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
viaWindow: await withTimeout(window.fetch(url)),
|
|
120
|
+
viaStash: await withTimeout(w.__omOriginalFetch!(url)),
|
|
121
|
+
}
|
|
122
|
+
}, replayUrl)
|
|
123
|
+
expect(replay.viaWindow, `window.fetch must still reach ${replayUrl} through the framework wrapper`).toBe(200)
|
|
124
|
+
expect(replay.viaStash, `the instrumented native fetch must still reach ${replayUrl}`).toBe(200)
|
|
125
|
+
|
|
126
|
+
// Exports are batched on a 3s delay, and a flush is forced when the page is hidden.
|
|
127
|
+
await page.evaluate(() => {
|
|
128
|
+
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true })
|
|
129
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
130
|
+
})
|
|
131
|
+
await expect.poll(() => ingest.length, {
|
|
132
|
+
message: 'the exporter must post its batches to the same-origin proxy',
|
|
133
|
+
timeout: 20_000,
|
|
134
|
+
}).toBeGreaterThan(0)
|
|
135
|
+
|
|
136
|
+
// Every answer must be one the OTLP exporter does NOT retry (429/502/503/504 would storm).
|
|
137
|
+
for (const record of ingest) {
|
|
138
|
+
expect([202, 204], `unexpected ingest status ${record.status}`).toContain(record.status)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const spanNames = ingest.flatMap((record) => {
|
|
142
|
+
try {
|
|
143
|
+
const payload = JSON.parse(record.body) as {
|
|
144
|
+
resourceSpans?: Array<{
|
|
145
|
+
resource?: { attributes?: Array<{ key: string; value?: { stringValue?: string } }> }
|
|
146
|
+
scopeSpans?: Array<{ spans?: Array<{ name: string }> }>
|
|
147
|
+
}>
|
|
148
|
+
}
|
|
149
|
+
for (const resourceSpan of payload.resourceSpans ?? []) {
|
|
150
|
+
const serviceName = resourceSpan.resource?.attributes?.find((a) => a.key === 'service.name')
|
|
151
|
+
expect(
|
|
152
|
+
serviceName?.value?.stringValue,
|
|
153
|
+
'browser spans must carry their own service name so they sort apart from server spans',
|
|
154
|
+
).toMatch(/-browser$/)
|
|
155
|
+
}
|
|
156
|
+
return (payload.resourceSpans ?? []).flatMap((r) => (r.scopeSpans ?? []).flatMap((s) => s.spans ?? [])).map((s) => s.name)
|
|
157
|
+
} catch {
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
expect(spanNames, 'document-load instrumentation must produce the navigation span').toContain('documentLoad')
|
|
162
|
+
expect(spanNames, 'document-load instrumentation must produce the document fetch span').toContain('documentFetch')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('ships nothing at all without the opt-in — the default path must cost a disabled app zero bytes', async ({ page }) => {
|
|
166
|
+
// No cookie: this is the shipped default, and the mirror image of the case above. It is the
|
|
167
|
+
// assertion that protects the "zero client-bundle cost while off" promise from regressing.
|
|
168
|
+
const ingest = recordIngest(page)
|
|
169
|
+
|
|
170
|
+
await login(page, 'admin')
|
|
171
|
+
await page.goto('/backend')
|
|
172
|
+
await page.goto('/backend/customers/people')
|
|
173
|
+
await expect(page.locator('table, [role="grid"]').first()).toBeVisible({ timeout: 30_000 })
|
|
174
|
+
await page.waitForTimeout(5_000)
|
|
175
|
+
|
|
176
|
+
expect(await otelApiIsRegistered(page), 'the web SDK chunk must never be requested while RUM is off').toBe(false)
|
|
177
|
+
expect(ingest, 'a disabled app must not post a single batch').toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
+
import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
|
|
5
|
+
import type { RateLimitConfig } from '@open-mercato/shared/lib/ratelimit/types'
|
|
6
|
+
import {
|
|
7
|
+
resolveBrowserTelemetryConfig,
|
|
8
|
+
resolveCollectorHeaders,
|
|
9
|
+
resolveCollectorTracesUrl,
|
|
10
|
+
} from '../../../../browser/server'
|
|
11
|
+
|
|
12
|
+
const logger = createLogger('telemetry').child({ component: 'browser-traces' })
|
|
13
|
+
|
|
14
|
+
export const metadata = {
|
|
15
|
+
// Sending a span is not a privileged action — every authenticated backoffice user may do it, so
|
|
16
|
+
// no feature grant. `requireAuth: true` is what keeps an unauthenticated span-ingest endpoint off
|
|
17
|
+
// a public host.
|
|
18
|
+
//
|
|
19
|
+
// Deliberately NO dispatcher-level `rateLimit`. That limiter keys on the client IP and falls back
|
|
20
|
+
// to a single `'global'` bucket whenever `RATE_LIMIT_TRUST_PROXY_DEPTH` is 0 (the default), so one
|
|
21
|
+
// flat budget would be shared by every browser in the deployment — and it answers `429`, which is
|
|
22
|
+
// in the OTLP exporter's retryable set (`429 || 502 || 503 || 504`). That combination would make
|
|
23
|
+
// a handful of busy tabs trigger the retry storm the handler below exists to prevent, under
|
|
24
|
+
// normal load rather than during an outage. The budget is enforced per authenticated user inside
|
|
25
|
+
// the handler instead, where it can answer with a non-retryable status.
|
|
26
|
+
POST: {
|
|
27
|
+
requireAuth: true,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-user ingest budget, sized from the export cadence rather than picked round: the
|
|
33
|
+
* `BatchSpanProcessor` in `BrowserTelemetry.tsx` uses `scheduledDelayMillis: 3_000`, i.e. at most
|
|
34
|
+
* 20 export requests per minute per open tab. 400/min therefore covers ~20 concurrent backoffice
|
|
35
|
+
* tabs for one user before anything is shed, and a user who exceeds it can only ever starve
|
|
36
|
+
* themselves — never the rest of the deployment, which the shared IP bucket could.
|
|
37
|
+
*/
|
|
38
|
+
const INGEST_BUDGET: RateLimitConfig = {
|
|
39
|
+
points: 400,
|
|
40
|
+
duration: 60,
|
|
41
|
+
keyPrefix: 'telemetry_browser_traces',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const openApi = {
|
|
45
|
+
tags: ['Telemetry'],
|
|
46
|
+
summary: 'Same-origin OTLP/HTTP trace ingest for browser RUM spans — forwards to the collector',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Batched browser exports are a few KB; 1 MB is a generous ceiling that still refuses abuse. */
|
|
50
|
+
const MAX_BODY_BYTES = 1_000_000
|
|
51
|
+
/** The collector is expected to be one hop away (in-cluster or a tunnel). */
|
|
52
|
+
const COLLECTOR_TIMEOUT_MS = 5_000
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Proxies a browser OTLP/HTTP trace payload to the environment's collector, adding the
|
|
56
|
+
* credential server-side.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately fire-and-forget: a telemetry pipeline must never be able to degrade the app it
|
|
59
|
+
* measures, so every failure path returns a success-ish status and is logged instead. Dropped spans
|
|
60
|
+
* are cheaper than a retry storm from every open browser tab.
|
|
61
|
+
*
|
|
62
|
+
* That is why a collector failure answers 202 rather than 502: 502 is in the OTLP exporter's
|
|
63
|
+
* retryable set (`429 || 502 || 503 || 504`), so a collector outage would make every open tab
|
|
64
|
+
* retry each batch with backoff — the exact amplification this contract rules out — and surface a
|
|
65
|
+
* failed request in the user's devtools on every attempt. The outage is observable in the server
|
|
66
|
+
* log instead, where it belongs.
|
|
67
|
+
*
|
|
68
|
+
* Raw `fetch` is correct here (the AGENTS.md rule targets app-to-own-API calls that have a framework
|
|
69
|
+
* primitive) — this is a server-to-collector hop over plain OTLP/HTTP with no DI-provided client.
|
|
70
|
+
*/
|
|
71
|
+
export async function POST(req: Request, ctx?: { auth?: AuthContext | null }): Promise<Response> {
|
|
72
|
+
// Refuse on the declared size BEFORE anything else: App Router route handlers have no default
|
|
73
|
+
// body limit, so reading first would let an authenticated client force a large allocation. A
|
|
74
|
+
// caller can lie or omit content-length, hence the second check inside the read — but the honest
|
|
75
|
+
// path (the OTel exporter always sets it) never allocates. Checked even while disabled so the
|
|
76
|
+
// size cap is an unconditional contract of the endpoint.
|
|
77
|
+
const declaredLength = Number(req.headers.get('content-length'))
|
|
78
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
79
|
+
return new Response(null, { status: 413 })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Not configured / disabled for this environment: accept and drop, so a stale client that still
|
|
83
|
+
// has the SDK loaded doesn't retry or surface errors.
|
|
84
|
+
if (!resolveBrowserTelemetryConfig({ cookieHeader: req.headers.get('cookie') })) {
|
|
85
|
+
return new Response(null, { status: 204 })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Over the per-user budget: drop the batch with the same non-retryable accept-and-drop status a
|
|
89
|
+
// disabled environment uses. A `429` here would be read as retryable by the exporter and turn a
|
|
90
|
+
// single looping tab into a backoff storm.
|
|
91
|
+
if (await isOverIngestBudget(ctx?.auth?.sub)) return new Response(null, { status: 204 })
|
|
92
|
+
|
|
93
|
+
const target = resolveCollectorTracesUrl()
|
|
94
|
+
if (!target) return new Response(null, { status: 204 })
|
|
95
|
+
|
|
96
|
+
const body = await readCappedBody(req)
|
|
97
|
+
if (body === null) return new Response(null, { status: 413 })
|
|
98
|
+
if (body.byteLength === 0) return new Response(null, { status: 202 })
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(target, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
// Credential and operator headers first, so the forwarded content headers below always
|
|
105
|
+
// win: an operator whose OTEL_EXPORTER_OTLP_HEADERS happens to carry a `content-type` must
|
|
106
|
+
// not silently relabel the body the browser actually sent.
|
|
107
|
+
...resolveCollectorHeaders(),
|
|
108
|
+
// The browser exporter posts JSON; keep whatever it declared so a future switch to the
|
|
109
|
+
// protobuf exporter needs no change here.
|
|
110
|
+
'content-type': req.headers.get('content-type') ?? 'application/json',
|
|
111
|
+
// Forward compression verbatim — dropping it would hand the collector a gzip blob
|
|
112
|
+
// labelled as plain JSON the moment the exporter enables compression.
|
|
113
|
+
...(req.headers.get('content-encoding')
|
|
114
|
+
? { 'content-encoding': req.headers.get('content-encoding') as string }
|
|
115
|
+
: {}),
|
|
116
|
+
},
|
|
117
|
+
body,
|
|
118
|
+
signal: AbortSignal.timeout(COLLECTOR_TIMEOUT_MS),
|
|
119
|
+
cache: 'no-store',
|
|
120
|
+
})
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
logger.warn('telemetry.browser.export.rejected', {
|
|
123
|
+
status: res.status,
|
|
124
|
+
bytes: body.byteLength,
|
|
125
|
+
})
|
|
126
|
+
return new Response(null, { status: 202 })
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
logger.warn('telemetry.browser.export.failed', {
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
bytes: body.byteLength,
|
|
132
|
+
})
|
|
133
|
+
return new Response(null, { status: 202 })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return new Response(null, { status: 202 })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Consumes one point of the authenticated user's ingest budget and reports whether the batch
|
|
141
|
+
* should be shed.
|
|
142
|
+
*
|
|
143
|
+
* Fails open in every degraded case — no user id, no limiter registered, a limiter whose backing
|
|
144
|
+
* store is unreachable, or a throw on the way there. An unenforced telemetry budget is strictly
|
|
145
|
+
* better than a telemetry endpoint that starts refusing traffic because its limiter is sick.
|
|
146
|
+
*/
|
|
147
|
+
async function isOverIngestBudget(userId: string | null | undefined): Promise<boolean> {
|
|
148
|
+
if (!userId) return false
|
|
149
|
+
try {
|
|
150
|
+
const container = await createRequestContainer()
|
|
151
|
+
const limiter = tryResolve<RateLimiterService>(container, 'rateLimiterService')
|
|
152
|
+
if (!limiter) return false
|
|
153
|
+
const result = await limiter.consume(userId, INGEST_BUDGET)
|
|
154
|
+
if (result.allowed || result.degraded) return false
|
|
155
|
+
logger.warn('telemetry.browser.export.throttled', {
|
|
156
|
+
userId,
|
|
157
|
+
budgetPerMinute: INGEST_BUDGET.points,
|
|
158
|
+
})
|
|
159
|
+
return true
|
|
160
|
+
} catch {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function tryResolve<T>(container: { resolve: (name: string) => unknown }, name: string): T | null {
|
|
166
|
+
try {
|
|
167
|
+
return (container.resolve(name) as T) ?? null
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Reads the body but abandons it the moment it exceeds the cap, so a client that omits or
|
|
175
|
+
* understates `content-length` still cannot make us buffer an arbitrary payload. Returns `null`
|
|
176
|
+
* when the cap is breached.
|
|
177
|
+
*/
|
|
178
|
+
async function readCappedBody(req: Request): Promise<ArrayBuffer | null> {
|
|
179
|
+
if (!req.body) return new ArrayBuffer(0)
|
|
180
|
+
const reader = req.body.getReader()
|
|
181
|
+
const chunks: Uint8Array[] = []
|
|
182
|
+
let total = 0
|
|
183
|
+
try {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const { done, value } = await reader.read()
|
|
186
|
+
if (done) break
|
|
187
|
+
total += value.byteLength
|
|
188
|
+
if (total > MAX_BODY_BYTES) {
|
|
189
|
+
await reader.cancel()
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
chunks.push(value)
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
const out = new Uint8Array(total)
|
|
198
|
+
let offset = 0
|
|
199
|
+
for (const chunk of chunks) {
|
|
200
|
+
out.set(chunk, offset)
|
|
201
|
+
offset += chunk.byteLength
|
|
202
|
+
}
|
|
203
|
+
// `.buffer` is exactly the allocation above, so the cast is sound and keeps `fetch` happy
|
|
204
|
+
// (a generic Uint8Array is not assignable to BodyInit under this TS lib).
|
|
205
|
+
return out.buffer as ArrayBuffer
|
|
206
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModuleInfo } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
|
|
3
|
+
// Browser RUM support. The browser usually cannot reach the OTLP collector
|
|
4
|
+
// directly (in-cluster service, auth in front of it), so this module owns the
|
|
5
|
+
// same-origin proxy the browser exporter posts to. Server-side telemetry stays
|
|
6
|
+
// with the `@open-mercato/telemetry` facade — this module never initializes it.
|
|
7
|
+
export const metadata: ModuleInfo = {
|
|
8
|
+
name: 'telemetry',
|
|
9
|
+
title: 'Telemetry',
|
|
10
|
+
version: '0.1.0',
|
|
11
|
+
description:
|
|
12
|
+
'Same-origin OTLP proxy for browser RUM spans (document load, fetch, user interaction), ' +
|
|
13
|
+
'forwarding to the collector configured for the environment.',
|
|
14
|
+
author: 'Open Mercato Team',
|
|
15
|
+
}
|
|
@@ -59,6 +59,7 @@ import type {
|
|
|
59
59
|
TraceContext,
|
|
60
60
|
} from '../types'
|
|
61
61
|
import { readTelemetryEnv } from '../env'
|
|
62
|
+
import { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../trace-headers'
|
|
62
63
|
import { redactAttributes, redactPii } from '../facade/redact'
|
|
63
64
|
import { serializeError } from '../facade/serialize'
|
|
64
65
|
import { runSpan } from './run-span'
|
|
@@ -74,8 +75,8 @@ const queuePropagator = new W3CTraceContextPropagator()
|
|
|
74
75
|
const W3C_TRACEPARENT = 'traceparent'
|
|
75
76
|
const W3C_TRACESTATE = 'tracestate'
|
|
76
77
|
// Backup headers that mirror the W3C context but a proxy/LB won't rewrite.
|
|
77
|
-
const BACKUP_TRACEPARENT =
|
|
78
|
-
const BACKUP_TRACESTATE =
|
|
78
|
+
const BACKUP_TRACEPARENT = BACKUP_TRACEPARENT_HEADER
|
|
79
|
+
const BACKUP_TRACESTATE = BACKUP_TRACESTATE_HEADER
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* Global propagator: standard W3C plus a backup copy
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup trace headers shared by the server propagator (`provider/otlp-provider.ts`)
|
|
3
|
+
* and the browser RUM wiring (`browser/`). Kept in a dependency-free module so the
|
|
4
|
+
* client bundle can import the names without pulling any server-only code.
|
|
5
|
+
*
|
|
6
|
+
* The "backup header" pattern: our own callers inject `x-original-traceparent`
|
|
7
|
+
* alongside the standard `traceparent`, so the original context survives a proxy
|
|
8
|
+
* that rewrites `traceparent` in between. The backup header is *not* itself a
|
|
9
|
+
* trust signal — at an HTTP boundary it is caller-controlled exactly like the
|
|
10
|
+
* standard one, so the server ignores both and starts a fresh root unless
|
|
11
|
+
* `TELEMETRY_TRUST_INBOUND_TRACE=true`. That flag is the supported way to stitch
|
|
12
|
+
* a browser span to its server span; see `browser/propagator.ts` and the
|
|
13
|
+
* extractor in `provider/otlp-provider.ts`.
|
|
14
|
+
*/
|
|
15
|
+
export const BACKUP_TRACEPARENT_HEADER = 'x-original-traceparent'
|
|
16
|
+
export const BACKUP_TRACESTATE_HEADER = 'x-original-tracestate'
|