Skip to content

MCP tool-call telemetry fires without waitUntil, unlike this repo's own scheduleAfterResponse pattern #7233

Description

@JSONbored

Context

src/mcp/server.ts's handleMcpRequest (the single entrypoint for every /mcp tools/call request) calls
recordMcpToolTelemetry(...) synchronously and un-awaited, immediately before returning the response (or
before re-throwing):

export async function handleMcpRequest(c: AppContext): Promise<Response> {
  ...
  try {
    const response = await createMcpHandler(...)(...);
    if (typeof usageMetadata.toolName === "string") {
      recordMcpToolTelemetry(c.env, usageMetadata.toolName, response.status < 400, Date.now() - startedAt);
    }
    await recordProductUsageEvent(c.env, { ... }).catch(() => undefined);
    return response;
  } catch (error) {
    if (typeof usageMetadata.toolName === "string") {
      recordMcpToolTelemetry(c.env, usageMetadata.toolName, false, Date.now() - startedAt);
    }
    ...
    throw error;
  }
}

function recordMcpToolTelemetry(env: Env, tool: string, ok: boolean, durationMs: number): void {
  try {
    recordMcpToolCall(env, { tool, callerType: "remote", ok, durationMs });
  } catch { ... }
}

recordMcpToolCall (src/mcp/telemetry.ts) does real async I/O internally — it constructs a new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }) and calls client.capture(...), which schedules a
network request to PostHog. recordMcpToolTelemetry is a synchronous (non-async) function that never
awaits or returns that work, and handleMcpRequest never passes it through
c.executionCtx.waitUntil(...) either — it just falls through to await recordProductUsageEvent(...) (a
different, already-awaited call) and then return response;.

On Cloudflare Workers, once a request handler returns its Response (or the request context otherwise
finishes), any in-flight I/O that was not registered via waitUntil is not guaranteed to complete — the
runtime can tear down the execution context mid-flight. That means the PostHog capture() call from
recordMcpToolCall can be silently cut off before its HTTP request to api/us.i.posthog.com (or the
configured POSTHOG_HOST) actually lands, dropping the tool-call event this whole #6228/#6237 telemetry
feature exists to record — with no error surfaced anywhere, since recordMcpToolCall and
recordMcpToolTelemetry both intentionally swallow every error by design (try { } catch { }, documented as
"Telemetry must never affect the tool response").

This repo has an established, already-used pattern for exactly this situation — deferring background work
past the point the response is returned, via Cloudflare's waitUntil:

  • src/orb/webhook.ts's scheduleAfterResponse(c, task) wraps (c.executionCtx as ...).waitUntil(task)
    (falling back to fire-and-forget only when there is no execution context, e.g. a unit-test harness or the
    self-host server's own shim), specifically so a slow background task "can't delay the webhook ACK past
    GitHub's ~10s delivery deadline" — the same shape of problem (must not block the response, but must not be
    silently dropped either).
  • src/mcp/server.ts itself already has direct access to the execution context in this exact function via
    getExecutionContext(c) (used two lines earlier to construct createMcpHandler(...)'s third argument), so
    the fix requires no new plumbing.

Requirements

  • recordMcpToolTelemetry's call to recordMcpToolCall (both call sites inside handleMcpRequest — the
    success path and the catch path) must be scheduled via c.executionCtx.waitUntil(...) (or
    getExecutionContext(c).waitUntil(...), already available in this function), not fired synchronously and
    discarded.
  • recordMcpToolCall itself is synchronous today (returns void, not Promise<void>) because
    client.capture() is fire-and-forget by the PostHog SDK's own design — the fix must give waitUntil
    something awaitable to hold the execution context open for (e.g. by having recordMcpToolCall await
    client.flush() / client.shutdownAsync() internally and return that promise, or by wrapping the call at
    the recordMcpToolTelemetry call site).
  • Must not change recordMcpToolCall's no-throw guarantee (telemetry failures still must never surface into
    the MCP tool response) or its "safe no-op when POSTHOG_API_KEY is unset" behavior.
  • Must not delay handleMcpRequest's own response — the fix defers the telemetry work past the response via
    waitUntil, it does not make handleMcpRequest await it inline (that would reintroduce the added-latency
    tradeoff recordProductUsageEvent's direct await already accepts for a different call).

Deliverables

  • Both recordMcpToolTelemetry(...) call sites in handleMcpRequest (success and catch branches) run
    inside getExecutionContext(c).waitUntil(...) (or equivalent), matching scheduleAfterResponse's
    pattern in src/orb/webhook.ts.
  • recordMcpToolCall (or a thin wrapper) exposes a promise that resolves once the PostHog client has
    actually flushed the event, so waitUntil has real work to hold open.
  • A regression test asserting that when telemetry is configured (POSTHOG_API_KEY set), the capture call
    is passed to the execution context's waitUntil rather than only fired synchronously and forgotten.

Test Coverage Requirements

Both src/mcp/server.ts and src/mcp/telemetry.ts are under src/**, so codecov/patch (99% target,
branch-counted, 0% threshold) gates this change directly on every changed line/branch. Cover both the
success-path and catch-path call sites in handleMcpRequest, and the new awaitable-flush path in
recordMcpToolCall/telemetry.ts — including the already-existing "unconfigured / capture throws" no-op
branches, which must remain covered after the change.

Expected Outcome

MCP tool-call telemetry events (mcp_tool_call in PostHog) are reliably delivered instead of being
silently dropped whenever the Workers runtime tears down the request's execution context before an
unregistered background fetch completes — bringing recordMcpToolCall's dispatch in line with this repo's
own established waitUntil convention for exactly this class of "must not block the response, must not be
silently lost" background work.

Links & Resources

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:bugGittensor-scored bug fix — scores a 0.05x multiplier.help wantedExtra attention is needed

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions