Skip to content

Commit 9651044

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 0bfdc81 commit 9651044

7 files changed

Lines changed: 238 additions & 27 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: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,26 @@ 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 (
65+
command === "serve" &&
66+
!process.env.MOTEL_URL &&
67+
!env.VITE_PUBLIC_OTLP_TRACES_URL
68+
) {
69+
delete (publicEnv as Record<string, string | undefined>)
70+
.VITE_PUBLIC_OTLP_TRACES_URL;
71+
}
5972

6073
return {
6174
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
}

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)