Skip to content

Commit 3e1f4ea

Browse files
committed
Browser span export works in production: same-origin OTLP → Axiom
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).
1 parent 5a144db commit 3e1f4ea

8 files changed

Lines changed: 250 additions & 31 deletions

File tree

apps/cloud/src/env-augment.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ declare global {
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
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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(
20+
browserTracesResponse(makeRequest({ path: "/api/tools" }), baseEnv),
21+
).toBeNull();
22+
});
23+
24+
it("drops batches silently when Axiom is not configured", async () => {
25+
const response = await browserTracesResponse(
26+
makeRequest({ headers: { cookie: "wos-session=abc" } }),
27+
{} as Env,
28+
);
29+
expect(response?.status).toBe(204);
30+
});
31+
32+
it("rejects anonymous posts", async () => {
33+
const response = await browserTracesResponse(makeRequest(), baseEnv);
34+
expect(response?.status).toBe(401);
35+
});
36+
37+
it("forwards to Axiom with server-held credentials and hides the upstream body", async () => {
38+
let seen:
39+
| { url: string; auth: string | null; dataset: string | null }
40+
| undefined;
41+
const response = await browserTracesResponse(
42+
makeRequest({ headers: { cookie: "wos-session=abc" } }),
43+
baseEnv,
44+
(async (url: RequestInfo | URL, init?: RequestInit) => {
45+
seen = {
46+
url: String(url),
47+
auth: new Headers(init?.headers).get("authorization"),
48+
dataset: new Headers(init?.headers).get("x-axiom-dataset"),
49+
};
50+
return new Response("axiom-internals", { status: 200 });
51+
}) as typeof fetch,
52+
);
53+
expect(seen?.url).toBe("https://api.axiom.co/v1/traces");
54+
expect(seen?.auth).toBe("Bearer axiom-secret");
55+
expect(seen?.dataset).toBe("executor-cloud");
56+
expect(response?.status).toBe(204);
57+
expect(await response?.text()).toBe("");
58+
});
59+
60+
it("reports upstream failure as 502 without leaking detail", async () => {
61+
const response = await browserTracesResponse(
62+
makeRequest({ headers: { cookie: "wos-session=abc" } }),
63+
baseEnv,
64+
(async () => new Response("denied", { status: 403 })) as typeof fetch,
65+
);
66+
expect(response?.status).toBe(502);
67+
});
68+
69+
it("refuses oversized batches", async () => {
70+
const response = await browserTracesResponse(
71+
makeRequest({
72+
headers: {
73+
cookie: "wos-session=abc",
74+
"content-length": String(3_000_000),
75+
},
76+
}),
77+
baseEnv,
78+
);
79+
expect(response?.status).toBe(413);
80+
});
81+
82+
it("only accepts POST", async () => {
83+
const response = await browserTracesResponse(
84+
new Request("https://executor.sh/v1/traces", { method: "GET" }),
85+
baseEnv,
86+
);
87+
expect(response?.status).toBe(405);
88+
});
89+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import * as Sentry from "@sentry/cloudflare";
1010
import handler from "@tanstack/react-start/server-entry";
1111

1212
import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object";
13-
import { flushTracerProvider, installTracerProvider } from "./observability/telemetry";
13+
import { browserTracesResponse } from "./observability/browser-traces";
14+
import {
15+
flushTracerProvider,
16+
installTracerProvider,
17+
} from "./observability/telemetry";
1418

1519
// ---------------------------------------------------------------------------
1620
// Sentry config
@@ -68,6 +72,11 @@ const tracer = trace.getTracer("executor-cloud-worker");
6872

6973
const cloudflareHandler: ExportedHandler<Env> = {
7074
fetch: async (request, env, ctx) => {
75+
// Browser OTLP ingress — before the server span opens: exporter traffic
76+
// must never trace itself (the browser already excludes /v1/traces from
77+
// its own tracing for the same reason).
78+
const browserTraces = browserTracesResponse(request, env);
79+
if (browserTraces) return browserTraces;
7180
if (!installTracerProvider()) {
7281
return fetchHandler(request, env, ctx);
7382
}
@@ -76,9 +85,10 @@ const cloudflareHandler: ExportedHandler<Env> = {
7685
// sends traceparent on every API fetch, so the browser's spans and this
7786
// request share one trace id end to end. Same parsing the DO path does
7887
// in session-durable-object.ts.
79-
const traceparentMatch = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(
80-
request.headers.get("traceparent") ?? "",
81-
);
88+
const traceparentMatch =
89+
/^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(
90+
request.headers.get("traceparent") ?? "",
91+
);
8292
const parentContext = traceparentMatch
8393
? trace.setSpanContext(context.active(), {
8494
traceId: traceparentMatch[1]!,

apps/cloud/vite.config.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ const devCrashGuard = (): Plugin => {
2020
if (installed) return;
2121
installed = true;
2222
process.on("uncaughtException", (err, origin) => {
23-
console.error(`[dev-crash-guard] uncaughtException (origin=${origin}):`, err);
23+
console.error(
24+
`[dev-crash-guard] uncaughtException (origin=${origin}):`,
25+
err,
26+
);
2427
});
2528
process.on("unhandledRejection", (reason, promise) => {
2629
console.error("[dev-crash-guard] unhandledRejection:", reason, promise);
@@ -39,7 +42,9 @@ const loadWranglerPublicVars = () => {
3942
{ hideWarnings: true },
4043
);
4144
return Object.fromEntries(
42-
Object.entries(wranglerConfig.vars ?? {}).filter(([key]) => key.startsWith("VITE_PUBLIC_")),
45+
Object.entries(wranglerConfig.vars ?? {}).filter(([key]) =>
46+
key.startsWith("VITE_PUBLIC_"),
47+
),
4348
);
4449
};
4550

@@ -49,19 +54,35 @@ const loadWranglerPublicVars = () => {
4954
// proxy isn't routed anyway.
5055
const ANALYTICS_PATH = process.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a";
5156

52-
export default defineConfig(({ mode }) => {
57+
export default defineConfig(({ command, mode }) => {
5358
const env = loadEnv(mode, process.cwd(), "");
5459
const publicEnv = {
5560
...loadWranglerPublicVars(),
5661
VITE_PUBLIC_ANALYTICS_PATH: ANALYTICS_PATH,
5762
...env,
5863
};
64+
// The wrangler-declared OTLP endpoint is for DEPLOYED workers (the
65+
// /v1/traces forwarding route). Under `vite dev` that path is only the
66+
// proxy below — keep the exporter off unless something actually listens
67+
// (e2e/dev sets MOTEL_URL or the env var itself), or every dev session
68+
// posts spans into a dead proxy once a second.
69+
if (
70+
command === "serve" &&
71+
!process.env.MOTEL_URL &&
72+
!env.VITE_PUBLIC_OTLP_TRACES_URL
73+
) {
74+
delete (publicEnv as Record<string, string | undefined>)
75+
.VITE_PUBLIC_OTLP_TRACES_URL;
76+
}
5977

6078
return {
6179
define: Object.fromEntries(
6280
Object.entries(publicEnv)
6381
.filter(([key]) => key.startsWith("VITE_PUBLIC_"))
64-
.map(([key, value]) => [`import.meta.env.${key}`, JSON.stringify(value)]),
82+
.map(([key, value]) => [
83+
`import.meta.env.${key}`,
84+
JSON.stringify(value),
85+
]),
6586
),
6687
// Browser OTLP spans (VITE_PUBLIC_OTLP_TRACES_URL=/v1/traces, set by the
6788
// e2e global setup) go same-origin and proxy to the local motel server —

apps/cloud/wrangler.jsonc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,12 @@
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
}

e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"viewer:build": "bun scripts/rebuild-viewer.ts",
1212
"serve": "bun scripts/rebuild-viewer.ts && bun scripts/serve.ts",
1313
"typecheck": "tsc --noEmit",
14-
"test:desktop": "vitest run --project desktop",
14+
"test:desktop": "vitest run --project desktop",
1515
"motel": "MOTEL_OTEL_BASE_URL=http://127.0.0.1:4796 MOTEL_OTEL_DB_PATH=runs/.motel/telemetry.sqlite motel server"
1616
},
1717
"dependencies": {

0 commit comments

Comments
 (0)