Skip to content

Commit 71db822

Browse files
committed
E2e distributed tracing: suite-owned motel, browser OTLP, trace join
Every e2e run captures distributed traces. A suite-owned motel (local OTLP store) boots alongside the WorkOS/Autumn emulators — own port, runs-local store wiped per boot, torn down with the suite — and the cloud boot recipe gains an extraEnv seam so the globalsetup points the app's exporter at it (the same endpoint-agnostic layer that ships prod traces to Axiom). The web client exports OTLP spans when the build names an endpoint (VITE_PUBLIC_OTLP_TRACES_URL, proxied same-origin to motel in dev), and the cloud worker's http.server span adopts the caller's W3C traceparent — one trace id from the click in the browser through the worker to the database. Run pages list every harvested trace id with links into motel's waterfall. Also switches the worker's span processor Simple → Batch: per-span synchronous export fetches under the dev worker added enough request latency to flip the approval pause/resume race in e2e; batching removes the class, and the existing ctx.waitUntil(forceFlush) still drains per request in prod.
1 parent a735613 commit 71db822

9 files changed

Lines changed: 294 additions & 58 deletions

File tree

apps/cloud/src/observability/telemetry.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { trace } from "@opentelemetry/api";
3939
// which workerd does support.
4040
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http/build/esm/platform/browser/index.js";
4141
import { resourceFromAttributes } from "@opentelemetry/resources";
42-
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
42+
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
4343
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
4444
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
4545
import { env } from "cloudflare:workers";
@@ -61,14 +61,21 @@ const ensureGlobalTracerProvider = (): boolean => {
6161
[ATTR_SERVICE_VERSION]: SERVICE_VERSION,
6262
}),
6363
spanProcessors: [
64-
new SimpleSpanProcessor(
64+
// Batch, not Simple: SimpleSpanProcessor issues one synchronous fetch
65+
// per span END — a busy MCP request emits dozens of spans, and the
66+
// serialized export fetches add seconds of latency inside the request
67+
// (enough to flip pause/resume timing in e2e). Batch buffers and
68+
// ships on a timer; `ctx.waitUntil(flushTracerProvider())` at the end
69+
// of each request still drains the buffer before the isolate exits.
70+
new BatchSpanProcessor(
6571
new OTLPTraceExporter({
6672
url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces",
6773
headers: {
6874
Authorization: `Bearer ${env.AXIOM_TOKEN}`,
6975
"X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud",
7076
},
7177
}),
78+
{ scheduledDelayMillis: 1_000, maxExportBatchSize: 512 },
7279
),
7380
],
7481
});

apps/cloud/src/server.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SpanStatusCode, trace } from "@opentelemetry/api";
1+
import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
22
import {
33
ATTR_HTTP_REQUEST_METHOD,
44
ATTR_HTTP_RESPONSE_STATUS_CODE,
@@ -72,32 +72,52 @@ const cloudflareHandler: ExportedHandler<Env> = {
7272
return fetchHandler(request, env, ctx);
7373
}
7474
const url = new URL(request.url);
75-
return tracer.startActiveSpan(`http.server ${request.method}`, async (span) => {
76-
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
77-
span.setAttribute(ATTR_URL_FULL, request.url);
78-
span.setAttribute(ATTR_URL_PATH, url.pathname);
79-
span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, ""));
80-
// Adapter boundary: Cloudflare's fetch handler is a Promise-based
81-
// callback and the OTel span lifecycle needs to observe both the
82-
// resolved response and any thrown error before `span.end()`. Sentry's
83-
// outer wrapper still captures the exception; we only mark span status.
84-
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary
85-
try {
86-
const response = await fetchHandler(request, env, ctx);
87-
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
88-
if (response.status >= 500) {
75+
// Join the caller's W3C trace when the request carries one — the web UI
76+
// sends traceparent on every API fetch, so the browser's spans and this
77+
// request share one trace id end to end. Same parsing the DO path does
78+
// 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+
);
82+
const parentContext = traceparentMatch
83+
? trace.setSpanContext(context.active(), {
84+
traceId: traceparentMatch[1]!,
85+
spanId: traceparentMatch[2]!,
86+
traceFlags: parseInt(traceparentMatch[3]!, 16),
87+
isRemote: true,
88+
})
89+
: context.active();
90+
return tracer.startActiveSpan(
91+
`http.server ${request.method}`,
92+
{ kind: SpanKind.SERVER },
93+
parentContext,
94+
async (span) => {
95+
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
96+
span.setAttribute(ATTR_URL_FULL, request.url);
97+
span.setAttribute(ATTR_URL_PATH, url.pathname);
98+
span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, ""));
99+
// Adapter boundary: Cloudflare's fetch handler is a Promise-based
100+
// callback and the OTel span lifecycle needs to observe both the
101+
// resolved response and any thrown error before `span.end()`. Sentry's
102+
// outer wrapper still captures the exception; we only mark span status.
103+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary
104+
try {
105+
const response = await fetchHandler(request, env, ctx);
106+
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
107+
if (response.status >= 500) {
108+
span.setStatus({ code: SpanStatusCode.ERROR });
109+
}
110+
return response;
111+
} catch (err) {
89112
span.setStatus({ code: SpanStatusCode.ERROR });
113+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime
114+
throw err;
115+
} finally {
116+
span.end();
117+
ctx.waitUntil(flushTracerProvider());
90118
}
91-
return response;
92-
} catch (err) {
93-
span.setStatus({ code: SpanStatusCode.ERROR });
94-
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime
95-
throw err;
96-
} finally {
97-
span.end();
98-
ctx.waitUntil(flushTracerProvider());
99-
}
100-
});
119+
},
120+
);
101121
},
102122
};
103123

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

@@ -61,8 +66,20 @@ export default defineConfig(({ mode }) => {
6166
define: Object.fromEntries(
6267
Object.entries(publicEnv)
6368
.filter(([key]) => key.startsWith("VITE_PUBLIC_"))
64-
.map(([key, value]) => [`import.meta.env.${key}`, JSON.stringify(value)]),
69+
.map(([key, value]) => [
70+
`import.meta.env.${key}`,
71+
JSON.stringify(value),
72+
]),
6573
),
74+
// Browser OTLP spans (VITE_PUBLIC_OTLP_TRACES_URL=/v1/traces, set by the
75+
// e2e global setup) go same-origin and proxy to the local motel server —
76+
// motel serves no CORS headers, so a direct cross-origin post would die
77+
// in preflight. Dev-only; unrouted when nothing listens.
78+
server: {
79+
proxy: {
80+
"/v1/traces": process.env.MOTEL_URL ?? "http://127.0.0.1:27686",
81+
},
82+
},
6683
resolve: { tsconfigPaths: true },
6784
plugins: [
6885
devCrashGuard(),
@@ -81,7 +98,11 @@ export default defineConfig(({ mode }) => {
8198
virtualRouteConfig: rootRoute("__root.tsx", [
8299
...consoleRoutes({
83100
dir: "../../../../packages/react/src/routes",
84-
exclude: ["/secrets", "/resume/$executionId", "/plugins/$pluginId/$"],
101+
exclude: [
102+
"/secrets",
103+
"/resume/$executionId",
104+
"/plugins/$pluginId/$",
105+
],
85106
}),
86107
physical("", "app"),
87108
]),

e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"serve": "bun scripts/rebuild-viewer.ts && bun scripts/serve.ts",
1616
"typecheck": "tsc --noEmit",
1717
"test:desktop": "vitest run --project desktop",
18-
"motel": "motel"
18+
"motel": "MOTEL_OTEL_BASE_URL=http://127.0.0.1:4796 MOTEL_OTEL_DB_PATH=runs/.motel/telemetry.sqlite motel server"
1919
},
2020
"dependencies": {
2121
"@executor-js/api": "workspace:*",

e2e/setup/cloud.boot.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { createEmulator } from "@executor-js/emulate";
1212

1313
import { bootProcesses, waitForHttp } from "./boot";
1414

15-
export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));
15+
export const cloudDir = fileURLToPath(
16+
new URL("../../apps/cloud/", import.meta.url),
17+
);
1618

1719
export interface CloudBootOptions {
1820
readonly cloudPort: number;
@@ -35,6 +37,8 @@ export interface CloudBootOptions {
3537
/** Wipe the dev DB before boot (hermetic). Default true. */
3638
readonly fresh?: boolean;
3739
readonly logFile?: string;
40+
/** Extra env for the app's dev stack (e.g. the suite's OTLP exporter). */
41+
readonly extraEnv?: Record<string, string>;
3842
}
3943

4044
export interface CloudBooted {
@@ -44,7 +48,9 @@ export interface CloudBooted {
4448
readonly autumnUrl: string;
4549
}
4650

47-
export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> => {
51+
export const bootCloud = async (
52+
options: CloudBootOptions,
53+
): Promise<CloudBooted> => {
4854
// Fresh dev DB per boot — the WorkOS emulator mints org ids from a
4955
// per-process counter, so a persisted DB from a previous invocation
5056
// collides with the new boot's ids (identities land in polluted orgs /
@@ -60,7 +66,10 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
6066
port: options.workosPort,
6167
...(options.workosPublicUrl ? { baseUrl: options.workosPublicUrl } : {}),
6268
});
63-
const autumn = await createEmulator({ service: "autumn", port: options.autumnPort });
69+
const autumn = await createEmulator({
70+
service: "autumn",
71+
port: options.autumnPort,
72+
});
6473

6574
const workosUrl = options.workosPublicUrl ?? workos.url;
6675
const env = {
@@ -72,7 +81,8 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
7281
WORKOS_CLIENT_ID: options.workosClientId,
7382
WORKOS_COOKIE_PASSWORD: options.cookiePassword,
7483
AUTUMN_SECRET_KEY: "am_test_emulate",
75-
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
84+
ENCRYPTION_KEY:
85+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
7686
DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${options.dbPort}/postgres`,
7787
EXECUTOR_DIRECT_DATABASE_URL: "true",
7888
CLOUDFLARE_INCLUDE_PROCESS_ENV: "true",
@@ -87,6 +97,7 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
8797
// Vite rejects unknown Host headers; allow the public hostname when a
8898
// proxy fronts the app.
8999
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: new URL(options.publicUrl).hostname,
100+
...options.extraEnv,
90101
};
91102

92103
const procs = bootProcesses(

e2e/setup/cloud.globalsetup.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { claimPorts } from "../src/ports";
77
import { E2E_COOKIE_PASSWORD, E2E_WORKOS_CLIENT_ID } from "../targets/cloud";
88
import { waitForHttp } from "./boot";
99
import { bootCloud } from "./cloud.boot";
10+
import { bootMotel, motelExporterEnv } from "./motel";
1011

1112
export default async function setup(): Promise<(() => Promise<void>) | void> {
1213
if (process.env.E2E_CLOUD_URL) {
@@ -25,6 +26,10 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
2526
{ envVar: "E2E_AUTUMN_EMULATOR_PORT", offset: 3, label: "Autumn emulator" },
2627
]);
2728

29+
// Suite-owned trace store — every run captures distributed traces.
30+
const motel = await bootMotel();
31+
32+
const publicUrl = `http://127.0.0.1:${ports.E2E_CLOUD_PORT!}`;
2833
let booted;
2934
try {
3035
booted = await bootCloud({
@@ -34,14 +39,21 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
3439
autumnPort: ports.E2E_AUTUMN_EMULATOR_PORT!,
3540
workosClientId: E2E_WORKOS_CLIENT_ID,
3641
cookiePassword: E2E_COOKIE_PASSWORD,
37-
publicUrl: `http://127.0.0.1:${ports.E2E_CLOUD_PORT!}`,
42+
publicUrl,
43+
// Server + browser spans → the suite's motel. The app's exporter is
44+
// endpoint-agnostic, so the same layer that ships prod traces to
45+
// Axiom ships e2e traces to the suite store — "why was that page
46+
// slow" gets a span waterfall, not a guess.
47+
extraEnv: motelExporterEnv(motel, publicUrl),
3848
});
3949
} catch (error) {
50+
await motel?.teardown();
4051
await release();
4152
throw error;
4253
}
4354
return async () => {
4455
await booted.teardown();
56+
await motel?.teardown();
4557
await release();
4658
};
4759
}

e2e/setup/motel.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Suite-owned motel: a fresh local OTLP store booted alongside the target's
2+
// dev stack (same pattern as the WorkOS/Autumn emulators) so EVERY run
3+
// captures distributed traces — hermetically, in CI too, with no dependence
4+
// on a machine-global daemon whose health or leftover data could leak into
5+
// results. DB lives under runs/.motel so the suite's evidence stays with
6+
// the suite; wiped per boot like the target's dev DB.
7+
import { mkdirSync, rmSync } from "node:fs";
8+
import { join } from "node:path";
9+
import { fileURLToPath } from "node:url";
10+
11+
import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot";
12+
13+
export const MOTEL_PORT = 4796;
14+
export const MOTEL_URL = `http://127.0.0.1:${MOTEL_PORT}`;
15+
16+
const e2eDir = fileURLToPath(new URL("..", import.meta.url));
17+
18+
export interface SuiteMotel {
19+
readonly url: string;
20+
readonly teardown: () => Promise<void>;
21+
}
22+
23+
/** Boot the suite's motel server. Never fails the suite: if the binary or
24+
* the port is unavailable, tracing is simply off (null) and targets skip
25+
* the exporter env. */
26+
export const bootMotel = async (): Promise<SuiteMotel | null> => {
27+
const dataDir = join(e2eDir, "runs", ".motel");
28+
rmSync(dataDir, { recursive: true, force: true });
29+
mkdirSync(dataDir, { recursive: true });
30+
31+
let procs: BootedProcesses | null = null;
32+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional infrastructure; a motel-less host still runs the suite
33+
try {
34+
procs = bootProcesses(
35+
[
36+
{
37+
cmd: "bunx",
38+
args: ["motel", "server"],
39+
cwd: e2eDir,
40+
env: {
41+
MOTEL_OTEL_BASE_URL: MOTEL_URL,
42+
MOTEL_OTEL_DB_PATH: join(dataDir, "telemetry.sqlite"),
43+
},
44+
},
45+
],
46+
{ label: "motel" },
47+
);
48+
await waitForHttp(`${MOTEL_URL}/api/health`);
49+
console.log(`[e2e] traces → suite motel at ${MOTEL_URL}`);
50+
return { url: MOTEL_URL, teardown: procs.teardown };
51+
} catch (error) {
52+
console.warn(`[e2e] motel unavailable, tracing off: ${String(error)}`);
53+
await procs?.teardown();
54+
return null;
55+
}
56+
};
57+
58+
/** Exporter env for a target's dev stack: server spans via the app's
59+
* endpoint-agnostic Axiom exporter, browser spans via packages/react's
60+
* OTLP tracer (same-origin /v1/traces, proxied by the dev server — motel
61+
* serves no CORS headers). */
62+
export const motelExporterEnv = (
63+
motel: SuiteMotel | null,
64+
appBaseUrl: string,
65+
): Record<string, string> =>
66+
motel
67+
? {
68+
AXIOM_TRACES_URL: `${motel.url}/v1/traces`,
69+
AXIOM_TOKEN: "motel-local",
70+
AXIOM_DATASET: "executor-e2e",
71+
VITE_PUBLIC_OTLP_TRACES_URL: `${appBaseUrl}/v1/traces`,
72+
MOTEL_URL: motel.url,
73+
}
74+
: {};

0 commit comments

Comments
 (0)