Skip to content

Commit 7782add

Browse files
committed
fix(mcp): attach mcp_tool/error_code to the remote sink's exception capture
The remote MCP telemetry sink's captureException call named these two grouping properties in a comment but never actually sent them, so a PostHog exception breakdown by tool/cause silently dropped the surface with the most traffic while the stdio and miner sinks kept reporting. Extend capturePostHogWorkerError with an optional, scrubbed extra properties argument and pass mcp_tool/error_code through it, matching the other two sinks property-for-property. The HTTP middleware path keeps its existing request_path/request_method shape unchanged.
1 parent e055c59 commit 7782add

4 files changed

Lines changed: 107 additions & 3 deletions

File tree

src/api/worker-posthog.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,29 @@ export interface WorkerErrorRequestContext {
9999
/** Capture one exception from the hosted Worker path. Never throws -- a PostHog init/capture/flush failure
100100
* degrades to recording nothing, matching every other capture* function in this codebase's identical
101101
* best-effort guarantee. Resolves once the event has actually been flushed (or given up on), so the caller
102-
* should schedule this via ctx.waitUntil rather than await it inline on the request's hot path. */
103-
export async function capturePostHogWorkerError(env: WorkerPostHogEnv, error: unknown, context: WorkerErrorRequestContext): Promise<void> {
102+
* should schedule this via ctx.waitUntil rather than await it inline on the request's hot path.
103+
*
104+
* `extraProperties` lets a non-HTTP caller (the MCP dispatch sink, #10037) attach its own grouping
105+
* properties (e.g. `mcp_tool`/`error_code`) alongside the fixed `environment`/`request_path`/
106+
* `request_method` shape -- scrubbed the same way those are, then merged in. Omitting it leaves
107+
* `createWorkerPostHogErrorMiddleware`'s HTTP callers unaffected. */
108+
export async function capturePostHogWorkerError(
109+
env: WorkerPostHogEnv,
110+
error: unknown,
111+
context: WorkerErrorRequestContext,
112+
extraProperties?: Record<string, unknown>,
113+
): Promise<void> {
104114
try {
105115
const client = await buildClient(env);
106116
if (!client) return;
107117
const err = error instanceof Error ? error : new Error(String(error));
118+
const scrubbedExtra = extraProperties ? { ...extraProperties } : undefined;
119+
if (scrubbedExtra) scrubRecord(scrubbedExtra, 0);
108120
client.captureException(err, WORKER_ERROR_DISTINCT_ID, {
109121
environment: trimmedOrUndefined(env.WORKER_POSTHOG_ENVIRONMENT) ?? "production",
110122
request_path: scrubString(context.path),
111123
request_method: context.method,
124+
...scrubbedExtra,
112125
});
113126
await client.flush();
114127
} catch {

src/mcp/dispatch-telemetry-sink.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ export function createDispatchTelemetrySink(
102102
if (!isWorkerPostHogConfigured(env)) return;
103103
// `mcp_tool` + `error_code` are the grouping properties: an exception dashboard broken down by
104104
// tool and cause is the thing an operator can act on, unlike a stack-only view.
105-
defer(capturePostHogWorkerError(env, error, { path: `mcp.tool/${call.tool}`, method: call.errorCode ?? "unknown_error" }));
105+
const errorCode = call.errorCode ?? "unknown_error";
106+
defer(
107+
capturePostHogWorkerError(env, error, { path: `mcp.tool/${call.tool}`, method: errorCode }, { mcp_tool: call.tool, error_code: errorCode }),
108+
);
106109
},
107110
// The registry is consulted per call rather than captured at construction so a self-host boot
108111
// that fills the slot after the first request still traces.

test/unit/mcp-dispatch-telemetry-sink.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,60 @@ describe("MCP dispatch telemetry sink (#9525)", () => {
138138
});
139139
});
140140

141+
// The exception properties tests below mock posthog-node so they can read the properties the sink
142+
// actually hands to captureException, instead of only observing whether the deferred promise resolves.
143+
describe("MCP dispatch telemetry sink exception properties (#10037)", () => {
144+
afterEach(() => {
145+
vi.doUnmock("posthog-node");
146+
vi.resetModules();
147+
});
148+
149+
it("attaches mcp_tool and error_code to the captured exception, matching the stdio/miner sinks", async () => {
150+
vi.resetModules();
151+
const captureException = vi.fn();
152+
const flush = vi.fn().mockResolvedValue(undefined);
153+
vi.doMock("posthog-node", () => ({
154+
PostHog: vi.fn(function (this: { captureException: typeof captureException; flush: typeof flush }) {
155+
this.captureException = captureException;
156+
this.flush = flush;
157+
}),
158+
}));
159+
const { createDispatchTelemetrySink: freshCreateDispatchTelemetrySink } = await import("../../src/mcp/dispatch-telemetry-sink");
160+
const deferred: Promise<unknown>[] = [];
161+
const sink = freshCreateDispatchTelemetrySink(env({ WORKER_POSTHOG_API_KEY: "phc_worker" }), (work) => deferred.push(work));
162+
const forbiddenCall: McpToolCallTelemetry = { ...call, ok: false, errorCode: "forbidden" };
163+
164+
sink.captureException(new Error("boom"), forbiddenCall);
165+
expect(deferred).toHaveLength(1);
166+
await deferred[0];
167+
168+
const properties = captureException.mock.calls.at(-1)?.[2] as Record<string, unknown>;
169+
expect(properties).toMatchObject({ mcp_tool: forbiddenCall.tool, error_code: "forbidden" });
170+
});
171+
172+
it("defaults error_code to unknown_error when the call carries none", async () => {
173+
vi.resetModules();
174+
const captureException = vi.fn();
175+
const flush = vi.fn().mockResolvedValue(undefined);
176+
vi.doMock("posthog-node", () => ({
177+
PostHog: vi.fn(function (this: { captureException: typeof captureException; flush: typeof flush }) {
178+
this.captureException = captureException;
179+
this.flush = flush;
180+
}),
181+
}));
182+
const { createDispatchTelemetrySink: freshCreateDispatchTelemetrySink } = await import("../../src/mcp/dispatch-telemetry-sink");
183+
const deferred: Promise<unknown>[] = [];
184+
const sink = freshCreateDispatchTelemetrySink(env({ WORKER_POSTHOG_API_KEY: "phc_worker" }), (work) => deferred.push(work));
185+
const noCodeCall: McpToolCallTelemetry = { ...call, ok: false };
186+
187+
sink.captureException(new Error("boom"), noCodeCall);
188+
await deferred[0];
189+
190+
const properties = captureException.mock.calls.at(-1)?.[2] as Record<string, unknown>;
191+
expect(properties).toMatchObject({ mcp_tool: noCodeCall.tool, error_code: "unknown_error" });
192+
});
193+
});
194+
141195
describe("LoopoverMcp telemetry-sink injection (#9525)", () => {
142196
it("routes a real tool call through the injected sink", async () => {
143197
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");

test/unit/worker-posthog.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,30 @@ describe("capturePostHogWorkerError", () => {
149149
expect(flushed).toBe(true);
150150
});
151151

152+
it("merges extraProperties into the captured exception alongside the fixed request shape (#10037)", async () => {
153+
await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "mcp.tool/loopover_x", method: "forbidden" }, {
154+
mcp_tool: "loopover_x",
155+
error_code: "forbidden",
156+
});
157+
const properties = mocks.captureException.mock.calls.at(-1)?.[2] as Record<string, unknown>;
158+
expect(properties).toMatchObject({
159+
environment: "production",
160+
request_path: "mcp.tool/loopover_x",
161+
request_method: "forbidden",
162+
mcp_tool: "loopover_x",
163+
error_code: "forbidden",
164+
});
165+
});
166+
167+
it("scrubs a secret-shaped extraProperties value the same way request_path is scrubbed (#10037)", async () => {
168+
await capturePostHogWorkerError({ WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, new Error("boom"), { path: "/x", method: "GET" }, {
169+
leaked_token: `${"github" + "_pat_"}${"a".repeat(24)}`,
170+
});
171+
const properties = mocks.captureException.mock.calls.at(-1)?.[2] as Record<string, unknown>;
172+
expect(properties.leaked_token).not.toContain("github_pat_");
173+
expect(properties.leaked_token).toContain("[redacted]");
174+
});
175+
152176
it("never throws when the PostHog client construction fails", async () => {
153177
mocks.PostHog.mockImplementationOnce(() => {
154178
throw new Error("client construction failed");
@@ -223,6 +247,16 @@ describe("createWorkerPostHogErrorMiddleware", () => {
223247
expect((properties as Record<string, unknown>).request_method).toBe("GET");
224248
});
225249

250+
it("still carries only request_path/request_method, never mcp_tool, when no extraProperties argument is passed (#10037)", async () => {
251+
const { app, executionCtx, getWaited } = buildTestApp();
252+
const res = await app.fetch(new Request("https://loopover.test/boom"), { WORKER_POSTHOG_API_KEY: "phc_test" } as WorkerPostHogEnv, executionCtx);
253+
expect(res.status).toBe(500);
254+
await getWaited();
255+
const properties = mocks.captureException.mock.calls.at(-1)?.[2] as Record<string, unknown>;
256+
expect(properties).toMatchObject({ request_path: "/boom", request_method: "GET" });
257+
expect(properties).not.toHaveProperty("mcp_tool");
258+
});
259+
226260
it("falls back to a no-op executionCtx when c.executionCtx throws (self-host calling the same Worker fetch handler outside a real isolate)", async () => {
227261
const app = new Hono<{ Bindings: WorkerPostHogEnv }>();
228262
app.use(createWorkerPostHogErrorMiddleware());

0 commit comments

Comments
 (0)