Skip to content

Commit 615df1b

Browse files
committed
test(mcp): cover the telemetry sink, the span registry, and the miner chokepoint
codecov/patch flagged all three: the wrapper in dispatch-telemetry.ts had tests, the I/O half had none. Both sides of every gate now, including the two that are deliberately independent -- exception capture on with usage events off -- plus the never-rejects guarantee on the deferred capture with no reachable host. Also drops a redundant API-key guard inside captureEvents. Its only caller already gates on the key, so the second check was unreachable by construction; it now takes the resolved key instead of re-deriving one it cannot fail to find.
1 parent 984997b commit 615df1b

3 files changed

Lines changed: 162 additions & 5 deletions

File tree

src/mcp/dispatch-telemetry-sink.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,13 @@ function trimmedOrUndefined(value: string | undefined): string | undefined {
3737
/** Deferred work the caller schedules via `waitUntil`, so a slow flush never delays the response. */
3838
export type DeferWork = (work: Promise<unknown>) => void;
3939

40-
async function captureEvents(env: DispatchTelemetryEnv, properties: { usage: Record<string, unknown>; mcpToolCall: Record<string, unknown> }): Promise<void> {
41-
const apiKey = trimmedOrUndefined(env.POSTHOG_API_KEY);
42-
if (!apiKey) return;
40+
/** Only ever reached from behind `recordToolCall`'s gate, so it takes the resolved key rather than
41+
* re-deriving and re-checking it -- a second guard here would be unreachable by construction. */
42+
async function captureEvents(
43+
env: DispatchTelemetryEnv,
44+
apiKey: string,
45+
properties: { usage: Record<string, unknown>; mcpToolCall: Record<string, unknown> },
46+
): Promise<void> {
4347
try {
4448
const { PostHog } = await import("posthog-node");
4549
const client = new PostHog(apiKey, {
@@ -76,8 +80,9 @@ export function createDispatchTelemetrySink(
7680
): DispatchTelemetrySink {
7781
return {
7882
recordToolCall: (_call, properties) => {
79-
if (!trimmedOrUndefined(env.POSTHOG_API_KEY)) return;
80-
defer(captureEvents(env, properties));
83+
const apiKey = trimmedOrUndefined(env.POSTHOG_API_KEY);
84+
if (!apiKey) return;
85+
defer(captureEvents(env, apiKey, properties));
8186
},
8287
captureException: (error, call) => {
8388
if (!isWorkerPostHogConfigured(env)) return;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// The remote server's telemetry sink and span registry (#9525).
2+
//
3+
// The wrapper in dispatch-telemetry.ts is pure and covered separately; this covers the I/O half --
4+
// both sides of every gate, and the guarantee that a sink failure never reaches the tool caller.
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import type { McpToolCallTelemetry } from "@loopover/contract";
7+
import { createDispatchTelemetrySink, type DispatchTelemetryEnv } from "../../src/mcp/dispatch-telemetry-sink";
8+
import {
9+
getMcpDispatchSpanRunner,
10+
resetMcpDispatchSpanRunnerForTest,
11+
setMcpDispatchSpanRunner,
12+
} from "../../src/mcp/dispatch-span-registry";
13+
14+
const call: McpToolCallTelemetry = { tool: "loopover_get_repo_context", category: "maintainer", surface: "remote", ok: true, durationMs: 4 };
15+
const properties = { usage: { tool: call.tool }, mcpToolCall: { tool: call.tool } };
16+
17+
function env(overrides: Partial<DispatchTelemetryEnv> = {}): DispatchTelemetryEnv {
18+
return overrides as DispatchTelemetryEnv;
19+
}
20+
21+
afterEach(() => {
22+
resetMcpDispatchSpanRunnerForTest();
23+
vi.restoreAllMocks();
24+
});
25+
26+
describe("MCP dispatch span registry (#9525)", () => {
27+
it("is empty until a self-host boot fills it, and clears again", () => {
28+
expect(getMcpDispatchSpanRunner()).toBeUndefined();
29+
const runner = async <T>(_name: string, _attributes: Record<string, unknown>, fn: () => Promise<T>): Promise<T> => fn();
30+
setMcpDispatchSpanRunner(runner);
31+
expect(getMcpDispatchSpanRunner()).toBe(runner);
32+
setMcpDispatchSpanRunner(null);
33+
expect(getMcpDispatchSpanRunner()).toBeUndefined();
34+
});
35+
});
36+
37+
describe("MCP dispatch telemetry sink (#9525)", () => {
38+
it("records nothing and defers nothing when POSTHOG_API_KEY is unset", () => {
39+
const deferred: Promise<unknown>[] = [];
40+
const sink = createDispatchTelemetrySink(env(), (work) => deferred.push(work));
41+
sink.recordToolCall(call, properties);
42+
expect(deferred).toEqual([]);
43+
});
44+
45+
it("treats a blank POSTHOG_API_KEY as unset", () => {
46+
const deferred: Promise<unknown>[] = [];
47+
const sink = createDispatchTelemetrySink(env({ POSTHOG_API_KEY: " " }), (work) => deferred.push(work));
48+
sink.recordToolCall(call, properties);
49+
expect(deferred).toEqual([]);
50+
});
51+
52+
it("defers one capture when the key is set, and never rejects even with no reachable host", async () => {
53+
const deferred: Promise<unknown>[] = [];
54+
const sink = createDispatchTelemetrySink(env({ POSTHOG_API_KEY: "phc_test", POSTHOG_HOST: "http://127.0.0.1:1" }), (work) => deferred.push(work));
55+
sink.recordToolCall(call, properties);
56+
expect(deferred).toHaveLength(1);
57+
// The never-throws guarantee: a PostHog init/capture/flush failure records nothing and resolves.
58+
await expect(deferred[0]).resolves.toBeUndefined();
59+
});
60+
61+
it("falls back to the US-cloud host when POSTHOG_HOST is unset", async () => {
62+
const deferred: Promise<unknown>[] = [];
63+
const sink = createDispatchTelemetrySink(env({ POSTHOG_API_KEY: "phc_test" }), (work) => deferred.push(work));
64+
sink.recordToolCall(call, properties);
65+
expect(deferred).toHaveLength(1);
66+
// Reaches the real default host and fails there; the guarantee under test is that it resolves
67+
// rather than rejecting into the tool caller.
68+
await expect(deferred[0]).resolves.toBeUndefined();
69+
}, 20_000);
70+
71+
it("captures nothing when the Worker exception key is unset", () => {
72+
const deferred: Promise<unknown>[] = [];
73+
const sink = createDispatchTelemetrySink(env({ POSTHOG_API_KEY: "phc_test" }), (work) => deferred.push(work));
74+
sink.captureException(new Error("boom"), call);
75+
expect(deferred).toEqual([]);
76+
});
77+
78+
it("defers an exception capture when the Worker key IS set -- a separate gate from the usage one", async () => {
79+
const deferred: Promise<unknown>[] = [];
80+
const sink = createDispatchTelemetrySink(
81+
env({ WORKER_POSTHOG_API_KEY: "phc_worker", WORKER_POSTHOG_HOST: "http://127.0.0.1:1" }),
82+
(work) => deferred.push(work),
83+
);
84+
// No POSTHOG_API_KEY here: the two gates are deliberately independent (see the sink's header),
85+
// so exception capture is on while usage events stay off.
86+
sink.recordToolCall(call, properties);
87+
sink.captureException(new Error("boom"), call);
88+
expect(deferred).toHaveLength(1);
89+
await expect(deferred[0]).resolves.toBeUndefined();
90+
});
91+
92+
it("passes the call through untouched when no span runner is registered", async () => {
93+
const sink = createDispatchTelemetrySink(env(), () => undefined);
94+
await expect(sink.withSpan("mcp.tool/x", { tool: "x" }, async () => "through")).resolves.toBe("through");
95+
});
96+
97+
it("uses the registry's runner when a self-host boot has filled it", async () => {
98+
const seen: Array<{ name: string; attributes: Record<string, unknown> }> = [];
99+
setMcpDispatchSpanRunner(async (name, attributes, fn) => {
100+
seen.push({ name, attributes });
101+
return fn();
102+
});
103+
const sink = createDispatchTelemetrySink(env(), () => undefined);
104+
await expect(sink.withSpan("mcp.tool/x", { tool: "x" }, async () => "wrapped")).resolves.toBe("wrapped");
105+
expect(seen).toEqual([{ name: "mcp.tool/x", attributes: { tool: "x" } }]);
106+
});
107+
108+
it("prefers an explicitly injected runner over the registry", async () => {
109+
setMcpDispatchSpanRunner(async () => {
110+
throw new Error("registry runner should not have been used");
111+
});
112+
let injectedCalls = 0;
113+
const injected = async <T>(_name: string, _attributes: Record<string, unknown>, fn: () => Promise<T>): Promise<T> => {
114+
injectedCalls += 1;
115+
return fn();
116+
};
117+
const sink = createDispatchTelemetrySink(env(), () => undefined, injected);
118+
await expect(sink.withSpan("mcp.tool/x", {}, async () => "injected")).resolves.toBe("injected");
119+
expect(injectedCalls).toBe(1);
120+
});
121+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// The miner MCP server's dispatch-telemetry chokepoint (#9525).
2+
//
3+
// Its sink is this package's own opt-in PostHog client, so both sides of that gate are driven here
4+
// via the module's exported reset helper rather than by mocking the SDK.
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { recordMinerDispatchTelemetry } from "../../packages/loopover-miner/lib/mcp-dispatch-telemetry";
7+
import { resetMinerPostHogForTesting } from "../../packages/loopover-miner/lib/posthog";
8+
9+
afterEach(() => {
10+
resetMinerPostHogForTesting();
11+
});
12+
13+
describe("miner dispatch telemetry (#9525)", () => {
14+
it("is a silent no-op when the miner's PostHog client is not initialized", () => {
15+
expect(() =>
16+
recordMinerDispatchTelemetry({ tool: "loopover_miner_ping", ok: true, durationMs: 3, args: {}, result: { status: "ok" } }),
17+
).not.toThrow();
18+
});
19+
20+
it("never throws on the failure path, with or without an error value", () => {
21+
expect(() => recordMinerDispatchTelemetry({ tool: "loopover_miner_ping", ok: false, durationMs: 3, error: new Error("boom") })).not.toThrow();
22+
// `error` absent on a failed call: the exception-capture arm must be skipped, not passed undefined.
23+
expect(() => recordMinerDispatchTelemetry({ tool: "loopover_miner_ping", ok: false, durationMs: 3 })).not.toThrow();
24+
});
25+
26+
it("tolerates a tool with no contract entry rather than throwing on the path it instruments", () => {
27+
// The contract validator (#9520) makes this unreachable in practice; telemetry still must not be
28+
// the thing that breaks a tool call if it ever happens.
29+
expect(() => recordMinerDispatchTelemetry({ tool: "loopover_not_in_the_registry", ok: true, durationMs: 1, args: { a: 1 } })).not.toThrow();
30+
});
31+
});

0 commit comments

Comments
 (0)