@open-mercato/telemetry 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7294.1.0ef99fc518
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
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:telemetry] found
|
|
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
|
@@ -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,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
|
+
}
|