Skip to content

Commit 5c2d4cc

Browse files
committed
CLI/MCP calls join the run's distributed-trace ledger
The web app's HttpClient sends a traceparent on its own, but mcporter's plain fetch does not — so the terminal half of a session was invisible in traces.json. The MCP surface now wraps the global fetch: every POST to the target's MCP endpoint gets a minted traceparent (the worker and DO already join whatever arrives) and lands in the ledger with the JSON-RPC method or tool name as its label, duration, status, and source: terminal. traces.json becomes a shared append-merge ledger (src/trace-harvest.ts) so the browser surface's harvested entries and the MCP surface's minted ones interleave by wall clock, and the trace rail tags each row with its window (⌨ terminal / ⊕ browser).
1 parent f2e8790 commit 5c2d4cc

8 files changed

Lines changed: 192 additions & 99 deletions

File tree

apps/cloud/vite.config.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,7 @@ const devCrashGuard = (): Plugin => {
2020
if (installed) return;
2121
installed = true;
2222
process.on("uncaughtException", (err, origin) => {
23-
console.error(
24-
`[dev-crash-guard] uncaughtException (origin=${origin}):`,
25-
err,
26-
);
23+
console.error(`[dev-crash-guard] uncaughtException (origin=${origin}):`, err);
2724
});
2825
process.on("unhandledRejection", (reason, promise) => {
2926
console.error("[dev-crash-guard] unhandledRejection:", reason, promise);
@@ -42,9 +39,7 @@ const loadWranglerPublicVars = () => {
4239
{ hideWarnings: true },
4340
);
4441
return Object.fromEntries(
45-
Object.entries(wranglerConfig.vars ?? {}).filter(([key]) =>
46-
key.startsWith("VITE_PUBLIC_"),
47-
),
42+
Object.entries(wranglerConfig.vars ?? {}).filter(([key]) => key.startsWith("VITE_PUBLIC_")),
4843
);
4944
};
5045

@@ -66,10 +61,7 @@ export default defineConfig(({ mode }) => {
6661
define: Object.fromEntries(
6762
Object.entries(publicEnv)
6863
.filter(([key]) => key.startsWith("VITE_PUBLIC_"))
69-
.map(([key, value]) => [
70-
`import.meta.env.${key}`,
71-
JSON.stringify(value),
72-
]),
64+
.map(([key, value]) => [`import.meta.env.${key}`, JSON.stringify(value)]),
7365
),
7466
// Browser OTLP spans (VITE_PUBLIC_OTLP_TRACES_URL=/v1/traces, set by the
7567
// e2e global setup) go same-origin and proxy to the local motel server —
@@ -98,11 +90,7 @@ export default defineConfig(({ mode }) => {
9890
virtualRouteConfig: rootRoute("__root.tsx", [
9991
...consoleRoutes({
10092
dir: "../../../../packages/react/src/routes",
101-
exclude: [
102-
"/secrets",
103-
"/resume/$executionId",
104-
"/plugins/$pluginId/$",
105-
],
93+
exclude: ["/secrets", "/resume/$executionId", "/plugins/$pluginId/$"],
10694
}),
10795
physical("", "app"),
10896
]),

e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"viewer:build": "bun scripts/rebuild-viewer.ts",
1515
"serve": "bun scripts/rebuild-viewer.ts && bun scripts/serve.ts",
1616
"typecheck": "tsc --noEmit",
17-
"test:desktop": "vitest run --project desktop",
17+
"test:desktop": "vitest run --project desktop",
1818
"motel": "MOTEL_OTEL_BASE_URL=http://127.0.0.1:4796 MOTEL_OTEL_DB_PATH=runs/.motel/telemetry.sqlite motel server"
1919
},
2020
"dependencies": {

e2e/setup/cloud.boot.ts

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

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

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

1917
export interface CloudBootOptions {
2018
readonly cloudPort: number;
@@ -48,9 +46,7 @@ export interface CloudBooted {
4846
readonly autumnUrl: string;
4947
}
5048

51-
export const bootCloud = async (
52-
options: CloudBootOptions,
53-
): Promise<CloudBooted> => {
49+
export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> => {
5450
// Fresh dev DB per boot — the WorkOS emulator mints org ids from a
5551
// per-process counter, so a persisted DB from a previous invocation
5652
// collides with the new boot's ids (identities land in polluted orgs /
@@ -81,8 +77,7 @@ export const bootCloud = async (
8177
WORKOS_CLIENT_ID: options.workosClientId,
8278
WORKOS_COOKIE_PASSWORD: options.cookiePassword,
8379
AUTUMN_SECRET_KEY: "am_test_emulate",
84-
ENCRYPTION_KEY:
85-
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
80+
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
8681
DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${options.dbPort}/postgres`,
8782
EXECUTOR_DIRECT_DATABASE_URL: "true",
8883
CLOUDFLARE_INCLUDE_PROCESS_ENV: "true",

e2e/src/scenario.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const contextFor = (target: TargetShape, dir: string): Context.Context<AllServic
5858
const has = target.capabilities.has.bind(target.capabilities);
5959
if (has("api")) context = Context.add(context, Api, makeApiSurface(target));
6060
if (has("browser")) context = Context.add(context, Browser, makeBrowserSurface(dir, target));
61-
if (has("mcp-oauth")) context = Context.add(context, Mcp, makeMcpSurface(target));
61+
if (has("mcp-oauth")) context = Context.add(context, Mcp, makeMcpSurface(target, dir));
6262
if (has("billing")) context = Context.add(context, Billing, true);
6363
if (hasOpenCode()) {
6464
context = Context.add(context, OpenCode, {
@@ -218,7 +218,11 @@ const extractScenarioSource = (filePath: string, name: string): string | undefin
218218
}
219219
}
220220
if (end === -1) return undefined; // unbalanced — bail to be safe
221-
blocks.push({ start: index, end, mine: source.slice(index, end).includes(`"${name}"`) });
221+
blocks.push({
222+
start: index,
223+
end,
224+
mine: source.slice(index, end).includes(`"${name}"`),
225+
});
222226
index = end;
223227
}
224228
if (!blocks.some((b) => b.mine)) return undefined;

e2e/src/surfaces/browser.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
// everywhere), per-step screenshots, and a failure screenshot. The scenario
55
// drives `page` directly; assertions are vitest's job.
66
import { execFile } from "node:child_process";
7-
import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7+
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
88
import { join } from "node:path";
99
import { promisify } from "node:util";
1010

1111
import { Effect } from "effect";
1212
import { chromium, type Page } from "playwright";
1313

1414
import { markFocus, markRecordingStart } from "../timeline";
15+
import { appendTraces, type TraceEntry } from "../trace-harvest";
1516
import type { Identity, Target } from "../target";
1617

1718
export interface BrowserSession {
@@ -60,10 +61,17 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface
6061
recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } },
6162
baseURL: target.baseUrl,
6263
});
63-
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
64+
await context.tracing.start({
65+
screenshots: true,
66+
snapshots: true,
67+
sources: true,
68+
});
6469
if (identity.cookies?.length) {
6570
await context.addCookies(
66-
identity.cookies.map((cookie) => ({ ...cookie, url: target.baseUrl })),
71+
identity.cookies.map((cookie) => ({
72+
...cookie,
73+
url: target.baseUrl,
74+
})),
6775
);
6876
}
6977
const page = await context.newPage();
@@ -73,17 +81,48 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface
7381
// Harvest distributed-trace ids: every app API request carries a W3C
7482
// traceparent (Effect's HttpClient), and each id names one
7583
// click→server→DB trace in whatever OTLP store the run exported to
76-
// (motel locally). Written to traces.json so the runs viewer can
77-
// link a recording to its traces.
78-
const traceIds: { id: string; at: number; url: string }[] = [];
84+
// (motel locally). Appended to traces.json (shared with the MCP
85+
// surface's terminal-side entries) so the runs viewer can link a
86+
// recording to its traces. Duration comes from the finished/failed
87+
// event so the viewer can answer "why did that take so long"
88+
// without leaving the run page.
89+
const traceIds: Array<TraceEntry & { ms?: number; status?: number }> = [];
90+
const inflight = new Map<unknown, (typeof traceIds)[number]>();
7991
page.on("request", (request) => {
8092
const traceparent = request.headers()["traceparent"];
8193
const match = traceparent ? /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(traceparent) : null;
8294
if (match?.[1]) {
83-
traceIds.push({ id: match[1], at: Date.now(), url: request.url() });
95+
const entry: (typeof traceIds)[number] = {
96+
id: match[1],
97+
at: Date.now(),
98+
url: request.url(),
99+
source: "browser",
100+
};
101+
traceIds.push(entry);
102+
inflight.set(request, entry);
84103
}
85104
});
86-
return { browser, context, page, videoTmp, shots: { count: 0 }, traceIds };
105+
page.on("requestfinished", async (request) => {
106+
const entry = inflight.get(request);
107+
if (!entry) return;
108+
inflight.delete(request);
109+
entry.ms = Date.now() - entry.at;
110+
entry.status = (await request.response().catch(() => null))?.status();
111+
});
112+
page.on("requestfailed", (request) => {
113+
const entry = inflight.get(request);
114+
if (!entry) return;
115+
inflight.delete(request);
116+
entry.ms = Date.now() - entry.at;
117+
});
118+
return {
119+
browser,
120+
context,
121+
page,
122+
videoTmp,
123+
shots: { count: 0 },
124+
traceIds,
125+
};
87126
}),
88127
({ page, context, shots }) =>
89128
Effect.promise(async () => {
@@ -110,9 +149,7 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface
110149
}),
111150
({ browser, context, page, videoTmp, traceIds }) =>
112151
Effect.promise(async () => {
113-
if (traceIds.length > 0) {
114-
writeFileSync(join(dir, "traces.json"), JSON.stringify(traceIds, null, 1));
115-
}
152+
appendTraces(dir, traceIds);
116153
await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {});
117154
const video = page.video();
118155
await context.close(); // flushes the recording

e2e/src/surfaces/mcp.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,83 @@ import { Effect } from "effect";
1111

1212
import { createRuntime, type Runtime } from "@executor-js/mcporter";
1313

14+
import { appendTraces } from "../trace-harvest";
1415
import type { Identity, Target } from "../target";
1516

17+
// ---------------------------------------------------------------------------
18+
// Distributed traces for MCP calls. The web app's HttpClient sends a W3C
19+
// traceparent on its own; mcporter's plain fetch does not — so the agent/CLI
20+
// side of a session was invisible in the run's trace ledger. mcporter rides
21+
// the global fetch, so the surface wraps it once per process: every POST to
22+
// the target's MCP endpoint gets a freshly minted traceparent (the server
23+
// joins whatever arrives — worker and DO both parse the header), and the
24+
// request lands in traces.json with the JSON-RPC method as its label,
25+
// duration, status, and source: "terminal".
26+
// ---------------------------------------------------------------------------
27+
28+
let traceFetchInstalled = false;
29+
let traceSink: { mcpUrl: string; runDir: string } | null = null;
30+
31+
/** JSON-RPC body → a human label: tool name for tools/call, else method. */
32+
const rpcLabel = (body: unknown): string | undefined => {
33+
if (typeof body !== "string") return undefined;
34+
try {
35+
const parsed = JSON.parse(body) as {
36+
method?: string;
37+
params?: { name?: string };
38+
};
39+
if (!parsed.method) return undefined;
40+
return parsed.method === "tools/call" && parsed.params?.name
41+
? `${parsed.params.name}()`
42+
: parsed.method;
43+
} catch {
44+
return undefined;
45+
}
46+
};
47+
48+
const installTraceparentFetch = (mcpUrl: string, runDir: string): void => {
49+
traceSink = { mcpUrl, runDir };
50+
if (traceFetchInstalled) return;
51+
traceFetchInstalled = true;
52+
const original = globalThis.fetch;
53+
globalThis.fetch = async (input, init) => {
54+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
55+
const method = (
56+
init?.method ?? (input instanceof Request ? input.method : "GET")
57+
).toUpperCase();
58+
const sink = traceSink;
59+
if (!sink || method !== "POST" || !url.startsWith(sink.mcpUrl)) {
60+
return original(input, init);
61+
}
62+
const traceId = randomBytes(16).toString("hex");
63+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : {}));
64+
headers.set("traceparent", `00-${traceId}-${randomBytes(8).toString("hex")}-01`);
65+
const at = Date.now();
66+
const finish = (status?: number) =>
67+
appendTraces(sink.runDir, [
68+
{
69+
id: traceId,
70+
at,
71+
url,
72+
ms: Date.now() - at,
73+
...(status === undefined ? {} : { status }),
74+
source: "terminal" as const,
75+
label: rpcLabel(init?.body),
76+
},
77+
]);
78+
try {
79+
const response = await original(input, { ...init, headers });
80+
finish(response.status);
81+
return response;
82+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- fetch adapter boundary
83+
} catch (error) {
84+
finish();
85+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- fetch adapter boundary
86+
throw error;
87+
}
88+
};
89+
};
90+
1691
export interface McpCallResult {
1792
readonly raw: unknown;
1893
readonly text: string;
@@ -128,10 +203,11 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> =>
128203
return token.access_token;
129204
};
130205

131-
export const makeMcpSurface = (target: Target): McpSurface => ({
206+
export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ({
132207
url: target.mcpUrl,
133208
mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
134209
session: (identity) => {
210+
if (runDir) installTraceparentFetch(target.mcpUrl, runDir);
135211
const serverName = target.name;
136212
let runtimePromise: Promise<Runtime> | undefined;
137213
let connected = false;

e2e/src/trace-harvest.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// The run's distributed-trace ledger (traces.json): every request the
2+
// session made against the target, with the trace id that names its
3+
// click→server→DB waterfall in the OTLP store the run exported to.
4+
//
5+
// Two writers share it — the browser surface (ids harvested off the wire,
6+
// the web app sends traceparent itself) and the MCP surface (ids MINTED
7+
// here, since mcporter's plain fetch sends none; the server joins whatever
8+
// traceparent arrives). Append is read-merge-write so neither clobbers the
9+
// other; entries stay sorted by wall clock.
10+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
11+
import { join } from "node:path";
12+
13+
export interface TraceEntry {
14+
readonly id: string;
15+
readonly at: number;
16+
readonly url: string;
17+
readonly ms?: number;
18+
readonly status?: number;
19+
/** Which window made the request — the viewer's rail tags rows with it. */
20+
readonly source?: "terminal" | "browser";
21+
/** Readable name when the URL alone says nothing (MCP: every call POSTs
22+
* the same endpoint; the JSON-RPC method/tool is the real identity). */
23+
readonly label?: string;
24+
}
25+
26+
const fileFor = (runDir: string) => join(runDir, "traces.json");
27+
28+
export const appendTraces = (runDir: string, entries: ReadonlyArray<TraceEntry>): void => {
29+
if (entries.length === 0) return;
30+
const file = fileFor(runDir);
31+
const existing: TraceEntry[] = existsSync(file)
32+
? (JSON.parse(readFileSync(file, "utf8")) as TraceEntry[])
33+
: [];
34+
const merged = [...existing, ...entries].sort((a, b) => a.at - b.at);
35+
writeFileSync(file, JSON.stringify(merged, null, 1));
36+
};

0 commit comments

Comments
 (0)