- Add explicit viper.BindEnv() for all gateway.node_tls_proxy.* keys to fix viper's AutomaticEnv+Unmarshal nested struct bug where env vars are silently ignored when config.yaml lacks the corresponding section - Sync proxy.js CLI_VERSION 2.1.84→2.1.87 and BUILD_TIME to match constants.go, eliminating API/telemetry version mismatch
256 lines
5.8 KiB
Go
256 lines
5.8 KiB
Go
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
|
|
}
|