Skip to content

Commit 364794c

Browse files
committed
Add the log package
1 parent 9f19934 commit 364794c

File tree

3 files changed

+386
-0
lines changed

3 files changed

+386
-0
lines changed

pkg/log/doc.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package log
2+
3+
// Package log provides a very small opinionated wrapper around Go's standard
4+
// library logging facilities. Its goal is to offer a consistent way to emit
5+
// logs per service / datasource while keeping migration friction low.
6+
//
7+
// Key Features
8+
//
9+
// - Per service / datasource loggers via ForService(name)
10+
// - Automatic prefix in every line: `[name>]` (example: `[github>] repository synced`)
11+
// - Convenience level helpers: Infof, Warnf, Errorf, Debugf
12+
// - Debug logging can be enabled globally (SetGlobalDebug) or per service
13+
// (EnableDebugFor / DisableDebugFor)
14+
// - Uses the standard library *log.Logger* under the hood (no external deps)
15+
// - Central output writer (SetOutput) that updates existing loggers
16+
//
17+
// Non‑Goals (for now)
18+
//
19+
// - Full-featured leveled logging framework
20+
// - Structured / JSON logging
21+
// - Log sampling, rotation, or asynchronous buffering
22+
//
23+
// These can be added later if explicitly requested. Keeping the surface minimal
24+
// simplifies the incremental refactor away from directly using the stdlib log
25+
// package across the codebase.
26+
//
27+
// Basic Usage
28+
//
29+
// import (
30+
// "github.com/your/module/ergs/pkg/log"
31+
// )
32+
//
33+
// func main() {
34+
// // Enable global debug logs if desired.
35+
// log.SetGlobalDebug(true)
36+
//
37+
// // Acquire a logger for a datasource/service.
38+
// git := log.ForService("github")
39+
//
40+
// git.Infof("starting sync")
41+
// git.Warnf("rate limit near exhaustion")
42+
// git.Debugf("detailed payload: %v", "...") // printed because global debug enabled
43+
// }
44+
//
45+
// Selective Debug
46+
//
47+
// // Only enable debug for the 'github' service.
48+
// log.EnableDebugFor("github")
49+
// log.ForService("github").Debugf("visible")
50+
// log.ForService("rss").Debugf("NOT visible")
51+
//
52+
// Output Routing
53+
//
54+
// // Send logs to a file (ensure proper closing in real code).
55+
// f, _ := os.Create("ergs.log")
56+
// log.SetOutput(f)
57+
//
58+
// Thread Safety
59+
//
60+
// All exported functions are safe for concurrent use. Internally the package
61+
// relies on sync.Map and atomic primitives for minimal locking.
62+
//
63+
// Prefix Format
64+
//
65+
// The chosen prefix format `[name>]` (with a trailing > inside the bracket)
66+
// visually separates the service name from the message while remaining compact.
67+
//
68+
// Migration Strategy
69+
//
70+
// 1. Replace imports of the standard log package in a file with this package.
71+
// 2. Obtain a local logger via ForService using an appropriate stable name
72+
// (e.g. the datasource slug).
73+
// 3. Replace calls to log.Printf(...) with logger.Infof(...) or another
74+
// appropriate level helper.
75+
// 4. Avoid introducing new direct stdlib log calls in refactored files.
76+
//
77+
// Testing
78+
//
79+
// Tests can redirect output by calling SetOutput with a bytes.Buffer,
80+
// enabling assertions on log contents.
81+
//
82+
// Future Extensions
83+
//
84+
// The package intentionally exposes only what is needed now. Potential (yet
85+
// intentionally deferred) enhancements:
86+
// - Structured fields: logger.With(k, v).Infof(...)
87+
// - JSON output mode
88+
// - Context propagation helpers
89+
// - Config-driven initialization
90+
//
91+
// Add these only when a concrete requirement emerges.
92+
//
93+
// Use responsibly and keep it minimal.
94+
//

pkg/log/log.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package log
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log"
7+
"os"
8+
"sync"
9+
"sync/atomic"
10+
"time"
11+
)
12+
13+
// Package log provides a thin wrapper around the Go standard library logger.
14+
// It adds:
15+
// - Named (service/datasource) loggers via ForService(name)
16+
// - Automatic message prefix: "[<name>>]" (mirrors project spec; we render "[name>]" so there is a visual '>' separator)
17+
// - Warn and Debug levels (Info is the default level, Error is also provided)
18+
// - Ability to enable debug globally or selectively per service
19+
//
20+
// This is intentionally minimal so we can gradually refactor existing code that
21+
// currently imports the stdlib log package. Please avoid adding features not requested.
22+
//
23+
// Usage:
24+
// l := log.ForService("github")
25+
// l.Infof("indexed %d repos", n)
26+
// l.Warnf("rate limit low: %d", remaining)
27+
// l.Debugf("raw response: %s", body) // only prints if debug enabled (globally or for "github")
28+
//
29+
// To enable debug globally:
30+
// log.SetGlobalDebug(true)
31+
//
32+
// To enable debug for a specific service only:
33+
// log.EnableDebugFor("github")
34+
//
35+
// NOTE: The package name intentionally collides with stdlib "log". When importing
36+
// this package alongside the standard library, alias one of them, e.g.:
37+
// import (
38+
// stdlog "log"
39+
// ergslog "github.com/your/module/ergs/pkg/log"
40+
// )
41+
//
42+
// For internal code we will gradually migrate from stdlog to this wrapper.
43+
44+
// Logger represents a named logger with helper methods.
45+
type Logger struct {
46+
name string
47+
std *log.Logger
48+
warnOnce sync.Once
49+
}
50+
51+
// writerHolder wraps an io.Writer so that atomic.Value always stores the same
52+
// concrete type, avoiding the "inconsistently typed value" panic when changing
53+
// from *os.File to *bytes.Buffer (or any other writer) in tests or runtime config.
54+
type writerHolder struct {
55+
w io.Writer
56+
}
57+
58+
var (
59+
// globalDebug holds global debug enablement.
60+
globalDebug atomic.Bool
61+
62+
// serviceDebug stores per-service debug overrides.
63+
serviceDebug sync.Map // map[string]*atomic.Bool
64+
65+
// loggers caches created named loggers.
66+
loggers sync.Map // map[string]*Logger
67+
68+
// outputWriter holds the destination for all loggers (wrapped in writerHolder).
69+
outputWriter atomic.Value // writerHolder
70+
)
71+
72+
func init() {
73+
outputWriter.Store(writerHolder{w: os.Stderr})
74+
}
75+
76+
// ForService returns (and memoizes) a named logger for the given service or datasource.
77+
// The name SHOULD be stable (e.g. datasource slug).
78+
func ForService(name string) *Logger {
79+
if name == "" {
80+
name = "unknown"
81+
}
82+
if l, ok := loggers.Load(name); ok {
83+
return l.(*Logger)
84+
}
85+
current := outputWriter.Load().(writerHolder).w
86+
std := log.New(current, "", log.LstdFlags|log.Lmicroseconds)
87+
logger := &Logger{name: name, std: std}
88+
actual, _ := loggers.LoadOrStore(name, logger)
89+
return actual.(*Logger)
90+
}
91+
92+
// SetGlobalDebug enables or disables debug logging globally.
93+
func SetGlobalDebug(enabled bool) {
94+
globalDebug.Store(enabled)
95+
}
96+
97+
// GlobalDebug returns whether global debug logging is enabled.
98+
func GlobalDebug() bool {
99+
return globalDebug.Load()
100+
}
101+
102+
// EnableDebugFor enables debug logging for a specific service/datasource.
103+
func EnableDebugFor(name string) {
104+
if name == "" {
105+
return
106+
}
107+
val, _ := serviceDebug.LoadOrStore(name, &atomic.Bool{})
108+
val.(*atomic.Bool).Store(true)
109+
}
110+
111+
// DisableDebugFor disables debug logging for a specific service/datasource.
112+
func DisableDebugFor(name string) {
113+
if name == "" {
114+
return
115+
}
116+
if val, ok := serviceDebug.Load(name); ok {
117+
val.(*atomic.Bool).Store(false)
118+
}
119+
}
120+
121+
// DebugEnabledFor returns whether debug is enabled for the given service (either
122+
// globally or specifically for the service).
123+
func DebugEnabledFor(name string) bool {
124+
if globalDebug.Load() {
125+
return true
126+
}
127+
if val, ok := serviceDebug.Load(name); ok {
128+
return val.(*atomic.Bool).Load()
129+
}
130+
return false
131+
}
132+
133+
// SetOutput sets the output writer for all subsequently created loggers.
134+
// Existing loggers will also adopt the new writer.
135+
func SetOutput(w io.Writer) {
136+
if w == nil {
137+
return
138+
}
139+
outputWriter.Store(writerHolder{w: w})
140+
loggers.Range(func(_, v any) bool {
141+
l := v.(*Logger)
142+
l.std.SetOutput(w)
143+
return true
144+
})
145+
}
146+
147+
// prefix builds the standard prefix for the logger, following the spec.
148+
func (l *Logger) prefix() string {
149+
return "[" + l.name + ">]"
150+
}
151+
152+
// logInternal formats and outputs the final log line.
153+
func (l *Logger) logInternal(level string, msg string) {
154+
if level != "" {
155+
level = level + " "
156+
}
157+
l.std.Println(level + l.prefix() + " " + msg)
158+
}
159+
160+
// Infof logs an informational message with fmt.Sprintf semantics.
161+
func (l *Logger) Infof(format string, args ...any) {
162+
l.logInternal(LevelInfo, fmt.Sprintf(format, args...))
163+
}
164+
165+
// Warnf logs a warning message.
166+
func (l *Logger) Warnf(format string, args ...any) {
167+
l.warnOnce.Do(func() {
168+
l.logInternal(LevelWarn, "warnings active for this logger")
169+
})
170+
l.logInternal(LevelWarn, fmt.Sprintf(format, args...))
171+
}
172+
173+
// Errorf logs an error message.
174+
func (l *Logger) Errorf(format string, args ...any) {
175+
l.logInternal(LevelError, fmt.Sprintf(format, args...))
176+
}
177+
178+
// Debugf logs a debug message if debug is enabled (globally or for this logger's service).
179+
func (l *Logger) Debugf(format string, args ...any) {
180+
if !DebugEnabledFor(l.name) {
181+
return
182+
}
183+
l.logInternal(LevelDebug, fmt.Sprintf(format, args...))
184+
}
185+
186+
// Flush is a no-op placeholder (future: buffered/async logging).
187+
func Flush() {}
188+
189+
// Timestamp returns current time (exposed to allow deterministic overrides in tests later if needed).
190+
var Timestamp = func() time.Time {
191+
return time.Now()
192+
}
193+
194+
// Level names are currently fixed. Expose constants for potential future checks.
195+
const (
196+
LevelInfo = "INFO"
197+
LevelWarn = "WARN"
198+
LevelError = "ERROR"
199+
LevelDebug = "DEBUG"
200+
)
201+
202+
// TODO (future): structured fields, JSON output, sampling. Avoid implementing until requested.

pkg/log/log_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package log
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// helper resets output and returns buffer and logger
10+
func newTestLogger(t *testing.T, name string) (*Logger, *bytes.Buffer) {
11+
t.Helper()
12+
buf := &bytes.Buffer{}
13+
SetOutput(buf)
14+
return ForService(name), buf
15+
}
16+
17+
func TestPrefixInfo(t *testing.T) {
18+
SetGlobalDebug(false)
19+
20+
const name = "prefix_service_test"
21+
l, buf := newTestLogger(t, name)
22+
23+
l.Infof("hello world")
24+
out := buf.String()
25+
26+
if !strings.Contains(out, "["+name+">]") {
27+
t.Fatalf("expected prefix [%s>] in output, got: %q", name, out)
28+
}
29+
if !strings.Contains(out, "hello world") {
30+
t.Fatalf("expected message in output, got: %q", out)
31+
}
32+
}
33+
34+
func TestDebugPerService(t *testing.T) {
35+
SetGlobalDebug(false)
36+
37+
const name = "debug_service_specific"
38+
DisableDebugFor(name) // ensure clean state
39+
l, buf := newTestLogger(t, name)
40+
41+
l.Debugf("should not appear")
42+
if strings.Contains(buf.String(), "should not appear") {
43+
t.Fatalf("debug message appeared while debug disabled (per service & global)")
44+
}
45+
46+
EnableDebugFor(name)
47+
l.Debugf("visible now")
48+
if !strings.Contains(buf.String(), "visible now") {
49+
t.Fatalf("expected debug message after enabling per-service debug; got: %q", buf.String())
50+
}
51+
}
52+
53+
func TestDebugGlobal(t *testing.T) {
54+
SetGlobalDebug(false)
55+
56+
const name = "debug_service_global"
57+
DisableDebugFor(name)
58+
l, buf := newTestLogger(t, name)
59+
60+
l.Debugf("hidden")
61+
if strings.Contains(buf.String(), "hidden") {
62+
t.Fatalf("debug message appeared while global debug disabled")
63+
}
64+
65+
SetGlobalDebug(true)
66+
defer SetGlobalDebug(false) // cleanup for other tests
67+
68+
l.Debugf("global visible")
69+
if !strings.Contains(buf.String(), "global visible") {
70+
t.Fatalf("expected debug message after enabling global debug; got: %q", buf.String())
71+
}
72+
}
73+
74+
func TestWarnIncludesPrefix(t *testing.T) {
75+
SetGlobalDebug(false)
76+
77+
const name = "warn_service_test"
78+
l, buf := newTestLogger(t, name)
79+
80+
l.Warnf("attention needed")
81+
out := buf.String()
82+
83+
// Warn emits a one-time "warnings active" line first; we only ensure prefix & message appear
84+
if !strings.Contains(out, "["+name+">]") {
85+
t.Fatalf("expected prefix [%s>] in warn output, got: %q", name, out)
86+
}
87+
if !strings.Contains(out, "attention needed") {
88+
t.Fatalf("expected warn message in output, got: %q", out)
89+
}
90+
}

0 commit comments

Comments
 (0)