refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
// Package applog provides geniusrun's structured JSON logging: a
|
||||
// log/slog-based logger writing to stdout, plus context helpers so a
|
||||
// logger enriched in one layer (e.g. internal/api's HTTP middleware,
|
||||
// attaching a request_id) is picked up by another (e.g. internal/garmin's
|
||||
// wrapper-call logging) without either package depending on the other.
|
||||
// log/slog-based logger writing to stdout, plus the App helper that tags
|
||||
// records with the mandatory schema fields every geniusrun log line
|
||||
// carries -- "type" ("app" | "http" | "wrapper"), "class" (Go type with
|
||||
// its package, or the package/module name for free functions), and
|
||||
// "method" (the Go or Python function emitting the record). "msg" is
|
||||
// optional: NewLogger's handler drops it when empty.
|
||||
package applog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -14,9 +15,28 @@ import (
|
||||
|
||||
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
|
||||
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
|
||||
// defaults to info).
|
||||
// defaults to info). Empty msg values are omitted from the output --
|
||||
// records whose meaning is fully carried by type/class/method and attrs
|
||||
// (e.g. the HTTP request line) don't need one.
|
||||
func NewLogger(level string, w io.Writer) *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: parseLevel(level)}))
|
||||
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||
Level: parseLevel(level),
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) == 0 && a.Key == slog.MessageKey && a.Value.String() == "" {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// App returns the default logger tagged with the mandatory schema fields
|
||||
// for an ordinary application record: type=app, plus the emitting class
|
||||
// and method. The http and wrapper emitters (internal/api's logging
|
||||
// middleware, internal/garmin's execute/forwardWrapperStderr) tag their
|
||||
// own type instead.
|
||||
func App(class, method string) *slog.Logger {
|
||||
return slog.Default().With("type", "app", "class", class, "method", method)
|
||||
}
|
||||
|
||||
func parseLevel(level string) slog.Level {
|
||||
@@ -31,21 +51,3 @@ func parseLevel(level string) slog.Level {
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithLogger returns a context carrying logger, retrievable via FromContext.
|
||||
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, logger)
|
||||
}
|
||||
|
||||
// FromContext returns the logger stashed by WithLogger, or slog.Default()
|
||||
// if none was -- callers (e.g. the background incremental-sync loop, or
|
||||
// tests that don't bother injecting one) always get a working logger,
|
||||
// never nil.
|
||||
func FromContext(ctx context.Context) *slog.Logger {
|
||||
if logger, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
|
||||
return logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package applog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -38,17 +37,36 @@ func TestNewLogger_WritesValidJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLogger_FromContext_RoundTrip(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
|
||||
ctx := WithLogger(context.Background(), logger)
|
||||
func TestNewLogger_OmitsEmptyMsg(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := NewLogger("info", &buf)
|
||||
logger.Info("", "type", "http", "status", 200)
|
||||
|
||||
if got := FromContext(ctx); got != logger {
|
||||
t.Errorf("FromContext returned a different logger than what was stashed")
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output not JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if _, present := decoded["msg"]; present {
|
||||
t.Errorf("empty msg should be omitted, got %+v", decoded)
|
||||
}
|
||||
if decoded["type"] != "http" {
|
||||
t.Errorf("attrs lost alongside dropped msg: %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContext_DefaultsWhenNoneSet(t *testing.T) {
|
||||
if got := FromContext(context.Background()); got == nil {
|
||||
t.Fatal("FromContext on a bare context returned nil, want slog.Default()")
|
||||
func TestApp_TagsMandatoryFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(NewLogger("info", &buf))
|
||||
defer slog.SetDefault(prev)
|
||||
|
||||
App("api.Server", "handleThing").Info("did it", "extra", 1)
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("output not JSON: %v", err)
|
||||
}
|
||||
if decoded["type"] != "app" || decoded["class"] != "api.Server" || decoded["method"] != "handleThing" {
|
||||
t.Errorf("mandatory fields = %+v, want type=app class=api.Server method=handleThing", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user