@@ -18,11 +18,17 @@ import (
1818 "bufio"
1919 "context"
2020 "encoding/json"
21+ "errors"
2122 "fmt"
2223 "io"
24+ "net"
2325 "os"
26+ osexec "os/exec"
2427 "sync"
2528 "time"
29+
30+ "github.com/codag-megalith/codag-cli/internal/analytics"
31+ "github.com/codag-megalith/codag-cli/internal/api"
2632)
2733
2834// time aliases — kept local so the heartbeat code is easy to mock in tests
@@ -292,6 +298,11 @@ func (s *Server) handleToolCall(req jrpcRequest) {
292298 for {
293299 select {
294300 case r := <- done :
301+ // One analytics event per tool invocation, covering both the
302+ // success and error paths. This is the only place MCP (agent)
303+ // usage is observable — cobra-level events don't see individual
304+ // tool calls inside a long-lived `codag mcp` session.
305+ captureToolCall (p .Name , startTime , r .err )
295306 if r .err != nil {
296307 s .writeResult (req .ID , map [string ]interface {}{
297308 "content" : []map [string ]interface {}{
@@ -316,6 +327,7 @@ func (s *Server) handleToolCall(req jrpcRequest) {
316327 // (Some clients ignore this response since they sent the cancel
317328 // themselves, but emitting a clean error keeps the protocol
318329 // state consistent and avoids the "hang on Calling codag…" UI.)
330+ captureToolCall (p .Name , startTime , context .Canceled )
319331 s .writeResult (req .ID , map [string ]interface {}{
320332 "content" : []map [string ]interface {}{
321333 {"type" : "text" , "text" : "tool call cancelled" },
@@ -327,6 +339,60 @@ func (s *Server) handleToolCall(req jrpcRequest) {
327339 }
328340}
329341
342+ // captureToolCall records one mcp_tool_called analytics event per tool
343+ // invocation, mirroring the cli_command_completed/failed shape used for cobra
344+ // commands so MCP (agent) usage shows up alongside direct CLI usage in the same
345+ // PostHog project. analytics.Capture no-ops under CODAG_TELEMETRY opt-out, and
346+ // only error_code (never raw error text) is sent.
347+ func captureToolCall (name string , start time.Time , err error ) {
348+ props := map [string ]any {
349+ "tool" : name ,
350+ "duration_ms" : int (timeSinceSeconds (start ) * 1000 ),
351+ "ok" : err == nil ,
352+ }
353+ if err != nil {
354+ props ["error_code" ] = mcpErrorCode (err , props )
355+ }
356+ analytics .Capture ("mcp_tool_called" , props )
357+ }
358+
359+ // mcpErrorCode classifies an error into a stable, low-cardinality code and
360+ // stamps any safe structured detail onto props. Mirrors cmd.addTelemetryError;
361+ // duplicated here because internal/mcp cannot import the cmd package.
362+ func mcpErrorCode (err error , props map [string ]any ) string {
363+ var httpErr * api.HTTPError
364+ var billingErr * api.BillingError
365+ var exitErr * osexec.ExitError
366+ var netErr net.Error
367+ switch {
368+ case errors .Is (err , api .ErrUnauthenticated ):
369+ return "unauthenticated"
370+ case errors .Is (err , api .ErrQuotaExceeded ):
371+ return "quota_exceeded"
372+ case errors .As (err , & httpErr ):
373+ props ["http_status" ] = httpErr .Status
374+ return "http_error"
375+ case errors .As (err , & billingErr ):
376+ props ["has_upgrade_path" ] = billingErr .UpgradePath != ""
377+ return "billing_required"
378+ case errors .Is (err , api .ErrBillingRequired ):
379+ return "billing_required"
380+ case errors .As (err , & exitErr ):
381+ props ["exit_code" ] = exitErr .ExitCode ()
382+ return "child_exit"
383+ case errors .Is (err , context .DeadlineExceeded ):
384+ return "timeout"
385+ case errors .Is (err , context .Canceled ):
386+ return "canceled"
387+ case errors .As (err , & netErr ):
388+ if netErr .Timeout () {
389+ return "network_timeout"
390+ }
391+ return "network_error"
392+ }
393+ return "unknown"
394+ }
395+
330396// notify writes an unsolicited JSON-RPC notification (no id field).
331397func (s * Server ) notify (method string , params any ) {
332398 s .mu .Lock ()
0 commit comments