diff --git a/.env.example b/.env.example index bdfda40..14af120 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,11 @@ HEALTH_CHECK_TIMEOUT_SECONDS=2 METRICS_ENABLED=true # Gateway Prometheus endpoint path (default: /metrics) METRICS_PATH=/metrics +# Log output format. Set to "json" for one structured JSON line per request +# (status, latency, correlation_id, cache/payment status, sanitized +# internal_error) suitable for log collectors. Any other value (default) keeps +# the human-readable gin/text logs. +# LOG_FORMAT=json diff --git a/README.md b/README.md index eed1f7b..91f9245 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ Core local variables live in [.env.example](.env.example). Production placeholde | `METRICS_PATH` | Gateway | Gateway metrics path. Default `/metrics`; values without a leading slash are normalized. | | `ALLOWED_ORIGINS` | Gateway | Comma-separated CORS origins, no paths or query strings. | | `TRUSTED_PROXIES` | Gateway | Comma-separated trusted proxy CIDRs for production IP handling. | +| `LOG_FORMAT` | Gateway | Set to `json` for one structured JSON log line per request (status, latency, correlation_id, cache/payment status, sanitized internal_error). Any other value (default) keeps human-readable text logs. | | `NEXT_PUBLIC_GATEWAY_URL` | Web | Gateway base URL. Browser fetches `/api/ai/summarize` and `/api/receipts/:id` here. | | `NEXT_PUBLIC_EXPECTED_CHAIN_ID` | Web | Chain ID the wallet widget targets. Must match the gateway's `CHAIN_ID`. Default `84532`. | | `NEXT_PUBLIC_EXPECTED_CHAIN_NAME` | Web | Display name paired with the chain ID — used by the wallet widget's "Switch to " button, hero headline, stat bar, and page title. Must be set when `CHAIN_ID` is overridden (e.g. `Base` for `8453`). | diff --git a/gateway/README.md b/gateway/README.md index 47ad390..2c74264 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -105,6 +105,7 @@ Common optional variables: | `CACHE_TTL_SECONDS` | `3600` | Response cache TTL. | | `METRICS_ENABLED` | enabled unless set to `false` | Exposes Prometheus metrics. | | `METRICS_PATH` | `/metrics` | Gateway metrics endpoint path. Missing leading slash is normalized. | +| `LOG_FORMAT` | text | Set to `json` for one structured JSON log line per request (status, latency, correlation_id, cache/payment status, sanitized internal_error). Any other value keeps human-readable text logs. | | `RATE_LIMIT_ENABLED` | disabled unless set to `true` or `1` | Enables token bucket middleware. | | `REQUEST_TIMEOUT_SECONDS` | `60` | Global request timeout. | | `AI_REQUEST_TIMEOUT_SECONDS` | `30` | AI route timeout. | diff --git a/gateway/cache.go b/gateway/cache.go index 031d172..70da4de 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -17,6 +17,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) // CachedResponse represents the data stored in Redis @@ -87,7 +88,9 @@ func CacheMiddleware() gin.HandlerFunc { var req SummarizeRequest if err := json.Unmarshal(requestBody, &req); err != nil { // Invalid JSON - reject immediately to prevent cache bypass attacks - log.Printf("[DEBUG] Invalid JSON in request: %v", err) + if !jsonLogging { + log.Printf("[DEBUG] Invalid JSON in request: %v", err) + } c.JSON(400, gin.H{"error": "Invalid request body", "message": "Request must be valid JSON"}) c.Abort() return @@ -114,8 +117,12 @@ func CacheMiddleware() gin.HandlerFunc { cacheKey := getCacheKey(req.Text, model) // Check Cache - if cached, err := getFromCache(c.Request.Context(), cacheKey); err == nil { - log.Printf("Cache HIT: %s", cacheKey) + cached, cacheErr := getFromCache(c.Request.Context(), cacheKey) + if cacheErr == nil { + if !jsonLogging { + log.Printf("Cache HIT: %s", cacheKey) + } + c.Set("cache_status", "hit") routePath := c.FullPath() if routePath == "" { @@ -141,7 +148,9 @@ func CacheMiddleware() gin.HandlerFunc { if err != nil { verificationTotal.WithLabelValues("error").Inc() - log.Printf("Verification error on cache hit: %v", err) + if !jsonLogging { + log.Printf("Verification error on cache hit: %v", sanitizeErrorString(err.Error())) + } if errors.Is(err, context.DeadlineExceeded) { respondError(c, 504, "verifier_timeout", err) @@ -168,6 +177,10 @@ func CacheMiddleware() gin.HandlerFunc { // Payment Verified. Store verification for downstream if needed (though we abort) c.Set("payment_verification", verifyResp) c.Set("payment_context", paymentCtx) + // Mirror handleSummarize so cache-hit responses carry complete payment + // telemetry; the structured logger only sees what we set on the context. + c.Set("payment_status", "success") + c.Set("payer", verifyResp.RecoveredAddress) // Generate Receipt and Respond // We treat the cached result as the AI result @@ -175,15 +188,30 @@ func CacheMiddleware() gin.HandlerFunc { // Note: request_hash matches current request, response is from cache, // but both are cryptographically valid since cache key ensures identical text. if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, cached.Result); err != nil { - log.Printf("Failed to send cached response receipt: %v", err) + if !jsonLogging { + log.Printf("Failed to send cached response receipt: %v", sanitizeErrorString(err.Error())) + } // generateAndSendReceipt already sent an error response (500) } c.Abort() return } - // Cache MISS - log.Printf("Cache MISS: %s", cacheKey) + // Cache MISS (or a backend failure we treat as a bypass). Distinguish a + // genuine absent key (redis.Nil) from connection errors / corrupt cached + // data so telemetry doesn't mask Redis outages as ordinary misses. + cacheStatus := "miss" + if !errors.Is(cacheErr, redis.Nil) { + cacheStatus = "error" + } + if !jsonLogging { + if cacheStatus == "error" { + log.Printf("Cache lookup error (treating as bypass): %s: %v", cacheKey, sanitizeErrorString(cacheErr.Error())) + } else { + log.Printf("Cache MISS: %s", cacheKey) + } + } + c.Set("cache_status", cacheStatus) routePath := c.FullPath() if routePath == "" { diff --git a/gateway/errors.go b/gateway/errors.go index 95044a8..0113ef0 100644 --- a/gateway/errors.go +++ b/gateway/errors.go @@ -4,20 +4,55 @@ import ( "fmt" "log" "net/http" + "regexp" "strings" "github.com/gin-gonic/gin" ) +var ( + // Match an optional 0x prefix so real private keys (which are usually + // 0x-prefixed) are caught — a leading \b fails between "x" and the first + // hex digit because both are word characters. The trailing \b avoids + // chopping into longer hex runs. + hex64Regex = regexp.MustCompile(`(?:0x)?[0-9a-fA-F]{64}\b`) + // Allow dashes so versioned keys like sk-or-v1- are redacted in + // full, not just the "sk-or-v1" prefix. + openRouterKeyRegex = regexp.MustCompile(`sk-or-[a-zA-Z0-9-]+`) +) + +func sanitizeErrorString(s string) string { + // Redact API keys before hex so a key whose secret happens to contain a + // 64-hex run collapses to a single [redacted_api_key] token. + s = openRouterKeyRegex.ReplaceAllString(s, "[redacted_api_key]") + s = hex64Regex.ReplaceAllString(s, "[redacted_hex_64]") + return s +} + func respondError(c *gin.Context, code int, publicMsg string, internalErr error) { - correlationID := responseCorrelationID(c) + // Only mark the payment failed if an earlier stage hasn't already recorded + // an outcome. handleSummarize sets payment_status="success" once the + // verifier accepts the signature; a later receipt-generation error must not + // overwrite that and misreport a paid request as a payment failure. + if _, ok := c.Get("payment_status"); !ok { + c.Set("payment_status", "failed") + } + c.Set("payment_error", publicMsg) + + var sanitizedErr string if internalErr != nil { + sanitizedErr = sanitizeErrorString(internalErr.Error()) + c.Set("internal_error", sanitizedErr) + } + + correlationID := responseCorrelationID(c) + if internalErr != nil && !jsonLogging { log.Printf( - "[ERROR] correlation_id=%s status=%d error=%s internal=%v", + "[ERROR] correlation_id=%s status=%d error=%s internal=%s", correlationID, code, publicMsg, - internalErr, + sanitizedErr, ) } diff --git a/gateway/main.go b/gateway/main.go index 953c94d..8e7647f 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -269,7 +269,23 @@ func main() { fmt.Println("[WARN] CHAIN_ID not set, using default: 84532(Base Sepolia)") } - r := gin.Default() + initLogFormat() + + var r *gin.Engine + if jsonLogging { + gin.SetMode(gin.ReleaseMode) + r = gin.New() + // Register the JSON logger BEFORE recovery so a downstream panic still + // unwinds back through the logger's post-c.Next() block and produces a + // structured entry for the recovered 500. Recovery uses a custom writer + // that redacts x402 payment headers (signature/nonce/timestamp), which + // the default recovery dump would otherwise leak in plaintext — a nonce + // may stay replayable until it is consumed. + r.Use(JSONLoggerMiddleware()) + r.Use(gin.RecoveryWithWriter(redactedRecoveryWriter())) + } else { + r = gin.Default() + } // Restrict trusted proxies to prevent X-Forwarded-For spoofing. // IP-based rate limiting relies on c.ClientIP(), which reads @@ -438,6 +454,9 @@ func handleSummarize(c *gin.Context) { // Basic check if signature == "" || nonce == "" { + // Distinct status so dashboards can separate the normal unsigned x402 + // challenge (the most frequent payment event) from unrelated 402s. + c.Set("payment_status", "required") c.JSON(402, gin.H{ "error": "Payment Required", "message": "Please sign the payment context", @@ -505,6 +524,9 @@ func handleSummarize(c *gin.Context) { return } + c.Set("payment_status", "success") + c.Set("payer", verifyResp.RecoveredAddress) + verificationTotal.WithLabelValues("success").Inc() // 2. Parse Request @@ -533,7 +555,12 @@ func handleSummarize(c *gin.Context) { // 4. Generate & Send Receipt if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, summary); err != nil { - log.Printf("Failed to generate receipt: %v", err) + // generateAndSendReceipt already routes its failures through respondError + // (which records a sanitized internal_error), so this is just a + // human-readable echo for text-mode logs. + if !jsonLogging { + log.Printf("Failed to generate receipt: %v", err) + } // generateAndSendReceipt sends error response if it fails? // No, it returns error, we might have already written status if we aren't careful. // Let's implement generateAndSendReceipt to handle sending response. @@ -941,7 +968,14 @@ func handleGetReceipt(c *gin.Context) { receipt, exists, err := getReceiptWithContext(c.Request.Context(), id) if err != nil { - log.Printf("Failed to retrieve receipt %s: %v", id, err) + if jsonLogging { + // The plaintext log is suppressed in JSON mode; record a sanitized + // internal_error on the context so the structured entry still + // explains the 500 (Redis / receipt-store outages stay diagnosable). + c.Set("internal_error", sanitizeErrorString(err.Error())) + } else { + log.Printf("Failed to retrieve receipt %s: %v", id, err) + } c.JSON(http.StatusInternalServerError, gin.H{ "error": "failed to retrieve receipt", }) diff --git a/gateway/main_test.go b/gateway/main_test.go index 686bbda..3a54326 100644 --- a/gateway/main_test.go +++ b/gateway/main_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -753,3 +754,207 @@ func TestHandleGetReceipt_ValidationAndLookupPaths(t *testing.T) { }) } } + +func TestJSONLoggerMiddleware(t *testing.T) { + // Capturing stdout + oldStdout := os.Stdout + rPipe, wPipe, _ := os.Pipe() + os.Stdout = wPipe + + defer func() { + os.Stdout = oldStdout + }() + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("correlation_id", "test-corr-123") + c.Set("payment_status", "success") + c.Set("payer", "0xPayerAddress") + c.Set("cache_status", "hit") + c.Next() + }) + r.Use(JSONLoggerMiddleware()) + + r.GET("/test-json-log", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + req, _ := http.NewRequest("GET", "/test-json-log", nil) + req.RemoteAddr = "127.0.0.1:12345" + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Close write pipe and read captured output + wPipe.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(rPipe) + output := buf.String() + + require.NotEmpty(t, output) + + // Parse JSON output + var logEntry JSONLogEntry + err := json.Unmarshal([]byte(output), &logEntry) + require.NoError(t, err) + + require.Equal(t, 200, logEntry.Status) + require.Equal(t, "/test-json-log", logEntry.Path) + require.Equal(t, "GET", logEntry.Method) + require.Equal(t, "test-corr-123", logEntry.CorrelationID) + require.Equal(t, "success", logEntry.PaymentStatus) + require.Equal(t, "0xPayerAddress", logEntry.Payer) + require.Equal(t, "hit", logEntry.CacheStatus) + require.Equal(t, "info", logEntry.Level) + require.NotEmpty(t, logEntry.Timestamp) + require.True(t, logEntry.LatencyMs >= 0) + + // Verify no sensitive fields (like private key or signature) are leaked + require.NotContains(t, output, "PRIVATE_KEY") + require.NotContains(t, output, "0x1234567890abcdef") // sample private key / sig values +} +func TestJSONLoggerMiddleware_Sanitization(t *testing.T) { + // Capturing stdout + oldStdout := os.Stdout + rPipe, wPipe, _ := os.Pipe() + os.Stdout = wPipe + + defer func() { + os.Stdout = oldStdout + }() + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("correlation_id", "test-corr-123") + c.Set("payment_status", "failed") + // Seed sensitive private key hex string in internal error + c.Set("internal_error", "failed to connect: openrouter key sk-or-1234567890abcdef1234567890abcdef and wallet key 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + c.Next() + }) + r.Use(JSONLoggerMiddleware()) + + r.GET("/test-json-log-sanitize", func(c *gin.Context) { + c.JSON(500, gin.H{"status": "error"}) + }) + + req, _ := http.NewRequest("GET", "/test-json-log-sanitize", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Close write pipe and read captured output + wPipe.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(rPipe) + output := buf.String() + + require.NotEmpty(t, output) + + // Parse JSON output + var logEntry JSONLogEntry + err := json.Unmarshal([]byte(output), &logEntry) + require.NoError(t, err) + + require.Equal(t, 500, logEntry.Status) + require.Equal(t, "test-corr-123", logEntry.CorrelationID) + require.Equal(t, "failed", logEntry.PaymentStatus) + require.Equal(t, "error", logEntry.Level) + + // Assert that sensitive fields are redacted in the log entry's internal error field + require.NotContains(t, logEntry.InternalError, "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + require.NotContains(t, logEntry.InternalError, "sk-or-1234567890abcdef1234567890abcdef") + require.Contains(t, logEntry.InternalError, "[redacted_hex_64]") + require.Contains(t, logEntry.InternalError, "[redacted_api_key]") +} + +func TestSanitizeErrorString_RealKeyFormats(t *testing.T) { + // 0x-prefixed private keys are the common real-world shape. The old + // \b-anchored regex failed to match these because there is no word + // boundary between "x" and the first hex digit. + hex := "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + withPrefix := sanitizeErrorString("signing failed with key 0x" + hex) + require.NotContains(t, withPrefix, hex) + require.Contains(t, withPrefix, "[redacted_hex_64]") + + bare := sanitizeErrorString("wallet key " + hex + " leaked") + require.NotContains(t, bare, hex) + require.Contains(t, bare, "[redacted_hex_64]") + + // Versioned OpenRouter keys contain dashes; the secret after sk-or-v1- + // must be fully redacted, not just the prefix. + orKey := "sk-or-v1-abcdef0123456789abcdef0123456789" + redactedKey := sanitizeErrorString("openrouter rejected: " + orKey) + require.NotContains(t, redactedKey, "abcdef0123456789abcdef0123456789") + require.NotContains(t, redactedKey, orKey) + require.Contains(t, redactedKey, "[redacted_api_key]") +} + +func TestRespondError_DoesNotOverwriteSuccessfulPayment(t *testing.T) { + gin.SetMode(gin.TestMode) + + // A receipt-stage failure after the payment was already verified must not + // flip payment_status from "success" to "failed". + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("GET", "/", nil) + c.Set("payment_status", "success") + + respondError(c, 500, "receipt_store_failed", errors.New("redis down")) + + require.Equal(t, "success", c.GetString("payment_status")) + require.Equal(t, "receipt_store_failed", c.GetString("payment_error")) +} + +func TestRespondError_MarksFailedWhenNoPriorStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("GET", "/", nil) + + respondError(c, 502, "verification_unavailable", errors.New("verifier offline")) + + require.Equal(t, "failed", c.GetString("payment_status")) + require.Equal(t, "verification_unavailable", c.GetString("payment_error")) +} + +func TestRedactingWriter_ScrubsX402Headers(t *testing.T) { + var buf bytes.Buffer + rw := redactingWriter{w: &buf} + + // Mimic gin's recovery request dump where header lines are indented. + dump := "GET /api/ai/summarize HTTP/1.1\r\n" + + "Host: example.com\r\n" + + "X-402-Signature: 0xdeadbeefsignature\r\n" + + "X-402-Nonce: nonce-abc-123\r\n" + + "X-402-Timestamp: 1700000000\r\n" + + "Content-Type: application/json\r\n" + + n, err := rw.Write([]byte(dump)) + require.NoError(t, err) + require.Equal(t, len(dump), n, "must report original byte count to gin") + + out := buf.String() + require.NotContains(t, out, "0xdeadbeefsignature") + require.NotContains(t, out, "nonce-abc-123") + require.NotContains(t, out, "1700000000") + require.Contains(t, out, "X-402-Signature: [redacted]") + require.Contains(t, out, "X-402-Nonce: [redacted]") + require.Contains(t, out, "X-402-Timestamp: [redacted]") + // Non-sensitive lines pass through untouched. + require.Contains(t, out, "Content-Type: application/json") + require.Contains(t, out, "Host: example.com") +} + +func TestHandleSummarize_402SetsRequiredPaymentStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("POST", "/api/ai/summarize", nil) + + handleSummarize(c) + + require.Equal(t, 402, w.Code) + require.Equal(t, "required", c.GetString("payment_status")) +} diff --git a/gateway/middleware.go b/gateway/middleware.go index 652bc33..3050e8e 100644 --- a/gateway/middleware.go +++ b/gateway/middleware.go @@ -4,11 +4,15 @@ import ( "bufio" "bytes" "context" + "encoding/json" "fmt" + "io" "log" "net" "net/http" + "os" "strconv" + "strings" "sync" "time" @@ -35,6 +39,17 @@ func safeCorrelationID(id string) string { return id } +// jsonLogging is set once at startup from LOG_FORMAT and read on the request +// hot paths instead of calling os.Getenv repeatedly. Centralizing it here +// keeps the "json" comparison in one place so suppression stays consistent. +var jsonLogging bool + +// initLogFormat resolves the LOG_FORMAT env var once. Call early in main() +// before the router and handlers start serving traffic. +func initLogFormat() { + jsonLogging = os.Getenv("LOG_FORMAT") == "json" +} + // CorrelationIDMiddleware checks for an existing X-Correlation-ID header // or generates a new one, ensuring requests can be traced across services. func CorrelationIDMiddleware() gin.HandlerFunc { @@ -49,7 +64,9 @@ func CorrelationIDMiddleware() gin.HandlerFunc { c.Request = c.Request.WithContext(ctx) c.Header("X-Correlation-ID", id) - log.Printf("[CorrelationID: %s] %s %s", id, c.Request.Method, c.Request.URL.Path) + if !jsonLogging { + log.Printf("[CorrelationID: %s] %s %s", id, c.Request.Method, c.Request.URL.Path) + } c.Next() } } @@ -316,3 +333,100 @@ func recordRequestMetrics(method, path string, statusCode int, start time.Time) requestsTotal.WithLabelValues(method, path, status).Inc() requestsDuration.WithLabelValues(method, path).Observe(time.Since(start).Seconds()) } + +type JSONLogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + Status int `json:"status"` + Path string `json:"path"` + Method string `json:"method"` + LatencyMs float64 `json:"latency_ms"` + ClientIP string `json:"client_ip"` + CorrelationID string `json:"correlation_id"` + CacheStatus string `json:"cache_status,omitempty"` + PaymentStatus string `json:"payment_status,omitempty"` + PaymentError string `json:"payment_error,omitempty"` + Payer string `json:"payer,omitempty"` + InternalError string `json:"internal_error,omitempty"` +} + +func JSONLoggerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + latency := time.Since(start) + + status := c.Writer.Status() + level := "info" + if status >= 500 { + level = "error" + } else if status >= 400 { + level = "warn" + } + + correlationID, _ := c.Get("correlation_id") + corrStr, _ := correlationID.(string) + + entry := JSONLogEntry{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Level: level, + Status: status, + Path: c.Request.URL.Path, + Method: c.Request.Method, + LatencyMs: float64(latency.Nanoseconds()) / 1e6, + ClientIP: c.ClientIP(), + CorrelationID: corrStr, + CacheStatus: c.GetString("cache_status"), + PaymentStatus: c.GetString("payment_status"), + PaymentError: c.GetString("payment_error"), + Payer: c.GetString("payer"), + InternalError: sanitizeErrorString(c.GetString("internal_error")), + } + + if data, err := json.Marshal(entry); err == nil { + fmt.Println(string(data)) + } + } +} + +// x402SensitiveHeaders are payment headers that must never reach the panic +// recovery dump in plaintext. A leaked signature can stay replayable until its +// nonce is consumed by the verifier. +var x402SensitiveHeaders = []string{ + "x-402-signature", + "x-402-nonce", + "x-402-timestamp", +} + +// redactingWriter wraps an io.Writer and strips the values of x402 payment +// headers out of anything written through it. gin's recovery dump only scrubs +// Authorization, so we filter the rest here. +type redactingWriter struct { + w io.Writer +} + +func (rw redactingWriter) Write(p []byte) (int, error) { + lines := strings.Split(string(p), "\n") + for i, line := range lines { + lower := strings.ToLower(strings.TrimLeft(line, " \t")) + for _, h := range x402SensitiveHeaders { + if strings.HasPrefix(lower, h+":") { + if idx := strings.Index(line, ":"); idx >= 0 { + lines[i] = line[:idx+1] + " [redacted]" + } + break + } + } + } + if _, err := io.WriteString(rw.w, strings.Join(lines, "\n")); err != nil { + return 0, err + } + // Report the original length so gin sees a complete write. + return len(p), nil +} + +// redactedRecoveryWriter returns the writer gin.RecoveryWithWriter uses for its +// panic dump, with x402 payment headers redacted. +func redactedRecoveryWriter() io.Writer { + return redactingWriter{w: os.Stderr} +}