mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-23 03:01:41 +00:00
feat(logging): configure levels and structured runtime output (#144)
* feat(logging): configure levels and structured runtime output * fix(logging): bound request-derived log fields * fix(logging): retain malformed IATA source attributes safely * test(logging): capture structured REST rate-limit records
This commit is contained in:
1 parent
595ef4442b
commit
dab228e9bb
36 files changed
+801
-201
No files matched your search
@@ -5,7 +5,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
@@ -26,7 +26,7 @@ func parseLimit(r *http.Request, defaultLimit int32) (int32, error) {
|
||||
return 0, errors.New("limit must be positive")
|
||||
}
|
||||
if limit > int64(maxListLimit) {
|
||||
log.Printf("api: clamped list limit from %d to %d for %s on %s", limit, maxListLimit, r.RemoteAddr, r.URL.Path)
|
||||
slog.Info("api: clamped list limit", "component", "api", "requested_limit", limit, "limit", maxListLimit)
|
||||
return maxListLimit, nil
|
||||
}
|
||||
return int32(limit), nil
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLimitLogOmitsRequestValues(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
previous, writer, flags := slog.Default(), log.Writer(), log.Flags()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&out, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previous); log.SetOutput(writer); log.SetFlags(flags) })
|
||||
req := httptest.NewRequest("GET", "/private-path%0Aforged?limit=1000", nil)
|
||||
req.RemoteAddr = "private-peer"
|
||||
limit, err := parseLimit(req, 50)
|
||||
if err != nil || limit != 200 {
|
||||
t.Fatalf("limit=%d error=%v", limit, err)
|
||||
}
|
||||
if strings.Contains(out.String(), "private-") || strings.Contains(out.String(), "forged") {
|
||||
t.Fatal("limit log retained request identity")
|
||||
}
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["requested_limit"] != float64(1000) || record["limit"] != float64(200) || record["component"] != "api" {
|
||||
t.Fatalf("clamp diagnostics missing: %v", record)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -25,7 +26,7 @@ func respond(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
log.Printf("api: failed to encode response: %v", err)
|
||||
slog.Error("api: failed to encode response", "component", "api", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ func respond(w http.ResponseWriter, status int, data any) {
|
||||
// The error code is derived automatically from the HTTP status text.
|
||||
func respondError(w http.ResponseWriter, status int, message string) {
|
||||
if status >= 500 {
|
||||
log.Printf("api: error %d: %s", status, message)
|
||||
slog.Error(fmt.Sprintf("api: error %d: %s", status, message), "component", "api")
|
||||
}
|
||||
code := strings.ToLower(strings.ReplaceAll(http.StatusText(status), " ", "_"))
|
||||
respond(w, status, map[string]APIError{"error": {Code: code, Message: message}})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -66,7 +66,7 @@ func getStatsOverview(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
overview, err := reader.GetStatsOverview(r.Context(), iatas)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsOverview failed: %v", err)
|
||||
slog.Error("api: GetStatsOverview failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func getStatsObservations(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
points, err := reader.GetStatsObservations(r.Context(), iatas, since)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsObservations failed: %v", err)
|
||||
slog.Error("api: GetStatsObservations failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func getStatsPayloadBreakdown(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
breakdown, err := reader.GetStatsPayloadBreakdown(r.Context(), iatas, since)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsPayloadBreakdown failed: %v", err)
|
||||
slog.Error("api: GetStatsPayloadBreakdown failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func getStatsTopNodes(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
nodes, err := reader.GetStatsTopNodes(r.Context(), iatas, limit)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsTopNodes failed: %v", err)
|
||||
slog.Error("api: GetStatsTopNodes failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
@@ -236,7 +236,7 @@ func getStatsTopObservers(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
observers, err := reader.GetStatsTopObservers(r.Context(), iatas, since, limit)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsTopObservers failed: %v", err)
|
||||
slog.Error("api: GetStatsTopObservers failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func getStatsTopAdvertisers(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
advertisers, err := reader.GetStatsTopAdvertisers(r.Context(), iatas, since, limit)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsTopAdvertisers failed: %v", err)
|
||||
slog.Error("api: GetStatsTopAdvertisers failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func getStatsClockDrift(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
entries, err := reader.GetStatsClockDrift(r.Context(), iatas, limit)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsClockDrift failed: %v", err)
|
||||
slog.Error("api: GetStatsClockDrift failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
@@ -370,7 +370,7 @@ func getStatsTopTalkers(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
talkers, err := reader.GetStatsTopTalkers(r.Context(), iatas, since, limit)
|
||||
if err != nil {
|
||||
log.Printf("api: GetStatsTopTalkers failed: %v", err)
|
||||
slog.Error("api: GetStatsTopTalkers failed", "component", "api", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// RequestLogger uses chi's response wrapper to preserve streaming/hijacking support.
|
||||
// Place it after TrustedProxyIP and RequestID, and before Recoverer.
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return chimw.RequestLogger(requestLogFormatter{})(next)
|
||||
}
|
||||
|
||||
type requestLogFormatter struct{}
|
||||
type requestLogEntry struct{ request *http.Request }
|
||||
|
||||
func (requestLogFormatter) NewLogEntry(r *http.Request) chimw.LogEntry {
|
||||
return &requestLogEntry{request: r}
|
||||
}
|
||||
|
||||
func (e *requestLogEntry) logger() *slog.Logger {
|
||||
path := "unmatched"
|
||||
if ctx := chi.RouteContext(e.request.Context()); ctx != nil && ctx.RoutePattern() != "" {
|
||||
path = ctx.RoutePattern()
|
||||
}
|
||||
return slog.Default().With("component", "http", "method", singleLineLogValue(e.request.Method), "path", path,
|
||||
"client_ip", singleLineLogValue(e.request.RemoteAddr), "request_id", singleLineLogValue(chimw.GetReqID(e.request.Context())))
|
||||
}
|
||||
|
||||
// Strip line breaks before request fields reach a handler or downstream log consumer.
|
||||
func singleLineLogValue(value string) string {
|
||||
value = strings.ReplaceAll(value, "\r", "")
|
||||
return strings.ReplaceAll(value, "\n", "")
|
||||
}
|
||||
|
||||
func (e *requestLogEntry) Write(status, bytes int, _ http.Header, elapsed time.Duration, _ any) {
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
if status >= 500 {
|
||||
level = slog.LevelError
|
||||
} else if status >= 400 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
if !slog.Default().Enabled(e.request.Context(), level) {
|
||||
return
|
||||
}
|
||||
e.logger().Log(e.request.Context(), level, "request complete", "status", status, "bytes", bytes, "duration_ms", float64(elapsed)/float64(time.Millisecond))
|
||||
}
|
||||
|
||||
func (e *requestLogEntry) Panic(value any, stack []byte) {
|
||||
e.logger().ErrorContext(e.request.Context(), "request panic", "panic", fmt.Sprint(value), "stack", string(stack))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func TestRequestLogging(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
panic bool
|
||||
level slog.Level
|
||||
wantLevel string
|
||||
}{
|
||||
{200, false, slog.LevelInfo, "INFO"}, {429, false, slog.LevelWarn, "WARN"},
|
||||
{500, false, slog.LevelError, "ERROR"}, {200, false, slog.LevelWarn, ""}, {500, true, slog.LevelError, "ERROR"},
|
||||
} {
|
||||
t.Run(tc.level.String()+"/"+http.StatusText(tc.status), func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
previous, writer, flags := slog.Default(), log.Writer(), log.Flags()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&out, &slog.HandlerOptions{Level: tc.level})))
|
||||
t.Cleanup(func() { slog.SetDefault(previous); log.SetOutput(writer); log.SetFlags(flags) })
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(TrustedProxyIP([]netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}))
|
||||
r.Use(RequestLogger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Get("/items/{id}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if tc.panic {
|
||||
panic("test panic")
|
||||
}
|
||||
w.WriteHeader(tc.status)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/items/private-id?token=private-token", nil)
|
||||
req.RemoteAddr = "192.0.2.1:4444"
|
||||
req.Header.Set("X-Real-IP", "203.0.113.7")
|
||||
req.Header.Set("True-Client-IP", "198.51.100.8")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.status {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
if tc.wantLevel == "" {
|
||||
if out.Len() != 0 {
|
||||
t.Fatal("info request was not filtered")
|
||||
}
|
||||
return
|
||||
}
|
||||
if strings.Contains(out.String(), "private-id") || strings.Contains(out.String(), "private-token") || strings.Contains(out.String(), "198.51.100.8") {
|
||||
t.Fatal("request values leaked or spoofed identity used")
|
||||
}
|
||||
var completed bool
|
||||
for _, line := range bytes.Split(bytes.TrimSpace(out.Bytes()), []byte("\n")) {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(line, &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["level"] != tc.wantLevel || record["component"] != "http" || record["client_ip"] != "203.0.113.7" || record["path"] != "/items/{id}" {
|
||||
t.Fatalf("bad record: %v", record)
|
||||
}
|
||||
if record["msg"] == "request complete" {
|
||||
completed = true
|
||||
if record["status"] != float64(tc.status) {
|
||||
t.Fatal("wrong logged status")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !completed {
|
||||
t.Fatal("request completion missing")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogInputBoundary(t *testing.T) {
|
||||
for _, format := range []string{"text", "json"} {
|
||||
for _, path := range []string{"/items/private-path", "/unmatched-private-path%0D%0Aforged"} {
|
||||
t.Run(format+path, func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
previous, writer, flags := slog.Default(), log.Writer(), log.Flags()
|
||||
var handler slog.Handler = slog.NewTextHandler(&out, nil)
|
||||
if format == "json" {
|
||||
handler = slog.NewJSONHandler(&out, nil)
|
||||
}
|
||||
slog.SetDefault(slog.New(handler))
|
||||
t.Cleanup(func() { slog.SetDefault(previous); log.SetOutput(writer); log.SetFlags(flags) })
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimw.RequestID, RequestLogger)
|
||||
r.Get("/items/{id}", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
req := httptest.NewRequest(http.MethodGet, path+"?token=private-token", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
req.Header.Set("X-Request-ID", "client\r\nforged")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
wantPath, wantStatus := "/items/{id}", 200
|
||||
if strings.HasPrefix(path, "/unmatched") {
|
||||
wantPath, wantStatus = "unmatched", 404
|
||||
}
|
||||
if w.Code != wantStatus || strings.Count(out.String(), "\n") != 1 || strings.ContainsAny(strings.TrimSuffix(out.String(), "\n"), "\r\n") {
|
||||
t.Fatalf("status or log boundary changed: %d %q", w.Code, out.String())
|
||||
}
|
||||
if strings.Contains(out.String(), "private-path") || strings.Contains(out.String(), "private-token") {
|
||||
t.Fatal("raw path or query was retained")
|
||||
}
|
||||
if format == "json" {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["path"] != wantPath || record["request_id"] != "clientforged" || record["client_ip"] != "192.0.2.1:1234" {
|
||||
t.Fatalf("unexpected request fields: %v", record)
|
||||
}
|
||||
} else if !strings.Contains(out.String(), "path="+wantPath) || !strings.Contains(out.String(), "request_id=clientforged") {
|
||||
t.Fatalf("unexpected text fields: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleLineLogValue(t *testing.T) {
|
||||
for _, tc := range []struct{ input, want string }{
|
||||
{"", ""}, {"GET", "GET"}, {"[2001:db8::1]:80", "[2001:db8::1]:80"},
|
||||
{"client\r\nentry\n", "cliententry"}, {"café", "café"},
|
||||
} {
|
||||
if got := singleLineLogValue(tc.input); got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -17,8 +18,6 @@ import (
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/config"
|
||||
)
|
||||
|
||||
@@ -53,9 +52,9 @@ func rateRequest(handler http.Handler, path, peer, headerIP string) *httptest.Re
|
||||
|
||||
func TestAPIRateLimitContractAndLogging(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
previous := middleware.DefaultLogger
|
||||
middleware.DefaultLogger = middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: log.New(&logs, "", 0), NoColor: true})
|
||||
t.Cleanup(func() { middleware.DefaultLogger = previous })
|
||||
previous, writer, flags := slog.Default(), log.Writer(), log.Flags()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previous); log.SetOutput(writer); log.SetFlags(flags) })
|
||||
proxy := config.ServerConfig{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}}
|
||||
handler := New(nil, nil, nil, 5, config.CORSConfig{}, proxy, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 2, Burst: 10})
|
||||
for _, path := range []string{"/api/v1/brokers", "/api/v1/packets?limit=0"} {
|
||||
@@ -77,8 +76,21 @@ func TestAPIRateLimitContractAndLogging(t *testing.T) {
|
||||
if response.Header().Get("Content-Type") != "application/json" || response.Header().Get("Retry-After") != "60" || !strings.EqualFold(response.Header().Get("Access-Control-Expose-Headers"), "Retry-After") || response.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||
t.Fatalf("missing JSON/backoff/CORS headers: %v", response.Header())
|
||||
}
|
||||
if strings.Count(logs.String(), " - 429 ") != 1 || !strings.Contains(logs.String(), "from 198.51.100.25 - 429 ") || !strings.Contains(logs.String(), "/api/v1/brokers") {
|
||||
t.Fatalf("expected one rejection log with resolved client and path: %s", logs.String())
|
||||
rejections := 0
|
||||
for _, line := range bytes.Split(bytes.TrimSpace(logs.Bytes()), []byte("\n")) {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(line, &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record["status"] == float64(http.StatusTooManyRequests) {
|
||||
rejections++
|
||||
if record["level"] != "WARN" || record["client_ip"] != "198.51.100.25" || record["path"] != "/api/v1/*" {
|
||||
t.Fatalf("wrong rejection log: %v", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rejections != 1 {
|
||||
t.Fatalf("got %d rejection logs, want 1", rejections)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP
|
||||
// ── Global middleware ────────────────────────────────────────────────────
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(mw.TrustedProxyIP(serverCfg.TrustedProxies))
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(mw.RequestLogger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.CleanPath)
|
||||
r.Use(middleware.StripSlashes)
|
||||
|
||||
Reference in new issue
Block a user