Skip to content

Commit 31eb6cd

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 eb848df commit 31eb6cd

8 files changed

Lines changed: 181 additions & 6 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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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 { 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");
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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,21 @@ const loadWranglerPublicVars = () => {
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

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
}

bun.lock

Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react/src/api/client.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>)
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
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.

0 commit comments

Comments
 (0)