@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
@@ -1,2 +1,2 @@
1
- [build:telemetry] found 20 entry points
1
+ [build:telemetry] found 30 entry points
2
2
  [build:telemetry] built successfully
package/AGENTS.md CHANGED
@@ -53,7 +53,10 @@ off by default. Spec:
53
53
  - Ask before adding a built-in metric, auto-instrumentation, production
54
54
  dependency, or new global hook.
55
55
  - OpenTelemetry packages must stay optional and may only be imported by
56
- `provider/otlp-provider.ts`.
56
+ `provider/otlp-provider.ts` (Node SDK) and `browser/BrowserTelemetry.tsx` (web
57
+ SDK). Both must load them through a dynamic `import()` so a disabled
58
+ deployment never resolves them. Adding a third runtime importer needs
59
+ approval; `import type` is erased and unrestricted.
57
60
  - Ask before changing the `pg` `enhancedDatabaseReporting: false` guard or
58
61
  broadening the accepted inbound-trace trust model.
59
62
 
@@ -74,7 +77,17 @@ off by default. Spec:
74
77
  - Never store provider, shared-logger extension, or runtime bridge state only in
75
78
  a module local; cross-bundle state uses `globalThis` symbol registries.
76
79
  - Never replace provider-owned span delegation with a finished-span sink.
77
- - Never import this package from a client component.
80
+ - Never import this package from a client component, except the dedicated
81
+ `@open-mercato/telemetry/browser` entry — the only client-safe surface. It must
82
+ stay env-free, must never import the server facade (`node:async_hooks`), and
83
+ must keep the OTel web SDK behind a dynamic `import()` gated on a non-null
84
+ server-resolved config.
85
+ - Never move an env-reading or collector-credential helper into the `/browser`
86
+ entry; those belong to `@open-mercato/telemetry/browser/server`, which throws
87
+ on load in a browser.
88
+ - Never expose the OTLP collector endpoint or its credential to the browser;
89
+ browser spans go through the same-origin `telemetry` module proxy, which adds
90
+ `OTEL_EXPORTER_OTLP_HEADERS` server-side.
78
91
 
79
92
  ## Architecture
80
93
 
@@ -95,6 +108,13 @@ host/queue shared runtime bridge ── absent while off
95
108
  registration.
96
109
  - `src/nextjs-config.ts`: build-time constants only; no runtime imports.
97
110
  - `src/nextjs.ts`: enabled runtime helper.
111
+ - `src/browser/*`: browser RUM. `config.ts` (shared, env-free contract) and
112
+ `BrowserTelemetry.tsx` (boots the web SDK) form the client-safe `/browser`
113
+ entry; `server.ts` is the server-only `/browser/server` entry that reads env
114
+ and the collector credential. Off unless `TELEMETRY_BROWSER_ENABLED` is set
115
+ alongside an active backend.
116
+ - `src/modules/telemetry/*`: the `telemetry` module — the authenticated,
117
+ rate-limited same-origin OTLP proxy (`POST /api/telemetry/browser-traces`).
98
118
 
99
119
  ## Validation
100
120
 
package/README.md CHANGED
@@ -78,6 +78,79 @@ names, a custom-provider bootstrap must import `@open-mercato/telemetry`,
78
78
  register a provider whose `name` matches `TELEMETRY_BACKEND`, and call
79
79
  `initTelemetry()` directly. Unregistered names remain a hard no-op.
80
80
 
81
+ ## Browser RUM (client-side telemetry)
82
+
83
+ Server traces can only prove where the time *isn't*: a page can render an API
84
+ answer in 50 ms yet leave the user waiting seconds on bundle download,
85
+ hydration, or a click handler — all invisible to server spans. The
86
+ `@open-mercato/telemetry/browser` entry closes that gap with the OpenTelemetry
87
+ web SDK: document-load and fetch spans from the backoffice.
88
+
89
+ Interaction spans are deliberately **not** collected. The upstream
90
+ `UserInteractionInstrumentation` patches `addEventListener` when it starts, but
91
+ React attaches a single delegated `click` listener to the root container during
92
+ hydration — before this SDK boots from `useEffect` — so the patch would never see
93
+ a backoffice click. Booting earlier would put the SDK back on the critical path,
94
+ which defeats the point; an instrumentation that silently emits nothing is worse
95
+ than an absent one.
96
+
97
+ Browser RUM is off by default and needs an explicit opt-in on top of an active
98
+ server backend:
99
+
100
+ ```dotenv
101
+ TELEMETRY_BROWSER_ENABLED=true
102
+ # TELEMETRY_BROWSER_SAMPLING_RATIO=1.0 # 0.0-1.0 (default 1.0)
103
+ # TELEMETRY_BROWSER_SERVICE_NAME= # default: <OTEL_SERVICE_NAME>-browser
104
+ ```
105
+
106
+ The wiring has three parts, all shipped with a fresh scaffold:
107
+
108
+ - `resolveBrowserTelemetryConfig()` — from the server-only
109
+ `@open-mercato/telemetry/browser/server` entry — runs on the server (the
110
+ backend layout is `force-dynamic`) and returns `null` unless browser telemetry
111
+ is fully configured. Deliberately not `NEXT_PUBLIC_*`, so toggling needs no
112
+ rebuild.
113
+ - `<BrowserTelemetry config={...} />` is a `'use client'` component that
114
+ dynamically `import()`s the web SDK only when the config is non-null; the SDK
115
+ never enters the critical bundle path, and every failure is swallowed.
116
+ - The `telemetry` module (enabled in `modules.ts` via
117
+ `{ id: 'telemetry', from: '@open-mercato/telemetry' }`) serves the
118
+ same-origin proxy `POST /api/telemetry/browser-traces` — authenticated,
119
+ budgeted per user, size-capped — and forwards batches to the configured OTLP
120
+ collector, adding `OTEL_EXPORTER_OTLP_HEADERS` server-side so the credential
121
+ never reaches the browser.
122
+
123
+ The ingest budget is 400 batches/min **per authenticated user**, sized from the
124
+ export cadence: the batch processor's 3s delay means at most 20 batches/min per
125
+ open tab, so the budget covers roughly 20 concurrent tabs. Over it, a batch is
126
+ dropped with `204` — never `429`, which the OTLP exporter treats as retryable
127
+ and would answer with a backoff storm. The endpoint deliberately does not use
128
+ the dispatcher's IP-keyed rate limiter, which collapses to one deployment-wide
129
+ bucket whenever `RATE_LIMIT_TRUST_PROXY_DEPTH` is 0.
130
+
131
+ `@open-mercato/telemetry/browser` is the ONLY part of this package a client
132
+ bundle may import. Everything that reads the environment or the collector
133
+ credential lives behind `@open-mercato/telemetry/browser/server`, which throws if
134
+ it is ever loaded in a browser; the server facade stays server-only
135
+ (`node:async_hooks`).
136
+
137
+ ### End-to-end traces need `TELEMETRY_TRUST_INBOUND_TRACE=true`
138
+
139
+ The browser injects `traceparent` plus the `x-original-traceparent` backup header
140
+ the server propagator understands. The server nevertheless **ignores every
141
+ inbound trace header by default** — at an HTTP boundary they are
142
+ caller-controlled, so accepting them would let any caller dictate trace ids.
143
+
144
+ ```dotenv
145
+ TELEMETRY_TRUST_INBOUND_TRACE=true
146
+ ```
147
+
148
+ Set it only when the app is reachable exclusively through trusted infrastructure;
149
+ it applies to *all* inbound requests, not just RUM. Without it, browser RUM still
150
+ works — each page simply produces one browser trace and one server trace instead
151
+ of a single joined one, and the server logs
152
+ `telemetry.browser.trace_continuity_disabled` once at startup to say so.
153
+
81
154
  ## Existing apps
82
155
 
83
156
  Fresh create-app scaffolds are already wired. Older apps can run:
@@ -279,7 +352,9 @@ consumer.
279
352
  - Metric labels must remain low-cardinality and must never contain tenant,
280
353
  organization, or user IDs.
281
354
 
282
- The package is server-only because span context uses `node:async_hooks`.
355
+ The package is server-only because span context uses `node:async_hooks` — with
356
+ one exception: `@open-mercato/telemetry/browser` is the dedicated client-safe
357
+ entry for browser RUM and never imports the server facade.
283
358
 
284
359
  ## Validation
285
360
 
package/build.mjs CHANGED
@@ -6,5 +6,5 @@ const packageDir = dirname(fileURLToPath(import.meta.url))
6
6
 
7
7
  await buildPackage(packageDir, {
8
8
  name: 'telemetry',
9
- entryPoints: 'src/**/*.ts',
9
+ entryPoints: 'src/**/*.{ts,tsx}',
10
10
  })
@@ -0,0 +1,110 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { createBackupHeaderPropagator } from "./propagator.js";
4
+ let bootstrapped = false;
5
+ function resetBrowserTelemetryBootstrap() {
6
+ bootstrapped = false;
7
+ }
8
+ async function startBrowserTelemetry(config) {
9
+ const [
10
+ { WebTracerProvider },
11
+ { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler },
12
+ { OTLPTraceExporter },
13
+ { resourceFromAttributes },
14
+ { registerInstrumentations },
15
+ { DocumentLoadInstrumentation },
16
+ { FetchInstrumentation },
17
+ { W3CTraceContextPropagator },
18
+ { defaultTextMapSetter }
19
+ ] = await Promise.all([
20
+ import("@opentelemetry/sdk-trace-web"),
21
+ import("@opentelemetry/sdk-trace-base"),
22
+ import("@opentelemetry/exporter-trace-otlp-http"),
23
+ import("@opentelemetry/resources"),
24
+ import("@opentelemetry/instrumentation"),
25
+ import("@opentelemetry/instrumentation-document-load"),
26
+ import("@opentelemetry/instrumentation-fetch"),
27
+ import("@opentelemetry/core"),
28
+ import("@opentelemetry/api")
29
+ ]);
30
+ const provider = new WebTracerProvider({
31
+ resource: resourceFromAttributes({
32
+ "service.name": config.serviceName,
33
+ // Plain attribute name (not the semconv `deployment.environment.name`) so browser spans sort
34
+ // alongside the server spans, which use OTEL_RESOURCE_ATTRIBUTES.
35
+ ...config.environment ? { "deployment.environment": config.environment } : {},
36
+ "user_agent.original": navigator.userAgent
37
+ }),
38
+ sampler: new ParentBasedSampler({
39
+ root: new TraceIdRatioBasedSampler(config.samplingRatio)
40
+ }),
41
+ spanProcessors: [
42
+ new BatchSpanProcessor(
43
+ new OTLPTraceExporter({ url: config.endpoint }),
44
+ // The interesting spans arrive in bursts around a navigation; a short delay keeps them
45
+ // close together in the trace view without one request per span.
46
+ { scheduledDelayMillis: 3e3, maxQueueSize: 512 }
47
+ )
48
+ ]
49
+ });
50
+ provider.register({
51
+ propagator: createBackupHeaderPropagator(new W3CTraceContextPropagator(), defaultTextMapSetter)
52
+ });
53
+ const patchTarget = window;
54
+ const frameworkFetch = patchTarget.__omOriginalFetch ? window.fetch : null;
55
+ if (frameworkFetch && patchTarget.__omOriginalFetch) {
56
+ window.fetch = patchTarget.__omOriginalFetch;
57
+ }
58
+ try {
59
+ registerInstrumentations({
60
+ tracerProvider: provider,
61
+ instrumentations: [
62
+ // Navigation + resource timings: chunk download vs. parse vs. hydrate.
63
+ new DocumentLoadInstrumentation(),
64
+ // The client-side START of each API call — the number the server can never see.
65
+ new FetchInstrumentation({
66
+ // Never instrument our own export, or each batch would generate the next one.
67
+ ignoreUrls: [new RegExp(escapeRegExp(config.endpoint))],
68
+ clearTimingResources: true
69
+ })
70
+ // No `UserInteractionInstrumentation` here, deliberately. It works by patching
71
+ // `addEventListener` when `enable()` runs, but React attaches a SINGLE delegated `click`
72
+ // listener to the root container during `createRoot`/hydration — and this SDK boots from
73
+ // `useEffect`, which React runs strictly after hydration. The patch would land after the
74
+ // only listener that will ever see a backoffice click, so it produces no spans at all
75
+ // (confirmed in manual QA). Booting early enough to beat hydration would mean putting the
76
+ // SDK back on the critical path, which is the one thing this file refuses to do. An
77
+ // instrumentation that silently produces nothing is worse than an absent one: an empty
78
+ // interaction view reads as "nobody clicks" rather than "this is broken".
79
+ ]
80
+ });
81
+ } finally {
82
+ if (frameworkFetch) {
83
+ patchTarget.__omOriginalFetch = window.fetch;
84
+ window.fetch = frameworkFetch;
85
+ }
86
+ }
87
+ const flush = () => void provider.forceFlush().catch(() => {
88
+ });
89
+ document.addEventListener("visibilitychange", () => {
90
+ if (document.visibilityState === "hidden") flush();
91
+ });
92
+ window.addEventListener("pagehide", flush);
93
+ }
94
+ function escapeRegExp(value) {
95
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
+ }
97
+ function BrowserTelemetry({ config }) {
98
+ React.useEffect(() => {
99
+ if (!config || bootstrapped) return;
100
+ bootstrapped = true;
101
+ void startBrowserTelemetry(config).catch(() => {
102
+ });
103
+ }, [config]);
104
+ return null;
105
+ }
106
+ export {
107
+ BrowserTelemetry,
108
+ resetBrowserTelemetryBootstrap
109
+ };
110
+ //# sourceMappingURL=BrowserTelemetry.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/browser/BrowserTelemetry.tsx"],
4
+ "sourcesContent": ["'use client'\n\n/**\n * Browser RUM. Boots the OpenTelemetry web SDK once per page load and exports\n * spans through the same-origin proxy (`/api/telemetry/browser-traces`).\n *\n * Why this exists: server traces can only prove where the time *isn't*. A page\n * can render an API answer in 50 ms yet leave the user waiting seconds before\n * the request is even sent \u2014 bundle download, hydration, or a click handler\n * stuck behind a re-render are all invisible to every server span.\n *\n * Two rules this file lives by:\n * - **The SDK is `import()`ed, never statically imported.** RUM often\n * diagnoses routes suspected to be bundle-bound; shipping the SDK in the\n * critical path would be self-defeating. Document-load timings survive the\n * late start because they are read back from the PerformanceTimeline.\n * - **It can never break the app.** Every failure is swallowed. No\n * user-facing surface, no retry.\n *\n * This file, `config.ts`, and `propagator.ts` are the only parts of\n * `@open-mercato/telemetry` a client bundle may reach (via the `/browser` entry).\n * None of them may import the server facade (`node:async_hooks`) or the\n * env-reading `browser/server.ts`; the two wirings are independent and meet only\n * at the collector.\n */\n\nimport * as React from 'react'\nimport type { BrowserTelemetryConfig } from './config'\nimport { createBackupHeaderPropagator } from './propagator'\n\n/** Module-level so React StrictMode's double-invoke (and any remount) cannot start a second SDK. */\nlet bootstrapped = false\n\n/**\n * Test seam. Production has exactly one bootstrap per page load, so nothing else\n * may call this \u2014 a second bootstrap would double every span.\n */\nexport function resetBrowserTelemetryBootstrap(): void {\n bootstrapped = false\n}\n\nasync function startBrowserTelemetry(config: BrowserTelemetryConfig): Promise<void> {\n const [\n { WebTracerProvider },\n { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler },\n { OTLPTraceExporter },\n { resourceFromAttributes },\n { registerInstrumentations },\n { DocumentLoadInstrumentation },\n { FetchInstrumentation },\n { W3CTraceContextPropagator },\n { defaultTextMapSetter },\n ] = await Promise.all([\n import('@opentelemetry/sdk-trace-web'),\n import('@opentelemetry/sdk-trace-base'),\n import('@opentelemetry/exporter-trace-otlp-http'),\n import('@opentelemetry/resources'),\n import('@opentelemetry/instrumentation'),\n import('@opentelemetry/instrumentation-document-load'),\n import('@opentelemetry/instrumentation-fetch'),\n import('@opentelemetry/core'),\n import('@opentelemetry/api'),\n ])\n\n const provider = new WebTracerProvider({\n resource: resourceFromAttributes({\n 'service.name': config.serviceName,\n // Plain attribute name (not the semconv `deployment.environment.name`) so browser spans sort\n // alongside the server spans, which use OTEL_RESOURCE_ATTRIBUTES.\n ...(config.environment ? { 'deployment.environment': config.environment } : {}),\n 'user_agent.original': navigator.userAgent,\n }),\n sampler: new ParentBasedSampler({\n root: new TraceIdRatioBasedSampler(config.samplingRatio),\n }),\n spanProcessors: [\n new BatchSpanProcessor(\n new OTLPTraceExporter({ url: config.endpoint }),\n // The interesting spans arrive in bursts around a navigation; a short delay keeps them\n // close together in the trace view without one request per span.\n { scheduledDelayMillis: 3_000, maxQueueSize: 512 },\n ),\n ],\n })\n\n // Standard `traceparent` plus the backup copy the server continues once\n // TELEMETRY_TRUST_INBOUND_TRACE is on \u2014 see `propagator.ts` for why that flag is the supported\n // way to get one end-to-end trace, and what happens without it.\n provider.register({\n propagator: createBackupHeaderPropagator(new W3CTraceContextPropagator(), defaultTextMapSetter),\n })\n\n // \u2500\u2500 The fetch-patch dance (do not simplify) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // `@open-mercato/ui`'s api utils install a global wrapper at module load so that stray\n // `fetch()` calls still get the 401 session-refresh / 403 banner behaviour:\n // w.__omOriginalFetch = window.fetch // pristine native fetch\n // window.fetch = (i, x) => apiFetch(i, x)\n // and `apiFetch` then calls `__omOriginalFetch` to avoid recursing into itself. Since the whole\n // backoffice (DataTable, CrudForm, react-query queryFns\u2026) imports `apiFetch` DIRECTLY, those\n // calls never touch `window.fetch` \u2014 so instrumenting `window.fetch` alone yields document-load\n // spans and almost no API spans, which is worse than none at all: it looks like it works.\n //\n // Fix: let the instrumentation patch the *native* fetch, then hand the patched version back as\n // the stash. Both paths end up instrumented exactly once:\n // apiFetch \u2192 __omOriginalFetch = OTel(native) \u2713\n // window.fetch \u2192 apiFetch wrapper \u2192 OTel(native) \u2713\n // Assigning `__omOriginalFetch = window.fetch` instead would loop forever\n // (apiFetch \u2192 wrapper \u2192 apiFetch \u2192 \u2026). The swap is synchronous \u2014 `registerInstrumentations`\n // calls `enable()` inline \u2014 so no caller can observe the intermediate state.\n const patchTarget = window as Window & { __omOriginalFetch?: typeof window.fetch }\n const frameworkFetch = patchTarget.__omOriginalFetch ? window.fetch : null\n if (frameworkFetch && patchTarget.__omOriginalFetch) {\n window.fetch = patchTarget.__omOriginalFetch\n }\n\n // `finally`, not a plain sequence: the instrumentation constructors below are evaluated inside\n // this window, and a throw from any of them (SDK version skew, a browser missing an API an\n // instrumentation touches) would otherwise leave `window.fetch` as the raw native fetch stashed\n // above. `apiFetch` would keep working \u2014 it reads `__omOriginalFetch` \u2014 but every stray\n // `window.fetch(...)` would silently bypass the framework wrapper's 401 session-refresh and 403\n // banner behaviour for the rest of the page's life. This is the one path where the file's\n // \"every failure is swallowed\" rule would leave global state degraded.\n try {\n registerInstrumentations({\n tracerProvider: provider,\n instrumentations: [\n // Navigation + resource timings: chunk download vs. parse vs. hydrate.\n new DocumentLoadInstrumentation(),\n // The client-side START of each API call \u2014 the number the server can never see.\n new FetchInstrumentation({\n // Never instrument our own export, or each batch would generate the next one.\n ignoreUrls: [new RegExp(escapeRegExp(config.endpoint))],\n clearTimingResources: true,\n }),\n // No `UserInteractionInstrumentation` here, deliberately. It works by patching\n // `addEventListener` when `enable()` runs, but React attaches a SINGLE delegated `click`\n // listener to the root container during `createRoot`/hydration \u2014 and this SDK boots from\n // `useEffect`, which React runs strictly after hydration. The patch would land after the\n // only listener that will ever see a backoffice click, so it produces no spans at all\n // (confirmed in manual QA). Booting early enough to beat hydration would mean putting the\n // SDK back on the critical path, which is the one thing this file refuses to do. An\n // instrumentation that silently produces nothing is worse than an absent one: an empty\n // interaction view reads as \"nobody clicks\" rather than \"this is broken\".\n ],\n })\n } finally {\n if (frameworkFetch) {\n // On the success path `window.fetch` is OTel's patched native fetch; on the throw path it is\n // the pristine native one. Either way this restores the framework wrapper on top of whatever\n // the stash should now hold, so the pre-swap contract holds in both.\n patchTarget.__omOriginalFetch = window.fetch\n window.fetch = frameworkFetch\n }\n }\n\n // BatchSpanProcessor would otherwise drop whatever is still queued when the user navigates away\n // \u2014 which is exactly when a slow page gets abandoned, i.e. the spans we most want.\n //\n // `forceFlush` exports through the OTLP fetch transport, which sets `keepalive: true` on the\n // request whenever the payload and the number of in-flight requests are within the browser's\n // limits \u2014 so the batch survives the page going away. That is the durable path here;\n // `sendBeacon` would be the other candidate, but upstream deprecated its export delegate in\n // favour of the fetch one, so reaching for it would trade a supported API for a doomed one.\n //\n // Both events, because neither alone covers every exit: `visibilitychange` misses some bfcache\n // entries, and `pagehide` does not fire when a tab is merely backgrounded. A second flush with\n // an empty queue is a no-op, so the overlap costs nothing.\n const flush = () => void provider.forceFlush().catch(() => {})\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden') flush()\n })\n window.addEventListener('pagehide', flush)\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/**\n * Renders nothing. `config` is resolved server-side (`resolveBrowserTelemetryConfig()`) and is\n * `null` whenever browser telemetry is disabled for the environment \u2014 which is the default, and\n * keeps the SDK chunk from ever being requested.\n */\nexport function BrowserTelemetry({ config }: { config: BrowserTelemetryConfig | null }) {\n React.useEffect(() => {\n if (!config || bootstrapped) return\n bootstrapped = true\n void startBrowserTelemetry(config).catch(() => {\n // Telemetry must never degrade the app it measures: stay silent and stay down (no retry).\n })\n }, [config])\n\n return null\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,5 @@
1
+ const BROWSER_TRACES_PATH = "/api/telemetry/browser-traces";
2
+ export {
3
+ BROWSER_TRACES_PATH
4
+ };
5
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/browser/config.ts"],
4
+ "sourcesContent": ["/**\n * The browser-RUM contract shared by both halves of the feature: the shape the\n * server resolves (`browser/server.ts`) and the shape the client component\n * consumes (`browser/BrowserTelemetry.tsx`).\n *\n * Deliberately dependency-free and env-free so it can sit in a client bundle.\n * Everything that reads `process.env` lives in `browser/server.ts`, which is a\n * server-only module and is never reachable from the `/browser` entry.\n */\n\n/** Same-origin path the browser exporter posts to; served by the `telemetry` module's API route. */\nexport const BROWSER_TRACES_PATH = '/api/telemetry/browser-traces'\n\nexport type BrowserTelemetryConfig = {\n /** Same-origin OTLP endpoint (the proxy route). */\n endpoint: string\n serviceName: string\n /** `deployment.environment` \u2014 kept as a plain attribute name to match existing server spans. */\n environment: string | null\n samplingRatio: number\n}\n"],
5
+ "mappings": "AAWO,MAAM,sBAAsB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,10 @@
1
+ import { BrowserTelemetry } from "./BrowserTelemetry.js";
2
+ import { BROWSER_TRACES_PATH } from "./config.js";
3
+ import { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from "../trace-headers.js";
4
+ export {
5
+ BACKUP_TRACEPARENT_HEADER,
6
+ BACKUP_TRACESTATE_HEADER,
7
+ BROWSER_TRACES_PATH,
8
+ BrowserTelemetry
9
+ };
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/browser/index.ts"],
4
+ "sourcesContent": ["/**\n * Browser RUM entry \u2014 the ONLY part of `@open-mercato/telemetry` a client\n * bundle may reach. Import from `@open-mercato/telemetry/browser`.\n *\n * Everything here is env-free. The server half \u2014 `resolveBrowserTelemetryConfig()`\n * plus the collector endpoint/credential readers \u2014 lives behind\n * `@open-mercato/telemetry/browser/server`, which refuses to load in a browser.\n * A server component resolves the config there and passes it to\n * `<BrowserTelemetry />`, a `'use client'` boundary that dynamically loads the\n * OTel web SDK only when the config is non-null.\n */\nexport { BrowserTelemetry } from './BrowserTelemetry'\nexport { BROWSER_TRACES_PATH } from './config'\nexport type { BrowserTelemetryConfig } from './config'\nexport { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../trace-headers'\n"],
5
+ "mappings": "AAWA,SAAS,wBAAwB;AACjC,SAAS,2BAA2B;AAEpC,SAAS,2BAA2B,gCAAgC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,19 @@
1
+ import { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from "../trace-headers.js";
2
+ function createBackupHeaderPropagator(w3c, defaultTextMapSetter) {
3
+ return {
4
+ inject(ctx, carrier, setter) {
5
+ w3c.inject(ctx, carrier, setter);
6
+ const mirror = {};
7
+ w3c.inject(ctx, mirror, defaultTextMapSetter);
8
+ if (mirror.traceparent) setter.set(carrier, BACKUP_TRACEPARENT_HEADER, mirror.traceparent);
9
+ if (mirror.tracestate) setter.set(carrier, BACKUP_TRACESTATE_HEADER, mirror.tracestate);
10
+ },
11
+ // A browser is always the root of its own trace — nothing inbound to extract.
12
+ extract: (ctx) => ctx,
13
+ fields: () => [...w3c.fields(), BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER]
14
+ };
15
+ }
16
+ export {
17
+ createBackupHeaderPropagator
18
+ };
19
+ //# sourceMappingURL=propagator.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/browser/propagator.ts"],
4
+ "sourcesContent": ["/**\n * The browser's outbound trace propagator: standard W3C plus the backup copy\n * (`x-original-traceparent`) that a load balancer will not rewrite \u2014 the mirror\n * image of `provider/otlp-provider.ts`'s `backupHeaderPropagator`.\n *\n * Injecting these headers is necessary but NOT sufficient for one end-to-end\n * trace. The server ignores every inbound trace header \u2014 standard and backup\n * alike \u2014 unless `TELEMETRY_TRUST_INBOUND_TRACE=true`, because at an HTTP\n * boundary they are caller-controlled. That flag is therefore the supported way\n * to stitch a browser span to its server span; the backup header is what\n * survives a proxy in between. Without the flag RUM still works, it just yields\n * two disconnected traces per page (the server warns about that once).\n *\n * The W3C propagator is passed in rather than imported: the web SDK is only ever\n * loaded through a dynamic `import()` inside `BrowserTelemetry.tsx`. Keeping the\n * logic here \u2014 free of any runtime OTel import \u2014 is also what lets a test drive\n * the real injector against the real server extractor.\n */\nimport type { TextMapPropagator, TextMapSetter } from '@opentelemetry/api'\nimport { BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER } from '../trace-headers'\n\nexport function createBackupHeaderPropagator(\n w3c: TextMapPropagator,\n defaultTextMapSetter: TextMapSetter<Record<string, string>>,\n): TextMapPropagator {\n return {\n inject(ctx, carrier, setter) {\n w3c.inject(ctx, carrier, setter)\n // Inject into a plain temp carrier so the values can be mirrored regardless\n // of the real carrier's setter shape.\n const mirror: Record<string, string> = {}\n w3c.inject(ctx, mirror, defaultTextMapSetter)\n if (mirror.traceparent) setter.set(carrier, BACKUP_TRACEPARENT_HEADER, mirror.traceparent)\n if (mirror.tracestate) setter.set(carrier, BACKUP_TRACESTATE_HEADER, mirror.tracestate)\n },\n // A browser is always the root of its own trace \u2014 nothing inbound to extract.\n extract: (ctx) => ctx,\n fields: () => [...w3c.fields(), BACKUP_TRACEPARENT_HEADER, BACKUP_TRACESTATE_HEADER],\n }\n}\n"],
5
+ "mappings": "AAmBA,SAAS,2BAA2B,gCAAgC;AAE7D,SAAS,6BACd,KACA,sBACmB;AACnB,SAAO;AAAA,IACL,OAAO,KAAK,SAAS,QAAQ;AAC3B,UAAI,OAAO,KAAK,SAAS,MAAM;AAG/B,YAAM,SAAiC,CAAC;AACxC,UAAI,OAAO,KAAK,QAAQ,oBAAoB;AAC5C,UAAI,OAAO,YAAa,QAAO,IAAI,SAAS,2BAA2B,OAAO,WAAW;AACzF,UAAI,OAAO,WAAY,QAAO,IAAI,SAAS,0BAA0B,OAAO,UAAU;AAAA,IACxF;AAAA;AAAA,IAEA,SAAS,CAAC,QAAQ;AAAA,IAClB,QAAQ,MAAM,CAAC,GAAG,IAAI,OAAO,GAAG,2BAA2B,wBAAwB;AAAA,EACrF;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,95 @@
1
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
2
+ import { isTelemetryBackendEnabled } from "@open-mercato/shared/lib/telemetry/runtime";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
4
+ import { readTelemetryEnv } from "../env.js";
5
+ import { BROWSER_TRACES_PATH } from "./config.js";
6
+ if (typeof window !== "undefined") {
7
+ throw new Error("[internal] @open-mercato/telemetry/browser/server is server-only; import @open-mercato/telemetry/browser instead");
8
+ }
9
+ const logger = createLogger("telemetry").child({ component: "browser-config" });
10
+ function readDeploymentEnvironment(raw) {
11
+ if (!raw) return null;
12
+ for (const pair of raw.split(",")) {
13
+ const idx = pair.indexOf("=");
14
+ if (idx === -1) continue;
15
+ if (pair.slice(0, idx).trim() !== "deployment.environment") continue;
16
+ const value = pair.slice(idx + 1).trim();
17
+ if (value) return value;
18
+ }
19
+ return null;
20
+ }
21
+ function readSamplingRatio(raw) {
22
+ if (!raw) return 1;
23
+ const parsed = Number(raw);
24
+ if (!Number.isFinite(parsed)) return 1;
25
+ return Math.min(1, Math.max(0, parsed));
26
+ }
27
+ let warnedAboutTraceContinuity = false;
28
+ function warnWhenTracesCannotStitch() {
29
+ if (warnedAboutTraceContinuity) return;
30
+ if (readTelemetryEnv().trustInboundTrace) return;
31
+ warnedAboutTraceContinuity = true;
32
+ logger.warn("telemetry.browser.trace_continuity_disabled", {
33
+ detail: "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."
34
+ });
35
+ }
36
+ function resetBrowserTelemetryWarnings() {
37
+ warnedAboutTraceContinuity = false;
38
+ }
39
+ const BROWSER_TELEMETRY_TEST_COOKIE = "om_test_browser_telemetry";
40
+ function resolveTestOptIn(cookieHeader) {
41
+ if (process.env.OM_TEST_MODE !== "1") return null;
42
+ if (process.env.OM_TEST_BROWSER_TELEMETRY_MODE !== "opt-in") return null;
43
+ if (!cookieHeader) return null;
44
+ const pattern = new RegExp(`(?:^|;)\\s*${BROWSER_TELEMETRY_TEST_COOKIE}=on(?:;|$)`);
45
+ if (!pattern.test(cookieHeader)) return null;
46
+ return {
47
+ endpoint: BROWSER_TRACES_PATH,
48
+ serviceName: `${process.env.OTEL_SERVICE_NAME?.trim() || "open-mercato"}-browser`,
49
+ environment: readDeploymentEnvironment(process.env.OTEL_RESOURCE_ATTRIBUTES),
50
+ samplingRatio: 1
51
+ };
52
+ }
53
+ function resolveBrowserTelemetryConfig(options = {}) {
54
+ const testOptIn = resolveTestOptIn(options.cookieHeader);
55
+ if (testOptIn) return testOptIn;
56
+ if (!parseBooleanWithDefault(process.env.TELEMETRY_BROWSER_ENABLED, false)) return null;
57
+ if (!isTelemetryBackendEnabled()) return null;
58
+ if (!resolveCollectorTracesUrl()) return null;
59
+ warnWhenTracesCannotStitch();
60
+ const serviceName = process.env.TELEMETRY_BROWSER_SERVICE_NAME?.trim() || `${process.env.OTEL_SERVICE_NAME?.trim() || "open-mercato"}-browser`;
61
+ return {
62
+ endpoint: BROWSER_TRACES_PATH,
63
+ serviceName,
64
+ environment: readDeploymentEnvironment(process.env.OTEL_RESOURCE_ATTRIBUTES),
65
+ samplingRatio: readSamplingRatio(process.env.TELEMETRY_BROWSER_SAMPLING_RATIO)
66
+ };
67
+ }
68
+ function resolveCollectorTracesUrl() {
69
+ const signal = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim();
70
+ if (signal) return signal;
71
+ const generic = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
72
+ if (!generic) return null;
73
+ return `${generic.replace(/\/+$/, "")}/v1/traces`;
74
+ }
75
+ function resolveCollectorHeaders() {
76
+ const raw = process.env.OTEL_EXPORTER_OTLP_HEADERS?.trim();
77
+ if (!raw) return {};
78
+ const headers = {};
79
+ for (const pair of raw.split(",")) {
80
+ const idx = pair.indexOf("=");
81
+ if (idx <= 0) continue;
82
+ const key = pair.slice(0, idx).trim();
83
+ const value = pair.slice(idx + 1).trim();
84
+ if (key && value) headers[key] = value;
85
+ }
86
+ return headers;
87
+ }
88
+ export {
89
+ BROWSER_TELEMETRY_TEST_COOKIE,
90
+ resetBrowserTelemetryWarnings,
91
+ resolveBrowserTelemetryConfig,
92
+ resolveCollectorHeaders,
93
+ resolveCollectorTracesUrl
94
+ };
95
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/browser/server.ts"],
4
+ "sourcesContent": ["/**\n * Server-only half of browser RUM: resolves the client configuration and the\n * collector coordinates from the telemetry env the host already sets\n * (`TELEMETRY_BACKEND`, `OTEL_EXPORTER_OTLP_*`), so enabling RUM is one flag and\n * no new endpoint plumbing.\n *\n * Import from `@open-mercato/telemetry/browser/server` \u2014 NOT from\n * `@open-mercato/telemetry/browser`, which is the client-safe entry. The split is\n * the point: `resolveCollectorHeaders()` returns the collector credential, which\n * must never be reachable from a client bundle. The guard below turns a wrong\n * import into an immediate, loud failure instead of a silent leak.\n *\n * Resolved at request time in the backend layout (`force-dynamic`) and passed to\n * the client as props \u2014 deliberately NOT `NEXT_PUBLIC_*`, which would bake the\n * values into the build and require a rebuild to toggle.\n */\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { isTelemetryBackendEnabled } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readTelemetryEnv } from '../env'\nimport { BROWSER_TRACES_PATH, type BrowserTelemetryConfig } from './config'\n\nif (typeof window !== 'undefined') {\n throw new Error('[internal] @open-mercato/telemetry/browser/server is server-only; import @open-mercato/telemetry/browser instead')\n}\n\nconst logger = createLogger('telemetry').child({ component: 'browser-config' })\n\n/** `deployment.environment=dev,foo=bar` \u2192 `dev`. */\nfunction readDeploymentEnvironment(raw: string | undefined): string | null {\n if (!raw) return null\n for (const pair of raw.split(',')) {\n const idx = pair.indexOf('=')\n if (idx === -1) continue\n if (pair.slice(0, idx).trim() !== 'deployment.environment') continue\n const value = pair.slice(idx + 1).trim()\n if (value) return value\n }\n return null\n}\n\nfunction readSamplingRatio(raw: string | undefined): number {\n if (!raw) return 1\n const parsed = Number(raw)\n if (!Number.isFinite(parsed)) return 1\n return Math.min(1, Math.max(0, parsed))\n}\n\nlet warnedAboutTraceContinuity = false\n\n/**\n * Browser spans stitch to their server spans only when the server continues the\n * inbound trace, which is exactly what `TELEMETRY_TRUST_INBOUND_TRACE` gates. RUM\n * without it still works \u2014 it just yields two disconnected traces per page, which\n * looks like a bug and is the single most likely misconfiguration, so say it once\n * per process rather than let an operator discover it in the trace view.\n */\nfunction warnWhenTracesCannotStitch(): void {\n if (warnedAboutTraceContinuity) return\n // Same reader the provider's propagator consults, so the warning can never\n // disagree with the behaviour it describes.\n if (readTelemetryEnv().trustInboundTrace) return\n warnedAboutTraceContinuity = true\n logger.warn('telemetry.browser.trace_continuity_disabled', {\n detail:\n '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.',\n })\n}\n\n/** Test seam: the warn-once latch is process-wide, which a second test case would otherwise miss. */\nexport function resetBrowserTelemetryWarnings(): void {\n warnedAboutTraceContinuity = false\n}\n\n/**\n * Cookie an integration spec sets to boot RUM for its own page only. See\n * `resolveTestOptIn` for why a per-request switch is the only way to cover the\n * enabled path.\n */\nexport const BROWSER_TELEMETRY_TEST_COOKIE = 'om_test_browser_telemetry'\n\n/**\n * Browser RUM is an environment-wide switch read from `process.env`, so a Playwright spec cannot\n * turn it on for one page \u2014 and turning it on for the whole integration environment would boot the\n * web SDK on every page of every other spec. This is the same targeted escape hatch the auth and\n * AI-chat rate limiters use (`OM_TEST_MODE=1` plus a second, explicit `*_MODE=opt-in` variable):\n * both are absent in production, and only then is the request's cookie consulted at all.\n *\n * The synthetic config it returns is deliberately self-contained \u2014 no `TELEMETRY_BACKEND`, no\n * collector endpoint \u2014 so enabling it costs the integration environment nothing but the one page\n * that asked for it. With no collector configured the proxy answers `204`, which is exactly the\n * accept-and-drop contract the route documents, and the spec asserts on the batch the browser\n * posted rather than on anything the collector received.\n */\nfunction resolveTestOptIn(cookieHeader: string | null | undefined): BrowserTelemetryConfig | null {\n if (process.env.OM_TEST_MODE !== '1') return null\n if (process.env.OM_TEST_BROWSER_TELEMETRY_MODE !== 'opt-in') return null\n if (!cookieHeader) return null\n const pattern = new RegExp(`(?:^|;)\\\\s*${BROWSER_TELEMETRY_TEST_COOKIE}=on(?:;|$)`)\n if (!pattern.test(cookieHeader)) return null\n return {\n endpoint: BROWSER_TRACES_PATH,\n serviceName: `${process.env.OTEL_SERVICE_NAME?.trim() || 'open-mercato'}-browser`,\n environment: readDeploymentEnvironment(process.env.OTEL_RESOURCE_ATTRIBUTES),\n samplingRatio: 1,\n }\n}\n\n/**\n * Returns `null` when browser telemetry is off \u2014 which is the default\n * everywhere, including local. Requires an active server telemetry backend\n * *and* an explicit opt-in: RUM without a reachable collector is pure overhead\n * in the client bundle.\n *\n * `options.cookieHeader` is the request's raw `Cookie` header, and is read only by the\n * integration-test opt-in above; production resolution is env-only and never consults it.\n */\nexport function resolveBrowserTelemetryConfig(\n options: { cookieHeader?: string | null } = {},\n): BrowserTelemetryConfig | null {\n const testOptIn = resolveTestOptIn(options.cookieHeader)\n if (testOptIn) return testOptIn\n\n if (!parseBooleanWithDefault(process.env.TELEMETRY_BROWSER_ENABLED, false)) return null\n if (!isTelemetryBackendEnabled()) return null\n if (!resolveCollectorTracesUrl()) return null\n\n warnWhenTracesCannotStitch()\n\n const serviceName =\n process.env.TELEMETRY_BROWSER_SERVICE_NAME?.trim() ||\n `${process.env.OTEL_SERVICE_NAME?.trim() || 'open-mercato'}-browser`\n\n return {\n endpoint: BROWSER_TRACES_PATH,\n serviceName,\n environment: readDeploymentEnvironment(process.env.OTEL_RESOURCE_ATTRIBUTES),\n samplingRatio: readSamplingRatio(process.env.TELEMETRY_BROWSER_SAMPLING_RATIO),\n }\n}\n\n/**\n * Where the proxy forwards to. Per the OTLP spec a signal-specific endpoint is\n * used verbatim, while the generic one gets `/v1/traces` appended.\n */\nexport function resolveCollectorTracesUrl(): string | null {\n const signal = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim()\n if (signal) return signal\n const generic = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()\n if (!generic) return null\n return `${generic.replace(/\\/+$/, '')}/v1/traces`\n}\n\n/**\n * `OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <b64>,key=value` \u2192 header record.\n * Split on the FIRST `=` only: base64 credentials routinely end in `=` padding.\n */\nexport function resolveCollectorHeaders(): Record<string, string> {\n const raw = process.env.OTEL_EXPORTER_OTLP_HEADERS?.trim()\n if (!raw) return {}\n const headers: Record<string, string> = {}\n for (const pair of raw.split(',')) {\n const idx = pair.indexOf('=')\n if (idx <= 0) continue\n const key = pair.slice(0, idx).trim()\n const value = pair.slice(idx + 1).trim()\n if (key && value) headers[key] = value\n }\n return headers\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ test.describe("TC-TELEMETRY-001: browser traces proxy", () => {
4
+ let token = "";
5
+ test.beforeAll(async ({ request }) => {
6
+ token = await getAuthToken(request, "admin");
7
+ });
8
+ test("unauthenticated span ingest is rejected", async ({ request }) => {
9
+ const res = await request.post("/api/telemetry/browser-traces", {
10
+ headers: { "Content-Type": "application/json" },
11
+ data: { resourceSpans: [] }
12
+ });
13
+ expect(res.status(), "span ingest must not be an unauthenticated endpoint").toBe(401);
14
+ });
15
+ test("authenticated batch is accepted", async ({ request }) => {
16
+ const res = await apiRequest(request, "POST", "/api/telemetry/browser-traces", {
17
+ token,
18
+ data: { resourceSpans: [] }
19
+ });
20
+ expect([204, 202], "an authenticated batch is always accepted").toContain(res.status());
21
+ });
22
+ test("an oversized payload is refused with 413", async ({ request }) => {
23
+ const oversized = `{"resourceSpans":["${"x".repeat(11e5)}"]}`;
24
+ const res = await request.post("/api/telemetry/browser-traces", {
25
+ headers: {
26
+ Authorization: `Bearer ${token}`,
27
+ "Content-Type": "application/json"
28
+ },
29
+ data: oversized
30
+ });
31
+ expect(res.status(), "payloads above the 1 MB cap are refused").toBe(413);
32
+ });
33
+ });
34
+ //# sourceMappingURL=TC-TELEMETRY-001.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/telemetry/__integration__/TC-TELEMETRY-001.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\n\n/**\n * TC-TELEMETRY-001: browser RUM span-ingest proxy contract.\n *\n * Route: POST /api/telemetry/browser-traces (requireAuth). The proxy accepts\n * OTLP/HTTP batches from authenticated backoffice sessions and forwards them\n * to the configured collector. It is deliberately fire-and-forget: when browser\n * telemetry is disabled for the environment (the default, and the state of the\n * integration test env) it accepts and drops with 204 so a stale client never\n * retries. Oversized payloads are refused with 413 before buffering.\n */\ntest.describe('TC-TELEMETRY-001: browser traces proxy', () => {\n let token = ''\n\n test.beforeAll(async ({ request }) => {\n token = await getAuthToken(request, 'admin')\n })\n\n test('unauthenticated span ingest is rejected', async ({ request }) => {\n const res = await request.post('/api/telemetry/browser-traces', {\n headers: { 'Content-Type': 'application/json' },\n data: { resourceSpans: [] },\n })\n expect(res.status(), 'span ingest must not be an unauthenticated endpoint').toBe(401)\n })\n\n test('authenticated batch is accepted', async ({ request }) => {\n const res = await apiRequest(request, 'POST', '/api/telemetry/browser-traces', {\n token,\n data: { resourceSpans: [] },\n })\n // 204 = telemetry disabled for the environment (accept-and-drop, the\n // default here); 202 = forwarded to a configured collector.\n expect([204, 202], 'an authenticated batch is always accepted').toContain(res.status())\n })\n\n test('an oversized payload is refused with 413', async ({ request }) => {\n const oversized = `{\"resourceSpans\":[\"${'x'.repeat(1_100_000)}\"]}`\n const res = await request.post('/api/telemetry/browser-traces', {\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n data: oversized,\n })\n expect(res.status(), 'payloads above the 1 MB cap are refused').toBe(413)\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AAYzC,KAAK,SAAS,0CAA0C,MAAM;AAC5D,MAAI,QAAQ;AAEZ,OAAK,UAAU,OAAO,EAAE,QAAQ,MAAM;AACpC,YAAQ,MAAM,aAAa,SAAS,OAAO;AAAA,EAC7C,CAAC;AAED,OAAK,2CAA2C,OAAO,EAAE,QAAQ,MAAM;AACrE,UAAM,MAAM,MAAM,QAAQ,KAAK,iCAAiC;AAAA,MAC9D,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,EAAE,eAAe,CAAC,EAAE;AAAA,IAC5B,CAAC;AACD,WAAO,IAAI,OAAO,GAAG,qDAAqD,EAAE,KAAK,GAAG;AAAA,EACtF,CAAC;AAED,OAAK,mCAAmC,OAAO,EAAE,QAAQ,MAAM;AAC7D,UAAM,MAAM,MAAM,WAAW,SAAS,QAAQ,iCAAiC;AAAA,MAC7E;AAAA,MACA,MAAM,EAAE,eAAe,CAAC,EAAE;AAAA,IAC5B,CAAC;AAGD,WAAO,CAAC,KAAK,GAAG,GAAG,2CAA2C,EAAE,UAAU,IAAI,OAAO,CAAC;AAAA,EACxF,CAAC;AAED,OAAK,4CAA4C,OAAO,EAAE,QAAQ,MAAM;AACtE,UAAM,YAAY,sBAAsB,IAAI,OAAO,IAAS,CAAC;AAC7D,UAAM,MAAM,MAAM,QAAQ,KAAK,iCAAiC;AAAA,MAC9D,SAAS;AAAA,QACP,eAAe,UAAU,KAAK;AAAA,QAC9B,gBAAgB;AAAA,MAClB;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AACD,WAAO,IAAI,OAAO,GAAG,yCAAyC,EAAE,KAAK,GAAG;AAAA,EAC1E,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }