History

Browser span export works in production: same-origin OTLP → Axiom (#981)

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).

Rhys Sullivan committed 5 months ago

Commit 2d865811 parent bf23a238 files · +181 −6Browse files

Unified · Split

8 files
  1. apps/cloud/src/env-augment.d.ts +2 −0
  2. apps/cloud/src/observability/browser-traces.test.ts +85 −0
  3. apps/cloud/src/observability/browser-traces.ts +52 −0
  4. apps/cloud/src/server.ts +6 −0
  5. apps/cloud/vite.config.ts +9 −1
  6. apps/cloud/wrangler.jsonc +7 −0
  7. bun.lock +9 −3
  8. packages/react/src/api/client.tsx +11 −2
apps/cloud/src/env-augment.d.ts+2−0
@@ -48,6 +48,8 @@ declare global { · 47 unmodified lines
4848
4949 // Shared with frontend
5050 VITE_PUBLIC_SITE_URL?: string;
51+ VITE_PUBLIC_OTLP_TRACES_URL?: string;
52+ VITE_PUBLIC_OTLP_SAMPLE_RATIO?: string;
5153 }
5254 }
5355 }
apps/cloud/src/observability/browser-traces.test.ts+85−0
@@ -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+});
apps/cloud/src/observability/browser-traces.ts+52−0
@@ -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+};
apps/cloud/src/server.ts+6−0
@@ -10,6 +10,7 @@ import * as Sentry from "@sentry/cloudflare"; · 9 unmodified lines
1010 import handler from "@tanstack/react-start/server-entry";
1111
1212 import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object";
13+import { browserTracesResponse } from "./observability/browser-traces";
1314 import { flushTracerProvider, installTracerProvider } from "./observability/telemetry";
1415
1516 // ---------------------------------------------------------------------------
@@ -68,6 +69,11 @@ const tracer = trace.getTracer("executor-cloud-worker"); · 52 unmodified lines
6869
6970 const cloudflareHandler: ExportedHandler<Env> = {
7071 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;
7177 if (!installTracerProvider()) {
7278 return fetchHandler(request, env, ctx);
7379 }
apps/cloud/vite.config.ts+9−1
@@ -49,13 +49,21 @@ const loadWranglerPublicVars = () => { · 48 unmodified lines
4949 // proxy isn't routed anyway.
5050 const ANALYTICS_PATH = process.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a";
5151
52−export default defineConfig(({ mode }) => {
52+export default defineConfig(({ command, mode }) => {
5353 const env = loadEnv(mode, process.cwd(), "");
5454 const publicEnv = {
5555 ...loadWranglerPublicVars(),
5656 VITE_PUBLIC_ANALYTICS_PATH: ANALYTICS_PATH,
5757 ...env,
5858 };
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+ }
5967
6068 return {
6169 define: Object.fromEntries(
apps/cloud/wrangler.jsonc+7−0
@@ -62,5 +62,12 @@ · 61 unmodified lines
6262 "vars": {
6363 "VITE_PUBLIC_SITE_URL": "https://executor.sh",
6464 "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",
6572 },
6673 }
bun.lock+9−3
@@ -3978,7 +3978,7 @@ · 3977 unmodified lines
39783978
39793979 "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=="],
39803980
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=="],
39823982
39833983 "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
39843984
@@ -5788,6 +5788,8 @@ · 1803 unmodified lines
57885788
57895789 "@sentry/react/@sentry/core": ["@sentry/core@10.48.0", "", {}, "sha512-h8F+fXVwYC9ro5ZaO8V+v3vqc0awlXHGblEAuVxSGgh4IV/oFX+QVzXeDTTrFOFS6v/Vn5vAyu240eJrJAS6/g=="],
57905790
5791+ "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
5792+
57915793 "@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=="],
57925794
57935795 "@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
58585860
58595861 "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=="],
58605862
5863+ "app-builder-lib/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
5864+
58615865 "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
58625866
58635867 "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
58705874
58715875 "atmn/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
58725876
5877+ "atmn/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
5878+
58735879 "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
58745880
58755881 "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
@@ -5990,6 +5996,8 @@ · 114 unmodified lines
59905996
59915997 "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
59925998
5999+ "knip/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
6000+
59936001 "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
59946002
59956003 "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@@ -6432,8 +6440,6 @@ · 436 unmodified lines
64326440
64336441 "@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=="],
64346442
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−
64376443 "@tanstack/router-cli/@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
64386444
64396445 "@tanstack/router-cli/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
packages/react/src/api/client.tsx+11−2
@@ -42,6 +42,11 @@ export const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>) · 41 unmodified lines
4242 // `import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL`; optional chaining would
4343 // dodge the replacement and always read undefined.
4444 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+);
4550
4651 // The tracer must reach the runtime context the atom effects EXECUTE in.
4752 // 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
5863 // error. URL-scoped, the leak is the desired behavior: any client posting
5964 // to the OTLP endpoint (the exporter) goes untraced, everything else is
6065 // 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) {
6269 Atom.runtime.addGlobalLayer(
6370 Layer.mergeAll(
6471 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(),
6675 resource: { serviceName: "executor-web" },
6776 // Browser sessions are short; the 5s default loses the tail spans
6877 // when the tab closes.