feat(observability): enrich request logs with auth and knowledge context
- Collect per-request auth diagnostics (header presence, scheme, token fingerprint, failure stage/reason, subject, expiry) in the auth middleware and expose them through RequestLogContextFromGin so the shared logger middleware can emit them. - Add route, query, user-agent, and referer fields to the request log. - Log knowledge resolve outcome per stage (no_search_points, no_candidate_text, no_active_candidates, rerank_failed_fallback, resolved) with candidate/selected snippet previews and precise facts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,18 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
|
||||
zap.String("request_id", RequestIDFromGin(c)),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
}
|
||||
if route := c.FullPath(); route != "" {
|
||||
fields = append(fields, zap.String("route", route))
|
||||
}
|
||||
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
|
||||
fields = append(fields, zap.String("query", rawQuery))
|
||||
}
|
||||
if userAgent := c.Request.UserAgent(); userAgent != "" {
|
||||
fields = append(fields, zap.String("user_agent", userAgent))
|
||||
}
|
||||
if referer := c.Request.Referer(); referer != "" {
|
||||
fields = append(fields, zap.String("referer", referer))
|
||||
}
|
||||
|
||||
if appErr, ok := response.AppErrorFromGin(c); ok {
|
||||
fields = append(fields,
|
||||
@@ -38,6 +51,31 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
|
||||
fields = append(fields, zap.String("error", c.Errors.String()))
|
||||
}
|
||||
|
||||
if authCtx, ok := auth.RequestLogContextFromGin(c); ok {
|
||||
fields = append(fields, zap.Bool("auth_header_present", authCtx.HeaderPresent))
|
||||
if authCtx.Scheme != "" {
|
||||
fields = append(fields, zap.String("auth_scheme", authCtx.Scheme))
|
||||
}
|
||||
if authCtx.TokenFingerprint != "" {
|
||||
fields = append(fields, zap.String("auth_token_fingerprint", authCtx.TokenFingerprint))
|
||||
}
|
||||
if authCtx.FailureStage != "" {
|
||||
fields = append(fields, zap.String("auth_failure_stage", authCtx.FailureStage))
|
||||
}
|
||||
if authCtx.FailureReason != "" {
|
||||
fields = append(fields, zap.String("auth_failure_reason", authCtx.FailureReason))
|
||||
}
|
||||
if authCtx.ParseError != "" {
|
||||
fields = append(fields, zap.String("auth_parse_error", authCtx.ParseError))
|
||||
}
|
||||
if authCtx.TokenSubject != "" {
|
||||
fields = append(fields, zap.String("auth_token_subject", authCtx.TokenSubject))
|
||||
}
|
||||
if authCtx.TokenExpiresAt != "" {
|
||||
fields = append(fields, zap.String("auth_token_expires_at", authCtx.TokenExpiresAt))
|
||||
}
|
||||
}
|
||||
|
||||
switch status := c.Writer.Status(); {
|
||||
case status >= 500:
|
||||
logger.Error("request", fields...)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
)
|
||||
|
||||
func TestLoggerIncludesDetailedAuthFailureContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = ""
|
||||
logger := zap.New(zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(&logBuffer),
|
||||
zapcore.DebugLevel,
|
||||
))
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||
mgr := sharedauth.NewManager("logger-test-secret", -1*time.Minute, 7*24*time.Hour)
|
||||
sessions := sharedauth.NewSessionStore(rdb)
|
||||
|
||||
pair, err := mgr.Issue(7, 42, "editor")
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(RequestID())
|
||||
router.Use(Logger(logger))
|
||||
router.Use(sharedauth.Middleware(mgr, sessions))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test?tab=cards", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
req.Header.Set("User-Agent", "geo-rankly-test/1.0")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||
|
||||
logLine := logBuffer.String()
|
||||
t.Log(logLine)
|
||||
|
||||
require.Contains(t, logLine, `"msg":"request"`)
|
||||
require.Contains(t, logLine, `"status":401`)
|
||||
require.Contains(t, logLine, `"route":"/test"`)
|
||||
require.Contains(t, logLine, `"query":"tab=cards"`)
|
||||
require.Contains(t, logLine, `"user_agent":"geo-rankly-test/1.0"`)
|
||||
require.Contains(t, logLine, `"app_message":"invalid_access_token"`)
|
||||
require.Contains(t, logLine, `"auth_header_present":true`)
|
||||
require.Contains(t, logLine, `"auth_scheme":"Bearer"`)
|
||||
require.Contains(t, logLine, `"auth_failure_stage":"parse_access_token"`)
|
||||
require.Contains(t, logLine, `"auth_failure_reason":"expired"`)
|
||||
require.Contains(t, logLine, `"auth_parse_error":"token has invalid claims: token is expired"`)
|
||||
require.Contains(t, logLine, `"auth_token_fingerprint":"`)
|
||||
}
|
||||
Reference in New Issue
Block a user