package service import ( "context" "database/sql" "encoding/json" "log/slog" "net/http" "strings" "sync/atomic" "time" ) // GatewayDebugLogEntry holds all fields for a single debug log row. type GatewayDebugLogEntry struct { UpstreamRequestID string AccountID int64 AccountEmail string AccountPlatform string EventType string // "api_call", "oauth_refresh", "error" Method string FullURL string RequestHeaders map[string]string RequestBody []byte // raw bytes, stored as TEXT RequestSize int ResponseStatus int ResponseHeaders map[string]string ResponseBodyPreview string ResponseSize int ModelRequested string ModelUpstream string IsStream bool DurationMs int TLSProfile string ErrorMessage string } // GatewayDebugLogger writes debug log entries to gateway_debug_logs. type GatewayDebugLogger struct { db *sql.DB enabled atomic.Bool } // NewGatewayDebugLogger creates a new debug logger (enabled by default). func NewGatewayDebugLogger(db *sql.DB) *GatewayDebugLogger { l := &GatewayDebugLogger{db: db} l.enabled.Store(true) return l } func (l *GatewayDebugLogger) IsEnabled() bool { return l != nil && l.enabled.Load() } // DB returns the underlying database handle (for admin queries). func (l *GatewayDebugLogger) DB() *sql.DB { if l == nil { return nil } return l.db } func (l *GatewayDebugLogger) Enable() { if l != nil { l.enabled.Store(true) slog.Info("gateway debug logging ENABLED") } } func (l *GatewayDebugLogger) Disable() { if l != nil { l.enabled.Store(false) slog.Info("gateway debug logging DISABLED") } } const insertDebugLogSQL = ` INSERT INTO gateway_debug_logs ( upstream_request_id, account_id, account_email, account_platform, event_type, method, full_url, request_headers, request_body, request_size, response_status, response_headers, response_body_preview, response_size, model_requested, model_upstream, is_stream, duration_ms, tls_profile, error_message ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 )` // Log writes a debug log entry asynchronously (fire-and-forget). func (l *GatewayDebugLogger) Log(entry GatewayDebugLogEntry) { if !l.IsEnabled() { return } go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _, err := l.db.ExecContext(ctx, insertDebugLogSQL, nullStr(entry.UpstreamRequestID), entry.AccountID, nullStr(entry.AccountEmail), nullStr(entry.AccountPlatform), coalesce(entry.EventType, "api_call"), nullStr(entry.Method), nullStr(entry.FullURL), mapToString(entry.RequestHeaders), bytesToString(entry.RequestBody), entry.RequestSize, entry.ResponseStatus, mapToString(entry.ResponseHeaders), nullStr(entry.ResponseBodyPreview), entry.ResponseSize, nullStr(entry.ModelRequested), nullStr(entry.ModelUpstream), entry.IsStream, entry.DurationMs, nullStr(entry.TLSProfile), nullStr(entry.ErrorMessage), ) if err != nil { slog.Warn("gateway debug log write failed", "error", err) } }() } // LogUpstreamRequest captures request+response from a gateway forward call. func (l *GatewayDebugLogger) LogUpstreamRequest( account *Account, upstreamReq *http.Request, upstreamBody []byte, resp *http.Response, responsePreview string, responseSize int, originalModel string, upstreamModel string, isStream bool, duration time.Duration, tlsProfile string, errMsg string, ) { if !l.IsEnabled() { return } entry := GatewayDebugLogEntry{ AccountID: account.ID, AccountEmail: account.Name, AccountPlatform: account.Platform, EventType: "api_call", Method: upstreamReq.Method, FullURL: upstreamReq.URL.String(), RequestHeaders: extractHeaders(upstreamReq.Header), RequestBody: upstreamBody, RequestSize: len(upstreamBody), ModelRequested: originalModel, ModelUpstream: upstreamModel, IsStream: isStream, DurationMs: int(duration.Milliseconds()), TLSProfile: tlsProfile, ErrorMessage: errMsg, } if resp != nil { entry.UpstreamRequestID = resp.Header.Get("x-request-id") entry.ResponseStatus = resp.StatusCode entry.ResponseHeaders = extractHeaders(resp.Header) entry.ResponseBodyPreview = debugTruncate(responsePreview, 4096) entry.ResponseSize = responseSize } l.Log(entry) } // LogOAuthRefresh logs an OAuth token refresh event. func (l *GatewayDebugLogger) LogOAuthRefresh(accountID int64, accountEmail string, duration time.Duration, errMsg string) { if !l.IsEnabled() { return } l.Log(GatewayDebugLogEntry{ AccountID: accountID, AccountEmail: accountEmail, EventType: "oauth_refresh", DurationMs: int(duration.Milliseconds()), ErrorMessage: errMsg, }) } // --- helpers --- func extractHeaders(h http.Header) map[string]string { out := make(map[string]string, len(h)) for k, vals := range h { lower := strings.ToLower(k) if lower == "authorization" || lower == "x-api-key" { out[k] = "[REDACTED]" continue } out[k] = strings.Join(vals, ", ") } return out } func debugTruncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] } func nullStr(s string) interface{} { if s == "" { return nil } return s } // bytesToString converts raw bytes to string for TEXT column. No validation. func bytesToString(data []byte) interface{} { if len(data) == 0 { return nil } return string(data) } // mapToString serializes a map to JSON string for TEXT column. func mapToString(m map[string]string) interface{} { if len(m) == 0 { return nil } data, err := json.Marshal(m) if err != nil { return nil } return string(data) } func coalesce(s, fallback string) string { if s == "" { return fallback } return s }