Files
query-orchestration/internal/bot/fastapi_slog_test.go
T

276 lines
10 KiB
Go
Raw Normal View History

// 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)
}
}