feat: absorb go-log into core — error.go + log.go in pkg/core

Brings go-log's errors and logger directly into the Core package:
  core.E("pkg.Method", "msg", err)     — structured errors
  core.Err{Op, Msg, Err, Code}         — error type
  core.Wrap(err, op, msg)              — error wrapping
  core.NewLogger(opts)                 — structured logger
  core.Info/Warn/Error/Debug(msg, kv)  — logging functions

Removed:
  pkg/core/e.go — was re-exporting from go-log, now source is inline
  pkg/log/ — was re-exporting, no longer needed

Renames to avoid conflicts:
  log.New() → core.NewLogger() (core.New is the DI constructor)
  log.Message() → core.ErrorMessage() (core.Message is the IPC type)

go-log still exists as a separate module for external consumers.
Core framework now has errors + logging built-in. Zero deps.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-18 01:23:02 +00:00
parent dd6803df10
commit 16a985ad5c
10 changed files with 615 additions and 671 deletions

View file

@ -1,26 +0,0 @@
// Package core re-exports the structured error types from go-log.
//
// All error construction in the framework MUST use E() (or Wrap, WrapCode, etc.)
// rather than fmt.Errorf. This ensures every error carries an operation context
// for structured logging and tracing.
//
// Example:
//
// return core.E("config.Load", "failed to load config file", err)
package core
import (
coreerr "forge.lthn.ai/core/go-log"
)
// Error is the structured error type from go-log.
// It carries Op (operation), Msg (human-readable), Err (underlying), and Code fields.
type Error = coreerr.Err
// E creates a new structured error with operation context.
// This is the primary way to create errors in the Core framework.
//
// The 'op' parameter should be in the format of 'package.function' or 'service.method'.
// The 'msg' parameter should be a human-readable message.
// The 'err' parameter is the underlying error (may be nil).
var E = coreerr.E

View file

@ -23,7 +23,7 @@ func TestE_Unwrap(t *testing.T) {
assert.True(t, errors.Is(err, originalErr))
var eErr *Error
var eErr *Err
assert.True(t, errors.As(err, &eErr))
assert.Equal(t, "test.op", eErr.Op)
}

270
pkg/core/error.go Normal file
View file

@ -0,0 +1,270 @@
// Package log provides structured logging and error handling for Core applications.
//
// This file implements structured error types and combined log-and-return helpers
// that simplify common error handling patterns.
package core
import (
"errors"
"fmt"
"iter"
"strings"
)
// Err represents a structured error with operational context.
// It implements the error interface and supports unwrapping.
type Err struct {
Op string // Operation being performed (e.g., "user.Save")
Msg string // Human-readable message
Err error // Underlying error (optional)
Code string // Error code (optional, e.g., "VALIDATION_FAILED")
}
// Error implements the error interface.
func (e *Err) Error() string {
var prefix string
if e.Op != "" {
prefix = e.Op + ": "
}
if e.Err != nil {
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
}
return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
}
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
}
return fmt.Sprintf("%s%s", prefix, e.Msg)
}
// Unwrap returns the underlying error for use with errors.Is and errors.As.
func (e *Err) Unwrap() error {
return e.Err
}
// --- Error Creation Functions ---
// E creates a new Err with operation context.
// The underlying error can be nil for creating errors without a cause.
//
// Example:
//
// return log.E("user.Save", "failed to save user", err)
// return log.E("api.Call", "rate limited", nil) // No underlying cause
func E(op, msg string, err error) error {
return &Err{Op: op, Msg: msg, Err: err}
}
// Wrap wraps an error with operation context.
// Returns nil if err is nil, to support conditional wrapping.
// Preserves error Code if the wrapped error is an *Err.
//
// Example:
//
// return log.Wrap(err, "db.Query", "database query failed")
func Wrap(err error, op, msg string) error {
if err == nil {
return nil
}
// Preserve Code from wrapped *Err
var logErr *Err
if As(err, &logErr) && logErr.Code != "" {
return &Err{Op: op, Msg: msg, Err: err, Code: logErr.Code}
}
return &Err{Op: op, Msg: msg, Err: err}
}
// WrapCode wraps an error with operation context and error code.
// Returns nil only if both err is nil AND code is empty.
// Useful for API errors that need machine-readable codes.
//
// Example:
//
// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email")
func WrapCode(err error, code, op, msg string) error {
if err == nil && code == "" {
return nil
}
return &Err{Op: op, Msg: msg, Err: err, Code: code}
}
// NewCode creates an error with just code and message (no underlying error).
// Useful for creating sentinel errors with codes.
//
// Example:
//
// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found")
func NewCode(code, msg string) error {
return &Err{Msg: msg, Code: code}
}
// --- Standard Library Wrappers ---
// Is reports whether any error in err's tree matches target.
// Wrapper around errors.Is for convenience.
func Is(err, target error) bool {
return errors.Is(err, target)
}
// As finds the first error in err's tree that matches target.
// Wrapper around errors.As for convenience.
func As(err error, target any) bool {
return errors.As(err, target)
}
// NewError creates a simple error with the given text.
// Wrapper around errors.New for convenience.
func NewError(text string) error {
return errors.New(text)
}
// Join combines multiple errors into one.
// Wrapper around errors.Join for convenience.
func Join(errs ...error) error {
return errors.Join(errs...)
}
// --- Error Introspection Helpers ---
// Op extracts the operation name from an error.
// Returns empty string if the error is not an *Err.
func Op(err error) string {
var e *Err
if As(err, &e) {
return e.Op
}
return ""
}
// ErrCode extracts the error code from an error.
// Returns empty string if the error is not an *Err or has no code.
func ErrCode(err error) string {
var e *Err
if As(err, &e) {
return e.Code
}
return ""
}
// Message extracts the message from an error.
// Returns the error's Error() string if not an *Err.
func ErrorMessage(err error) string {
if err == nil {
return ""
}
var e *Err
if As(err, &e) {
return e.Msg
}
return err.Error()
}
// Root returns the root cause of an error chain.
// Unwraps until no more wrapped errors are found.
func Root(err error) error {
if err == nil {
return nil
}
for {
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
return err
}
err = unwrapped
}
}
// AllOps returns an iterator over all operational contexts in the error chain.
// It traverses the error tree using errors.Unwrap.
func AllOps(err error) iter.Seq[string] {
return func(yield func(string) bool) {
for err != nil {
if e, ok := err.(*Err); ok {
if e.Op != "" {
if !yield(e.Op) {
return
}
}
}
err = errors.Unwrap(err)
}
}
}
// StackTrace returns the logical stack trace (chain of operations) from an error.
// It returns an empty slice if no operational context is found.
func StackTrace(err error) []string {
var stack []string
for op := range AllOps(err) {
stack = append(stack, op)
}
return stack
}
// FormatStackTrace returns a pretty-printed logical stack trace.
func FormatStackTrace(err error) string {
var ops []string
for op := range AllOps(err) {
ops = append(ops, op)
}
if len(ops) == 0 {
return ""
}
return strings.Join(ops, " -> ")
}
// --- Combined Log-and-Return Helpers ---
// LogError logs an error at Error level and returns a wrapped error.
// Reduces boilerplate in error handling paths.
//
// Example:
//
// // Before
// if err != nil {
// log.Error("failed to save", "err", err)
// return errors.Wrap(err, "user.Save", "failed to save")
// }
//
// // After
// if err != nil {
// return log.LogError(err, "user.Save", "failed to save")
// }
func LogError(err error, op, msg string) error {
if err == nil {
return nil
}
wrapped := Wrap(err, op, msg)
defaultLogger.Error(msg, "op", op, "err", err)
return wrapped
}
// LogWarn logs at Warn level and returns a wrapped error.
// Use for recoverable errors that should be logged but not treated as critical.
//
// Example:
//
// return log.LogWarn(err, "cache.Get", "cache miss, falling back to db")
func LogWarn(err error, op, msg string) error {
if err == nil {
return nil
}
wrapped := Wrap(err, op, msg)
defaultLogger.Warn(msg, "op", op, "err", err)
return wrapped
}
// Must panics if err is not nil, logging first.
// Use for errors that should never happen and indicate programmer error.
//
// Example:
//
// log.Must(Initialize(), "app", "startup failed")
func Must(err error, op, msg string) {
if err != nil {
defaultLogger.Error(msg, "op", op, "err", err)
panic(Wrap(err, op, msg))
}
}

View file

@ -28,9 +28,9 @@ func FuzzE(f *testing.F) {
}
// Round-trip: Unwrap should return the underlying error
var coreErr *Error
var coreErr *Err
if !errors.As(e, &coreErr) {
t.Fatal("errors.As failed for *Error")
t.Fatal("errors.As failed for *Err")
}
if withErr && coreErr.Unwrap() == nil {
t.Fatal("Unwrap() returned nil with underlying error")

342
pkg/core/log.go Normal file
View file

@ -0,0 +1,342 @@
// Package log provides structured logging and error handling for Core applications.
//
// log.SetLevel(log.LevelDebug)
// log.Info("server started", "port", 8080)
// log.Error("failed to connect", "err", err)
package core
import (
"fmt"
goio "io"
"os"
"os/user"
"slices"
"sync"
"time"
)
// Level defines logging verbosity.
type Level int
// Logging level constants ordered by increasing verbosity.
const (
// LevelQuiet suppresses all log output.
LevelQuiet Level = iota
// LevelError shows only error messages.
LevelError
// LevelWarn shows warnings and errors.
LevelWarn
// LevelInfo shows informational messages, warnings, and errors.
LevelInfo
// LevelDebug shows all messages including debug details.
LevelDebug
)
// String returns the level name.
func (l Level) String() string {
switch l {
case LevelQuiet:
return "quiet"
case LevelError:
return "error"
case LevelWarn:
return "warn"
case LevelInfo:
return "info"
case LevelDebug:
return "debug"
default:
return "unknown"
}
}
// Logger provides structured logging.
type Logger struct {
mu sync.RWMutex
level Level
output goio.Writer
// RedactKeys is a list of keys whose values should be masked in logs.
redactKeys []string
// Style functions for formatting (can be overridden)
StyleTimestamp func(string) string
StyleDebug func(string) string
StyleInfo func(string) string
StyleWarn func(string) string
StyleError func(string) string
StyleSecurity func(string) string
}
// RotationOptions defines the log rotation and retention policy.
type RotationOptions struct {
// Filename is the log file path. If empty, rotation is disabled.
Filename string
// MaxSize is the maximum size of the log file in megabytes before it gets rotated.
// It defaults to 100 megabytes.
MaxSize int
// MaxAge is the maximum number of days to retain old log files based on their
// file modification time. It defaults to 28 days.
// Note: set to a negative value to disable age-based retention.
MaxAge int
// MaxBackups is the maximum number of old log files to retain.
// It defaults to 5 backups.
MaxBackups int
// Compress determines if the rotated log files should be compressed using gzip.
// It defaults to true.
Compress bool
}
// Options configures a Logger.
type Options struct {
Level Level
// Output is the destination for log messages. If Rotation is provided,
// Output is ignored and logs are written to the rotating file instead.
Output goio.Writer
// Rotation enables log rotation to file. If provided, Filename must be set.
Rotation *RotationOptions
// RedactKeys is a list of keys whose values should be masked in logs.
RedactKeys []string
}
// RotationWriterFactory creates a rotating writer from options.
// Set this to enable log rotation (provided by core/go-io integration).
var RotationWriterFactory func(RotationOptions) goio.WriteCloser
// New creates a new Logger with the given options.
func NewLogger(opts Options) *Logger {
output := opts.Output
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
output = RotationWriterFactory(*opts.Rotation)
}
if output == nil {
output = os.Stderr
}
return &Logger{
level: opts.Level,
output: output,
redactKeys: slices.Clone(opts.RedactKeys),
StyleTimestamp: identity,
StyleDebug: identity,
StyleInfo: identity,
StyleWarn: identity,
StyleError: identity,
StyleSecurity: identity,
}
}
func identity(s string) string { return s }
// SetLevel changes the log level.
func (l *Logger) SetLevel(level Level) {
l.mu.Lock()
l.level = level
l.mu.Unlock()
}
// Level returns the current log level.
func (l *Logger) Level() Level {
l.mu.RLock()
defer l.mu.RUnlock()
return l.level
}
// SetOutput changes the output writer.
func (l *Logger) SetOutput(w goio.Writer) {
l.mu.Lock()
l.output = w
l.mu.Unlock()
}
// SetRedactKeys sets the keys to be redacted.
func (l *Logger) SetRedactKeys(keys ...string) {
l.mu.Lock()
l.redactKeys = slices.Clone(keys)
l.mu.Unlock()
}
func (l *Logger) shouldLog(level Level) bool {
l.mu.RLock()
defer l.mu.RUnlock()
return level <= l.level
}
func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
l.mu.RLock()
output := l.output
styleTimestamp := l.StyleTimestamp
redactKeys := l.redactKeys
l.mu.RUnlock()
timestamp := styleTimestamp(time.Now().Format("15:04:05"))
// Automatically extract context from error if present in keyvals
origLen := len(keyvals)
for i := 0; i < origLen; i += 2 {
if i+1 < origLen {
if err, ok := keyvals[i+1].(error); ok {
if op := Op(err); op != "" {
// Check if op is already in keyvals
hasOp := false
for j := 0; j < len(keyvals); j += 2 {
if k, ok := keyvals[j].(string); ok && k == "op" {
hasOp = true
break
}
}
if !hasOp {
keyvals = append(keyvals, "op", op)
}
}
if stack := FormatStackTrace(err); stack != "" {
// Check if stack is already in keyvals
hasStack := false
for j := 0; j < len(keyvals); j += 2 {
if k, ok := keyvals[j].(string); ok && k == "stack" {
hasStack = true
break
}
}
if !hasStack {
keyvals = append(keyvals, "stack", stack)
}
}
}
}
}
// Format key-value pairs
var kvStr string
if len(keyvals) > 0 {
kvStr = " "
for i := 0; i < len(keyvals); i += 2 {
if i > 0 {
kvStr += " "
}
key := keyvals[i]
var val any
if i+1 < len(keyvals) {
val = keyvals[i+1]
}
// Redaction logic
keyStr := fmt.Sprintf("%v", key)
if slices.Contains(redactKeys, keyStr) {
val = "[REDACTED]"
}
// Secure formatting to prevent log injection
if s, ok := val.(string); ok {
kvStr += fmt.Sprintf("%v=%q", key, s)
} else {
kvStr += fmt.Sprintf("%v=%v", key, val)
}
}
}
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, kvStr)
}
// Debug logs a debug message with optional key-value pairs.
func (l *Logger) Debug(msg string, keyvals ...any) {
if l.shouldLog(LevelDebug) {
l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...)
}
}
// Info logs an info message with optional key-value pairs.
func (l *Logger) Info(msg string, keyvals ...any) {
if l.shouldLog(LevelInfo) {
l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...)
}
}
// Warn logs a warning message with optional key-value pairs.
func (l *Logger) Warn(msg string, keyvals ...any) {
if l.shouldLog(LevelWarn) {
l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...)
}
}
// Error logs an error message with optional key-value pairs.
func (l *Logger) Error(msg string, keyvals ...any) {
if l.shouldLog(LevelError) {
l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...)
}
}
// Security logs a security event with optional key-value pairs.
// It uses LevelError to ensure security events are visible even in restrictive
// log configurations.
func (l *Logger) Security(msg string, keyvals ...any) {
if l.shouldLog(LevelError) {
l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...)
}
}
// Username returns the current system username.
// It uses os/user for reliability and falls back to environment variables.
func Username() string {
if u, err := user.Current(); err == nil {
return u.Username
}
// Fallback for environments where user lookup might fail
if u := os.Getenv("USER"); u != "" {
return u
}
return os.Getenv("USERNAME")
}
// --- Default logger ---
var defaultLogger = NewLogger(Options{Level: LevelInfo})
// Default returns the default logger.
func Default() *Logger {
return defaultLogger
}
// SetDefault sets the default logger.
func SetDefault(l *Logger) {
defaultLogger = l
}
// SetLevel sets the default logger's level.
func SetLevel(level Level) {
defaultLogger.SetLevel(level)
}
// SetRedactKeys sets the default logger's redaction keys.
func SetRedactKeys(keys ...string) {
defaultLogger.SetRedactKeys(keys...)
}
// Debug logs to the default logger.
func Debug(msg string, keyvals ...any) {
defaultLogger.Debug(msg, keyvals...)
}
// Info logs to the default logger.
func Info(msg string, keyvals ...any) {
defaultLogger.Info(msg, keyvals...)
}
// Warn logs to the default logger.
func Warn(msg string, keyvals ...any) {
defaultLogger.Warn(msg, keyvals...)
}
// Error logs to the default logger.
func Error(msg string, keyvals ...any) {
defaultLogger.Error(msg, keyvals...)
}
// Security logs to the default logger.
func Security(msg string, keyvals ...any) {
defaultLogger.Security(msg, keyvals...)
}

View file

@ -1,74 +0,0 @@
// Package log re-exports go-log and provides framework integration (Service)
// and log rotation (RotatingWriter) that depend on core/go internals.
//
// New code should import forge.lthn.ai/core/go-log directly.
package log
import (
"io"
golog "forge.lthn.ai/core/go-log"
)
// Type aliases — all go-log types available as log.X
type (
Level = golog.Level
Logger = golog.Logger
Options = golog.Options
RotationOptions = golog.RotationOptions
Err = golog.Err
)
// Level constants.
const (
LevelQuiet = golog.LevelQuiet
LevelError = golog.LevelError
LevelWarn = golog.LevelWarn
LevelInfo = golog.LevelInfo
LevelDebug = golog.LevelDebug
)
func init() {
// Wire rotation into go-log: when go-log's New() gets RotationOptions,
// it calls this factory to create the RotatingWriter (which needs go-io).
golog.RotationWriterFactory = func(opts RotationOptions) io.WriteCloser {
return NewRotatingWriter(opts, nil)
}
}
// --- Logging functions (re-exported from go-log) ---
var (
New = golog.New
Default = golog.Default
SetDefault = golog.SetDefault
SetLevel = golog.SetLevel
Debug = golog.Debug
Info = golog.Info
Warn = golog.Warn
Error = golog.Error
Security = golog.Security
Username = golog.Username
)
// --- Error functions (re-exported from go-log) ---
var (
E = golog.E
Wrap = golog.Wrap
WrapCode = golog.WrapCode
NewCode = golog.NewCode
Is = golog.Is
As = golog.As
NewError = golog.NewError
Join = golog.Join
Op = golog.Op
ErrCode = golog.ErrCode
Message = golog.Message
Root = golog.Root
StackTrace = golog.StackTrace
FormatStackTrace = golog.FormatStackTrace
LogError = golog.LogError
LogWarn = golog.LogWarn
Must = golog.Must
)

View file

@ -1,170 +0,0 @@
package log
import (
"fmt"
"io"
"sync"
"time"
coreio "forge.lthn.ai/core/go-io"
)
// RotatingWriter implements io.WriteCloser and provides log rotation.
type RotatingWriter struct {
opts RotationOptions
medium coreio.Medium
mu sync.Mutex
file io.WriteCloser
size int64
}
// NewRotatingWriter creates a new RotatingWriter with the given options and medium.
func NewRotatingWriter(opts RotationOptions, m coreio.Medium) *RotatingWriter {
if m == nil {
m = coreio.Local
}
if opts.MaxSize <= 0 {
opts.MaxSize = 100 // 100 MB
}
if opts.MaxBackups <= 0 {
opts.MaxBackups = 5
}
if opts.MaxAge == 0 {
opts.MaxAge = 28 // 28 days
} else if opts.MaxAge < 0 {
opts.MaxAge = 0 // disabled
}
return &RotatingWriter{
opts: opts,
medium: m,
}
}
// Write writes data to the current log file, rotating it if necessary.
func (w *RotatingWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
if err := w.openExistingOrNew(); err != nil {
return 0, err
}
}
if w.size+int64(len(p)) > int64(w.opts.MaxSize)*1024*1024 {
if err := w.rotate(); err != nil {
return 0, err
}
}
n, err = w.file.Write(p)
if err == nil {
w.size += int64(n)
}
return n, err
}
// Close closes the current log file.
func (w *RotatingWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.close()
}
func (w *RotatingWriter) close() error {
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *RotatingWriter) openExistingOrNew() error {
info, err := w.medium.Stat(w.opts.Filename)
if err == nil {
w.size = info.Size()
f, err := w.medium.Append(w.opts.Filename)
if err != nil {
return err
}
w.file = f
return nil
}
f, err := w.medium.Create(w.opts.Filename)
if err != nil {
return err
}
w.file = f
w.size = 0
return nil
}
func (w *RotatingWriter) rotate() error {
if err := w.close(); err != nil {
return err
}
if err := w.rotateFiles(); err != nil {
// Try to reopen current file even if rotation failed
_ = w.openExistingOrNew()
return err
}
if err := w.openExistingOrNew(); err != nil {
return err
}
w.cleanup()
return nil
}
func (w *RotatingWriter) rotateFiles() error {
// Rotate existing backups: log.N -> log.N+1
for i := w.opts.MaxBackups; i >= 1; i-- {
oldPath := w.backupPath(i)
newPath := w.backupPath(i + 1)
if w.medium.Exists(oldPath) {
if i+1 > w.opts.MaxBackups {
_ = w.medium.Delete(oldPath)
} else {
_ = w.medium.Rename(oldPath, newPath)
}
}
}
// log -> log.1
return w.medium.Rename(w.opts.Filename, w.backupPath(1))
}
func (w *RotatingWriter) backupPath(n int) string {
return fmt.Sprintf("%s.%d", w.opts.Filename, n)
}
func (w *RotatingWriter) cleanup() {
// 1. Remove backups beyond MaxBackups
// This is already partially handled by rotateFiles but we can be thorough
for i := w.opts.MaxBackups + 1; ; i++ {
path := w.backupPath(i)
if !w.medium.Exists(path) {
break
}
_ = w.medium.Delete(path)
}
// 2. Remove backups older than MaxAge
if w.opts.MaxAge > 0 {
cutoff := time.Now().AddDate(0, 0, -w.opts.MaxAge)
for i := 1; i <= w.opts.MaxBackups; i++ {
path := w.backupPath(i)
info, err := w.medium.Stat(path)
if err == nil && info.ModTime().Before(cutoff) {
_ = w.medium.Delete(path)
}
}
}
}

View file

@ -1,215 +0,0 @@
package log
import (
"strings"
"testing"
"time"
"forge.lthn.ai/core/go-io"
)
func TestRotatingWriter_Basic(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1, // 1 MB
MaxBackups: 3,
}
w := NewRotatingWriter(opts, m)
defer w.Close()
msg := "test message\n"
_, err := w.Write([]byte(msg))
if err != nil {
t.Fatalf("failed to write: %v", err)
}
w.Close()
content, err := m.Read("test.log")
if err != nil {
t.Fatalf("failed to read from medium: %v", err)
}
if content != msg {
t.Errorf("expected %q, got %q", msg, content)
}
}
func TestRotatingWriter_Rotation(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1, // 1 MB
MaxBackups: 2,
}
w := NewRotatingWriter(opts, m)
defer w.Close()
// 1. Write almost 1MB
largeMsg := strings.Repeat("a", 1024*1024-10)
_, _ = w.Write([]byte(largeMsg))
// 2. Write more to trigger rotation
_, _ = w.Write([]byte("trigger rotation\n"))
w.Close()
// Check if test.log.1 exists and contains the large message
if !m.Exists("test.log.1") {
t.Error("expected test.log.1 to exist")
}
// Check if test.log exists and contains the new message
content, _ := m.Read("test.log")
if !strings.Contains(content, "trigger rotation") {
t.Errorf("expected test.log to contain new message, got %q", content)
}
}
func TestRotatingWriter_Retention(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1,
MaxBackups: 2,
}
w := NewRotatingWriter(opts, m)
defer w.Close()
// Trigger rotation 4 times to test retention of only the latest backups
for i := 1; i <= 4; i++ {
_, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1)))
}
w.Close()
// Should have test.log, test.log.1, test.log.2
// test.log.3 should have been deleted because MaxBackups is 2
if !m.Exists("test.log") {
t.Error("expected test.log to exist")
}
if !m.Exists("test.log.1") {
t.Error("expected test.log.1 to exist")
}
if !m.Exists("test.log.2") {
t.Error("expected test.log.2 to exist")
}
if m.Exists("test.log.3") {
t.Error("expected test.log.3 NOT to exist")
}
}
func TestRotatingWriter_Append(t *testing.T) {
m := io.NewMockMedium()
_ = m.Write("test.log", "existing content\n")
opts := RotationOptions{
Filename: "test.log",
}
w := NewRotatingWriter(opts, m)
_, _ = w.Write([]byte("new content\n"))
_ = w.Close()
content, _ := m.Read("test.log")
expected := "existing content\nnew content\n"
if content != expected {
t.Errorf("expected %q, got %q", expected, content)
}
}
func TestNewRotatingWriter_Defaults(t *testing.T) {
m := io.NewMockMedium()
// MaxAge < 0 disables age-based cleanup
w := NewRotatingWriter(RotationOptions{
Filename: "test.log",
MaxAge: -1,
}, m)
defer w.Close()
if w.opts.MaxSize != 100 {
t.Errorf("expected default MaxSize 100, got %d", w.opts.MaxSize)
}
if w.opts.MaxBackups != 5 {
t.Errorf("expected default MaxBackups 5, got %d", w.opts.MaxBackups)
}
if w.opts.MaxAge != 0 {
t.Errorf("expected MaxAge 0 (disabled), got %d", w.opts.MaxAge)
}
}
func TestRotatingWriter_RotateEndToEnd(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1, // 1 MB
MaxBackups: 2,
}
w := NewRotatingWriter(opts, m)
// Write just under 1 MB
_, _ = w.Write([]byte(strings.Repeat("a", 1024*1024-10)))
// Write more to trigger rotation
_, err := w.Write([]byte(strings.Repeat("b", 20)))
if err != nil {
t.Fatalf("write after rotation failed: %v", err)
}
w.Close()
// Verify rotation happened
if !m.Exists("test.log.1") {
t.Error("expected test.log.1 after rotation")
}
content, _ := m.Read("test.log")
if !strings.Contains(content, "bbb") {
t.Errorf("expected new data in test.log after rotation, got %q", content)
}
}
func TestRotatingWriter_AgeRetention(t *testing.T) {
m := io.NewMockMedium()
opts := RotationOptions{
Filename: "test.log",
MaxSize: 1,
MaxBackups: 5,
MaxAge: 7, // 7 days
}
w := NewRotatingWriter(opts, m)
// Create some backup files
m.Write("test.log.1", "recent")
m.ModTimes["test.log.1"] = time.Now()
m.Write("test.log.2", "old")
m.ModTimes["test.log.2"] = time.Now().AddDate(0, 0, -10) // 10 days old
// Trigger rotation to run cleanup
_, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1)))
w.Close()
if !m.Exists("test.log.1") {
t.Error("expected test.log.1 (now test.log.2) to exist as it's recent")
}
// Note: test.log.1 becomes test.log.2 after rotation, etc.
// But wait, my cleanup runs AFTER rotation.
// Initial state:
// test.log.1 (now)
// test.log.2 (-10d)
// Write triggers rotation:
// test.log -> test.log.1
// test.log.1 -> test.log.2
// test.log.2 -> test.log.3
// Then cleanup runs:
// test.log.1 (now) - keep
// test.log.2 (now) - keep
// test.log.3 (-10d) - delete (since MaxAge is 7)
if m.Exists("test.log.3") {
t.Error("expected test.log.3 to be deleted as it's too old")
}
}

View file

@ -1,57 +0,0 @@
package log
import (
"context"
"forge.lthn.ai/core/go/pkg/core"
)
// Service wraps Logger for Core framework integration.
type Service struct {
*core.ServiceRuntime[Options]
*Logger
}
// NewService creates a log service factory for Core.
func NewService(opts Options) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
logger := New(opts)
return &Service{
ServiceRuntime: core.NewServiceRuntime(c, opts),
Logger: logger,
}, nil
}
}
// OnStartup registers query and task handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// QueryLevel returns the current log level.
type QueryLevel struct{}
// TaskSetLevel changes the log level.
type TaskSetLevel struct {
Level Level
}
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryLevel:
return s.Level(), true, nil
}
return nil, false, nil
}
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch m := t.(type) {
case TaskSetLevel:
s.SetLevel(m.Level)
return nil, true, nil
}
return nil, false, nil
}

View file

@ -1,126 +0,0 @@
package log
import (
"context"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewService_Good(t *testing.T) {
opts := Options{Level: LevelInfo}
factory := NewService(opts)
c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) {
return factory(cc)
}))
require.NoError(t, err)
svc := c.Service("log")
require.NotNil(t, svc)
logSvc, ok := svc.(*Service)
require.True(t, ok)
assert.NotNil(t, logSvc.Logger)
assert.NotNil(t, logSvc.ServiceRuntime)
}
func TestService_OnStartup_Good(t *testing.T) {
opts := Options{Level: LevelInfo}
factory := NewService(opts)
c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) {
return factory(cc)
}))
require.NoError(t, err)
svc := c.Service("log").(*Service)
err = svc.OnStartup(context.Background())
assert.NoError(t, err)
}
func TestService_QueryLevel_Good(t *testing.T) {
opts := Options{Level: LevelDebug}
factory := NewService(opts)
c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) {
return factory(cc)
}))
require.NoError(t, err)
svc := c.Service("log").(*Service)
err = svc.OnStartup(context.Background())
require.NoError(t, err)
result, handled, err := c.QUERY(QueryLevel{})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, LevelDebug, result)
}
func TestService_QueryLevel_Bad(t *testing.T) {
opts := Options{Level: LevelInfo}
factory := NewService(opts)
c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) {
return factory(cc)
}))
require.NoError(t, err)
svc := c.Service("log").(*Service)
err = svc.OnStartup(context.Background())
require.NoError(t, err)
// Unknown query type should not be handled
result, handled, err := c.QUERY("unknown")
assert.NoError(t, err)
assert.False(t, handled)
assert.Nil(t, result)
}
func TestService_TaskSetLevel_Good(t *testing.T) {
opts := Options{Level: LevelInfo}
factory := NewService(opts)
c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) {
return factory(cc)
}))
require.NoError(t, err)
svc := c.Service("log").(*Service)
err = svc.OnStartup(context.Background())
require.NoError(t, err)
// Change level via task
_, handled, err := c.PERFORM(TaskSetLevel{Level: LevelError})
assert.NoError(t, err)
assert.True(t, handled)
// Verify level changed via query
result, handled, err := c.QUERY(QueryLevel{})
assert.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, LevelError, result)
}
func TestService_TaskSetLevel_Bad(t *testing.T) {
opts := Options{Level: LevelInfo}
factory := NewService(opts)
c, err := core.New(core.WithName("log", func(cc *core.Core) (any, error) {
return factory(cc)
}))
require.NoError(t, err)
svc := c.Service("log").(*Service)
err = svc.OnStartup(context.Background())
require.NoError(t, err)
// Unknown task type should not be handled
_, handled, err := c.PERFORM("unknown")
assert.NoError(t, err)
assert.False(t, handled)
}