feat(observability): configurable logging, request ids and error logs
- internal/logging: LOG_LEVEL/LOG_FORMAT parsing and slog handler - request_id middleware (X-Request-ID or generated, echoed) + debug access log - httpx registers errors; middleware logs internal causes with request_id - health excluded, objects/sse debug-only, no query/body/secrets in logs - new observability capability spec; README/.env.example docs
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Package logging configures the process-wide structured logger from
|
||||
// environment-driven settings.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseLevel maps a textual level name to a slog.Level.
|
||||
func ParseLevel(name string) (slog.Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "info":
|
||||
return slog.LevelInfo, nil
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid log level %q (want debug, info, warn or error)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFormat normalizes a format name. An empty name defaults to JSON.
|
||||
func ParseFormat(name string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "", "json":
|
||||
return "json", nil
|
||||
case "text":
|
||||
return "text", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid log format %q (want json or text)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// NewHandler builds a slog handler writing to stdout in the requested format.
|
||||
// An empty format defaults to JSON.
|
||||
func NewHandler(format string, level slog.Level) (slog.Handler, error) {
|
||||
f, err := ParseFormat(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if f == "text" {
|
||||
return slog.NewTextHandler(os.Stdout, opts), nil
|
||||
}
|
||||
return slog.NewJSONHandler(os.Stdout, opts), nil
|
||||
}
|
||||
|
||||
type requestIDKey struct{}
|
||||
|
||||
// WithRequestID stores a request identifier in the context.
|
||||
func WithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey{}, id)
|
||||
}
|
||||
|
||||
// RequestID returns the request identifier stored in the context, or "".
|
||||
func RequestID(ctx context.Context) string {
|
||||
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewRequestID returns a fresh random request identifier.
|
||||
func NewRequestID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// ValidRequestID reports whether an incoming identifier is safe to reuse.
|
||||
func ValidRequestID(id string) bool {
|
||||
if id == "" || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '-', r == '_', r == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want slog.Level
|
||||
ok bool
|
||||
}{
|
||||
{"debug", slog.LevelDebug, true},
|
||||
{"DEBUG", slog.LevelDebug, true},
|
||||
{" info ", slog.LevelInfo, true},
|
||||
{"warn", slog.LevelWarn, true},
|
||||
{"warning", slog.LevelWarn, true},
|
||||
{"error", slog.LevelError, true},
|
||||
{"trace", 0, false},
|
||||
{"", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ParseLevel(c.in)
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("ParseLevel(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("ParseLevel(%q) expected error, got %v", c.in, got)
|
||||
}
|
||||
if c.ok && got != c.want {
|
||||
t.Errorf("ParseLevel(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"", "json", true},
|
||||
{"json", "json", true},
|
||||
{"TEXT", "text", true},
|
||||
{" text ", "text", true},
|
||||
{"xml", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ParseFormat(c.in)
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("ParseFormat(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("ParseFormat(%q) expected error, got %q", c.in, got)
|
||||
}
|
||||
if c.ok && got != c.want {
|
||||
t.Errorf("ParseFormat(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDHelpers(t *testing.T) {
|
||||
if !ValidRequestID("req-abc_123.4") {
|
||||
t.Error("ValidRequestID(valid) = false")
|
||||
}
|
||||
for _, bad := range []string{"", "has space", "has\nnewline", "semi;colon", strings.Repeat("a", 129)} {
|
||||
if ValidRequestID(bad) {
|
||||
t.Errorf("ValidRequestID(%q) = true, want false", bad)
|
||||
}
|
||||
}
|
||||
id := NewRequestID()
|
||||
if id == "" || !ValidRequestID(id) {
|
||||
t.Fatalf("NewRequestID() = %q, want a valid non-empty id", id)
|
||||
}
|
||||
if NewRequestID() == id {
|
||||
t.Fatal("NewRequestID() returned the same value twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHandler(t *testing.T) {
|
||||
for _, format := range []string{"", "json", "JSON", "text"} {
|
||||
h, err := NewHandler(format, slog.LevelInfo)
|
||||
if err != nil {
|
||||
t.Errorf("NewHandler(%q) unexpected error: %v", format, err)
|
||||
}
|
||||
if h == nil {
|
||||
t.Errorf("NewHandler(%q) returned nil handler", format)
|
||||
}
|
||||
}
|
||||
if _, err := NewHandler("xml", slog.LevelInfo); err == nil {
|
||||
t.Error("NewHandler(\"xml\") expected error, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user