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
9 changes: 2 additions & 7 deletions apps/cloud/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { ApiKeyService } from "./auth/api-keys";
import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider";
import { DbService } from "./db/db";
import { cloudMcpAuth } from "./mcp";
import { McpSessionDOSqlite } from "./mcp/session-durable-object";
import { ErrorCaptureLive } from "./observability";
import { AutumnService } from "./extensions/billing/service";
import {
Expand All @@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry";
// The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed-
// session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket,
// the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object
// session store (the DO surfaced via `config.mcpExport`), console+Sentry error
// session store (the DO class is exported straight from server.ts), console+Sentry error
// capture — and Autumn BILLING entering ONLY as extensions: the engine
// metering decorator, the account seat-gate, the `/api/billing/*` proxy route,
// and the createOrganization free-limit gate. `diff` against
Expand Down Expand Up @@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService);
// satisfied by `boot`, `DbService` per request via `requestScoped`).
const cloudDb: Layer.Layer<DbProvider, never, DbService | AutumnService> = CloudDbProvider;

const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({
const { appLayer, toWebHandler } = ExecutorApp.make({
plugins: cloudPlugins,
providers: {
// Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS
Expand Down Expand Up @@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({
failure: cloudIdentityFailureStrategy,
// The MCP session Durable Object class — a top-level Workers export a Layer
// can't return; surfaced so `server.ts` can re-export it.
mcpExport: McpSessionDOSqlite,
},
// The long-lived (boot-scoped) context provideMerge'd under everything: the
// WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request
Expand All @@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({
requestScoped: RequestScopedServicesLive,
});

export { McpSessionDOSqlite };

export const CloudAppLayer = appLayer;
export const cloudMcpExport = mcpExport;

// The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs),
// /mcp, /.well-known/* — everything the worker dispatches.
Expand Down
53 changes: 52 additions & 1 deletion apps/cloud/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,26 @@ export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execut
// until the in-flight export resolves.
// ---------------------------------------------------------------------------

const fetchHandler = handler.fetch as (
const rawFetchHandler = handler.fetch as (
request: Request,
env: Env,
ctx: ExecutionContext,
) => Response | Promise<Response>;

/**
* Every entry into TanStack Start goes through here so `startGraphEntered`
* reflects whether this isolate has already paid the lazy `loadEntries`
* import — the cost that dominates a cold page request.
*/
const fetchHandler = (
request: Request,
env: Env,
ctx: ExecutionContext,
): Response | Promise<Response> => {
markStartGraphEntered();
return rawFetchHandler(request, env, ctx);
};

const tracer = trace.getTracer("executor-cloud-worker");

const traceparentValueFor = (spanContext: SpanContext): string =>
Expand Down Expand Up @@ -178,8 +192,43 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({
traceRequest: traceCloudMcpRequest,
});

// ---------------------------------------------------------------------------
// Isolate lifecycle signals
// ---------------------------------------------------------------------------
//
// The Aug 2026 page-latency hunt kept stalling on one blind spot: every span we
// emit starts INSIDE the fetch handler, so nothing could distinguish "this
// isolate is cold and paying Start's lazy `loadEntries` import" from "this
// isolate came up slowly before our code ran at all". Both look like one slow
// span. These three attributes make the distinction queryable:
//
// executor.isolate.request_seq - 1 means this request is the isolate's
// first; page requests measured 1.04 per
// isolate, which is why nearly every one
// pays the cold cost.
// executor.start_graph.entered - whether anything had already driven
// TanStack Start in this isolate. /mcp and
// the passthrough proxies return before
// `fetchHandler`, so an isolate can serve
// plenty of traffic and still be cold here.
// Measured with a temporary entry-lag probe correlated against `wrangler tail`
// event timestamps: a cold isolate (`request_seq` 1) spends **840-1844ms**
// coming up before our first line runs, while a reused one spends **0-2ms**.
// That startup is invisible to every span we emit, and it sits on top of the
// ~3.1s `loadEntries` import — together the 3-5s a signed-in page costs.
//
// Both counters are cheap: two increments and no I/O.
let isolateRequestSeq = 0;
let startGraphEntered = false;

const markStartGraphEntered = (): void => {
startGraphEntered = true;
};

const cloudflareHandler: ExportedHandler<Env> = {
fetch: async (request, env, ctx) => {
isolateRequestSeq += 1;

// Public pages must not enter TanStack Start: its first-request dynamic
// import loads the entire React + Effect server graph and can take seconds
// on a cold isolate. Classify and service-bind marketing at the Worker
Expand Down Expand Up @@ -250,6 +299,8 @@ const cloudflareHandler: ExportedHandler<Env> = {
async (span) => {
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
span.setAttribute(ATTR_URL_PATH, url.pathname);
span.setAttribute("executor.isolate.request_seq", isolateRequestSeq);
span.setAttribute("executor.start_graph.entered", startGraphEntered);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep the flush alive past the response
try {
const response = await fetchHandler(
Expand Down
Loading
Loading