Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@executor-js/sdk": "workspace:*",
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sentry/cloudflare": "^10.48.0",
"@tanstack/react-router": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
Expand Down
6 changes: 3 additions & 3 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export interface CloudflareEnv {
*/
readonly AI_GATEWAY_TOKEN?: SecretsStoreBinding;
readonly VITE_PUBLIC_SITE_URL?: string;
/** Sentry DSN (a `wrangler secret`). Unset leaves Sentry disabled. */
readonly SENTRY_DSN?: string;
/**
* Dev/single-user escape hatch: when "true", skip Cloudflare Access entirely
* and treat every request as a fixed admin. For local `wrangler dev` and
Expand Down Expand Up @@ -124,9 +126,7 @@ type CloudflareAccessEnv = Pick<

// Both ids are required: a gateway URL missing either one resolves to a 404 that
// would look like "Jev found nothing" rather than "Jev was never configured".
const resolveJevGateway = (
env: CloudflareConfigEnv,
): CloudflareConfig["jevGateway"] => {
const resolveJevGateway = (env: CloudflareConfigEnv): CloudflareConfig["jevGateway"] => {
const accountId = env.CLOUDFLARE_ACCOUNT_ID?.trim() ?? "";
const gatewayId = env.AI_GATEWAY_ID?.trim() ?? "";
if (accountId.length === 0 || gatewayId.length === 0) {
Expand Down
41 changes: 37 additions & 4 deletions apps/host-cloudflare/src/observability.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,40 @@
// Cloudflare host `ErrorCapture` — the shared console implementation with a
// `cloudflare-` trace-id prefix. Worker stdout is routed to Logpush/the
// dashboard, so the squashed cause is grep-able by the opaque 500 traceId.
// Cloudflare host `ErrorCapture`: the shared console implementation (a
// `cloudflare-` trace id, grep-able in Workers logs) plus a Sentry report
// tagged with that trace id. Sentry stays a no-op while SENTRY_DSN is unset.

import * as Sentry from "@sentry/cloudflare";
import { Cause, Effect, Layer } from "effect";
import { ErrorCapture } from "@executor-js/api";
import { consoleErrorCapture } from "@executor-js/api/server";

export const ErrorCaptureLive = consoleErrorCapture("cloudflare");
export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.effect(
ErrorCapture,
Effect.gen(function* () {
const consoleCapture = yield* ErrorCapture;
return ErrorCapture.of({
captureException: (cause) =>
consoleCapture.captureException(cause).pipe(
Effect.tap((traceId) =>
Effect.sync(() => {
const [error] = Cause.prettyErrors(cause);
Sentry.captureException(error ?? Cause.squash(cause), { tags: { traceId } });
}),
),
),
});
}),
).pipe(Layer.provide(consoleErrorCapture("cloudflare")));

/**
* A call the client was waiting on that will never get a real answer: its
* session Durable Object died (memory or CPU limit, deploy) or nothing came
* back before the deadline. The dying isolate cannot report itself, so the
* front worker, which answers the client for it, reports it instead.
*/
export const reportLostCall = (detail: Readonly<Record<string, unknown>>): void => {
Sentry.captureMessage(`MCP call lost: ${String(detail.reason ?? "unknown")}`, {
level: "error",
tags: { reason: String(detail.reason ?? "unknown") },
extra: detail,
});
};
7 changes: 7 additions & 0 deletions apps/host-cloudflare/src/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { CloudflareEnv } from "./config";

export const sentryOptions = (env: CloudflareEnv) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 0,
sendDefaultPii: false,
});
21 changes: 17 additions & 4 deletions apps/host-cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import * as Sentry from "@sentry/cloudflare";

import { makeCloudflareApp } from "./app";
import {
cloudflareAccessConfigErrorMessage,
missingCloudflareAccessVars,
type CloudflareEnv,
} from "./config";
import { McpSessionDO as McpSessionDOBase } from "./mcp";
import { mcpResourceFromPath } from "./mcp/resource";
import { reportLostCall } from "./observability";
import { sentryOptions } from "./sentry";

(globalThis as { __executorReportLostCall?: typeof reportLostCall }).__executorReportLostCall =
reportLostCall;

// The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported
// at the Worker entry module scope for the runtime to find them.
export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp";
// at the Worker entry module scope for the runtime to find them. Wrapping the
// session DO initialises Sentry inside its isolate.
export { McpExecutionOwnerDirectoryDO } from "./mcp";
export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry(
sentryOptions,
McpSessionDOBase,
);

// ---------------------------------------------------------------------------
// The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web
Expand Down Expand Up @@ -41,7 +54,7 @@ const accessConfigErrorResponse = (missingVars: readonly string[]): Response =>
},
});

export default {
export default Sentry.withSentry(sentryOptions, {
fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
const missingAccessVars = missingCloudflareAccessVars(env);
if (missingAccessVars.length > 0) {
Expand All @@ -55,4 +68,4 @@ export default {
}
return serve.app(request);
},
};
} satisfies ExportedHandler<CloudflareEnv>);
1 change: 1 addition & 0 deletions bun.lock

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

25 changes: 25 additions & 0 deletions packages/hosts/cloudflare/src/mcp/agents-post-stream-loss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,31 @@ describe("POST bridge: lost-execution visibility", () => {
}
});

it("reports each lost call to the host's lost-call hook, and nothing on the happy path", async () => {
const reported: unknown[] = [];
const holder = globalThis as { __executorReportLostCall?: (detail: unknown) => void };
holder.__executorReportLostCall = (detail) => reported.push(detail);
try {
const delivered = await postToBridge(toolCall(1));
const deliveredBody = drainResponse(delivered.response);
emitResponse(delivered.ws, { id: 1, jsonrpc: "2.0", result: { ok: true } });
await flushMicrotasks();
emitAbnormalClose(delivered.ws);
await deliveredBody;
expect(reported).toEqual([]);

const lost = await postToBridge(toolCall(2));
const lostBody = drainResponse(lost.response);
emitAbnormalClose(lost.ws, 1006, "WebSocket disconnected without sending Close frame.");
await lostBody;
expect(reported).toEqual([
expect.objectContaining({ outstandingCount: 1, reason: "session_reset" }),
]);
} finally {
delete holder.__executorReportLostCall;
}
});

it("writes nothing extra when the response was delivered before the close", async () => {
const { response, ws } = await postToBridge(toolCall(1));
const drained = drainResponse(response);
Expand Down
Loading
Loading