// Package bot — slog observability tests for the FastAPI client. // // Plan reference: plans/chatbot.stuff/fix.chat.bugs.1.md §2 (Issue #1). // Captures slog records from FastAPIClient.Chat via slog.SetDefault // swap, then asserts the documented level / message / attribute set // for the `bot.fastapi.attempt`, `bot.fastapi.exhausted`, and // `bot.fastapi.context_canceled` events. // // Tests in this file MUST NOT call t.Parallel: they swap the process // default slog logger for the duration of the test and restore it on // cleanup. The other tests in fastapi_test.go also do not run in // parallel, so the default-logger swap is safe within this package. package bot_test import ( "context" "encoding/json" "log/slog" "net/http" "strings" "sync" "testing" "time" "queryorchestration/internal/bot" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // recordingHandler is a slog.Handler that appends every record into an // in-memory slice. Used to assert which events the FastAPI retry loop // emits and what attributes they carry. type recordingHandler struct { mu sync.Mutex records []recordedEvent } // recordedEvent is one captured slog record. The handler flattens // attributes into a map[string]any so tests can read them by key. type recordedEvent struct { level slog.Level message string attrs map[string]any } func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error { attrs := make(map[string]any, r.NumAttrs()) r.Attrs(func(a slog.Attr) bool { attrs[a.Key] = a.Value.Any() return true }) h.mu.Lock() defer h.mu.Unlock() h.records = append(h.records, recordedEvent{ level: r.Level, message: r.Message, attrs: attrs, }) return nil } func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h } // snapshot returns a copy of the captured records for assertion. func (h *recordingHandler) snapshot() []recordedEvent { h.mu.Lock() defer h.mu.Unlock() out := make([]recordedEvent, len(h.records)) copy(out, h.records) return out } // installRecordingLogger replaces slog.Default with a recording handler // for the duration of the test. The previous default is restored via // t.Cleanup so other tests are unaffected. func installRecordingLogger(t *testing.T) *recordingHandler { t.Helper() h := &recordingHandler{} prev := slog.Default() slog.SetDefault(slog.New(h)) t.Cleanup(func() { slog.SetDefault(prev) }) return h } // eventsByMessage filters captured records by message name. func eventsByMessage(records []recordedEvent, message string) []recordedEvent { out := make([]recordedEvent, 0, len(records)) for _, r := range records { if r.message == message { out = append(out, r) } } return out } // TestFastAPIClient_SlogAttempt_Success: 200 success → exactly one // bot.fastapi.attempt event at DEBUG with outcome=success and attempt=1. // Plan §2.4 case 1. func TestFastAPIClient_SlogAttempt_Success(t *testing.T) { rec := installRecordingLogger(t) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt") require.Len(t, attempts, 1, "expected exactly one bot.fastapi.attempt event on a single success") ev := attempts[0] assert.Equal(t, slog.LevelDebug, ev.level, "plan §8 B2: success-path attempts log at DEBUG") assert.Equal(t, "success", ev.attrs["outcome"]) assert.EqualValues(t, 1, ev.attrs["attempt"]) assert.EqualValues(t, http.StatusOK, ev.attrs["status_code"]) assert.Equal(t, fastAPITestRequestID, ev.attrs["request_id"]) _, hasError := ev.attrs["error"] assert.False(t, hasError, "success records must not include an error attr") assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted")) assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.context_canceled")) } // TestFastAPIClient_SlogAttempt_RetryThenSuccess: 500 → 500 → 200 emits // three bot.fastapi.attempt events (two retryable_failure WARN, one // success DEBUG); no exhausted event. Plan §2.4 case 2. func TestFastAPIClient_SlogAttempt_RetryThenSuccess(t *testing.T) { rec := installRecordingLogger(t) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)}, {status: http.StatusInternalServerError, body: []byte(`{"error":"oops again"}`)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt") require.Len(t, attempts, 3) assert.Equal(t, slog.LevelWarn, attempts[0].level) assert.Equal(t, "retryable_failure", attempts[0].attrs["outcome"]) assert.EqualValues(t, http.StatusInternalServerError, attempts[0].attrs["status_code"]) assert.Equal(t, slog.LevelWarn, attempts[1].level) assert.Equal(t, "retryable_failure", attempts[1].attrs["outcome"]) assert.Equal(t, slog.LevelDebug, attempts[2].level) assert.Equal(t, "success", attempts[2].attrs["outcome"]) assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted"), "a successful retry must not emit an exhausted event") } // TestFastAPIClient_SlogAttempt_Exhausted: 500×3 emits three attempt // records and one exhausted record. Plan §2.4 case 3. func TestFastAPIClient_SlogAttempt_Exhausted(t *testing.T) { rec := installRecordingLogger(t) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)}, {status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)}, {status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.Error(t, err) attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt") assert.Len(t, attempts, 3) for i, a := range attempts { assert.Equalf(t, slog.LevelWarn, a.level, "attempt %d level", i+1) assert.Equalf(t, "retryable_failure", a.attrs["outcome"], "attempt %d outcome", i+1) } exhausted := eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted") require.Len(t, exhausted, 1, "exhausted retry budget must emit exactly one event") ex := exhausted[0] assert.Equal(t, slog.LevelError, ex.level) assert.EqualValues(t, 3, ex.attrs["attempts"]) assert.EqualValues(t, http.StatusInternalServerError, ex.attrs["last_status"]) if errMsg, ok := ex.attrs["last_error"].(string); assert.True(t, ok, "last_error must be string") { assert.NotEmpty(t, errMsg) } } // TestFastAPIClient_SlogAttempt_NonRetryable: a 400 response yields one // attempt with outcome=nonretryable_failure at ERROR level; no // exhausted event. Plan §2.4 case 4. func TestFastAPIClient_SlogAttempt_NonRetryable(t *testing.T) { rec := installRecordingLogger(t) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusBadRequest, body: []byte(`{"error":"bad request"}`)}, }) client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(t.Context(), validChatRequest()) require.Error(t, err) attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt") require.Len(t, attempts, 1) assert.Equal(t, slog.LevelError, attempts[0].level) assert.Equal(t, "nonretryable_failure", attempts[0].attrs["outcome"]) assert.EqualValues(t, http.StatusBadRequest, attempts[0].attrs["status_code"]) assert.Empty(t, eventsByMessage(rec.snapshot(), "bot.fastapi.exhausted"), "non-retryable failures must not emit exhausted events") } // TestFastAPIClient_SlogAttempt_TransportTimeout: server hangs past the // per-attempt timeout; resulting attempt records have outcome= // retryable_failure and no status_code attribute (no HTTP response was // received). Plan §2.4 case 5. func TestFastAPIClient_SlogAttempt_TransportTimeout(t *testing.T) { rec := installRecordingLogger(t) fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID), delay: 5 * time.Second}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID), delay: 5 * time.Second}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID), delay: 5 * time.Second}, }) parentCtx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() client := bot.NewFastAPIClient(shortTimeoutCfg(fs.server.URL)) _, err := client.Chat(parentCtx, validChatRequest()) require.Error(t, err) attempts := eventsByMessage(rec.snapshot(), "bot.fastapi.attempt") require.NotEmpty(t, attempts, "transport timeouts must still log per-attempt events") for i, a := range attempts { assert.Equalf(t, "retryable_failure", a.attrs["outcome"], "attempt %d outcome", i+1) _, hasStatus := a.attrs["status_code"] assert.Falsef(t, hasStatus, "transport timeout attempt %d must omit status_code (no HTTP response received)", i+1) } } // TestFastAPIClient_SlogAttempt_NoAPIKeyLeak: the configured API key // must never appear as the value of any logged attribute, nor anywhere // in the rendered attribute payload. Plan §2.5 risk row "accidentally // logging the API key". func TestFastAPIClient_SlogAttempt_NoAPIKeyLeak(t *testing.T) { rec := installRecordingLogger(t) const secret = `{"AGENT_SERVICE_API_KEY":"sl0g-le4k-c4n4ry"}` fs := newFlakeServer(t, []flakeResponse{ {status: http.StatusInternalServerError, body: []byte(`{"error":"oops"}`)}, {status: http.StatusOK, body: successChatResponseBody(t, fastAPITestRequestID)}, }) cfg := shortTimeoutCfg(fs.server.URL) cfg.APIKey = secret client := bot.NewFastAPIClient(cfg) _, err := client.Chat(t.Context(), validChatRequest()) require.NoError(t, err) for _, ev := range rec.snapshot() { // Stringify all attribute values; substring-search for the API key. // json.Marshal is convenient and safe — the recordingHandler holds // only basic Go types coming from a.Value.Any(). raw, mErr := json.Marshal(ev.attrs) require.NoError(t, mErr) assert.Falsef(t, strings.Contains(string(raw), secret), "event %q leaked the API key in attrs: %s", ev.message, raw) } }