diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 69de5c9ad..8911a8303 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -48,6 +48,8 @@ declare global { // Shared with frontend VITE_PUBLIC_SITE_URL?: string; + VITE_PUBLIC_OTLP_TRACES_URL?: string; + VITE_PUBLIC_OTLP_SAMPLE_RATIO?: string; } } } diff --git a/apps/cloud/src/observability/browser-traces.test.ts b/apps/cloud/src/observability/browser-traces.test.ts new file mode 100644 index 000000000..ce20707b0 --- /dev/null +++ b/apps/cloud/src/observability/browser-traces.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { browserTracesResponse } from "./browser-traces"; + +const makeRequest = (init?: RequestInit & { path?: string }) => + new Request(`https://executor.sh${init?.path ?? "/v1/traces"}`, { + method: "POST", + body: "{}", + ...init, + }); + +const baseEnv = { + AXIOM_TOKEN: "axiom-secret", + AXIOM_DATASET: "executor-cloud", +} as Env; + +describe("browserTracesResponse", () => { + it("ignores non-/v1/traces requests entirely", () => { + expect(browserTracesResponse(makeRequest({ path: "/api/tools" }), baseEnv)).toBeNull(); + }); + + it("drops batches silently when Axiom is not configured", async () => { + const response = await browserTracesResponse( + makeRequest({ headers: { cookie: "wos-session=abc" } }), + {} as Env, + ); + expect(response?.status).toBe(204); + }); + + it("rejects anonymous posts", async () => { + const response = await browserTracesResponse(makeRequest(), baseEnv); + expect(response?.status).toBe(401); + }); + + it("forwards to Axiom with server-held credentials and hides the upstream body", async () => { + let seen: { url: string; auth: string | null; dataset: string | null } | undefined; + const response = await browserTracesResponse( + makeRequest({ headers: { cookie: "wos-session=abc" } }), + baseEnv, + (async (url: RequestInfo | URL, init?: RequestInit) => { + seen = { + url: String(url), + auth: new Headers(init?.headers).get("authorization"), + dataset: new Headers(init?.headers).get("x-axiom-dataset"), + }; + return new Response("axiom-internals", { status: 200 }); + }) as typeof fetch, + ); + expect(seen?.url).toBe("https://api.axiom.co/v1/traces"); + expect(seen?.auth).toBe("Bearer axiom-secret"); + expect(seen?.dataset).toBe("executor-cloud"); + expect(response?.status).toBe(204); + expect(await response?.text()).toBe(""); + }); + + it("reports upstream failure as 502 without leaking detail", async () => { + const response = await browserTracesResponse( + makeRequest({ headers: { cookie: "wos-session=abc" } }), + baseEnv, + (async () => new Response("denied", { status: 403 })) as typeof fetch, + ); + expect(response?.status).toBe(502); + }); + + it("refuses oversized batches", async () => { + const response = await browserTracesResponse( + makeRequest({ + headers: { + cookie: "wos-session=abc", + "content-length": String(3_000_000), + }, + }), + baseEnv, + ); + expect(response?.status).toBe(413); + }); + + it("only accepts POST", async () => { + const response = await browserTracesResponse( + new Request("https://executor.sh/v1/traces", { method: "GET" }), + baseEnv, + ); + expect(response?.status).toBe(405); + }); +}); diff --git a/apps/cloud/src/observability/browser-traces.ts b/apps/cloud/src/observability/browser-traces.ts new file mode 100644 index 000000000..8505678e2 --- /dev/null +++ b/apps/cloud/src/observability/browser-traces.ts @@ -0,0 +1,52 @@ +// Browser → Axiom OTLP ingress. The web client exports its spans to +// same-origin /v1/traces (it can never hold AXIOM_TOKEN); this worker route +// forwards the batch to Axiom with the server-held credentials. Locally and +// in e2e the vite dev server proxies the same path to motel before the +// worker ever sees it, so this route only serves deployed workers. +// +// Guards, not auth: the endpoint is write-only into a server-pinned dataset, +// but it should not be an anonymous internet ingest — a session cookie must +// at least be present, and bodies are capped. We deliberately do NOT verify +// the session (that would put a WorkOS round-trip on every span batch). + +const MAX_BODY_BYTES = 2_000_000; + +export const BROWSER_TRACES_PATH = "/v1/traces"; + +export const browserTracesResponse = ( + request: Request, + env: Env, + fetchImpl: typeof fetch = fetch, +): Promise | null => { + const url = new URL(request.url); + if (url.pathname !== BROWSER_TRACES_PATH) return null; + if (request.method !== "POST") { + return Promise.resolve(new Response(null, { status: 405 })); + } + // Tracing not configured on this deployment — accept and drop so the + // client exporter stays quiet (it would retry/log on errors). + if (!env.AXIOM_TOKEN) { + return Promise.resolve(new Response(null, { status: 204 })); + } + if (!(request.headers.get("cookie") ?? "").includes("wos-session=")) { + return Promise.resolve(new Response(null, { status: 401 })); + } + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (contentLength > MAX_BODY_BYTES) { + return Promise.resolve(new Response(null, { status: 413 })); + } + return fetchImpl(env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", { + method: "POST", + headers: { + "content-type": request.headers.get("content-type") ?? "application/json", + authorization: `Bearer ${env.AXIOM_TOKEN}`, + "x-axiom-dataset": env.AXIOM_DATASET ?? "executor-cloud", + }, + body: request.body, + }).then( + // The exporter only needs success/failure; never reflect Axiom's + // response body (or its headers) back to an unauthenticated caller. + (upstream) => new Response(null, { status: upstream.ok ? 204 : 502 }), + () => new Response(null, { status: 502 }), + ); +}; diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 66a3c4a8f..7bdd7bace 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -10,6 +10,7 @@ import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object"; +import { browserTracesResponse } from "./observability/browser-traces"; import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; // --------------------------------------------------------------------------- @@ -68,6 +69,11 @@ const tracer = trace.getTracer("executor-cloud-worker"); const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + // Browser OTLP ingress — before the server span opens: exporter traffic + // must never trace itself (the browser already excludes /v1/traces from + // its own tracing for the same reason). + const browserTraces = browserTracesResponse(request, env); + if (browserTraces) return browserTraces; if (!installTracerProvider()) { return fetchHandler(request, env, ctx); } diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 3a9090e27..dfe511190 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -49,13 +49,21 @@ const loadWranglerPublicVars = () => { // proxy isn't routed anyway. const ANALYTICS_PATH = process.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a"; -export default defineConfig(({ mode }) => { +export default defineConfig(({ command, mode }) => { const env = loadEnv(mode, process.cwd(), ""); const publicEnv = { ...loadWranglerPublicVars(), VITE_PUBLIC_ANALYTICS_PATH: ANALYTICS_PATH, ...env, }; + // The wrangler-declared OTLP endpoint is for DEPLOYED workers (the + // /v1/traces forwarding route). Under `vite dev` that path is only the + // proxy below — keep the exporter off unless something actually listens + // (e2e/dev sets MOTEL_URL or the env var itself), or every dev session + // posts spans into a dead proxy once a second. + if (command === "serve" && !process.env.MOTEL_URL && !env.VITE_PUBLIC_OTLP_TRACES_URL) { + delete (publicEnv as Record).VITE_PUBLIC_OTLP_TRACES_URL; + } return { define: Object.fromEntries( diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 830797392..0e92fbfa3 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -62,5 +62,12 @@ "vars": { "VITE_PUBLIC_SITE_URL": "https://executor.sh", "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", + // Browser OTLP spans → same-origin, forwarded to Axiom by the worker + // (src/observability/browser-traces.ts). Relative on purpose: the + // client resolves it against the page origin, so previews work too. + "VITE_PUBLIC_OTLP_TRACES_URL": "/v1/traces", + // Per-session sampling: 1 = every signed-in session exports. Dial down + // (e.g. "0.1") if Axiom volume becomes a cost concern. + "VITE_PUBLIC_OTLP_SAMPLE_RATIO": "1", }, } diff --git a/bun.lock b/bun.lock index 696e986e1..7cb8abdac 100644 --- a/bun.lock +++ b/bun.lock @@ -3978,7 +3978,7 @@ "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=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], @@ -5788,6 +5788,8 @@ "@sentry/react/@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="], + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "@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=="], "@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 @@ "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=="], + "app-builder-lib/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "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 @@ "atmn/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "atmn/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -5990,6 +5996,8 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -6432,8 +6440,6 @@ "@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=="], - "@tanstack/router-cli/@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tanstack/router-cli/@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@tanstack/router-cli/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], diff --git a/packages/react/src/api/client.tsx b/packages/react/src/api/client.tsx index a75436383..b9b9ca738 100644 --- a/packages/react/src/api/client.tsx +++ b/packages/react/src/api/client.tsx @@ -42,6 +42,11 @@ export const reportApiClientInfrastructureCause = (cause: Cause.Cause) // `import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL`; optional chaining would // dodge the replacement and always read undefined. const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as string | undefined; +// Per-SESSION sampling for production: a page either traces everything it +// does or nothing (per-span sampling would shred the waterfalls). Unset = 1. +const otlpSampleRatio = Number( + (import.meta.env.VITE_PUBLIC_OTLP_SAMPLE_RATIO as string | undefined) ?? "1", +); // The tracer must reach the runtime context the atom effects EXECUTE in. // 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 // error. URL-scoped, the leak is the desired behavior: any client posting // to the OTLP endpoint (the exporter) goes untraced, everything else is // traced. -if (otlpTracesUrl) { +// Browser-only (this module is also evaluated during SSR, where the worker +// has its own tracer and a relative exporter URL would be meaningless). +if (otlpTracesUrl && typeof document !== "undefined" && Math.random() < otlpSampleRatio) { Atom.runtime.addGlobalLayer( Layer.mergeAll( OtlpTracer.layer({ - url: otlpTracesUrl, + // Relative paths (the prod shape: "/v1/traces" → the worker's + // forwarding route) resolve against the page's own origin. + url: new URL(otlpTracesUrl, window.location.origin).toString(), resource: { serviceName: "executor-web" }, // Browser sessions are short; the 5s default loses the tail spans // when the tab closes.