sub2api/backend/internal/service/gateway_debug_logger.go
win d3d885cf75
Some checks failed
CI / test (push) Failing after 6s
CI / golangci-lint (push) Failing after 5s
Security Scan / backend-security (push) Failing after 5s
Security Scan / frontend-security (push) Failing after 7s
fix: node-tls-proxy not receiving traffic due to viper BindEnv bug
- 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
2026-03-31 13:16:02 +08:00

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
}