Skip to content

Commit 903b339

Browse files
feat(logging): structured slog logger with JSON/text output (#67)
Implements issue #27. Adds pkg/logging with: - New(Config): configurable level (debug/info/warn/error) and format (json/text) - NewDefault(): JSON at info level to stderr - NewDebug(): text at debug level for local development - WithRequestID/WithTraceID/WithComponent: child loggers with attached attributes Uses stdlib log/slog (Go 1.21+), no external dependencies. Co-authored-by: Ona <no-reply@ona.com>
1 parent 85cf702 commit 903b339

2 files changed

Lines changed: 248 additions & 0 deletions

File tree

pkg/logging/logging.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Package logging initialises a structured slog.Logger for the Distill server.
2+
// It supports JSON (production) and text (human-readable) output formats and
3+
// four log levels: debug, info, warn, error.
4+
//
5+
// Usage:
6+
//
7+
// logger := logging.New(logging.Config{Level: "info", Format: "json"})
8+
// logger.Info("request completed", "path", "/v1/dedupe", "latency_ms", 14)
9+
package logging
10+
11+
import (
12+
"io"
13+
"log/slog"
14+
"os"
15+
"strings"
16+
)
17+
18+
// Format selects the log output format.
19+
type Format string
20+
21+
const (
22+
FormatJSON Format = "json"
23+
FormatText Format = "text"
24+
)
25+
26+
// Config controls logger initialisation.
27+
type Config struct {
28+
// Level is one of "debug", "info", "warn", "error". Default: "info".
29+
Level string
30+
31+
// Format is "json" or "text". Default: "json".
32+
Format Format
33+
34+
// Output is the writer to log to. Default: os.Stderr.
35+
Output io.Writer
36+
37+
// AddSource adds the source file and line to every log record.
38+
AddSource bool
39+
}
40+
41+
// New creates a configured *slog.Logger. It does not replace the default
42+
// slog logger — callers should store and pass the returned logger explicitly.
43+
func New(cfg Config) *slog.Logger {
44+
if cfg.Output == nil {
45+
cfg.Output = os.Stderr
46+
}
47+
48+
level := parseLevel(cfg.Level)
49+
50+
opts := &slog.HandlerOptions{
51+
Level: level,
52+
AddSource: cfg.AddSource,
53+
}
54+
55+
var handler slog.Handler
56+
if cfg.Format == FormatText {
57+
handler = slog.NewTextHandler(cfg.Output, opts)
58+
} else {
59+
handler = slog.NewJSONHandler(cfg.Output, opts)
60+
}
61+
62+
return slog.New(handler)
63+
}
64+
65+
// NewDefault returns a JSON logger at info level writing to stderr.
66+
// Equivalent to New(Config{}).
67+
func NewDefault() *slog.Logger {
68+
return New(Config{})
69+
}
70+
71+
// NewDebug returns a text logger at debug level — useful for local development.
72+
func NewDebug() *slog.Logger {
73+
return New(Config{Level: "debug", Format: FormatText})
74+
}
75+
76+
// parseLevel converts a string level to slog.Level. Unknown values default to Info.
77+
func parseLevel(s string) slog.Level {
78+
switch strings.ToLower(strings.TrimSpace(s)) {
79+
case "debug":
80+
return slog.LevelDebug
81+
case "warn", "warning":
82+
return slog.LevelWarn
83+
case "error":
84+
return slog.LevelError
85+
default:
86+
return slog.LevelInfo
87+
}
88+
}
89+
90+
// WithRequestID returns a child logger with a request_id attribute attached.
91+
func WithRequestID(logger *slog.Logger, requestID string) *slog.Logger {
92+
return logger.With("request_id", requestID)
93+
}
94+
95+
// WithTraceID returns a child logger with a trace_id attribute attached.
96+
func WithTraceID(logger *slog.Logger, traceID string) *slog.Logger {
97+
return logger.With("trace_id", traceID)
98+
}
99+
100+
// WithComponent returns a child logger scoped to a named component.
101+
func WithComponent(logger *slog.Logger, component string) *slog.Logger {
102+
return logger.With("component", component)
103+
}

pkg/logging/logging_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package logging
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"log/slog"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestNew_JSONFormat(t *testing.T) {
12+
var buf bytes.Buffer
13+
logger := New(Config{Level: "info", Format: FormatJSON, Output: &buf})
14+
logger.Info("test message", "key", "value")
15+
16+
var record map[string]any
17+
if err := json.Unmarshal(buf.Bytes(), &record); err != nil {
18+
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, buf.String())
19+
}
20+
if record["msg"] != "test message" {
21+
t.Errorf("expected msg=test message, got %v", record["msg"])
22+
}
23+
if record["key"] != "value" {
24+
t.Errorf("expected key=value, got %v", record["key"])
25+
}
26+
}
27+
28+
func TestNew_TextFormat(t *testing.T) {
29+
var buf bytes.Buffer
30+
logger := New(Config{Level: "info", Format: FormatText, Output: &buf})
31+
logger.Info("hello world")
32+
33+
if !strings.Contains(buf.String(), "hello world") {
34+
t.Errorf("expected 'hello world' in output, got: %s", buf.String())
35+
}
36+
}
37+
38+
func TestNew_DebugFiltered(t *testing.T) {
39+
var buf bytes.Buffer
40+
logger := New(Config{Level: "info", Format: FormatJSON, Output: &buf})
41+
logger.Debug("should be filtered")
42+
43+
if buf.Len() > 0 {
44+
t.Errorf("debug message should be filtered at info level, got: %s", buf.String())
45+
}
46+
}
47+
48+
func TestNew_DebugVisible(t *testing.T) {
49+
var buf bytes.Buffer
50+
logger := New(Config{Level: "debug", Format: FormatJSON, Output: &buf})
51+
logger.Debug("debug visible")
52+
53+
if buf.Len() == 0 {
54+
t.Error("debug message should be visible at debug level")
55+
}
56+
}
57+
58+
func TestParseLevel(t *testing.T) {
59+
cases := []struct {
60+
input string
61+
want slog.Level
62+
}{
63+
{"debug", slog.LevelDebug},
64+
{"DEBUG", slog.LevelDebug},
65+
{"info", slog.LevelInfo},
66+
{"warn", slog.LevelWarn},
67+
{"warning", slog.LevelWarn},
68+
{"error", slog.LevelError},
69+
{"unknown", slog.LevelInfo},
70+
{"", slog.LevelInfo},
71+
}
72+
for _, c := range cases {
73+
got := parseLevel(c.input)
74+
if got != c.want {
75+
t.Errorf("parseLevel(%q) = %v, want %v", c.input, got, c.want)
76+
}
77+
}
78+
}
79+
80+
func TestNewDefault(t *testing.T) {
81+
logger := NewDefault()
82+
if logger == nil {
83+
t.Error("NewDefault returned nil")
84+
}
85+
}
86+
87+
func TestNewDebug(t *testing.T) {
88+
logger := NewDebug()
89+
if logger == nil {
90+
t.Error("NewDebug returned nil")
91+
}
92+
}
93+
94+
func TestWithRequestID(t *testing.T) {
95+
var buf bytes.Buffer
96+
logger := New(Config{Level: "info", Format: FormatJSON, Output: &buf})
97+
l := WithRequestID(logger, "req-123")
98+
l.Info("with request id")
99+
100+
var record map[string]any
101+
if err := json.Unmarshal(buf.Bytes(), &record); err != nil {
102+
t.Fatalf("invalid JSON: %v", err)
103+
}
104+
if record["request_id"] != "req-123" {
105+
t.Errorf("expected request_id=req-123, got %v", record["request_id"])
106+
}
107+
}
108+
109+
func TestWithTraceID(t *testing.T) {
110+
var buf bytes.Buffer
111+
logger := New(Config{Level: "info", Format: FormatJSON, Output: &buf})
112+
l := WithTraceID(logger, "trace-abc")
113+
l.Info("with trace id")
114+
115+
var record map[string]any
116+
if err := json.Unmarshal(buf.Bytes(), &record); err != nil {
117+
t.Fatalf("invalid JSON: %v", err)
118+
}
119+
if record["trace_id"] != "trace-abc" {
120+
t.Errorf("expected trace_id=trace-abc, got %v", record["trace_id"])
121+
}
122+
}
123+
124+
func TestWithComponent(t *testing.T) {
125+
var buf bytes.Buffer
126+
logger := New(Config{Level: "info", Format: FormatJSON, Output: &buf})
127+
l := WithComponent(logger, "dedup")
128+
l.Info("component log")
129+
130+
var record map[string]any
131+
if err := json.Unmarshal(buf.Bytes(), &record); err != nil {
132+
t.Fatalf("invalid JSON: %v", err)
133+
}
134+
if record["component"] != "dedup" {
135+
t.Errorf("expected component=dedup, got %v", record["component"])
136+
}
137+
}
138+
139+
func TestNew_DefaultOutput(t *testing.T) {
140+
// Should not panic when Output is nil (defaults to stderr).
141+
logger := New(Config{Level: "info"})
142+
if logger == nil {
143+
t.Error("expected non-nil logger")
144+
}
145+
}

0 commit comments

Comments
 (0)