Skip to content

Commit 431b7ac

Browse files
committed
test(mcp): close the remaining telemetry coverage gaps in the three package sinks
codecov/patch's second pass flagged what the first round missed: the stdio dispatch emitter's no-contract and no-error branches, the miner's thin capture send, and the sink-injected path through LoopoverMcp's constructor -- every existing test builds that server without a sink, so only the NOOP fallback side of that '??' was ever taken. Also makes withMinerToolErrorHandling's toolName REQUIRED. It was optional so an un-threaded caller would still compile; every registration passes it, and leaving it optional made 'instrumented' a property of each call site rather than of the wrapper -- plus an unreachable branch on both arms.
1 parent 615df1b commit 431b7ac

4 files changed

Lines changed: 115 additions & 7 deletions

File tree

packages/loopover-miner/bin/loopover-miner-mcp.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,19 +114,19 @@ function isTextOverride<T extends object>(value: MinerToolRun<T>): value is { st
114114
async function withMinerToolErrorHandling<T extends object>(
115115
run: () => Promise<MinerToolRun<T>> | MinerToolRun<T>,
116116
// #9525: the tool name, so this same wrapper doubles as the miner's dispatch-telemetry
117-
// chokepoint. Optional so an existing caller that has not been threaded through yet still
118-
// compiles; every registration below passes it.
119-
toolName?: string,
117+
// chokepoint. REQUIRED -- every registration passes it, and leaving it optional would have made
118+
// "instrumented" a property of each call site rather than of the wrapper.
119+
toolName: string,
120120
): Promise<{ content: [{ type: "text"; text: string }]; structuredContent: Record<string, unknown>; isError?: true }> {
121121
const startedAt = Date.now();
122122
try {
123123
const result = await run();
124124
const payload = isTextOverride(result) ? minerToolResult(result.structured, result.text) : minerToolResult(result);
125-
if (toolName) recordMinerDispatchTelemetry({ tool: toolName, ok: true, durationMs: Date.now() - startedAt, result: payload.structuredContent });
125+
recordMinerDispatchTelemetry({ tool: toolName, ok: true, durationMs: Date.now() - startedAt, result: payload.structuredContent });
126126
return payload;
127127
} catch (error) {
128128
const data = { error: { code: toolErrorCode(error), message: error instanceof Error ? error.message : String(error) } };
129-
if (toolName) recordMinerDispatchTelemetry({ tool: toolName, ok: false, durationMs: Date.now() - startedAt, error });
129+
recordMinerDispatchTelemetry({ tool: toolName, ok: false, durationMs: Date.now() - startedAt, error });
130130
return { ...minerToolResult(data), isError: true };
131131
}
132132
}

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,33 @@ describe("MCP dispatch telemetry sink (#9525)", () => {
119119
expect(injectedCalls).toBe(1);
120120
});
121121
});
122+
123+
describe("LoopoverMcp telemetry-sink injection (#9525)", () => {
124+
it("routes a real tool call through the injected sink", async () => {
125+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
126+
const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
127+
const { LoopoverMcp } = await import("../../src/mcp/server");
128+
const { createTestEnv } = await import("../helpers/d1");
129+
130+
const recorded: McpToolCallTelemetry[] = [];
131+
const sink = {
132+
recordToolCall: (entry: McpToolCallTelemetry) => recorded.push(entry),
133+
captureException: () => undefined,
134+
withSpan: async <T>(_name: string, _attributes: Record<string, unknown>, fn: () => Promise<T>) => fn(),
135+
};
136+
137+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
138+
const client = new Client({ name: "sink-injection-test", version: "0.0.0" });
139+
await Promise.all([new LoopoverMcp(createTestEnv(), undefined, sink).createServer().connect(serverTransport), client.connect(clientTransport)]);
140+
try {
141+
await client.callTool({ name: "loopover_get_repo_context", arguments: { owner: "acme", repo: "widgets" } });
142+
} finally {
143+
await client.close().catch(() => undefined);
144+
}
145+
146+
// The chokepoint is the register wrapper, so this proves the injection reaches every tool
147+
// rather than just the one under test -- there is only one wrapper.
148+
expect(recorded).toHaveLength(1);
149+
expect(recorded[0]).toMatchObject({ tool: "loopover_get_repo_context", category: "maintainer", surface: "remote" });
150+
}, 30_000);
151+
});

test/unit/mcp-local-telemetry.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
const h = vi.hoisted(() => ({
77
constructSpy: vi.fn(),
88
captureSpy: vi.fn(),
9+
captureExceptionSpy: vi.fn(),
910
flushSpy: vi.fn(),
1011
state: {
1112
throwOnConstruct: false,
@@ -26,6 +27,9 @@ vi.mock("posthog-node", () => ({
2627
h.captureSpy(message);
2728
if (h.state.throwOnCapture) throw new Error("posthog capture failed");
2829
}
30+
captureException(error: unknown, distinctId: unknown, properties: unknown): void {
31+
h.captureExceptionSpy(error, distinctId, properties);
32+
}
2933
async flush(): Promise<void> {
3034
h.flushSpy();
3135
if (h.state.throwOnFlush) throw new Error("posthog flush failed");
@@ -34,7 +38,7 @@ vi.mock("posthog-node", () => ({
3438
},
3539
}));
3640

37-
const { recordMcpToolCall, recordStdioToolTelemetry, wrapStdioToolHandler } = await import(
41+
const { recordMcpToolCall, recordStdioDispatchTelemetry, recordStdioToolTelemetry, wrapStdioToolHandler } = await import(
3842
"../../packages/loopover-mcp/lib/telemetry"
3943
);
4044

@@ -47,6 +51,7 @@ describe("recordMcpToolCall (local MCP wrapper, #6236)", () => {
4751
beforeEach(() => {
4852
h.constructSpy.mockClear();
4953
h.captureSpy.mockClear();
54+
h.captureExceptionSpy.mockClear();
5055
h.flushSpy.mockClear();
5156
h.state.throwOnConstruct = false;
5257
h.state.throwOnCapture = false;
@@ -229,6 +234,7 @@ describe("recordStdioToolTelemetry / wrapStdioToolHandler (#8690)", () => {
229234
beforeEach(() => {
230235
h.constructSpy.mockClear();
231236
h.captureSpy.mockClear();
237+
h.captureExceptionSpy.mockClear();
232238
h.flushSpy.mockClear();
233239
h.state.throwOnConstruct = false;
234240
h.state.throwOnCapture = false;
@@ -315,9 +321,44 @@ describe("recordStdioToolTelemetry / wrapStdioToolHandler (#8690)", () => {
315321
throw new Error("handler boom");
316322
});
317323
await expect(wrapped()).rejects.toThrow("handler boom");
318-
expect(h.flushSpy).toHaveBeenCalledTimes(1);
324+
// Two flushes since #9525, same as the success path: the legacy event, then the shared pair.
325+
expect(h.flushSpy).toHaveBeenCalledTimes(2);
319326
const message = h.captureSpy.mock.calls[0]![0] as CapturedMessage;
320327
expect(message.properties).toMatchObject({ tool: "loopover_demo", ok: false });
328+
// The throw is additionally captured as an exception, grouped by tool and closed error code.
329+
expect(h.captureExceptionSpy).toHaveBeenCalledOnce();
330+
expect(h.captureExceptionSpy.mock.calls[0]![2]).toMatchObject({ mcp_tool: "loopover_demo" });
331+
});
332+
333+
// #9525: the shared dispatch pair, driven directly so the branches the wrapper cannot reach are
334+
// covered -- a tool with no contract entry, and a failed call that carries no error value.
335+
it("recordStdioDispatchTelemetry sends nothing unless BOTH gates are open", async () => {
336+
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
337+
await recordStdioDispatchTelemetry(false, { tool: "loopover_lint_pr_text", ok: true, durationMs: 1 });
338+
expect(h.captureSpy).not.toHaveBeenCalled();
339+
340+
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "");
341+
await recordStdioDispatchTelemetry(true, { tool: "loopover_lint_pr_text", ok: true, durationMs: 1 });
342+
expect(h.captureSpy).not.toHaveBeenCalled();
343+
});
344+
345+
it("recordStdioDispatchTelemetry falls back to the unknown category for a tool with no contract entry", async () => {
346+
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
347+
await recordStdioDispatchTelemetry(true, { tool: "loopover_not_in_the_registry", ok: true, durationMs: 2, args: { a: 1 } });
348+
const usage = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "usage_event")!;
349+
expect(usage.properties).toMatchObject({ category: "unknown", surface: "stdio" });
350+
// No contract means no way to know the payload is safe, so it is withheld.
351+
const toolCall = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "$mcp_tool_call")!;
352+
expect(toolCall.properties).toMatchObject({ payloads_excluded: true });
353+
});
354+
355+
it("recordStdioDispatchTelemetry captures an exception only when the failure carried one", async () => {
356+
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
357+
await recordStdioDispatchTelemetry(true, { tool: "loopover_lint_pr_text", ok: false, durationMs: 2 });
358+
expect(h.captureExceptionSpy).not.toHaveBeenCalled();
359+
360+
await recordStdioDispatchTelemetry(true, { tool: "loopover_lint_pr_text", ok: false, durationMs: 2, error: "a bare string" });
361+
expect(h.captureExceptionSpy).toHaveBeenCalledOnce();
321362
});
322363

323364
it("wrapStdioToolHandler is a no-op for PostHog when telemetry is disabled", async () => {

test/unit/miner-posthog.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ vi.mock("posthog-node", () => ({ PostHog: posthogMock.PostHog }));
1818

1919
import {
2020
captureMinerPostHogAiGeneration,
21+
captureMinerPostHogEvent,
2122
captureMinerPostHogError,
2223
captureMinerPostHogErrorAndFlush,
2324
flushMinerPostHog,
@@ -246,3 +247,39 @@ describe("loopover-miner opt-in PostHog (#8292, epic #8286)", () => {
246247
});
247248
});
248249
});
250+
251+
// #9525: the thin send the MCP dispatch chokepoint composes its events for. The properties come
252+
// from @loopover/contract so all three servers emit one shape; what is asserted here is this
253+
// function's own contract -- gated, scrubbed, and never throwing.
254+
describe("captureMinerPostHogEvent (#9525)", () => {
255+
it("sends nothing when PostHog was never initialized", () => {
256+
captureMinerPostHogEvent("usage_event", { tool: "loopover_miner_ping" });
257+
expect(posthogMock.capture).not.toHaveBeenCalled();
258+
});
259+
260+
it("sends the event anonymously, with geoip disabled, once initialized", async () => {
261+
await initMinerPostHog({ LOOPOVER_MINER_POSTHOG_API_KEY: "phc_test_key" });
262+
captureMinerPostHogEvent("usage_event", { tool: "loopover_miner_ping", ok: true });
263+
expect(posthogMock.capture).toHaveBeenCalledOnce();
264+
expect(posthogMock.capture.mock.calls[0]![0]).toMatchObject({
265+
distinctId: "loopover-miner",
266+
event: "usage_event",
267+
properties: { tool: "loopover_miner_ping", ok: true },
268+
disableGeoip: true,
269+
});
270+
});
271+
272+
it("scrubs a secret-shaped property key on the way out", async () => {
273+
await initMinerPostHog({ LOOPOVER_MINER_POSTHOG_API_KEY: "phc_test_key" });
274+
captureMinerPostHogEvent("usage_event", { tool: "t", githubToken: "ghp_realtokenvaluehere" });
275+
expect(posthogMock.capture.mock.calls[0]![0].properties.githubToken).toBe("[redacted]");
276+
});
277+
278+
it("never throws when the SDK's capture does", async () => {
279+
await initMinerPostHog({ LOOPOVER_MINER_POSTHOG_API_KEY: "phc_test_key" });
280+
posthogMock.capture.mockImplementationOnce(() => {
281+
throw new Error("capture failed");
282+
});
283+
expect(() => captureMinerPostHogEvent("usage_event", { tool: "t" })).not.toThrow();
284+
});
285+
});

0 commit comments

Comments
 (0)