Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions sast-engine/analytics/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ const (
QueryCommandJSON = "executed_query_command_json_mode"
ErrorProcessingQuery = "error_processing_query"
QueryCommandStdin = "executed_query_command_stdin_mode"

// MCP Server events.
MCPServerStarted = "mcp_server_started"
MCPServerStopped = "mcp_server_stopped"
MCPToolCall = "mcp_tool_call"
MCPIndexingStarted = "mcp_indexing_started"
MCPIndexingComplete = "mcp_indexing_complete"
MCPIndexingFailed = "mcp_indexing_failed"
MCPClientConnected = "mcp_client_connected"
)

var (
Expand Down Expand Up @@ -60,6 +69,12 @@ func LoadEnvFile() {
}

func ReportEvent(event string) {
ReportEventWithProperties(event, nil)
}

// ReportEventWithProperties sends an event with additional properties.
// Properties should not contain any PII (no file paths, code, user info).
func ReportEventWithProperties(event string, properties map[string]interface{}) {
if enableMetrics && PublicKey != "" {
client, err := posthog.NewWithConfig(
PublicKey,
Expand All @@ -71,11 +86,21 @@ func ReportEvent(event string) {
fmt.Println(err)
return
}
err = client.Enqueue(posthog.Capture{
defer client.Close()

capture := posthog.Capture{
DistinctId: os.Getenv("uuid"),
Event: event,
})
defer client.Close()
}

if properties != nil {
capture.Properties = posthog.NewProperties()
for k, v := range properties {
capture.Properties.Set(k, v)
}
}

err = client.Enqueue(capture)
if err != nil {
fmt.Println(err)
return
Expand Down
16 changes: 14 additions & 2 deletions sast-engine/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,29 @@ Transport modes:
func init() {
rootCmd.AddCommand(serveCmd)
serveCmd.Flags().StringP("project", "p", ".", "Project path to index")
serveCmd.Flags().String("python-version", "3.11", "Python version for stdlib resolution")
serveCmd.Flags().String("python-version", "", "Python version override (auto-detected from .python-version or pyproject.toml)")
serveCmd.Flags().Bool("http", false, "Use HTTP transport instead of stdio")
serveCmd.Flags().String("address", ":8080", "HTTP server address (only with --http)")
}

func runServe(cmd *cobra.Command, _ []string) error {
projectPath, _ := cmd.Flags().GetString("project")
pythonVersion, _ := cmd.Flags().GetString("python-version")
pythonVersionOverride, _ := cmd.Flags().GetString("python-version")
useHTTP, _ := cmd.Flags().GetBool("http")
address, _ := cmd.Flags().GetString("address")

fmt.Fprintln(os.Stderr, "Building index...")
start := time.Now()

// Auto-detect Python version (same logic as BuildCallGraph).
pythonVersion := builder.DetectPythonVersion(projectPath)
if pythonVersionOverride != "" {
pythonVersion = pythonVersionOverride
fmt.Fprintf(os.Stderr, "Using Python version override: %s\n", pythonVersion)
} else {
fmt.Fprintf(os.Stderr, "Detected Python version: %s\n", pythonVersion)
}

// Create logger for build process (verbose to stderr)
logger := output.NewLogger(output.VerbosityVerbose)

Expand Down Expand Up @@ -90,6 +99,9 @@ func runServe(cmd *cobra.Command, _ []string) error {
}

func runHTTPServer(mcpServer *mcp.Server, address string) error {
// Set transport type for analytics.
mcpServer.SetTransport("http")

config := &mcp.HTTPConfig{
Address: address,
ReadTimeout: 30 * time.Second,
Expand Down
114 changes: 114 additions & 0 deletions sast-engine/mcp/analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package mcp

import (
"runtime"
"time"

"github.com/shivasurya/code-pathfinder/sast-engine/analytics"
)

// Analytics provides MCP-specific telemetry helpers.
// All events are anonymous and contain no PII.
type Analytics struct {
transport string // "stdio" or "http"
startTime time.Time
}

// NewAnalytics creates a new analytics instance.
func NewAnalytics(transport string) *Analytics {
return &Analytics{
transport: transport,
startTime: time.Now(),
}
}

// ReportServerStarted reports that the MCP server has started.
func (a *Analytics) ReportServerStarted() {
analytics.ReportEventWithProperties(analytics.MCPServerStarted, map[string]interface{}{
"transport": a.transport,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
})
}

// ReportServerStopped reports that the MCP server has stopped.
func (a *Analytics) ReportServerStopped() {
analytics.ReportEventWithProperties(analytics.MCPServerStopped, map[string]interface{}{
"transport": a.transport,
"uptime_seconds": time.Since(a.startTime).Seconds(),
})
}

// ReportToolCall reports a tool invocation with timing and success info.
// No file paths or code content is included.
func (a *Analytics) ReportToolCall(toolName string, durationMs int64, success bool) {
analytics.ReportEventWithProperties(analytics.MCPToolCall, map[string]interface{}{
"tool": toolName,
"duration_ms": durationMs,
"success": success,
"transport": a.transport,
})
}

// ReportIndexingStarted reports that indexing has begun.
func (a *Analytics) ReportIndexingStarted() {
analytics.ReportEventWithProperties(analytics.MCPIndexingStarted, map[string]interface{}{
"transport": a.transport,
})
}

// ReportIndexingComplete reports successful indexing completion.
// Only aggregate counts are reported, no file paths.
func (a *Analytics) ReportIndexingComplete(stats *IndexingStats) {
props := map[string]interface{}{
"transport": a.transport,
"duration_seconds": stats.BuildDuration.Seconds(),
"function_count": stats.Functions,
"call_edge_count": stats.CallEdges,
"module_count": stats.Modules,
"file_count": stats.Files,
}
analytics.ReportEventWithProperties(analytics.MCPIndexingComplete, props)
}

// ReportIndexingFailed reports indexing failure.
// Error messages are not included to avoid potential PII.
func (a *Analytics) ReportIndexingFailed(phase string) {
analytics.ReportEventWithProperties(analytics.MCPIndexingFailed, map[string]interface{}{
"transport": a.transport,
"phase": phase,
})
}

// ReportClientConnected reports a client connection with client info.
// Only client name/version (from MCP protocol) is reported.
func (a *Analytics) ReportClientConnected(clientName, clientVersion string) {
analytics.ReportEventWithProperties(analytics.MCPClientConnected, map[string]interface{}{
"transport": a.transport,
"client_name": clientName,
"client_version": clientVersion,
})
}

// ToolCallMetrics holds metrics for a tool call.
type ToolCallMetrics struct {
StartTime time.Time
ToolName string
}

// StartToolCall begins tracking a tool call.
func (a *Analytics) StartToolCall(toolName string) *ToolCallMetrics {
return &ToolCallMetrics{
StartTime: time.Now(),
ToolName: toolName,
}
}

// EndToolCall completes tracking and reports the metric.
func (a *Analytics) EndToolCall(m *ToolCallMetrics, success bool) {
if m == nil {
return
}
durationMs := time.Since(m.StartTime).Milliseconds()
a.ReportToolCall(m.ToolName, durationMs, success)
}
128 changes: 128 additions & 0 deletions sast-engine/mcp/analytics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package mcp

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestNewAnalytics(t *testing.T) {
a := NewAnalytics("stdio")

assert.NotNil(t, a)
assert.Equal(t, "stdio", a.transport)
assert.False(t, a.startTime.IsZero())
}

func TestNewAnalytics_HTTP(t *testing.T) {
a := NewAnalytics("http")

assert.Equal(t, "http", a.transport)
}

func TestAnalytics_ReportServerStarted(t *testing.T) {
a := NewAnalytics("stdio")

// Should not panic even with analytics disabled.
a.ReportServerStarted()
}

func TestAnalytics_ReportServerStopped(t *testing.T) {
a := NewAnalytics("http")

// Should not panic.
a.ReportServerStopped()
}

func TestAnalytics_ReportToolCall(t *testing.T) {
a := NewAnalytics("stdio")

// Should not panic.
a.ReportToolCall("find_symbol", 150, true)
a.ReportToolCall("get_callers", 50, false)
}

func TestAnalytics_ReportIndexingStarted(t *testing.T) {
a := NewAnalytics("stdio")

// Should not panic.
a.ReportIndexingStarted()
}

func TestAnalytics_ReportIndexingComplete(t *testing.T) {
a := NewAnalytics("http")

stats := &IndexingStats{
Functions: 100,
CallEdges: 500,
Modules: 20,
Files: 50,
BuildDuration: 5 * time.Second,
}

// Should not panic.
a.ReportIndexingComplete(stats)
}

func TestAnalytics_ReportIndexingFailed(t *testing.T) {
a := NewAnalytics("stdio")

// Should not panic.
a.ReportIndexingFailed("parsing")
a.ReportIndexingFailed("call_graph")
}

func TestAnalytics_ReportClientConnected(t *testing.T) {
a := NewAnalytics("http")

// Should not panic.
a.ReportClientConnected("claude-code", "1.0.0")
a.ReportClientConnected("", "")
}

func TestAnalytics_StartToolCall(t *testing.T) {
a := NewAnalytics("stdio")

metrics := a.StartToolCall("find_symbol")

assert.NotNil(t, metrics)
assert.Equal(t, "find_symbol", metrics.ToolName)
assert.False(t, metrics.StartTime.IsZero())
}

func TestAnalytics_EndToolCall(t *testing.T) {
a := NewAnalytics("stdio")

metrics := a.StartToolCall("get_callers")
time.Sleep(1 * time.Millisecond) // Small delay to ensure duration > 0

// Should not panic.
a.EndToolCall(metrics, true)
}

func TestAnalytics_EndToolCall_Nil(t *testing.T) {
a := NewAnalytics("stdio")

// Should not panic with nil metrics.
a.EndToolCall(nil, true)
}

func TestAnalytics_EndToolCall_Failed(t *testing.T) {
a := NewAnalytics("http")

metrics := a.StartToolCall("resolve_import")

// Should not panic.
a.EndToolCall(metrics, false)
}

func TestToolCallMetrics(t *testing.T) {
metrics := &ToolCallMetrics{
StartTime: time.Now(),
ToolName: "test_tool",
}

assert.Equal(t, "test_tool", metrics.ToolName)
assert.False(t, metrics.StartTime.IsZero())
}
12 changes: 9 additions & 3 deletions sast-engine/mcp/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,15 @@ func SymbolNotFoundError(symbol string, suggestions []string) *RPCError {
fmt.Sprintf("Symbol not found: %s", symbol), data)
}

// IndexNotReadyError creates an index not ready error.
func IndexNotReadyError() *RPCError {
return NewRPCError(ErrCodeIndexNotReady, nil)
// IndexNotReadyError creates an index not ready error with optional progress info.
func IndexNotReadyError(phase string, progress float64) *RPCError {
data := map[string]interface{}{
"phase": phase,
"progress": progress,
}
return NewRPCErrorWithMessage(ErrCodeIndexNotReady,
fmt.Sprintf("Index not ready: %s (%.0f%% complete)", phase, progress*100),
data)
}

// QueryTimeoutError creates a query timeout error.
Expand Down
9 changes: 7 additions & 2 deletions sast-engine/mcp/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,15 @@ func TestSymbolNotFoundError_NoSuggestions(t *testing.T) {
}

func TestIndexNotReadyError(t *testing.T) {
err := IndexNotReadyError()
err := IndexNotReadyError("parsing", 0.5)

assert.Equal(t, ErrCodeIndexNotReady, err.Code)
assert.Equal(t, "Index not ready", err.Message)
assert.Contains(t, err.Message, "parsing")
assert.Contains(t, err.Message, "50%")

data := err.Data.(map[string]interface{})
assert.Equal(t, "parsing", data["phase"])
assert.Equal(t, 0.5, data["progress"])
}

func TestQueryTimeoutError(t *testing.T) {
Expand Down
Loading
Loading