Skip to content

Commit b6d2abd

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 f949ba1 commit b6d2abd

7 files changed

Lines changed: 249 additions & 30 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ declare global {
4040

4141
// Shared with frontend
4242
VITE_PUBLIC_SITE_URL?: string;
43+
VITE_PUBLIC_OTLP_TRACES_URL?: string;
44+
VITE_PUBLIC_OTLP_SAMPLE_RATIO?: string;
4345
}
4446
}
4547
}
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
@@ -18,7 +18,10 @@ const devCrashGuard = (): Plugin => {
1818
if (installed) return;
1919
installed = true;
2020
process.on("uncaughtException", (err, origin) => {
21-
console.error(`[dev-crash-guard] uncaughtException (origin=${origin}):`, err);
21+
console.error(
22+
`[dev-crash-guard] uncaughtException (origin=${origin}):`,
23+
err,
24+
);
2225
});
2326
process.on("unhandledRejection", (reason, promise) => {
2427
console.error("[dev-crash-guard] unhandledRejection:", reason, promise);
@@ -37,7 +40,9 @@ const loadWranglerPublicVars = () => {
3740
{ hideWarnings: true },
3841
);
3942
return Object.fromEntries(
40-
Object.entries(wranglerConfig.vars ?? {}).filter(([key]) => key.startsWith("VITE_PUBLIC_")),
43+
Object.entries(wranglerConfig.vars ?? {}).filter(([key]) =>
44+
key.startsWith("VITE_PUBLIC_"),
45+
),
4146
);
4247
};
4348

@@ -47,19 +52,35 @@ const loadWranglerPublicVars = () => {
4752
// proxy isn't routed anyway.
4853
const ANALYTICS_PATH = process.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a";
4954

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

5876
return {
5977
define: Object.fromEntries(
6078
Object.entries(publicEnv)
6179
.filter(([key]) => key.startsWith("VITE_PUBLIC_"))
62-
.map(([key, value]) => [`import.meta.env.${key}`, JSON.stringify(value)]),
80+
.map(([key, value]) => [
81+
`import.meta.env.${key}`,
82+
JSON.stringify(value),
83+
]),
6384
),
6485
// Browser OTLP spans (VITE_PUBLIC_OTLP_TRACES_URL=/v1/traces, set by the
6586
// 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
@@ -54,5 +54,12 @@
5454
"vars": {
5555
"VITE_PUBLIC_SITE_URL": "https://executor.sh",
5656
"VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
57+
// Browser OTLP spans → same-origin, forwarded to Axiom by the worker
58+
// (src/observability/browser-traces.ts). Relative on purpose: the
59+
// client resolves it against the page origin, so previews work too.
60+
"VITE_PUBLIC_OTLP_TRACES_URL": "/v1/traces",
61+
// Per-session sampling: 1 = every signed-in session exports. Dial down
62+
// (e.g. "0.1") if Axiom volume becomes a cost concern.
63+
"VITE_PUBLIC_OTLP_SAMPLE_RATIO": "1",
5764
},
5865
}

packages/react/src/api/client.tsx

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as Atom from "effect/unstable/reactivity/Atom";
22
import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
3-
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3+
import {
4+
FetchHttpClient,
5+
HttpClient,
6+
HttpClientRequest,
7+
} from "effect/unstable/http";
48
import * as HttpClientError from "effect/unstable/http/HttpClientError";
59
import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
610
import { ExecutorApi } from "@executor-js/api/client";
@@ -11,15 +15,21 @@ import * as Option from "effect/Option";
1115
import * as Schema from "effect/Schema";
1216

1317
import { reportHandledFrontendError } from "./error-reporting";
14-
import { getExecutorApiBaseUrl, getExecutorServerAuthorizationHeader } from "./server-connection";
18+
import {
19+
getExecutorApiBaseUrl,
20+
getExecutorServerAuthorizationHeader,
21+
} from "./server-connection";
1522

1623
const isApiClientInfrastructureCause = (cause: Cause.Cause<unknown>): boolean =>
1724
Option.match(Cause.findErrorOption(cause), {
1825
onNone: () => false,
19-
onSome: (error) => Schema.isSchemaError(error) || HttpClientError.isHttpClientError(error),
26+
onSome: (error) =>
27+
Schema.isSchemaError(error) || HttpClientError.isHttpClientError(error),
2028
});
2129

22-
export const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>) =>
30+
export const reportApiClientInfrastructureCause = (
31+
cause: Cause.Cause<unknown>,
32+
) =>
2333
Effect.sync(() => {
2434
if (!isApiClientInfrastructureCause(cause)) return;
2535
reportHandledFrontendError(cause, {
@@ -41,7 +51,14 @@ export const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>)
4151
// Plain member access — vite `define` rewrites the exact expression
4252
// `import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL`; optional chaining would
4353
// dodge the replacement and always read undefined.
44-
const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as string | undefined;
54+
const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as
55+
| string
56+
| undefined;
57+
// Per-SESSION sampling for production: a page either traces everything it
58+
// does or nothing (per-span sampling would shred the waterfalls). Unset = 1.
59+
const otlpSampleRatio = Number(
60+
(import.meta.env.VITE_PUBLIC_OTLP_SAMPLE_RATIO as string | undefined) ?? "1",
61+
);
4562

4663
// The tracer must reach the runtime context the atom effects EXECUTE in.
4764
// Merging it into the `httpClient` option doesn't: AtomHttpApi builds the
@@ -58,17 +75,30 @@ const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as string | un
5875
// error. URL-scoped, the leak is the desired behavior: any client posting
5976
// to the OTLP endpoint (the exporter) goes untraced, everything else is
6077
// traced.
61-
if (otlpTracesUrl) {
78+
// Browser-only (this module is also evaluated during SSR, where the worker
79+
// has its own tracer and a relative exporter URL would be meaningless).
80+
if (
81+
otlpTracesUrl &&
82+
typeof document !== "undefined" &&
83+
Math.random() < otlpSampleRatio
84+
) {
6285
Atom.runtime.addGlobalLayer(
6386
Layer.mergeAll(
6487
OtlpTracer.layer({
65-
url: otlpTracesUrl,
88+
// Relative paths (the prod shape: "/v1/traces" → the worker's
89+
// forwarding route) resolve against the page's own origin.
90+
url: new URL(otlpTracesUrl, window.location.origin).toString(),
6691
resource: { serviceName: "executor-web" },
6792
// Browser sessions are short; the 5s default loses the tail spans
6893
// when the tab closes.
6994
exportInterval: "1 second",
70-
}).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)),
71-
Layer.succeed(HttpClient.TracerDisabledWhen, (request) => request.url.includes("/v1/traces")),
95+
}).pipe(
96+
Layer.provide(OtlpSerialization.layerJson),
97+
Layer.provide(FetchHttpClient.layer),
98+
),
99+
Layer.succeed(HttpClient.TracerDisabledWhen, (request) =>
100+
request.url.includes("/v1/traces"),
101+
),
72102
),
73103
);
74104
}
@@ -77,18 +107,26 @@ if (otlpTracesUrl) {
77107
// Core API client — tools + secrets
78108
// ---------------------------------------------------------------------------
79109

80-
const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()("ExecutorApiClient", {
81-
api: ExecutorApi,
82-
httpClient: FetchHttpClient.layer,
83-
transformClient: HttpClient.mapRequest((request) => {
84-
let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl());
85-
const authorization = getExecutorServerAuthorizationHeader();
86-
if (authorization) {
87-
next = HttpClientRequest.setHeader(next, "authorization", authorization);
88-
}
89-
return next;
90-
}),
91-
transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
92-
});
110+
const ExecutorApiClient = AtomHttpApi.Service<"ExecutorApiClient">()(
111+
"ExecutorApiClient",
112+
{
113+
api: ExecutorApi,
114+
httpClient: FetchHttpClient.layer,
115+
transformClient: HttpClient.mapRequest((request) => {
116+
let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl());
117+
const authorization = getExecutorServerAuthorizationHeader();
118+
if (authorization) {
119+
next = HttpClientRequest.setHeader(
120+
next,
121+
"authorization",
122+
authorization,
123+
);
124+
}
125+
return next;
126+
}),
127+
transformResponse: (effect) =>
128+
Effect.tapCause(effect, reportApiClientInfrastructureCause),
129+
},
130+
);
93131

94132
export { ExecutorApiClient };

0 commit comments

Comments
 (0)