debug: structured tool/HTTP logging, request IDs, config dump
check-and-test / check-and-test (pull_request) Has been cancelled
check-and-test / check-and-test (pull_request) Has been cancelled
Closes #13. Today GITEA_DEBUG=true only flips the zap level to debug, dumps a contextless `Text Result:` blob per tool call, and enables the gitea SDK's own debug stream (which doesn't merge with our log file). That is not enough to diagnose a misbehaving tool call in production. This change introduces: - A `pkg/middleware.ToolLogging` ToolHandlerMiddleware that logs each tool invocation on entry and exit with structured fields: tool name, redacted args, request_id, token source, duration_ms, and status. The request_id is stashed in the context so downstream layers can correlate. - A `loggingRoundTripper` in `pkg/gitea` that wraps the shared HTTP transport and emits one structured line per upstream Gitea API call (method, token-stripped URL, status, duration_ms, bytes). This replaces — and is far more useful than — `gitea.SetDebugMode()`. - An effective-config dump at startup (`logEffectiveConfig`) when debug+config scope is on, so "did my env var get picked up?" becomes answerable from the log file. - Token source tracking. CLI flag / GITEA_ACCESS_TOKEN / GITEA_ACCESS_TOKEN_FILE / per-request Authorization header are now distinguished in logs as flag/env/env-file/header. - A baseline redactor in `pkg/debug` that masks args / query params whose keys look like secrets (token, password, secret, api_key, authorization, …). This is the placeholder for issue #5's shared redactor; once that lands, callers here should defer to it. - `GITEA_DEBUG_SCOPES` env var to scope debug output to any subset of `tools,http,config`. Default (unset) is "everything". Test coverage was added for the redactor, scope flag, request-id helpers, preview truncation (multibyte-safe), and the middleware's context wiring + error propagation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
||||
)
|
||||
|
||||
func TestRedactArgs(t *testing.T) {
|
||||
in := map[string]any{
|
||||
"owner": "octocat",
|
||||
"repo": "hello",
|
||||
"token": "ghp_secret",
|
||||
"password": "hunter2",
|
||||
"nested": map[string]any{
|
||||
"api_key": "abc",
|
||||
"safe": "value",
|
||||
"authHeader": "Bearer xyz",
|
||||
"client_id": "ok-to-log",
|
||||
"credentials": "shh",
|
||||
},
|
||||
"items": []any{
|
||||
map[string]any{"secret": "zzz", "ok": "yes"},
|
||||
},
|
||||
}
|
||||
out := RedactArgs(in).(map[string]any)
|
||||
if out["owner"] != "octocat" {
|
||||
t.Fatalf("owner should be unchanged, got %v", out["owner"])
|
||||
}
|
||||
if out["token"] != "[REDACTED]" {
|
||||
t.Fatalf("token should be redacted, got %v", out["token"])
|
||||
}
|
||||
if out["password"] != "[REDACTED]" {
|
||||
t.Fatalf("password should be redacted, got %v", out["password"])
|
||||
}
|
||||
nested := out["nested"].(map[string]any)
|
||||
if nested["api_key"] != "[REDACTED]" || nested["authHeader"] != "[REDACTED]" || nested["credentials"] != "[REDACTED]" {
|
||||
t.Fatalf("nested sensitive keys not redacted: %+v", nested)
|
||||
}
|
||||
if nested["safe"] != "value" {
|
||||
t.Fatalf("safe key should be unchanged: %+v", nested)
|
||||
}
|
||||
// client_id does not match any sensitive substring; "id" alone shouldn't trip.
|
||||
if nested["client_id"] != "ok-to-log" {
|
||||
t.Fatalf("client_id should be unchanged: %+v", nested)
|
||||
}
|
||||
item := out["items"].([]any)[0].(map[string]any)
|
||||
if item["secret"] != "[REDACTED]" {
|
||||
t.Fatalf("list item secret not redacted: %+v", item)
|
||||
}
|
||||
if item["ok"] != "yes" {
|
||||
t.Fatalf("safe list item key changed: %+v", item)
|
||||
}
|
||||
// Original input must not be mutated.
|
||||
if in["token"] != "ghp_secret" {
|
||||
t.Fatalf("RedactArgs mutated input: %+v", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactArgs_NonMap(t *testing.T) {
|
||||
for _, v := range []any{"plain", 42, nil, true} {
|
||||
if got := RedactArgs(v); got != v {
|
||||
t.Fatalf("non-map value should pass through unchanged: got %v want %v", got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripQueryTokens(t *testing.T) {
|
||||
in := "https://gitea.example.com/api/v1/repos/x/y?token=abc&page=1&access_token=def"
|
||||
got := StripQueryTokens(in)
|
||||
if strings.Contains(got, "abc") || strings.Contains(got, "def") {
|
||||
t.Fatalf("token value leaked through: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "page=1") {
|
||||
t.Fatalf("non-sensitive params should be preserved: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripQueryTokens_NoChange(t *testing.T) {
|
||||
in := "https://gitea.example.com/api/v1/repos/x/y?page=1"
|
||||
if got := StripQueryTokens(in); got != in {
|
||||
t.Fatalf("non-sensitive URL was modified: %s -> %s", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatePreview(t *testing.T) {
|
||||
long := strings.Repeat("a", MaxResultPreview+50)
|
||||
got := TruncatePreview(long)
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncated preview should end with ellipsis: %q", got[len(got)-10:])
|
||||
}
|
||||
if len(got) > MaxResultPreview+5 {
|
||||
t.Fatalf("preview not bounded: len=%d", len(got))
|
||||
}
|
||||
|
||||
short := "hi"
|
||||
if got := TruncatePreview(short); got != short {
|
||||
t.Fatalf("short string should be returned as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatePreview_MultibyteSafe(t *testing.T) {
|
||||
// Make a string with multi-byte runes long enough to trip naive byte-slicing.
|
||||
r := strings.Repeat("日", MaxResultPreview+10)
|
||||
got := TruncatePreview(r)
|
||||
// Must remain valid UTF-8: a naive byte slice on this would split a rune.
|
||||
for _, b := range got {
|
||||
_ = b // ranging validates that decoding doesn't panic
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDFromContext(t *testing.T) {
|
||||
if got := RequestIDFromContext(context.Background()); got != "" {
|
||||
t.Fatalf("absent request id should return \"\", got %q", got)
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), mcpContext.RequestIDContextKey, "abc123")
|
||||
if got := RequestIDFromContext(ctx); got != "abc123" {
|
||||
t.Fatalf("got %q, want abc123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequestID_UniqueAndHex(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
id := NewRequestID()
|
||||
if id == "" {
|
||||
t.Fatal("empty request id")
|
||||
}
|
||||
if seen[id] {
|
||||
t.Fatalf("duplicate request id: %s", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user