The web client's OTLP exporter now ships in the production build: wrangler.jsonc bakes VITE_PUBLIC_OTLP_TRACES_URL=/v1/traces, the client resolves it against the page origin, and a new worker route (observability/browser-traces.ts) forwards the batch to Axiom with the server-held token — the browser never sees AXIOM_TOKEN. The route runs before the worker's server span so exporter traffic doesn't trace itself, requires a session cookie to be present, caps body size, and never reflects Axiom's response to the caller. Client hardening for prod: browser-only install (the module is also evaluated during SSR), relative exporter URLs resolved against the page origin, and per-SESSION sampling via VITE_PUBLIC_OTLP_SAMPLE_RATIO (a session traces everything or nothing — per-span sampling would shred waterfalls). vite dev strips the baked endpoint unless a local motel is actually listening, so plain bun dev doesn't post spans into a dead proxy. e2e keeps working unchanged (verified: 27 browser entries harvested on a fresh run).
Unified · Split
| @@ -48,6 +48,8 @@ declare global { · 47 unmodified lines | ||
| 48 | 48 | |
| 49 | 49 | // Shared with frontend |
| 50 | 50 | VITE_PUBLIC_SITE_URL?: string; |
| 51 | + VITE_PUBLIC_OTLP_TRACES_URL?: string; | |
| 52 | + VITE_PUBLIC_OTLP_SAMPLE_RATIO?: string; | |
| 51 | 53 | } |
| 52 | 54 | } |
| 53 | 55 | } |
| @@ -0,0 +1,85 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { browserTracesResponse } from "./browser-traces"; | |
| 4 | + | |
| 5 | +const makeRequest = (init?: RequestInit & { path?: string }) => | |
| 6 | + new Request(`https://executor.sh${init?.path ?? "/v1/traces"}`, { | |
| 7 | + method: "POST", | |
| 8 | + body: "{}", | |
| 9 | + ...init, | |
| 10 | + }); | |
| 11 | + | |
| 12 | +const baseEnv = { | |
| 13 | + AXIOM_TOKEN: "axiom-secret", | |
| 14 | + AXIOM_DATASET: "executor-cloud", | |
| 15 | +} as Env; | |
| 16 | + | |
| 17 | +describe("browserTracesResponse", () => { | |
| 18 | + it("ignores non-/v1/traces requests entirely", () => { | |
| 19 | + expect(browserTracesResponse(makeRequest({ path: "/api/tools" }), baseEnv)).toBeNull(); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it("drops batches silently when Axiom is not configured", async () => { | |
| 23 | + const response = await browserTracesResponse( | |
| 24 | + makeRequest({ headers: { cookie: "wos-session=abc" } }), | |
| 25 | + {} as Env, | |
| 26 | + ); | |
| 27 | + expect(response?.status).toBe(204); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("rejects anonymous posts", async () => { | |
| 31 | + const response = await browserTracesResponse(makeRequest(), baseEnv); | |
| 32 | + expect(response?.status).toBe(401); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("forwards to Axiom with server-held credentials and hides the upstream body", async () => { | |
| 36 | + let seen: { url: string; auth: string | null; dataset: string | null } | undefined; | |
| 37 | + const response = await browserTracesResponse( | |
| 38 | + makeRequest({ headers: { cookie: "wos-session=abc" } }), | |
| 39 | + baseEnv, | |
| 40 | + (async (url: RequestInfo | URL, init?: RequestInit) => { | |
| 41 | + seen = { | |
| 42 | + url: String(url), | |
| 43 | + auth: new Headers(init?.headers).get("authorization"), | |
| 44 | + dataset: new Headers(init?.headers).get("x-axiom-dataset"), | |
| 45 | + }; | |
| 46 | + return new Response("axiom-internals", { status: 200 }); | |
| 47 | + }) as typeof fetch, | |
| 48 | + ); | |
| 49 | + expect(seen?.url).toBe("https://api.axiom.co/v1/traces"); | |
| 50 | + expect(seen?.auth).toBe("Bearer axiom-secret"); | |
| 51 | + expect(seen?.dataset).toBe("executor-cloud"); | |
| 52 | + expect(response?.status).toBe(204); | |
| 53 | + expect(await response?.text()).toBe(""); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it("reports upstream failure as 502 without leaking detail", async () => { | |
| 57 | + const response = await browserTracesResponse( | |
| 58 | + makeRequest({ headers: { cookie: "wos-session=abc" } }), | |
| 59 | + baseEnv, | |
| 60 | + (async () => new Response("denied", { status: 403 })) as typeof fetch, | |
| 61 | + ); | |
| 62 | + expect(response?.status).toBe(502); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("refuses oversized batches", async () => { | |
| 66 | + const response = await browserTracesResponse( | |
| 67 | + makeRequest({ | |
| 68 | + headers: { | |
| 69 | + cookie: "wos-session=abc", | |
| 70 | + "content-length": String(3_000_000), | |
| 71 | + }, | |
| 72 | + }), | |
| 73 | + baseEnv, | |
| 74 | + ); | |
| 75 | + expect(response?.status).toBe(413); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("only accepts POST", async () => { | |
| 79 | + const response = await browserTracesResponse( | |
| 80 | + new Request("https://executor.sh/v1/traces", { method: "GET" }), | |
| 81 | + baseEnv, | |
| 82 | + ); | |
| 83 | + expect(response?.status).toBe(405); | |
| 84 | + }); | |
| 85 | +}); | |
| @@ -0,0 +1,52 @@ | ||
| 1 | +// Browser → Axiom OTLP ingress. The web client exports its spans to | |
| 2 | +// same-origin /v1/traces (it can never hold AXIOM_TOKEN); this worker route | |
| 3 | +// forwards the batch to Axiom with the server-held credentials. Locally and | |
| 4 | +// in e2e the vite dev server proxies the same path to motel before the | |
| 5 | +// worker ever sees it, so this route only serves deployed workers. | |
| 6 | +// | |
| 7 | +// Guards, not auth: the endpoint is write-only into a server-pinned dataset, | |
| 8 | +// but it should not be an anonymous internet ingest — a session cookie must | |
| 9 | +// at least be present, and bodies are capped. We deliberately do NOT verify | |
| 10 | +// the session (that would put a WorkOS round-trip on every span batch). | |
| 11 | + | |
| 12 | +const MAX_BODY_BYTES = 2_000_000; | |
| 13 | + | |
| 14 | +export const BROWSER_TRACES_PATH = "/v1/traces"; | |
| 15 | + | |
| 16 | +export const browserTracesResponse = ( | |
| 17 | + request: Request, | |
| 18 | + env: Env, | |
| 19 | + fetchImpl: typeof fetch = fetch, | |
| 20 | +): Promise<Response> | null => { | |
| 21 | + const url = new URL(request.url); | |
| 22 | + if (url.pathname !== BROWSER_TRACES_PATH) return null; | |
| 23 | + if (request.method !== "POST") { | |
| 24 | + return Promise.resolve(new Response(null, { status: 405 })); | |
| 25 | + } | |
| 26 | + // Tracing not configured on this deployment — accept and drop so the | |
| 27 | + // client exporter stays quiet (it would retry/log on errors). | |
| 28 | + if (!env.AXIOM_TOKEN) { | |
| 29 | + return Promise.resolve(new Response(null, { status: 204 })); | |
| 30 | + } | |
| 31 | + if (!(request.headers.get("cookie") ?? "").includes("wos-session=")) { | |
| 32 | + return Promise.resolve(new Response(null, { status: 401 })); | |
| 33 | + } | |
| 34 | + const contentLength = Number(request.headers.get("content-length") ?? 0); | |
| 35 | + if (contentLength > MAX_BODY_BYTES) { | |
| 36 | + return Promise.resolve(new Response(null, { status: 413 })); | |
| 37 | + } | |
| 38 | + return fetchImpl(env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", { | |
| 39 | + method: "POST", | |
| 40 | + headers: { | |
| 41 | + "content-type": request.headers.get("content-type") ?? "application/json", | |
| 42 | + authorization: `Bearer ${env.AXIOM_TOKEN}`, | |
| 43 | + "x-axiom-dataset": env.AXIOM_DATASET ?? "executor-cloud", | |
| 44 | + }, | |
| 45 | + body: request.body, | |
| 46 | + }).then( | |
| 47 | + // The exporter only needs success/failure; never reflect Axiom's | |
| 48 | + // response body (or its headers) back to an unauthenticated caller. | |
| 49 | + (upstream) => new Response(null, { status: upstream.ok ? 204 : 502 }), | |
| 50 | + () => new Response(null, { status: 502 }), | |
| 51 | + ); | |
| 52 | +}; | |
| @@ -10,6 +10,7 @@ import * as Sentry from "@sentry/cloudflare"; · 9 unmodified lines | ||
| 10 | 10 | import handler from "@tanstack/react-start/server-entry"; |
| 11 | 11 | |
| 12 | 12 | import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object"; |
| 13 | +import { browserTracesResponse } from "./observability/browser-traces"; | |
| 13 | 14 | import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; |
| 14 | 15 | |
| 15 | 16 | // --------------------------------------------------------------------------- |
| @@ -68,6 +69,11 @@ const tracer = trace.getTracer("executor-cloud-worker"); · 52 unmodified lines | ||
| 68 | 69 | |
| 69 | 70 | const cloudflareHandler: ExportedHandler<Env> = { |
| 70 | 71 | fetch: async (request, env, ctx) => { |
| 72 | + // Browser OTLP ingress — before the server span opens: exporter traffic | |
| 73 | + // must never trace itself (the browser already excludes /v1/traces from | |
| 74 | + // its own tracing for the same reason). | |
| 75 | + const browserTraces = browserTracesResponse(request, env); | |
| 76 | + if (browserTraces) return browserTraces; | |
| 71 | 77 | if (!installTracerProvider()) { |
| 72 | 78 | return fetchHandler(request, env, ctx); |
| 73 | 79 | } |
| @@ -49,13 +49,21 @@ const loadWranglerPublicVars = () => { · 48 unmodified lines | ||
| 49 | 49 | // proxy isn't routed anyway. |
| 50 | 50 | const ANALYTICS_PATH = process.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a"; |
| 51 | 51 | |
| 52 | −export default defineConfig(({ mode }) => { | |
| 52 | +export default defineConfig(({ command, mode }) => { | |
| 53 | 53 | const env = loadEnv(mode, process.cwd(), ""); |
| 54 | 54 | const publicEnv = { |
| 55 | 55 | ...loadWranglerPublicVars(), |
| 56 | 56 | VITE_PUBLIC_ANALYTICS_PATH: ANALYTICS_PATH, |
| 57 | 57 | ...env, |
| 58 | 58 | }; |
| 59 | + // The wrangler-declared OTLP endpoint is for DEPLOYED workers (the | |
| 60 | + // /v1/traces forwarding route). Under `vite dev` that path is only the | |
| 61 | + // proxy below — keep the exporter off unless something actually listens | |
| 62 | + // (e2e/dev sets MOTEL_URL or the env var itself), or every dev session | |
| 63 | + // posts spans into a dead proxy once a second. | |
| 64 | + if (command === "serve" && !process.env.MOTEL_URL && !env.VITE_PUBLIC_OTLP_TRACES_URL) { | |
| 65 | + delete (publicEnv as Record<string, string | undefined>).VITE_PUBLIC_OTLP_TRACES_URL; | |
| 66 | + } | |
| 59 | 67 | |
| 60 | 68 | return { |
| 61 | 69 | define: Object.fromEntries( |
| @@ -62,5 +62,12 @@ · 61 unmodified lines | ||
| 62 | 62 | "vars": { |
| 63 | 63 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", |
| 64 | 64 | "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", |
| 65 | + // Browser OTLP spans → same-origin, forwarded to Axiom by the worker | |
| 66 | + // (src/observability/browser-traces.ts). Relative on purpose: the | |
| 67 | + // client resolves it against the page origin, so previews work too. | |
| 68 | + "VITE_PUBLIC_OTLP_TRACES_URL": "/v1/traces", | |
| 69 | + // Per-session sampling: 1 = every signed-in session exports. Dial down | |
| 70 | + // (e.g. "0.1") if Axiom volume becomes a cost concern. | |
| 71 | + "VITE_PUBLIC_OTLP_SAMPLE_RATIO": "1", | |
| 65 | 72 | }, |
| 66 | 73 | } |
| @@ -3978,7 +3978,7 @@ · 3977 unmodified lines | ||
| 3978 | 3978 | |
| 3979 | 3979 | "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], |
| 3980 | 3980 | |
| 3981 | − "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | |
| 3981 | + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], | |
| 3982 | 3982 | |
| 3983 | 3983 | "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], |
| 3984 | 3984 | |
| @@ -5788,6 +5788,8 @@ · 1803 unmodified lines | ||
| 5788 | 5788 | |
| 5789 | 5789 | "@sentry/react/@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="], |
| 5790 | 5790 | |
| 5791 | + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | |
| 5792 | + | |
| 5791 | 5793 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], |
| 5792 | 5794 | |
| 5793 | 5795 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], |
| @@ -5858,6 +5860,8 @@ · 64 unmodified lines | ||
| 5858 | 5860 | |
| 5859 | 5861 | "app-builder-lib/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], |
| 5860 | 5862 | |
| 5863 | + "app-builder-lib/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | |
| 5864 | + | |
| 5861 | 5865 | "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], |
| 5862 | 5866 | |
| 5863 | 5867 | "astro/@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], |
| @@ -5870,6 +5874,8 @@ · 6 unmodified lines | ||
| 5870 | 5874 | |
| 5871 | 5875 | "atmn/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], |
| 5872 | 5876 | |
| 5877 | + "atmn/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | |
| 5878 | + | |
| 5873 | 5879 | "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], |
| 5874 | 5880 | |
| 5875 | 5881 | "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], |
| @@ -5990,6 +5996,8 @@ · 114 unmodified lines | ||
| 5990 | 5996 | |
| 5991 | 5997 | "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], |
| 5992 | 5998 | |
| 5999 | + "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | |
| 6000 | + | |
| 5993 | 6001 | "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], |
| 5994 | 6002 | |
| 5995 | 6003 | "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], |
| @@ -6432,8 +6440,6 @@ · 436 unmodified lines | ||
| 6432 | 6440 | |
| 6433 | 6441 | "@tanstack/router-cli/@tanstack/router-generator/@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="], |
| 6434 | 6442 | |
| 6435 | − "@tanstack/router-cli/@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], | |
| 6436 | − | |
| 6437 | 6443 | "@tanstack/router-cli/@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], |
| 6438 | 6444 | |
| 6439 | 6445 | "@tanstack/router-cli/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], |
| @@ -42,6 +42,11 @@ export const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>) · 41 unmodified lines | ||
| 42 | 42 | // `import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL`; optional chaining would |
| 43 | 43 | // dodge the replacement and always read undefined. |
| 44 | 44 | const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as string | undefined; |
| 45 | +// Per-SESSION sampling for production: a page either traces everything it | |
| 46 | +// does or nothing (per-span sampling would shred the waterfalls). Unset = 1. | |
| 47 | +const otlpSampleRatio = Number( | |
| 48 | + (import.meta.env.VITE_PUBLIC_OTLP_SAMPLE_RATIO as string | undefined) ?? "1", | |
| 49 | +); | |
| 45 | 50 | |
| 46 | 51 | // The tracer must reach the runtime context the atom effects EXECUTE in. |
| 47 | 52 | // Merging it into the `httpClient` option doesn't: AtomHttpApi builds the |
| @@ -58,11 +63,15 @@ const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as string | un · 10 unmodified lines | ||
| 58 | 63 | // error. URL-scoped, the leak is the desired behavior: any client posting |
| 59 | 64 | // to the OTLP endpoint (the exporter) goes untraced, everything else is |
| 60 | 65 | // traced. |
| 61 | −if (otlpTracesUrl) { | |
| 66 | +// Browser-only (this module is also evaluated during SSR, where the worker | |
| 67 | +// has its own tracer and a relative exporter URL would be meaningless). | |
| 68 | +if (otlpTracesUrl && typeof document !== "undefined" && Math.random() < otlpSampleRatio) { | |
| 62 | 69 | Atom.runtime.addGlobalLayer( |
| 63 | 70 | Layer.mergeAll( |
| 64 | 71 | OtlpTracer.layer({ |
| 65 | − url: otlpTracesUrl, | |
| 72 | + // Relative paths (the prod shape: "/v1/traces" → the worker's | |
| 73 | + // forwarding route) resolve against the page's own origin. | |
| 74 | + url: new URL(otlpTracesUrl, window.location.origin).toString(), | |
| 66 | 75 | resource: { serviceName: "executor-web" }, |
| 67 | 76 | // Browser sessions are short; the 5s default loses the tail spans |
| 68 | 77 | // when the tab closes. |