@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66const 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 ( ) => {
0 commit comments