diff --git a/pkg/cli/gateway_logs.go b/pkg/cli/gateway_logs.go index 48dbb184515..30d05a83858 100644 --- a/pkg/cli/gateway_logs.go +++ b/pkg/cli/gateway_logs.go @@ -1,1066 +1,9 @@ -// This file provides command-line interface functionality for gh-aw. -// This file (gateway_logs.go) contains functions for parsing and analyzing -// MCP gateway logs from gateway.jsonl or rpc-messages.jsonl files. -// -// Key responsibilities: -// - Parsing gateway.jsonl JSONL format logs (preferred) -// - Parsing rpc-messages.jsonl JSONL format logs (canonical fallback) -// - Extracting server and tool usage metrics -// - Aggregating gateway statistics -// - Rendering gateway metrics tables +// This file previously contained all MCP gateway log parsing logic. +// It has been split into concern-aligned files: +// - gateway_logs_types.go — type/struct definitions and constants +// - gateway_logs_rpc.go — parseRPCMessages, findRPCMessagesPath, buildToolCallsFromRPCMessages +// - gateway_logs_parsing.go — parseGatewayLogs, processGatewayLogEntry +// - gateway_logs_aggregation.go — calculateGatewayAggregates, buildGuardPolicySummary +// - gateway_logs_mcp.go — extractMCPToolUsageData package cli - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/github/gh-aw/pkg/console" - "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/timeutil" -) - -var gatewayLogsLog = logger.New("cli:gateway_logs") - -// maxScannerBufferSize is the maximum scanner buffer for large JSONL payloads (1 MB). -const maxScannerBufferSize = 1024 * 1024 - -// GatewayLogEntry represents a single log entry from gateway.jsonl -type GatewayLogEntry struct { - Timestamp string `json:"timestamp"` - Level string `json:"level"` - Type string `json:"type"` - Event string `json:"event"` - ServerName string `json:"server_name,omitempty"` - ServerID string `json:"server_id,omitempty"` // used by DIFC_FILTERED events - ToolName string `json:"tool_name,omitempty"` - Method string `json:"method,omitempty"` - Duration float64 `json:"duration,omitempty"` // in milliseconds - InputSize int `json:"input_size,omitempty"` - OutputSize int `json:"output_size,omitempty"` - Status string `json:"status,omitempty"` - Error string `json:"error,omitempty"` - Message string `json:"message,omitempty"` - Description string `json:"description,omitempty"` - Reason string `json:"reason,omitempty"` - SecrecyTags []string `json:"secrecy_tags,omitempty"` - IntegrityTags []string `json:"integrity_tags,omitempty"` - AuthorAssociation string `json:"author_association,omitempty"` - AuthorLogin string `json:"author_login,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - Number string `json:"number,omitempty"` -} - -// DifcFilteredEvent represents a DIFC_FILTERED log entry from gateway.jsonl. -// These events occur when a tool call is blocked by DIFC integrity or secrecy checks. -type DifcFilteredEvent struct { - Timestamp string `json:"timestamp"` - ServerID string `json:"server_id"` - ToolName string `json:"tool_name"` - Description string `json:"description,omitempty"` - Reason string `json:"reason"` - SecrecyTags []string `json:"secrecy_tags,omitempty"` - IntegrityTags []string `json:"integrity_tags,omitempty"` - AuthorAssociation string `json:"author_association,omitempty"` - AuthorLogin string `json:"author_login,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - Number string `json:"number,omitempty"` -} - -// Guard policy error codes from MCP Gateway. -// These JSON-RPC error codes indicate guard policy enforcement decisions. -const ( - guardPolicyErrorCodeAccessDenied = -32001 // General access denied - guardPolicyErrorCodeRepoNotAllowed = -32002 // Repository not in allowlist (repos) - guardPolicyErrorCodeInsufficientPerms = -32003 // Insufficient permissions (roles) - guardPolicyErrorCodePrivateRepoDenied = -32004 // Private repository access denied - guardPolicyErrorCodeBlockedUser = -32005 // Content from blocked user - guardPolicyErrorCodeIntegrityBelowMin = -32006 // Content integrity below minimum threshold (min-integrity) -) - -// GuardPolicyEvent represents a guard policy enforcement decision from the MCP Gateway. -// These events are extracted from JSON-RPC error responses with specific error codes -// (-32001 to -32006) in rpc-messages.jsonl. -type GuardPolicyEvent struct { - Timestamp string `json:"timestamp"` - ServerID string `json:"server_id"` - ToolName string `json:"tool_name"` - ErrorCode int `json:"error_code"` - Reason string `json:"reason"` // e.g., "repository_not_allowed", "min_integrity" - Message string `json:"message"` // Error message from JSON-RPC response - Details string `json:"details,omitempty"` // Additional details from error data - Repository string `json:"repository,omitempty"` // Repository involved (for repo scope blocks) -} - -// isGuardPolicyErrorCode returns true if the JSON-RPC error code indicates a -// guard policy enforcement decision. -func isGuardPolicyErrorCode(code int) bool { - return code >= guardPolicyErrorCodeIntegrityBelowMin && code <= guardPolicyErrorCodeAccessDenied -} - -// guardPolicyReasonFromCode returns a human-readable reason string for a guard policy error code. -func guardPolicyReasonFromCode(code int) string { - switch code { - case guardPolicyErrorCodeAccessDenied: - return "access_denied" - case guardPolicyErrorCodeRepoNotAllowed: - return "repo_not_allowed" - case guardPolicyErrorCodeInsufficientPerms: - return "insufficient_permissions" - case guardPolicyErrorCodePrivateRepoDenied: - return "private_repo_denied" - case guardPolicyErrorCodeBlockedUser: - return "blocked_user" - case guardPolicyErrorCodeIntegrityBelowMin: - return "integrity_below_minimum" - default: - return "unknown" - } -} - -// GatewayServerMetrics represents usage metrics for a single MCP server -type GatewayServerMetrics struct { - ServerName string - RequestCount int - ToolCallCount int - TotalDuration float64 // in milliseconds - ErrorCount int - FilteredCount int // number of DIFC_FILTERED events for this server - GuardPolicyBlocked int // number of tool calls blocked by guard policies for this server - Tools map[string]*GatewayToolMetrics -} - -// GatewayToolMetrics represents usage metrics for a specific tool -type GatewayToolMetrics struct { - ToolName string - CallCount int - TotalDuration float64 // in milliseconds - AvgDuration float64 // in milliseconds - MaxDuration float64 // in milliseconds - MinDuration float64 // in milliseconds - ErrorCount int - TotalInputSize int - TotalOutputSize int -} - -// GatewayMetrics represents aggregated metrics from gateway logs -type GatewayMetrics struct { - TotalRequests int - TotalToolCalls int - TotalErrors int - TotalFiltered int // number of DIFC_FILTERED events - TotalGuardBlocked int // number of tool calls blocked by guard policies - Servers map[string]*GatewayServerMetrics - FilteredEvents []DifcFilteredEvent - GuardPolicyEvents []GuardPolicyEvent - StartTime time.Time - EndTime time.Time - TotalDuration float64 // in milliseconds -} - -// RPCMessageEntry represents a single entry from rpc-messages.jsonl. -// This file is written by the Copilot CLI and contains raw JSON-RPC protocol messages -// exchanged between the AI engine and MCP servers, as well as DIFC_FILTERED events. -type RPCMessageEntry struct { - Timestamp string `json:"timestamp"` - Direction string `json:"direction"` // "IN" = received from server, "OUT" = sent to server; empty for DIFC_FILTERED - Type string `json:"type"` // "REQUEST", "RESPONSE", or "DIFC_FILTERED" - ServerID string `json:"server_id"` - Payload json.RawMessage `json:"payload"` - // Fields populated only for DIFC_FILTERED entries - ToolName string `json:"tool_name,omitempty"` - Description string `json:"description,omitempty"` - Reason string `json:"reason,omitempty"` - SecrecyTags []string `json:"secrecy_tags,omitempty"` - IntegrityTags []string `json:"integrity_tags,omitempty"` - AuthorAssociation string `json:"author_association,omitempty"` - AuthorLogin string `json:"author_login,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - Number string `json:"number,omitempty"` -} - -// rpcRequestPayload represents the JSON-RPC request payload fields we care about. -type rpcRequestPayload struct { - Method string `json:"method"` - ID any `json:"id"` - Params json.RawMessage `json:"params"` -} - -// rpcToolCallParams represents the params for a tools/call request. -type rpcToolCallParams struct { - Name string `json:"name"` -} - -// rpcResponsePayload represents the JSON-RPC response payload fields we care about. -type rpcResponsePayload struct { - ID any `json:"id"` - Error *rpcError `json:"error,omitempty"` -} - -// rpcError represents a JSON-RPC error object. -type rpcError struct { - Code int `json:"code"` - Message string `json:"message"` - Data *rpcErrorData `json:"data,omitempty"` -} - -// rpcErrorData represents the optional data field in a JSON-RPC error, used by -// guard policy enforcement to communicate the reason and context for a denial. -type rpcErrorData struct { - Reason string `json:"reason,omitempty"` - Repository string `json:"repository,omitempty"` - Details string `json:"details,omitempty"` -} - -// rpcPendingRequest tracks an in-flight tool call for duration calculation. -type rpcPendingRequest struct { - ServerID string - ToolName string - Timestamp time.Time -} - -// parseRPCMessages parses a rpc-messages.jsonl file and extracts GatewayMetrics. -// This is the canonical fallback when gateway.jsonl is not available. -func parseRPCMessages(logPath string, verbose bool) (*GatewayMetrics, error) { - gatewayLogsLog.Printf("Parsing rpc-messages.jsonl from: %s", logPath) - - file, err := os.Open(logPath) - if err != nil { - return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) - } - defer file.Close() - - metrics := &GatewayMetrics{ - Servers: make(map[string]*GatewayServerMetrics), - } - - // Track pending requests by (serverID, id) for duration calculation. - // Key format: "/" - pendingRequests := make(map[string]*rpcPendingRequest) - - scanner := bufio.NewScanner(file) - // Increase scanner buffer for large payloads - buf := make([]byte, maxScannerBufferSize) - scanner.Buffer(buf, maxScannerBufferSize) - lineNum := 0 - - for scanner.Scan() { - lineNum++ - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - var entry RPCMessageEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil { - gatewayLogsLog.Printf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage( - fmt.Sprintf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err))) - } - continue - } - - // Update time range - if entry.Timestamp != "" { - if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { - if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { - metrics.StartTime = t - } - if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { - metrics.EndTime = t - } - } - } - - if entry.ServerID == "" { - continue - } - - switch { - case entry.Type == "DIFC_FILTERED": - // DIFC integrity/secrecy filter event — not a REQUEST or RESPONSE - metrics.TotalFiltered++ - server := getOrCreateServer(metrics, entry.ServerID) - server.FilteredCount++ - metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ - Timestamp: entry.Timestamp, - ServerID: entry.ServerID, - ToolName: entry.ToolName, - Description: entry.Description, - Reason: entry.Reason, - SecrecyTags: entry.SecrecyTags, - IntegrityTags: entry.IntegrityTags, - AuthorAssociation: entry.AuthorAssociation, - AuthorLogin: entry.AuthorLogin, - HTMLURL: entry.HTMLURL, - Number: entry.Number, - }) - - case entry.Direction == "OUT" && entry.Type == "REQUEST": - // Outgoing request from AI engine to MCP server - var req rpcRequestPayload - if err := json.Unmarshal(entry.Payload, &req); err != nil { - continue - } - if req.Method != "tools/call" { - continue - } - - // Extract tool name - var params rpcToolCallParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.Name == "" { - continue - } - - metrics.TotalRequests++ - server := getOrCreateServer(metrics, entry.ServerID) - server.RequestCount++ - metrics.TotalToolCalls++ - server.ToolCallCount++ - - tool := getOrCreateTool(server, params.Name) - tool.CallCount++ - - // Store pending request for duration calculation - if req.ID != nil && entry.Timestamp != "" { - if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { - key := fmt.Sprintf("%s/%v", entry.ServerID, req.ID) - pendingRequests[key] = &rpcPendingRequest{ - ServerID: entry.ServerID, - ToolName: params.Name, - Timestamp: t, - } - } - } - - case entry.Direction == "IN" && entry.Type == "RESPONSE": - // Incoming response from MCP server to AI engine - var resp rpcResponsePayload - if err := json.Unmarshal(entry.Payload, &resp); err != nil { - continue - } - - // Track errors and detect guard policy blocks - if resp.Error != nil { - metrics.TotalErrors++ - server := getOrCreateServer(metrics, entry.ServerID) - server.ErrorCount++ - - // Detect guard policy enforcement errors - if isGuardPolicyErrorCode(resp.Error.Code) { - metrics.TotalGuardBlocked++ - server.GuardPolicyBlocked++ - - // Determine tool name from pending request if available - toolName := "" - if resp.ID != nil { - key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) - if pending, ok := pendingRequests[key]; ok { - toolName = pending.ToolName - } - } - - reason := guardPolicyReasonFromCode(resp.Error.Code) - if resp.Error.Data != nil && resp.Error.Data.Reason != "" { - reason = resp.Error.Data.Reason - } - - evt := GuardPolicyEvent{ - Timestamp: entry.Timestamp, - ServerID: entry.ServerID, - ToolName: toolName, - ErrorCode: resp.Error.Code, - Reason: reason, - Message: resp.Error.Message, - } - if resp.Error.Data != nil { - evt.Details = resp.Error.Data.Details - evt.Repository = resp.Error.Data.Repository - } - metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, evt) - } - } - - // Calculate duration by matching with pending request - if resp.ID != nil && entry.Timestamp != "" { - key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) - if pending, ok := pendingRequests[key]; ok { - delete(pendingRequests, key) - if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { - durationMs := float64(t.Sub(pending.Timestamp).Milliseconds()) - if durationMs >= 0 { - server := getOrCreateServer(metrics, entry.ServerID) - server.TotalDuration += durationMs - metrics.TotalDuration += durationMs - - tool := getOrCreateTool(server, pending.ToolName) - tool.TotalDuration += durationMs - if tool.MaxDuration == 0 || durationMs > tool.MaxDuration { - tool.MaxDuration = durationMs - } - if tool.MinDuration == 0 || durationMs < tool.MinDuration { - tool.MinDuration = durationMs - } - - if resp.Error != nil { - tool.ErrorCount++ - } - } - } - } - } - } - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) - } - - calculateGatewayAggregates(metrics) - - gatewayLogsLog.Printf("Successfully parsed rpc-messages.jsonl: %d servers, %d total requests", - len(metrics.Servers), metrics.TotalRequests) - - return metrics, nil -} - -// findRPCMessagesPath returns the path to rpc-messages.jsonl if it exists, or "" if not found. -func findRPCMessagesPath(logDir string) string { - // Check mcp-logs subdirectory (standard location) - mcpLogsPath := filepath.Join(logDir, "mcp-logs", "rpc-messages.jsonl") - if _, err := os.Stat(mcpLogsPath); err == nil { - return mcpLogsPath - } - // Check root directory as fallback - rootPath := filepath.Join(logDir, "rpc-messages.jsonl") - if _, err := os.Stat(rootPath); err == nil { - return rootPath - } - return "" -} - -// parseGatewayLogs parses a gateway.jsonl file and extracts metrics. -// Falls back to rpc-messages.jsonl (canonical fallback) when gateway.jsonl is not present. -func parseGatewayLogs(logDir string, verbose bool) (*GatewayMetrics, error) { - // Try root directory first (for older logs where gateway.jsonl was in the root) - gatewayLogPath := filepath.Join(logDir, "gateway.jsonl") - - // Check if gateway.jsonl exists in root - if _, err := os.Stat(gatewayLogPath); os.IsNotExist(err) { - // Try mcp-logs subdirectory (new path after artifact download) - // Gateway logs are uploaded from /tmp/gh-aw/mcp-logs/gateway.jsonl and the common parent - // /tmp/gh-aw/ is stripped during artifact upload, resulting in mcp-logs/gateway.jsonl after download - mcpLogsPath := filepath.Join(logDir, "mcp-logs", "gateway.jsonl") - if _, err := os.Stat(mcpLogsPath); os.IsNotExist(err) { - // Fall back to rpc-messages.jsonl (canonical fallback when gateway.jsonl is missing) - rpcPath := findRPCMessagesPath(logDir) - if rpcPath != "" { - gatewayLogsLog.Printf("gateway.jsonl not found; falling back to rpc-messages.jsonl: %s", rpcPath) - return parseRPCMessages(rpcPath, verbose) - } - gatewayLogsLog.Printf("gateway.jsonl not found at: %s or %s", gatewayLogPath, mcpLogsPath) - return nil, errors.New("gateway.jsonl not found") - } - gatewayLogPath = mcpLogsPath - gatewayLogsLog.Printf("Found gateway.jsonl in mcp-logs subdirectory") - } - - gatewayLogsLog.Printf("Parsing gateway.jsonl from: %s", gatewayLogPath) - - file, err := os.Open(gatewayLogPath) - if err != nil { - return nil, fmt.Errorf("failed to open gateway.jsonl: %w", err) - } - defer file.Close() - - metrics := &GatewayMetrics{ - Servers: make(map[string]*GatewayServerMetrics), - } - - scanner := bufio.NewScanner(file) - buf := make([]byte, maxScannerBufferSize) - scanner.Buffer(buf, maxScannerBufferSize) - lineNum := 0 - - for scanner.Scan() { - lineNum++ - line := strings.TrimSpace(scanner.Text()) - - // Skip empty lines - if line == "" { - continue - } - - var entry GatewayLogEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil { - gatewayLogsLog.Printf("Failed to parse line %d: %v", lineNum, err) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse gateway.jsonl line %d: %v", lineNum, err))) - } - continue - } - - // Process the entry based on its type/event - processGatewayLogEntry(&entry, metrics, verbose) - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading gateway.jsonl: %w", err) - } - - // Calculate aggregate statistics - calculateGatewayAggregates(metrics) - - gatewayLogsLog.Printf("Successfully parsed gateway.jsonl: %d servers, %d total requests", - len(metrics.Servers), metrics.TotalRequests) - - return metrics, nil -} - -// processGatewayLogEntry processes a single log entry and updates metrics -func processGatewayLogEntry(entry *GatewayLogEntry, metrics *GatewayMetrics, verbose bool) { - // Parse timestamp for time range (supports both RFC3339 and RFC3339Nano) - if entry.Timestamp != "" { - t, err := time.Parse(time.RFC3339Nano, entry.Timestamp) - if err != nil { - t, err = time.Parse(time.RFC3339, entry.Timestamp) - } - if err == nil { - if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { - metrics.StartTime = t - } - if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { - metrics.EndTime = t - } - } - } - - // Handle DIFC_FILTERED events - if entry.Type == "DIFC_FILTERED" { - metrics.TotalFiltered++ - // DIFC_FILTERED events use server_id; fall back to server_name for compatibility - serverKey := entry.ServerID - if serverKey == "" { - serverKey = entry.ServerName - } - if serverKey != "" { - server := getOrCreateServer(metrics, serverKey) - server.FilteredCount++ - } - metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ - Timestamp: entry.Timestamp, - ServerID: serverKey, - ToolName: entry.ToolName, - Description: entry.Description, - Reason: entry.Reason, - SecrecyTags: entry.SecrecyTags, - IntegrityTags: entry.IntegrityTags, - AuthorAssociation: entry.AuthorAssociation, - AuthorLogin: entry.AuthorLogin, - HTMLURL: entry.HTMLURL, - Number: entry.Number, - }) - return - } - - // Handle GUARD_POLICY_BLOCKED events from gateway.jsonl - if entry.Type == "GUARD_POLICY_BLOCKED" { - metrics.TotalGuardBlocked++ - serverKey := entry.ServerID - if serverKey == "" { - serverKey = entry.ServerName - } - if serverKey != "" { - server := getOrCreateServer(metrics, serverKey) - server.GuardPolicyBlocked++ - } - metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, GuardPolicyEvent{ - Timestamp: entry.Timestamp, - ServerID: serverKey, - ToolName: entry.ToolName, - Reason: entry.Reason, - Message: entry.Message, - Details: entry.Description, - }) - return - } - - // Track errors - if entry.Status == "error" || entry.Error != "" { - metrics.TotalErrors++ - if entry.ServerName != "" { - server := getOrCreateServer(metrics, entry.ServerName) - server.ErrorCount++ - - if entry.ToolName != "" { - tool := getOrCreateTool(server, entry.ToolName) - tool.ErrorCount++ - } - } - } - - // Process based on event type - switch entry.Event { - case "request", "tool_call", "rpc_call": - metrics.TotalRequests++ - - if entry.ServerName != "" { - server := getOrCreateServer(metrics, entry.ServerName) - server.RequestCount++ - - if entry.Duration > 0 { - server.TotalDuration += entry.Duration - metrics.TotalDuration += entry.Duration - } - - // Track tool calls - if entry.ToolName != "" || entry.Method != "" { - toolName := entry.ToolName - if toolName == "" { - toolName = entry.Method - } - - metrics.TotalToolCalls++ - server.ToolCallCount++ - - tool := getOrCreateTool(server, toolName) - tool.CallCount++ - - if entry.Duration > 0 { - tool.TotalDuration += entry.Duration - if tool.MaxDuration == 0 || entry.Duration > tool.MaxDuration { - tool.MaxDuration = entry.Duration - } - if tool.MinDuration == 0 || entry.Duration < tool.MinDuration { - tool.MinDuration = entry.Duration - } - } - - if entry.InputSize > 0 { - tool.TotalInputSize += entry.InputSize - } - if entry.OutputSize > 0 { - tool.TotalOutputSize += entry.OutputSize - } - } - } - } -} - -// getOrCreateServer gets or creates a server metrics entry -func getOrCreateServer(metrics *GatewayMetrics, serverName string) *GatewayServerMetrics { - if server, exists := metrics.Servers[serverName]; exists { - return server - } - - server := &GatewayServerMetrics{ - ServerName: serverName, - Tools: make(map[string]*GatewayToolMetrics), - } - metrics.Servers[serverName] = server - return server -} - -// getOrCreateTool gets or creates a tool metrics entry -func getOrCreateTool(server *GatewayServerMetrics, toolName string) *GatewayToolMetrics { - if tool, exists := server.Tools[toolName]; exists { - return tool - } - - tool := &GatewayToolMetrics{ - ToolName: toolName, - } - server.Tools[toolName] = tool - return tool -} - -// calculateGatewayAggregates calculates aggregate statistics -func calculateGatewayAggregates(metrics *GatewayMetrics) { - for _, server := range metrics.Servers { - for _, tool := range server.Tools { - if tool.CallCount > 0 { - tool.AvgDuration = tool.TotalDuration / float64(tool.CallCount) - } - } - } -} - -// buildToolCallsFromRPCMessages reads rpc-messages.jsonl and builds MCPToolCall records. -// Duration is computed by pairing outgoing requests with incoming responses. -// Input/output sizes are not available in rpc-messages.jsonl and will be 0. -func buildToolCallsFromRPCMessages(logPath string) ([]MCPToolCall, error) { - file, err := os.Open(logPath) - if err != nil { - return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) - } - defer file.Close() - - type pendingCall struct { - serverID string - toolName string - timestamp time.Time - } - pending := make(map[string]*pendingCall) // key: "/" - - // Collect requests first to pair with responses - type rawEntry struct { - entry RPCMessageEntry - req rpcRequestPayload - resp rpcResponsePayload - valid bool - } - var entries []rawEntry - - scanner := bufio.NewScanner(file) - buf := make([]byte, maxScannerBufferSize) - scanner.Buffer(buf, maxScannerBufferSize) - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - var e RPCMessageEntry - if err := json.Unmarshal([]byte(line), &e); err != nil { - continue - } - entries = append(entries, rawEntry{entry: e, valid: true}) - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) - } - - // Second pass: build MCPToolCall records. - // Declared before first pass so requests without IDs can be appended immediately. - var toolCalls []MCPToolCall - processedKeys := make(map[string]bool) - - // First pass: index outgoing tool-call requests by (serverID, id) - for i := range entries { - e := &entries[i] - if e.entry.Direction != "OUT" || e.entry.Type != "REQUEST" { - continue - } - if err := json.Unmarshal(e.entry.Payload, &e.req); err != nil || e.req.Method != "tools/call" { - continue - } - var params rpcToolCallParams - if err := json.Unmarshal(e.req.Params, ¶ms); err != nil || params.Name == "" { - continue - } - if e.req.ID == nil { - // Requests without an ID cannot be matched to responses. - // Emit the tool call immediately with "unknown" status so it appears - // in the tool_calls list (same as parseRPCMessages counts it in the summary). - toolCalls = append(toolCalls, MCPToolCall{ - Timestamp: e.entry.Timestamp, - ServerName: e.entry.ServerID, - ToolName: params.Name, - Status: "unknown", - }) - continue - } - t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp) - if err != nil { - continue - } - key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.req.ID) - pending[key] = &pendingCall{ - serverID: e.entry.ServerID, - toolName: params.Name, - timestamp: t, - } - } - - // Second pass: pair responses with pending requests to compute durations - - for i := range entries { - e := &entries[i] - switch { - case e.entry.Direction == "OUT" && e.entry.Type == "REQUEST": - // Outgoing tool-call request – we'll emit the record when we see the response - // (or after if no response found) - case e.entry.Direction == "IN" && e.entry.Type == "RESPONSE": - if err := json.Unmarshal(e.entry.Payload, &e.resp); err != nil { - continue - } - if e.resp.ID == nil { - continue - } - key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.resp.ID) - p, ok := pending[key] - if !ok { - continue - } - processedKeys[key] = true - - call := MCPToolCall{ - Timestamp: p.timestamp.Format(time.RFC3339Nano), - ServerName: p.serverID, - ToolName: p.toolName, - Status: "success", - } - if e.resp.Error != nil { - call.Status = "error" - call.Error = e.resp.Error.Message - } - if t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp); err == nil { - d := t.Sub(p.timestamp) - if d >= 0 { - call.Duration = timeutil.FormatDuration(d) - } - } - toolCalls = append(toolCalls, call) - } - } - - // Emit any requests that never received a response - for key, p := range pending { - if !processedKeys[key] { - toolCalls = append(toolCalls, MCPToolCall{ - Timestamp: p.timestamp.Format(time.RFC3339Nano), - ServerName: p.serverID, - ToolName: p.toolName, - Status: "unknown", - }) - } - } - - return toolCalls, nil -} - -// extractMCPToolUsageData creates detailed MCP tool usage data from gateway metrics -func extractMCPToolUsageData(logDir string, verbose bool) (*MCPToolUsageData, error) { - // Parse gateway logs (falls back to rpc-messages.jsonl automatically) - gatewayMetrics, err := parseGatewayLogs(logDir, verbose) - if err != nil { - // Return nil if no log file exists (not an error for workflows without MCP) - if strings.Contains(err.Error(), "not found") { - return nil, nil - } - return nil, fmt.Errorf("failed to parse gateway logs: %w", err) - } - - if gatewayMetrics == nil || len(gatewayMetrics.Servers) == 0 { - return nil, nil - } - - mcpData := &MCPToolUsageData{ - Summary: []MCPToolSummary{}, - ToolCalls: []MCPToolCall{}, - Servers: []MCPServerStats{}, - FilteredEvents: gatewayMetrics.FilteredEvents, - } - - // Build guard policy summary if there are guard policy events - if len(gatewayMetrics.GuardPolicyEvents) > 0 { - mcpData.GuardPolicySummary = buildGuardPolicySummary(gatewayMetrics) - } - - // Read the log file again to get individual tool call records. - // Prefer gateway.jsonl; fall back to rpc-messages.jsonl when not available. - gatewayLogPath := filepath.Join(logDir, "gateway.jsonl") - usingRPCMessages := false - - if _, err := os.Stat(gatewayLogPath); os.IsNotExist(err) { - mcpLogsPath := filepath.Join(logDir, "mcp-logs", "gateway.jsonl") - if _, err := os.Stat(mcpLogsPath); os.IsNotExist(err) { - // Fall back to rpc-messages.jsonl - rpcPath := findRPCMessagesPath(logDir) - if rpcPath == "" { - return nil, errors.New("gateway.jsonl not found") - } - gatewayLogPath = rpcPath - usingRPCMessages = true - } else { - gatewayLogPath = mcpLogsPath - } - } - - if usingRPCMessages { - // Build tool call records from rpc-messages.jsonl - toolCalls, err := buildToolCallsFromRPCMessages(gatewayLogPath) - if err != nil { - return nil, fmt.Errorf("failed to read rpc-messages.jsonl: %w", err) - } - mcpData.ToolCalls = toolCalls - } else { - file, err := os.Open(gatewayLogPath) - if err != nil { - return nil, fmt.Errorf("failed to open gateway.jsonl: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - buf := make([]byte, maxScannerBufferSize) - scanner.Buffer(buf, maxScannerBufferSize) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - var entry GatewayLogEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil { - continue // Skip malformed lines - } - - // Only process tool call events - if entry.Event == "tool_call" || entry.Event == "rpc_call" || entry.Event == "request" { - toolName := entry.ToolName - if toolName == "" { - toolName = entry.Method - } - - // Skip entries without tool information - if entry.ServerName == "" || toolName == "" { - continue - } - - // Create individual tool call record - toolCall := MCPToolCall{ - Timestamp: entry.Timestamp, - ServerName: entry.ServerName, - ToolName: toolName, - Method: entry.Method, - InputSize: entry.InputSize, - OutputSize: entry.OutputSize, - Status: entry.Status, - Error: entry.Error, - } - - if entry.Duration > 0 { - toolCall.Duration = timeutil.FormatDuration(time.Duration(entry.Duration * float64(time.Millisecond))) - } - - mcpData.ToolCalls = append(mcpData.ToolCalls, toolCall) - } - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading gateway.jsonl: %w", err) - } - } - - // Build summary statistics from aggregated metrics - for serverName, serverMetrics := range gatewayMetrics.Servers { - // Server-level stats - serverStats := MCPServerStats{ - ServerName: serverName, - RequestCount: serverMetrics.RequestCount, - ToolCallCount: serverMetrics.ToolCallCount, - TotalInputSize: 0, - TotalOutputSize: 0, - ErrorCount: serverMetrics.ErrorCount, - } - - if serverMetrics.RequestCount > 0 { - avgDur := serverMetrics.TotalDuration / float64(serverMetrics.RequestCount) - serverStats.AvgDuration = timeutil.FormatDuration(time.Duration(avgDur * float64(time.Millisecond))) - } - - // Tool-level stats - for toolName, toolMetrics := range serverMetrics.Tools { - summary := MCPToolSummary{ - ServerName: serverName, - ToolName: toolName, - CallCount: toolMetrics.CallCount, - TotalInputSize: toolMetrics.TotalInputSize, - TotalOutputSize: toolMetrics.TotalOutputSize, - MaxInputSize: 0, // Will be calculated below - MaxOutputSize: 0, // Will be calculated below - ErrorCount: toolMetrics.ErrorCount, - } - - if toolMetrics.AvgDuration > 0 { - summary.AvgDuration = timeutil.FormatDuration(time.Duration(toolMetrics.AvgDuration * float64(time.Millisecond))) - } - if toolMetrics.MaxDuration > 0 { - summary.MaxDuration = timeutil.FormatDuration(time.Duration(toolMetrics.MaxDuration * float64(time.Millisecond))) - } - - // Calculate max input/output sizes from individual tool calls - for _, tc := range mcpData.ToolCalls { - if tc.ServerName == serverName && tc.ToolName == toolName { - if tc.InputSize > summary.MaxInputSize { - summary.MaxInputSize = tc.InputSize - } - if tc.OutputSize > summary.MaxOutputSize { - summary.MaxOutputSize = tc.OutputSize - } - } - } - - mcpData.Summary = append(mcpData.Summary, summary) - - // Update server totals - serverStats.TotalInputSize += toolMetrics.TotalInputSize - serverStats.TotalOutputSize += toolMetrics.TotalOutputSize - } - - mcpData.Servers = append(mcpData.Servers, serverStats) - } - - // Sort summaries by server name, then tool name - sort.Slice(mcpData.Summary, func(i, j int) bool { - if mcpData.Summary[i].ServerName != mcpData.Summary[j].ServerName { - return mcpData.Summary[i].ServerName < mcpData.Summary[j].ServerName - } - return mcpData.Summary[i].ToolName < mcpData.Summary[j].ToolName - }) - - // Sort servers by name - sort.Slice(mcpData.Servers, func(i, j int) bool { - return mcpData.Servers[i].ServerName < mcpData.Servers[j].ServerName - }) - - return mcpData, nil -} - -// buildGuardPolicySummary creates a GuardPolicySummary from GatewayMetrics. -func buildGuardPolicySummary(metrics *GatewayMetrics) *GuardPolicySummary { - summary := &GuardPolicySummary{ - TotalBlocked: metrics.TotalGuardBlocked, - Events: metrics.GuardPolicyEvents, - BlockedToolCounts: make(map[string]int), - BlockedServerCounts: make(map[string]int), - } - - for _, evt := range metrics.GuardPolicyEvents { - // Categorize by error code - switch evt.ErrorCode { - case guardPolicyErrorCodeIntegrityBelowMin: - summary.IntegrityBlocked++ - case guardPolicyErrorCodeRepoNotAllowed: - summary.RepoScopeBlocked++ - case guardPolicyErrorCodeAccessDenied: - summary.AccessDenied++ - case guardPolicyErrorCodeBlockedUser: - summary.BlockedUserDenied++ - case guardPolicyErrorCodeInsufficientPerms: - summary.PermissionDenied++ - case guardPolicyErrorCodePrivateRepoDenied: - summary.PrivateRepoDenied++ - } - - // Track per-tool blocked counts - if evt.ToolName != "" { - summary.BlockedToolCounts[evt.ToolName]++ - } - - // Track per-server blocked counts - if evt.ServerID != "" { - summary.BlockedServerCounts[evt.ServerID]++ - } - } - - return summary -} diff --git a/pkg/cli/gateway_logs_aggregation.go b/pkg/cli/gateway_logs_aggregation.go new file mode 100644 index 00000000000..0ad9a8a1d71 --- /dev/null +++ b/pkg/cli/gateway_logs_aggregation.go @@ -0,0 +1,54 @@ +// This file contains aggregation functions for MCP gateway log analysis. + +package cli + +// calculateGatewayAggregates calculates aggregate statistics +func calculateGatewayAggregates(metrics *GatewayMetrics) { + for _, server := range metrics.Servers { + for _, tool := range server.Tools { + if tool.CallCount > 0 { + tool.AvgDuration = tool.TotalDuration / float64(tool.CallCount) + } + } + } +} + +// buildGuardPolicySummary creates a GuardPolicySummary from GatewayMetrics. +func buildGuardPolicySummary(metrics *GatewayMetrics) *GuardPolicySummary { + summary := &GuardPolicySummary{ + TotalBlocked: metrics.TotalGuardBlocked, + Events: metrics.GuardPolicyEvents, + BlockedToolCounts: make(map[string]int), + BlockedServerCounts: make(map[string]int), + } + + for _, evt := range metrics.GuardPolicyEvents { + // Categorize by error code + switch evt.ErrorCode { + case guardPolicyErrorCodeIntegrityBelowMin: + summary.IntegrityBlocked++ + case guardPolicyErrorCodeRepoNotAllowed: + summary.RepoScopeBlocked++ + case guardPolicyErrorCodeAccessDenied: + summary.AccessDenied++ + case guardPolicyErrorCodeBlockedUser: + summary.BlockedUserDenied++ + case guardPolicyErrorCodeInsufficientPerms: + summary.PermissionDenied++ + case guardPolicyErrorCodePrivateRepoDenied: + summary.PrivateRepoDenied++ + } + + // Track per-tool blocked counts + if evt.ToolName != "" { + summary.BlockedToolCounts[evt.ToolName]++ + } + + // Track per-server blocked counts + if evt.ServerID != "" { + summary.BlockedServerCounts[evt.ServerID]++ + } + } + + return summary +} diff --git a/pkg/cli/gateway_logs_mcp.go b/pkg/cli/gateway_logs_mcp.go new file mode 100644 index 00000000000..a51dccdf019 --- /dev/null +++ b/pkg/cli/gateway_logs_mcp.go @@ -0,0 +1,219 @@ +// This file contains the extractMCPToolUsageData function for MCP gateway log analysis. +// It orchestrates gateway/rpc-messages log parsing to produce MCPToolUsageData. + +package cli + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/github/gh-aw/pkg/timeutil" +) + +// extractMCPToolUsageData creates detailed MCP tool usage data from gateway metrics +func extractMCPToolUsageData(logDir string, verbose bool) (*MCPToolUsageData, error) { + // Parse gateway logs (falls back to rpc-messages.jsonl automatically) + gatewayMetrics, err := parseGatewayLogs(logDir, verbose) + if err != nil { + // Return nil if no log file exists (not an error for workflows without MCP) + if strings.Contains(err.Error(), "not found") { + return nil, nil + } + return nil, fmt.Errorf("failed to parse gateway logs: %w", err) + } + + if gatewayMetrics == nil || len(gatewayMetrics.Servers) == 0 { + return nil, nil + } + + mcpData := &MCPToolUsageData{ + Summary: []MCPToolSummary{}, + ToolCalls: []MCPToolCall{}, + Servers: []MCPServerStats{}, + FilteredEvents: gatewayMetrics.FilteredEvents, + } + + // Build guard policy summary if there are guard policy events + if len(gatewayMetrics.GuardPolicyEvents) > 0 { + mcpData.GuardPolicySummary = buildGuardPolicySummary(gatewayMetrics) + } + + // Read the log file again to get individual tool call records. + // Prefer gateway.jsonl; fall back to rpc-messages.jsonl when not available. + gatewayLogPath := filepath.Join(logDir, "gateway.jsonl") + usingRPCMessages := false + + if _, err := os.Stat(gatewayLogPath); os.IsNotExist(err) { + mcpLogsPath := filepath.Join(logDir, "mcp-logs", "gateway.jsonl") + if _, err := os.Stat(mcpLogsPath); os.IsNotExist(err) { + // Fall back to rpc-messages.jsonl + rpcPath := findRPCMessagesPath(logDir) + if rpcPath == "" { + return nil, errors.New("gateway.jsonl not found") + } + gatewayLogPath = rpcPath + usingRPCMessages = true + } else { + gatewayLogPath = mcpLogsPath + } + } + + if usingRPCMessages { + // Build tool call records from rpc-messages.jsonl + toolCalls, err := buildToolCallsFromRPCMessages(gatewayLogPath) + if err != nil { + return nil, fmt.Errorf("failed to read rpc-messages.jsonl: %w", err) + } + mcpData.ToolCalls = toolCalls + } else { + if err := extractToolCallsFromGatewayLog(gatewayLogPath, mcpData); err != nil { + return nil, err + } + } + + // Build summary statistics from aggregated metrics + buildMCPSummaryStats(gatewayMetrics, mcpData) + + return mcpData, nil +} + +// extractToolCallsFromGatewayLog reads gateway.jsonl and appends tool call records to mcpData. +func extractToolCallsFromGatewayLog(gatewayLogPath string, mcpData *MCPToolUsageData) error { + file, err := os.Open(gatewayLogPath) + if err != nil { + return fmt.Errorf("failed to open gateway.jsonl: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + buf := make([]byte, maxScannerBufferSize) + scanner.Buffer(buf, maxScannerBufferSize) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var entry GatewayLogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue // Skip malformed lines + } + + // Only process tool call events + if entry.Event == "tool_call" || entry.Event == "rpc_call" || entry.Event == "request" { + toolName := entry.ToolName + if toolName == "" { + toolName = entry.Method + } + + // Skip entries without tool information + if entry.ServerName == "" || toolName == "" { + continue + } + + // Create individual tool call record + toolCall := MCPToolCall{ + Timestamp: entry.Timestamp, + ServerName: entry.ServerName, + ToolName: toolName, + Method: entry.Method, + InputSize: entry.InputSize, + OutputSize: entry.OutputSize, + Status: entry.Status, + Error: entry.Error, + } + + if entry.Duration > 0 { + toolCall.Duration = timeutil.FormatDuration(time.Duration(entry.Duration * float64(time.Millisecond))) + } + + mcpData.ToolCalls = append(mcpData.ToolCalls, toolCall) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading gateway.jsonl: %w", err) + } + return nil +} + +// buildMCPSummaryStats populates mcpData.Summary and mcpData.Servers from aggregated gateway metrics. +func buildMCPSummaryStats(gatewayMetrics *GatewayMetrics, mcpData *MCPToolUsageData) { + for serverName, serverMetrics := range gatewayMetrics.Servers { + // Server-level stats + serverStats := MCPServerStats{ + ServerName: serverName, + RequestCount: serverMetrics.RequestCount, + ToolCallCount: serverMetrics.ToolCallCount, + TotalInputSize: 0, + TotalOutputSize: 0, + ErrorCount: serverMetrics.ErrorCount, + } + + if serverMetrics.RequestCount > 0 { + avgDur := serverMetrics.TotalDuration / float64(serverMetrics.RequestCount) + serverStats.AvgDuration = timeutil.FormatDuration(time.Duration(avgDur * float64(time.Millisecond))) + } + + // Tool-level stats + for toolName, toolMetrics := range serverMetrics.Tools { + summary := MCPToolSummary{ + ServerName: serverName, + ToolName: toolName, + CallCount: toolMetrics.CallCount, + TotalInputSize: toolMetrics.TotalInputSize, + TotalOutputSize: toolMetrics.TotalOutputSize, + MaxInputSize: 0, // Will be calculated below + MaxOutputSize: 0, // Will be calculated below + ErrorCount: toolMetrics.ErrorCount, + } + + if toolMetrics.AvgDuration > 0 { + summary.AvgDuration = timeutil.FormatDuration(time.Duration(toolMetrics.AvgDuration * float64(time.Millisecond))) + } + if toolMetrics.MaxDuration > 0 { + summary.MaxDuration = timeutil.FormatDuration(time.Duration(toolMetrics.MaxDuration * float64(time.Millisecond))) + } + + // Calculate max input/output sizes from individual tool calls + for _, tc := range mcpData.ToolCalls { + if tc.ServerName == serverName && tc.ToolName == toolName { + if tc.InputSize > summary.MaxInputSize { + summary.MaxInputSize = tc.InputSize + } + if tc.OutputSize > summary.MaxOutputSize { + summary.MaxOutputSize = tc.OutputSize + } + } + } + + mcpData.Summary = append(mcpData.Summary, summary) + + // Update server totals + serverStats.TotalInputSize += toolMetrics.TotalInputSize + serverStats.TotalOutputSize += toolMetrics.TotalOutputSize + } + + mcpData.Servers = append(mcpData.Servers, serverStats) + } + + // Sort summaries by server name, then tool name + sort.Slice(mcpData.Summary, func(i, j int) bool { + if mcpData.Summary[i].ServerName != mcpData.Summary[j].ServerName { + return mcpData.Summary[i].ServerName < mcpData.Summary[j].ServerName + } + return mcpData.Summary[i].ToolName < mcpData.Summary[j].ToolName + }) + + // Sort servers by name + sort.Slice(mcpData.Servers, func(i, j int) bool { + return mcpData.Servers[i].ServerName < mcpData.Servers[j].ServerName + }) +} diff --git a/pkg/cli/gateway_logs_parsing.go b/pkg/cli/gateway_logs_parsing.go new file mode 100644 index 00000000000..a95ffa824b9 --- /dev/null +++ b/pkg/cli/gateway_logs_parsing.go @@ -0,0 +1,224 @@ +// This file contains gateway.jsonl parsing functions for MCP gateway log analysis. + +package cli + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/github/gh-aw/pkg/console" +) + +// parseGatewayLogs parses a gateway.jsonl file and extracts metrics. +// Falls back to rpc-messages.jsonl (canonical fallback) when gateway.jsonl is not present. +func parseGatewayLogs(logDir string, verbose bool) (*GatewayMetrics, error) { + // Try root directory first (for older logs where gateway.jsonl was in the root) + gatewayLogPath := filepath.Join(logDir, "gateway.jsonl") + + // Check if gateway.jsonl exists in root + if _, err := os.Stat(gatewayLogPath); os.IsNotExist(err) { + // Try mcp-logs subdirectory (new path after artifact download) + // Gateway logs are uploaded from /tmp/gh-aw/mcp-logs/gateway.jsonl and the common parent + // /tmp/gh-aw/ is stripped during artifact upload, resulting in mcp-logs/gateway.jsonl after download + mcpLogsPath := filepath.Join(logDir, "mcp-logs", "gateway.jsonl") + if _, err := os.Stat(mcpLogsPath); os.IsNotExist(err) { + // Fall back to rpc-messages.jsonl (canonical fallback when gateway.jsonl is missing) + rpcPath := findRPCMessagesPath(logDir) + if rpcPath != "" { + gatewayLogsLog.Printf("gateway.jsonl not found; falling back to rpc-messages.jsonl: %s", rpcPath) + return parseRPCMessages(rpcPath, verbose) + } + gatewayLogsLog.Printf("gateway.jsonl not found at: %s or %s", gatewayLogPath, mcpLogsPath) + return nil, errors.New("gateway.jsonl not found") + } + gatewayLogPath = mcpLogsPath + gatewayLogsLog.Printf("Found gateway.jsonl in mcp-logs subdirectory") + } + + gatewayLogsLog.Printf("Parsing gateway.jsonl from: %s", gatewayLogPath) + + file, err := os.Open(gatewayLogPath) + if err != nil { + return nil, fmt.Errorf("failed to open gateway.jsonl: %w", err) + } + defer file.Close() + + metrics := &GatewayMetrics{ + Servers: make(map[string]*GatewayServerMetrics), + } + + scanner := bufio.NewScanner(file) + buf := make([]byte, maxScannerBufferSize) + scanner.Buffer(buf, maxScannerBufferSize) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines + if line == "" { + continue + } + + var entry GatewayLogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + gatewayLogsLog.Printf("Failed to parse line %d: %v", lineNum, err) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse gateway.jsonl line %d: %v", lineNum, err))) + } + continue + } + + // Process the entry based on its type/event + processGatewayLogEntry(&entry, metrics, verbose) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading gateway.jsonl: %w", err) + } + + // Calculate aggregate statistics + calculateGatewayAggregates(metrics) + + gatewayLogsLog.Printf("Successfully parsed gateway.jsonl: %d servers, %d total requests", + len(metrics.Servers), metrics.TotalRequests) + + return metrics, nil +} + +// processGatewayLogEntry processes a single log entry and updates metrics +func processGatewayLogEntry(entry *GatewayLogEntry, metrics *GatewayMetrics, verbose bool) { + // Parse timestamp for time range (supports both RFC3339 and RFC3339Nano) + if entry.Timestamp != "" { + t, err := time.Parse(time.RFC3339Nano, entry.Timestamp) + if err != nil { + t, err = time.Parse(time.RFC3339, entry.Timestamp) + } + if err == nil { + if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { + metrics.StartTime = t + } + if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { + metrics.EndTime = t + } + } + } + + // Handle DIFC_FILTERED events + if entry.Type == "DIFC_FILTERED" { + metrics.TotalFiltered++ + // DIFC_FILTERED events use server_id; fall back to server_name for compatibility + serverKey := entry.ServerID + if serverKey == "" { + serverKey = entry.ServerName + } + if serverKey != "" { + server := getOrCreateServer(metrics, serverKey) + server.FilteredCount++ + } + metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ + Timestamp: entry.Timestamp, + ServerID: serverKey, + ToolName: entry.ToolName, + Description: entry.Description, + Reason: entry.Reason, + SecrecyTags: entry.SecrecyTags, + IntegrityTags: entry.IntegrityTags, + AuthorAssociation: entry.AuthorAssociation, + AuthorLogin: entry.AuthorLogin, + HTMLURL: entry.HTMLURL, + Number: entry.Number, + }) + return + } + + // Handle GUARD_POLICY_BLOCKED events from gateway.jsonl + if entry.Type == "GUARD_POLICY_BLOCKED" { + metrics.TotalGuardBlocked++ + serverKey := entry.ServerID + if serverKey == "" { + serverKey = entry.ServerName + } + if serverKey != "" { + server := getOrCreateServer(metrics, serverKey) + server.GuardPolicyBlocked++ + } + metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, GuardPolicyEvent{ + Timestamp: entry.Timestamp, + ServerID: serverKey, + ToolName: entry.ToolName, + Reason: entry.Reason, + Message: entry.Message, + Details: entry.Description, + }) + return + } + + // Track errors + if entry.Status == "error" || entry.Error != "" { + metrics.TotalErrors++ + if entry.ServerName != "" { + server := getOrCreateServer(metrics, entry.ServerName) + server.ErrorCount++ + + if entry.ToolName != "" { + tool := getOrCreateTool(server, entry.ToolName) + tool.ErrorCount++ + } + } + } + + // Process based on event type + switch entry.Event { + case "request", "tool_call", "rpc_call": + metrics.TotalRequests++ + + if entry.ServerName != "" { + server := getOrCreateServer(metrics, entry.ServerName) + server.RequestCount++ + + if entry.Duration > 0 { + server.TotalDuration += entry.Duration + metrics.TotalDuration += entry.Duration + } + + // Track tool calls + if entry.ToolName != "" || entry.Method != "" { + toolName := entry.ToolName + if toolName == "" { + toolName = entry.Method + } + + metrics.TotalToolCalls++ + server.ToolCallCount++ + + tool := getOrCreateTool(server, toolName) + tool.CallCount++ + + if entry.Duration > 0 { + tool.TotalDuration += entry.Duration + if tool.MaxDuration == 0 || entry.Duration > tool.MaxDuration { + tool.MaxDuration = entry.Duration + } + if tool.MinDuration == 0 || entry.Duration < tool.MinDuration { + tool.MinDuration = entry.Duration + } + } + + if entry.InputSize > 0 { + tool.TotalInputSize += entry.InputSize + } + if entry.OutputSize > 0 { + tool.TotalOutputSize += entry.OutputSize + } + } + } + } +} diff --git a/pkg/cli/gateway_logs_rpc.go b/pkg/cli/gateway_logs_rpc.go new file mode 100644 index 00000000000..d0f47c73034 --- /dev/null +++ b/pkg/cli/gateway_logs_rpc.go @@ -0,0 +1,381 @@ +// This file contains RPC message parsing functions for MCP gateway log analysis. +// It handles rpc-messages.jsonl (canonical fallback when gateway.jsonl is absent). + +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/timeutil" +) + +// parseRPCMessages parses a rpc-messages.jsonl file and extracts GatewayMetrics. +// This is the canonical fallback when gateway.jsonl is not available. +func parseRPCMessages(logPath string, verbose bool) (*GatewayMetrics, error) { + gatewayLogsLog.Printf("Parsing rpc-messages.jsonl from: %s", logPath) + + file, err := os.Open(logPath) + if err != nil { + return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) + } + defer file.Close() + + metrics := &GatewayMetrics{ + Servers: make(map[string]*GatewayServerMetrics), + } + + // Track pending requests by (serverID, id) for duration calculation. + // Key format: "/" + pendingRequests := make(map[string]*rpcPendingRequest) + + scanner := bufio.NewScanner(file) + // Increase scanner buffer for large payloads + buf := make([]byte, maxScannerBufferSize) + scanner.Buffer(buf, maxScannerBufferSize) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var entry RPCMessageEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + gatewayLogsLog.Printf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err) + if verbose { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage( + fmt.Sprintf("Failed to parse rpc-messages.jsonl line %d: %v", lineNum, err))) + } + continue + } + + // Update time range + if entry.Timestamp != "" { + if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { + if metrics.StartTime.IsZero() || t.Before(metrics.StartTime) { + metrics.StartTime = t + } + if metrics.EndTime.IsZero() || t.After(metrics.EndTime) { + metrics.EndTime = t + } + } + } + + if entry.ServerID == "" { + continue + } + + switch { + case entry.Type == "DIFC_FILTERED": + // DIFC integrity/secrecy filter event — not a REQUEST or RESPONSE + metrics.TotalFiltered++ + server := getOrCreateServer(metrics, entry.ServerID) + server.FilteredCount++ + metrics.FilteredEvents = append(metrics.FilteredEvents, DifcFilteredEvent{ + Timestamp: entry.Timestamp, + ServerID: entry.ServerID, + ToolName: entry.ToolName, + Description: entry.Description, + Reason: entry.Reason, + SecrecyTags: entry.SecrecyTags, + IntegrityTags: entry.IntegrityTags, + AuthorAssociation: entry.AuthorAssociation, + AuthorLogin: entry.AuthorLogin, + HTMLURL: entry.HTMLURL, + Number: entry.Number, + }) + + case entry.Direction == "OUT" && entry.Type == "REQUEST": + // Outgoing request from AI engine to MCP server + var req rpcRequestPayload + if err := json.Unmarshal(entry.Payload, &req); err != nil { + continue + } + if req.Method != "tools/call" { + continue + } + + // Extract tool name + var params rpcToolCallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.Name == "" { + continue + } + + metrics.TotalRequests++ + server := getOrCreateServer(metrics, entry.ServerID) + server.RequestCount++ + metrics.TotalToolCalls++ + server.ToolCallCount++ + + tool := getOrCreateTool(server, params.Name) + tool.CallCount++ + + // Store pending request for duration calculation + if req.ID != nil && entry.Timestamp != "" { + if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { + key := fmt.Sprintf("%s/%v", entry.ServerID, req.ID) + pendingRequests[key] = &rpcPendingRequest{ + ServerID: entry.ServerID, + ToolName: params.Name, + Timestamp: t, + } + } + } + + case entry.Direction == "IN" && entry.Type == "RESPONSE": + // Incoming response from MCP server to AI engine + var resp rpcResponsePayload + if err := json.Unmarshal(entry.Payload, &resp); err != nil { + continue + } + + // Track errors and detect guard policy blocks + if resp.Error != nil { + metrics.TotalErrors++ + server := getOrCreateServer(metrics, entry.ServerID) + server.ErrorCount++ + + // Detect guard policy enforcement errors + if isGuardPolicyErrorCode(resp.Error.Code) { + metrics.TotalGuardBlocked++ + server.GuardPolicyBlocked++ + + // Determine tool name from pending request if available + toolName := "" + if resp.ID != nil { + key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) + if pending, ok := pendingRequests[key]; ok { + toolName = pending.ToolName + } + } + + reason := guardPolicyReasonFromCode(resp.Error.Code) + if resp.Error.Data != nil && resp.Error.Data.Reason != "" { + reason = resp.Error.Data.Reason + } + + evt := GuardPolicyEvent{ + Timestamp: entry.Timestamp, + ServerID: entry.ServerID, + ToolName: toolName, + ErrorCode: resp.Error.Code, + Reason: reason, + Message: resp.Error.Message, + } + if resp.Error.Data != nil { + evt.Details = resp.Error.Data.Details + evt.Repository = resp.Error.Data.Repository + } + metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, evt) + } + } + + // Calculate duration by matching with pending request + if resp.ID != nil && entry.Timestamp != "" { + key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) + if pending, ok := pendingRequests[key]; ok { + delete(pendingRequests, key) + if t, err := time.Parse(time.RFC3339Nano, entry.Timestamp); err == nil { + durationMs := float64(t.Sub(pending.Timestamp).Milliseconds()) + if durationMs >= 0 { + server := getOrCreateServer(metrics, entry.ServerID) + server.TotalDuration += durationMs + metrics.TotalDuration += durationMs + + tool := getOrCreateTool(server, pending.ToolName) + tool.TotalDuration += durationMs + if tool.MaxDuration == 0 || durationMs > tool.MaxDuration { + tool.MaxDuration = durationMs + } + if tool.MinDuration == 0 || durationMs < tool.MinDuration { + tool.MinDuration = durationMs + } + + if resp.Error != nil { + tool.ErrorCount++ + } + } + } + } + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) + } + + calculateGatewayAggregates(metrics) + + gatewayLogsLog.Printf("Successfully parsed rpc-messages.jsonl: %d servers, %d total requests", + len(metrics.Servers), metrics.TotalRequests) + + return metrics, nil +} + +// findRPCMessagesPath returns the path to rpc-messages.jsonl if it exists, or "" if not found. +func findRPCMessagesPath(logDir string) string { + // Check mcp-logs subdirectory (standard location) + mcpLogsPath := filepath.Join(logDir, "mcp-logs", "rpc-messages.jsonl") + if _, err := os.Stat(mcpLogsPath); err == nil { + return mcpLogsPath + } + // Check root directory as fallback + rootPath := filepath.Join(logDir, "rpc-messages.jsonl") + if _, err := os.Stat(rootPath); err == nil { + return rootPath + } + return "" +} + +// buildToolCallsFromRPCMessages reads rpc-messages.jsonl and builds MCPToolCall records. +// Duration is computed by pairing outgoing requests with incoming responses. +// Input/output sizes are not available in rpc-messages.jsonl and will be 0. +func buildToolCallsFromRPCMessages(logPath string) ([]MCPToolCall, error) { + file, err := os.Open(logPath) + if err != nil { + return nil, fmt.Errorf("failed to open rpc-messages.jsonl: %w", err) + } + defer file.Close() + + type pendingCall struct { + serverID string + toolName string + timestamp time.Time + } + pending := make(map[string]*pendingCall) // key: "/" + + // Collect requests first to pair with responses + type rawEntry struct { + entry RPCMessageEntry + req rpcRequestPayload + resp rpcResponsePayload + valid bool + } + var entries []rawEntry + + scanner := bufio.NewScanner(file) + buf := make([]byte, maxScannerBufferSize) + scanner.Buffer(buf, maxScannerBufferSize) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var e RPCMessageEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + continue + } + entries = append(entries, rawEntry{entry: e, valid: true}) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading rpc-messages.jsonl: %w", err) + } + + // Second pass: build MCPToolCall records. + // Declared before first pass so requests without IDs can be appended immediately. + var toolCalls []MCPToolCall + processedKeys := make(map[string]bool) + + // First pass: index outgoing tool-call requests by (serverID, id) + for i := range entries { + e := &entries[i] + if e.entry.Direction != "OUT" || e.entry.Type != "REQUEST" { + continue + } + if err := json.Unmarshal(e.entry.Payload, &e.req); err != nil || e.req.Method != "tools/call" { + continue + } + var params rpcToolCallParams + if err := json.Unmarshal(e.req.Params, ¶ms); err != nil || params.Name == "" { + continue + } + if e.req.ID == nil { + // Requests without an ID cannot be matched to responses. + // Emit the tool call immediately with "unknown" status so it appears + // in the tool_calls list (same as parseRPCMessages counts it in the summary). + toolCalls = append(toolCalls, MCPToolCall{ + Timestamp: e.entry.Timestamp, + ServerName: e.entry.ServerID, + ToolName: params.Name, + Status: "unknown", + }) + continue + } + t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp) + if err != nil { + continue + } + key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.req.ID) + pending[key] = &pendingCall{ + serverID: e.entry.ServerID, + toolName: params.Name, + timestamp: t, + } + } + + // Second pass: pair responses with pending requests to compute durations + for i := range entries { + e := &entries[i] + switch { + case e.entry.Direction == "OUT" && e.entry.Type == "REQUEST": + // Outgoing tool-call request – we'll emit the record when we see the response + // (or after if no response found) + case e.entry.Direction == "IN" && e.entry.Type == "RESPONSE": + if err := json.Unmarshal(e.entry.Payload, &e.resp); err != nil { + continue + } + if e.resp.ID == nil { + continue + } + key := fmt.Sprintf("%s/%v", e.entry.ServerID, e.resp.ID) + p, ok := pending[key] + if !ok { + continue + } + processedKeys[key] = true + + call := MCPToolCall{ + Timestamp: p.timestamp.Format(time.RFC3339Nano), + ServerName: p.serverID, + ToolName: p.toolName, + Status: "success", + } + if e.resp.Error != nil { + call.Status = "error" + call.Error = e.resp.Error.Message + } + if t, err := time.Parse(time.RFC3339Nano, e.entry.Timestamp); err == nil { + d := t.Sub(p.timestamp) + if d >= 0 { + call.Duration = timeutil.FormatDuration(d) + } + } + toolCalls = append(toolCalls, call) + } + } + + // Emit any requests that never received a response + for key, p := range pending { + if !processedKeys[key] { + toolCalls = append(toolCalls, MCPToolCall{ + Timestamp: p.timestamp.Format(time.RFC3339Nano), + ServerName: p.serverID, + ToolName: p.toolName, + Status: "unknown", + }) + } + } + + return toolCalls, nil +} diff --git a/pkg/cli/gateway_logs_types.go b/pkg/cli/gateway_logs_types.go new file mode 100644 index 00000000000..9320955986e --- /dev/null +++ b/pkg/cli/gateway_logs_types.go @@ -0,0 +1,236 @@ +// This file contains type definitions and constants for MCP gateway log parsing. + +package cli + +import ( + "encoding/json" + "time" + + "github.com/github/gh-aw/pkg/logger" +) + +var gatewayLogsLog = logger.New("cli:gateway_logs") + +// maxScannerBufferSize is the maximum scanner buffer for large JSONL payloads (1 MB). +const maxScannerBufferSize = 1024 * 1024 + +// GatewayLogEntry represents a single log entry from gateway.jsonl +type GatewayLogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + Type string `json:"type"` + Event string `json:"event"` + ServerName string `json:"server_name,omitempty"` + ServerID string `json:"server_id,omitempty"` // used by DIFC_FILTERED events + ToolName string `json:"tool_name,omitempty"` + Method string `json:"method,omitempty"` + Duration float64 `json:"duration,omitempty"` // in milliseconds + InputSize int `json:"input_size,omitempty"` + OutputSize int `json:"output_size,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Description string `json:"description,omitempty"` + Reason string `json:"reason,omitempty"` + SecrecyTags []string `json:"secrecy_tags,omitempty"` + IntegrityTags []string `json:"integrity_tags,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + AuthorLogin string `json:"author_login,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Number string `json:"number,omitempty"` +} + +// DifcFilteredEvent represents a DIFC_FILTERED log entry from gateway.jsonl. +// These events occur when a tool call is blocked by DIFC integrity or secrecy checks. +type DifcFilteredEvent struct { + Timestamp string `json:"timestamp"` + ServerID string `json:"server_id"` + ToolName string `json:"tool_name"` + Description string `json:"description,omitempty"` + Reason string `json:"reason"` + SecrecyTags []string `json:"secrecy_tags,omitempty"` + IntegrityTags []string `json:"integrity_tags,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + AuthorLogin string `json:"author_login,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Number string `json:"number,omitempty"` +} + +// Guard policy error codes from MCP Gateway. +// These JSON-RPC error codes indicate guard policy enforcement decisions. +const ( + guardPolicyErrorCodeAccessDenied = -32001 // General access denied + guardPolicyErrorCodeRepoNotAllowed = -32002 // Repository not in allowlist (repos) + guardPolicyErrorCodeInsufficientPerms = -32003 // Insufficient permissions (roles) + guardPolicyErrorCodePrivateRepoDenied = -32004 // Private repository access denied + guardPolicyErrorCodeBlockedUser = -32005 // Content from blocked user + guardPolicyErrorCodeIntegrityBelowMin = -32006 // Content integrity below minimum threshold (min-integrity) +) + +// GuardPolicyEvent represents a guard policy enforcement decision from the MCP Gateway. +// These events are extracted from JSON-RPC error responses with specific error codes +// (-32001 to -32006) in rpc-messages.jsonl. +type GuardPolicyEvent struct { + Timestamp string `json:"timestamp"` + ServerID string `json:"server_id"` + ToolName string `json:"tool_name"` + ErrorCode int `json:"error_code"` + Reason string `json:"reason"` // e.g., "repository_not_allowed", "min_integrity" + Message string `json:"message"` // Error message from JSON-RPC response + Details string `json:"details,omitempty"` // Additional details from error data + Repository string `json:"repository,omitempty"` // Repository involved (for repo scope blocks) +} + +// GatewayServerMetrics represents usage metrics for a single MCP server +type GatewayServerMetrics struct { + ServerName string + RequestCount int + ToolCallCount int + TotalDuration float64 // in milliseconds + ErrorCount int + FilteredCount int // number of DIFC_FILTERED events for this server + GuardPolicyBlocked int // number of tool calls blocked by guard policies for this server + Tools map[string]*GatewayToolMetrics +} + +// GatewayToolMetrics represents usage metrics for a specific tool +type GatewayToolMetrics struct { + ToolName string + CallCount int + TotalDuration float64 // in milliseconds + AvgDuration float64 // in milliseconds + MaxDuration float64 // in milliseconds + MinDuration float64 // in milliseconds + ErrorCount int + TotalInputSize int + TotalOutputSize int +} + +// GatewayMetrics represents aggregated metrics from gateway logs +type GatewayMetrics struct { + TotalRequests int + TotalToolCalls int + TotalErrors int + TotalFiltered int // number of DIFC_FILTERED events + TotalGuardBlocked int // number of tool calls blocked by guard policies + Servers map[string]*GatewayServerMetrics + FilteredEvents []DifcFilteredEvent + GuardPolicyEvents []GuardPolicyEvent + StartTime time.Time + EndTime time.Time + TotalDuration float64 // in milliseconds +} + +// RPCMessageEntry represents a single entry from rpc-messages.jsonl. +// This file is written by the Copilot CLI and contains raw JSON-RPC protocol messages +// exchanged between the AI engine and MCP servers, as well as DIFC_FILTERED events. +type RPCMessageEntry struct { + Timestamp string `json:"timestamp"` + Direction string `json:"direction"` // "IN" = received from server, "OUT" = sent to server; empty for DIFC_FILTERED + Type string `json:"type"` // "REQUEST", "RESPONSE", or "DIFC_FILTERED" + ServerID string `json:"server_id"` + Payload json.RawMessage `json:"payload"` + // Fields populated only for DIFC_FILTERED entries + ToolName string `json:"tool_name,omitempty"` + Description string `json:"description,omitempty"` + Reason string `json:"reason,omitempty"` + SecrecyTags []string `json:"secrecy_tags,omitempty"` + IntegrityTags []string `json:"integrity_tags,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + AuthorLogin string `json:"author_login,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Number string `json:"number,omitempty"` +} + +// rpcRequestPayload represents the JSON-RPC request payload fields we care about. +type rpcRequestPayload struct { + Method string `json:"method"` + ID any `json:"id"` + Params json.RawMessage `json:"params"` +} + +// rpcToolCallParams represents the params for a tools/call request. +type rpcToolCallParams struct { + Name string `json:"name"` +} + +// rpcResponsePayload represents the JSON-RPC response payload fields we care about. +type rpcResponsePayload struct { + ID any `json:"id"` + Error *rpcError `json:"error,omitempty"` +} + +// rpcError represents a JSON-RPC error object. +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data *rpcErrorData `json:"data,omitempty"` +} + +// rpcErrorData represents the optional data field in a JSON-RPC error, used by +// guard policy enforcement to communicate the reason and context for a denial. +type rpcErrorData struct { + Reason string `json:"reason,omitempty"` + Repository string `json:"repository,omitempty"` + Details string `json:"details,omitempty"` +} + +// rpcPendingRequest tracks an in-flight tool call for duration calculation. +type rpcPendingRequest struct { + ServerID string + ToolName string + Timestamp time.Time +} + +// isGuardPolicyErrorCode returns true if the JSON-RPC error code indicates a +// guard policy enforcement decision. +func isGuardPolicyErrorCode(code int) bool { + return code >= guardPolicyErrorCodeIntegrityBelowMin && code <= guardPolicyErrorCodeAccessDenied +} + +// guardPolicyReasonFromCode returns a human-readable reason string for a guard policy error code. +func guardPolicyReasonFromCode(code int) string { + switch code { + case guardPolicyErrorCodeAccessDenied: + return "access_denied" + case guardPolicyErrorCodeRepoNotAllowed: + return "repo_not_allowed" + case guardPolicyErrorCodeInsufficientPerms: + return "insufficient_permissions" + case guardPolicyErrorCodePrivateRepoDenied: + return "private_repo_denied" + case guardPolicyErrorCodeBlockedUser: + return "blocked_user" + case guardPolicyErrorCodeIntegrityBelowMin: + return "integrity_below_minimum" + default: + return "unknown" + } +} + +// getOrCreateServer gets or creates a server metrics entry +func getOrCreateServer(metrics *GatewayMetrics, serverName string) *GatewayServerMetrics { + if server, exists := metrics.Servers[serverName]; exists { + return server + } + + server := &GatewayServerMetrics{ + ServerName: serverName, + Tools: make(map[string]*GatewayToolMetrics), + } + metrics.Servers[serverName] = server + return server +} + +// getOrCreateTool gets or creates a tool metrics entry +func getOrCreateTool(server *GatewayServerMetrics, toolName string) *GatewayToolMetrics { + if tool, exists := server.Tools[toolName]; exists { + return tool + } + + tool := &GatewayToolMetrics{ + ToolName: toolName, + } + server.Tools[toolName] = tool + return tool +}