Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ declare global {

// Shared with frontend
VITE_PUBLIC_SITE_URL?: string;
VITE_PUBLIC_OTLP_TRACES_URL?: string;
VITE_PUBLIC_OTLP_SAMPLE_RATIO?: string;
}
}
}
Expand Down
85 changes: 85 additions & 0 deletions apps/cloud/src/observability/browser-traces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "@effect/vitest";

import { browserTracesResponse } from "./browser-traces";

const makeRequest = (init?: RequestInit & { path?: string }) =>
new Request(`https://executor.sh${init?.path ?? "/v1/traces"}`, {
method: "POST",
body: "{}",
...init,
});

const baseEnv = {
AXIOM_TOKEN: "axiom-secret",
AXIOM_DATASET: "executor-cloud",
} as Env;

describe("browserTracesResponse", () => {
it("ignores non-/v1/traces requests entirely", () => {
expect(browserTracesResponse(makeRequest({ path: "/api/tools" }), baseEnv)).toBeNull();
});

it("drops batches silently when Axiom is not configured", async () => {
const response = await browserTracesResponse(
makeRequest({ headers: { cookie: "wos-session=abc" } }),
{} as Env,
);
expect(response?.status).toBe(204);
});

it("rejects anonymous posts", async () => {
const response = await browserTracesResponse(makeRequest(), baseEnv);
expect(response?.status).toBe(401);
});

it("forwards to Axiom with server-held credentials and hides the upstream body", async () => {
let seen: { url: string; auth: string | null; dataset: string | null } | undefined;
const response = await browserTracesResponse(
makeRequest({ headers: { cookie: "wos-session=abc" } }),
baseEnv,
(async (url: RequestInfo | URL, init?: RequestInit) => {
seen = {
url: String(url),
auth: new Headers(init?.headers).get("authorization"),
dataset: new Headers(init?.headers).get("x-axiom-dataset"),
};
return new Response("axiom-internals", { status: 200 });
}) as typeof fetch,
);
expect(seen?.url).toBe("https://api.axiom.co/v1/traces");
expect(seen?.auth).toBe("Bearer axiom-secret");
expect(seen?.dataset).toBe("executor-cloud");
expect(response?.status).toBe(204);
expect(await response?.text()).toBe("");
});

it("reports upstream failure as 502 without leaking detail", async () => {
const response = await browserTracesResponse(
makeRequest({ headers: { cookie: "wos-session=abc" } }),
baseEnv,
(async () => new Response("denied", { status: 403 })) as typeof fetch,
);
expect(response?.status).toBe(502);
});

it("refuses oversized batches", async () => {
const response = await browserTracesResponse(
makeRequest({
headers: {
cookie: "wos-session=abc",
"content-length": String(3_000_000),
},
}),
baseEnv,
);
expect(response?.status).toBe(413);
});

it("only accepts POST", async () => {
const response = await browserTracesResponse(
new Request("https://executor.sh/v1/traces", { method: "GET" }),
baseEnv,
);
expect(response?.status).toBe(405);
});
});
52 changes: 52 additions & 0 deletions apps/cloud/src/observability/browser-traces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Browser → Axiom OTLP ingress. The web client exports its spans to
// same-origin /v1/traces (it can never hold AXIOM_TOKEN); this worker route
// forwards the batch to Axiom with the server-held credentials. Locally and
// in e2e the vite dev server proxies the same path to motel before the
// worker ever sees it, so this route only serves deployed workers.
//
// Guards, not auth: the endpoint is write-only into a server-pinned dataset,
// but it should not be an anonymous internet ingest — a session cookie must
// at least be present, and bodies are capped. We deliberately do NOT verify
// the session (that would put a WorkOS round-trip on every span batch).

const MAX_BODY_BYTES = 2_000_000;

export const BROWSER_TRACES_PATH = "/v1/traces";

export const browserTracesResponse = (
request: Request,
env: Env,
fetchImpl: typeof fetch = fetch,
): Promise<Response> | null => {
const url = new URL(request.url);
if (url.pathname !== BROWSER_TRACES_PATH) return null;
if (request.method !== "POST") {
return Promise.resolve(new Response(null, { status: 405 }));
}
// Tracing not configured on this deployment — accept and drop so the
// client exporter stays quiet (it would retry/log on errors).
if (!env.AXIOM_TOKEN) {
return Promise.resolve(new Response(null, { status: 204 }));
}
if (!(request.headers.get("cookie") ?? "").includes("wos-session=")) {
return Promise.resolve(new Response(null, { status: 401 }));
}
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > MAX_BODY_BYTES) {
return Promise.resolve(new Response(null, { status: 413 }));
}
return fetchImpl(env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", {
method: "POST",
headers: {
"content-type": request.headers.get("content-type") ?? "application/json",
authorization: `Bearer ${env.AXIOM_TOKEN}`,
"x-axiom-dataset": env.AXIOM_DATASET ?? "executor-cloud",
},
body: request.body,
}).then(
// The exporter only needs success/failure; never reflect Axiom's
// response body (or its headers) back to an unauthenticated caller.
(upstream) => new Response(null, { status: upstream.ok ? 204 : 502 }),
() => new Response(null, { status: 502 }),
);
};
6 changes: 6 additions & 0 deletions apps/cloud/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Sentry from "@sentry/cloudflare";
import handler from "@tanstack/react-start/server-entry";

import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object";
import { browserTracesResponse } from "./observability/browser-traces";
import { flushTracerProvider, installTracerProvider } from "./observability/telemetry";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -68,6 +69,11 @@ const tracer = trace.getTracer("executor-cloud-worker");

const cloudflareHandler: ExportedHandler<Env> = {
fetch: async (request, env, ctx) => {
// Browser OTLP ingress — before the server span opens: exporter traffic
// must never trace itself (the browser already excludes /v1/traces from
// its own tracing for the same reason).
const browserTraces = browserTracesResponse(request, env);
if (browserTraces) return browserTraces;
if (!installTracerProvider()) {
return fetchHandler(request, env, ctx);
}
Expand Down
10 changes: 9 additions & 1 deletion apps/cloud/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,21 @@ const loadWranglerPublicVars = () => {
// proxy isn't routed anyway.
const ANALYTICS_PATH = process.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a";

export default defineConfig(({ mode }) => {
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const publicEnv = {
...loadWranglerPublicVars(),
VITE_PUBLIC_ANALYTICS_PATH: ANALYTICS_PATH,
...env,
};
// The wrangler-declared OTLP endpoint is for DEPLOYED workers (the
// /v1/traces forwarding route). Under `vite dev` that path is only the
// proxy below — keep the exporter off unless something actually listens
// (e2e/dev sets MOTEL_URL or the env var itself), or every dev session
// posts spans into a dead proxy once a second.
if (command === "serve" && !process.env.MOTEL_URL && !env.VITE_PUBLIC_OTLP_TRACES_URL) {
delete (publicEnv as Record<string, string | undefined>).VITE_PUBLIC_OTLP_TRACES_URL;
}

return {
define: Object.fromEntries(
Expand Down
7 changes: 7 additions & 0 deletions apps/cloud/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,12 @@
"vars": {
"VITE_PUBLIC_SITE_URL": "https://executor.sh",
"VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL",
// Browser OTLP spans → same-origin, forwarded to Axiom by the worker
// (src/observability/browser-traces.ts). Relative on purpose: the
// client resolves it against the page origin, so previews work too.
"VITE_PUBLIC_OTLP_TRACES_URL": "/v1/traces",
// Per-session sampling: 1 = every signed-in session exports. Dial down
// (e.g. "0.1") if Axiom volume becomes a cost concern.
"VITE_PUBLIC_OTLP_SAMPLE_RATIO": "1",
},
}
12 changes: 9 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions packages/react/src/api/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export const reportApiClientInfrastructureCause = (cause: Cause.Cause<unknown>)
// `import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL`; optional chaining would
// dodge the replacement and always read undefined.
const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as string | undefined;
// Per-SESSION sampling for production: a page either traces everything it
// does or nothing (per-span sampling would shred the waterfalls). Unset = 1.
const otlpSampleRatio = Number(
(import.meta.env.VITE_PUBLIC_OTLP_SAMPLE_RATIO as string | undefined) ?? "1",
);

// The tracer must reach the runtime context the atom effects EXECUTE in.
// Merging it into the `httpClient` option doesn't: AtomHttpApi builds the
Expand All @@ -58,11 +63,15 @@ const otlpTracesUrl = import.meta.env.VITE_PUBLIC_OTLP_TRACES_URL as string | un
// error. URL-scoped, the leak is the desired behavior: any client posting
// to the OTLP endpoint (the exporter) goes untraced, everything else is
// traced.
if (otlpTracesUrl) {
// Browser-only (this module is also evaluated during SSR, where the worker
// has its own tracer and a relative exporter URL would be meaningless).
if (otlpTracesUrl && typeof document !== "undefined" && Math.random() < otlpSampleRatio) {
Atom.runtime.addGlobalLayer(
Layer.mergeAll(
OtlpTracer.layer({
url: otlpTracesUrl,
// Relative paths (the prod shape: "/v1/traces" → the worker's
// forwarding route) resolve against the page's own origin.
url: new URL(otlpTracesUrl, window.location.origin).toString(),
resource: { serviceName: "executor-web" },
// Browser sessions are short; the 5s default loses the tail spans
// when the tab closes.
Expand Down
Loading