package shadow import ( "bytes" "context" "encoding/json" "fmt" "hash/fnv" "io" "log/slog" "net/http" "net/url" "sync/atomic" "aigateway.local/core/internal/gateway" "aigateway.local/core/internal/platform/config" ) type Metrics struct{ eligible, sent, matched, mismatched, errors, skipped atomic.Uint64 } type Middleware struct { config config.Shadow logger *slog.Logger client *http.Client limit chan struct{} metrics Metrics } func New(cfg config.Shadow, logger *slog.Logger) *Middleware { return &Middleware{config: cfg, logger: logger, client: &http.Client{Timeout: cfg.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return fmt.Errorf("shadow redirects are disabled") }}, limit: make(chan struct{}, cfg.MaxConcurrent)} } func (m *Middleware) Wrap(next http.Handler) http.Handler { if m == nil || m.config.BaseURL == "" || m.config.SampleRate <= 0 { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !m.sample(gateway.RequestID(r.Context())) { next.ServeHTTP(w, r) return } body, eligible := m.captureRequest(r) if !eligible { next.ServeHTTP(w, r) return } m.metrics.eligible.Add(1) select { case m.limit <- struct{}{}: default: m.metrics.skipped.Add(1) next.ServeHTTP(w, r) return } completed := false defer func() { if !completed { // 主 handler 抛 panic(如代理中途客户端断开引发的 // http.ErrAbortHandler)时,shadow 比较 goroutine 从未启动, // 必须在此归还并发槽位,否则连续 N 次断开后 shadow 流量 // 被永久静默关闭。 <-m.limit } }() capture := &captureWriter{ResponseWriter: w, limit: m.config.MaxBodyBytes} next.ServeHTTP(capture, r) completed = true primaryStatus := capture.Status() primaryBody := append([]byte(nil), capture.body...) headers := shadowHeaders(r.Header) path := r.URL.RequestURI() go func() { defer func() { <-m.limit }() m.compare(context.WithoutCancel(r.Context()), path, headers, body, primaryStatus, primaryBody) }() }) } func (m *Middleware) captureRequest(r *http.Request) ([]byte, bool) { if r.Method != http.MethodPost || r.Body == nil || r.Body == http.NoBody { return nil, false } original := r.Body raw, err := io.ReadAll(io.LimitReader(original, m.config.MaxBodyBytes+1)) if err != nil || int64(len(raw)) > m.config.MaxBodyBytes { r.Body = &multiReadCloser{Reader: io.MultiReader(bytes.NewReader(raw), original), Closer: original} return nil, false } _ = original.Close() r.Body = io.NopCloser(bytes.NewReader(raw)) r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(raw)), nil } var envelope struct { Stream bool `json:"stream"` } if json.Unmarshal(raw, &envelope) != nil || envelope.Stream { return nil, false } return raw, true } type multiReadCloser struct { io.Reader io.Closer } func (m *Middleware) sample(requestID string) bool { hash := fnv.New64a() _, _ = hash.Write([]byte(requestID)) return float64(hash.Sum64()%1_000_000)/1_000_000 < m.config.SampleRate } func (m *Middleware) compare(ctx context.Context, path string, headers http.Header, body []byte, primaryStatus int, primaryBody []byte) { ctx, cancel := context.WithTimeout(ctx, m.config.Timeout) defer cancel() target, err := url.Parse(m.config.BaseURL + path) if err != nil { m.fail("invalid shadow URL", err) return } request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body)) if err != nil { m.fail("create shadow request", err) return } request.Header = headers request.Header.Set("Authorization", "Bearer "+m.config.APIKey) request.Header.Del("X-Gateway-API-Key") request.Header.Set("X-Gateway-Shadow", "true") response, err := m.client.Do(request) if err != nil { m.fail("shadow request failed", err) return } defer response.Body.Close() raw, err := io.ReadAll(io.LimitReader(response.Body, m.config.MaxBodyBytes+1)) if err != nil || int64(len(raw)) > m.config.MaxBodyBytes { m.fail("shadow response invalid", err) return } m.metrics.sent.Add(1) primarySignature := contractSignature(primaryStatus, primaryBody) shadowSignature := contractSignature(response.StatusCode, raw) if primarySignature == shadowSignature { m.metrics.matched.Add(1) return } m.metrics.mismatched.Add(1) if m.logger != nil { m.logger.Warn("shadow contract mismatch", "path", path, "primary_status", primaryStatus, "shadow_status", response.StatusCode, "primary_signature", primarySignature, "shadow_signature", shadowSignature) } } func (m *Middleware) fail(message string, err error) { m.metrics.errors.Add(1) if m.logger != nil { m.logger.Warn(message, "error", err) } } func contractSignature(status int, raw []byte) string { var value any if json.Unmarshal(raw, &value) != nil { return fmt.Sprintf("%d:non-json", status) } shape, _ := json.Marshal(jsonShape(value)) return fmt.Sprintf("%d:%s", status, shape) } func jsonShape(value any) any { switch item := value.(type) { case map[string]any: result := make(map[string]any, len(item)) for key, child := range item { switch key { case "id", "created", "created_at", "system_fingerprint": continue } result[key] = jsonShape(child) } return result case []any: if len(item) == 0 { return []any{} } return []any{jsonShape(item[0])} case string: return "string" case float64: return "number" case bool: return "boolean" case nil: return nil default: return fmt.Sprintf("%T", item) } } func shadowHeaders(source http.Header) http.Header { result := make(http.Header) for _, key := range []string{"Content-Type", "Accept", "Idempotency-Key", "X-Gateway-Provider"} { if value := source.Values(key); len(value) > 0 { result[key] = append([]string(nil), value...) } } return result } func (m *Middleware) Prometheus() string { if m == nil { return "" } return fmt.Sprintf("gateway_shadow_eligible_total %d\ngateway_shadow_sent_total %d\ngateway_shadow_contract_matches_total %d\ngateway_shadow_contract_mismatches_total %d\ngateway_shadow_errors_total %d\ngateway_shadow_skipped_total %d\n", m.metrics.eligible.Load(), m.metrics.sent.Load(), m.metrics.matched.Load(), m.metrics.mismatched.Load(), m.metrics.errors.Load(), m.metrics.skipped.Load()) } type captureWriter struct { http.ResponseWriter status int body []byte limit int64 } func (w *captureWriter) WriteHeader(status int) { if w.status == 0 { w.status = status } w.ResponseWriter.WriteHeader(status) } func (w *captureWriter) Write(value []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } if int64(len(w.body)) < w.limit { remaining := int(w.limit - int64(len(w.body))) w.body = append(w.body, value[:min(len(value), remaining)]...) } return w.ResponseWriter.Write(value) } func (w *captureWriter) Status() int { if w.status == 0 { return http.StatusOK } return w.status } func (w *captureWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } func (w *captureWriter) Flush() { if flusher, ok := w.ResponseWriter.(http.Flusher); ok { flusher.Flush() } }