logger

package module
v0.0.0-...-b758234 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 5 Imported by: 0

README

go-ruby-logger/logger

logger — go-ruby-logger

Docs License Go Coverage

A pure-Go (no cgo) reimplementation of the deterministic core of Ruby's stdlib Logger — the severity model, the default Logger::Formatter, level gating, and the LogDevice rotation policy of MRI 4.0.5 (the logger-1.7.0 gem). It decides what bytes a log line is, whether a message is gated out by the level, and whether — and to which filename — a log file should rotate, all as pure functions over an injected clock, pid and filesize, with no IO and no Ruby runtime.

It is the logging backend for go-embedded-ruby: rbgo wires the IO sink (the $stdout / File that receives the formatted bytes), the wall clock and the process id; this library is the standalone, reusable compute core — a sibling of go-ruby-regexp, go-ruby-erb and go-ruby-yaml.

What it is — and isn't. Building a log line, deciding the severity gate, and computing the rotation schedule and rotated-file names are fully deterministic and need no interpreter, so they live here as pure Go, validated against the ruby binary byte-for-byte. Opening the file, writing the bytes, renaming on rotation, and reading the live wall clock are the host's job: the library hands back the bytes and the rename plan, and the host's IO device performs them.

Features

A faithful port of Logger's deterministic core, validated against the ruby binary on every supported platform:

  • Severity modelDEBUG=0 INFO=1 WARN=2 ERROR=3 FATAL=4 UNKNOWN=5, the SeverityLabel mapping ("DEBUG".."FATAL", then "ANY" for UNKNOWN and anything out of range), and CoerceSeverity (MRI's Severity.coerce).
  • Default formatter, byte-for-byte — MRI's "%.1s, [%s #%d] %5s -- %s: %s\n" with the "%Y-%m-%dT%H:%M:%S.%6N" timestamp, the datetime_format override, and the msg2str coercion (String as-is, Exception"message (Class)\nbacktrace", else the host's inspect). The clock and pid are injected, so a fixed instant + pid reproduce MRI's exact bytes.
  • Level gatingAdd (alias Log) returns true and formats nothing when there is no sink or severity < level; the Debug/Info/Warn/Error/ Fatal/Unknown helpers, the <<-style raw Write, and the DebugQ..FatalQ predicates all mirror MRI.
  • Rotation policy (pure decision, no IO) — for an integer shift_age + shift_size: ShouldRotateBySize and the ShiftAgeSequence rename plan; for a calendar shift_age ("daily"/"weekly"/"monthly", plus "now"/ "everytime"): NextRotateTime, PreviousPeriodEnd, ShouldRotateByPeriod, and the PeriodAgeFile rotated-name (with MRI's .1...99 collision suffix).

CGO-free, dependency-free, 100% test coverage, gofmt + go vet clean, and green across the six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x) and three operating systems (Linux, macOS, Windows).

Install

go get github.com/go-ruby-logger/logger

Usage

package main

import (
	"fmt"
	"os"

	"github.com/go-ruby-logger/logger"
)

func main() {
	// The host wires the IO sink (here, stdout). The clock and pid default to
	// the real ones; pass fixed ones for deterministic output.
	log := logger.New(func(line string) { fmt.Print(line) })
	log.Progname = "myapp"
	log.Level = logger.INFO

	log.Debug("filtered out (below INFO)", "")
	log.Info("server started", "")
	log.Warn("disk almost full", "")
	// I, [2026-06-30T09:12:01.004212 #4242]  INFO -- myapp: server started
	// W, [2026-06-30T09:12:01.004271 #4242]  WARN -- myapp: disk almost full

	// Rotation is a pure decision the host acts on.
	if logger.ShouldRotateBySize(fileSize(), logger.DefaultShiftSize, logger.DefaultShiftAge) {
		for _, mv := range logger.ShiftAgeSequence("app.log", logger.DefaultShiftAge) {
			os.Rename(mv.From, mv.To) // host performs the rename plan
		}
	}
}

func fileSize() int64 { return 2 << 20 }

API

// Severity model (logger/severity.rb).
type Severity int
const (DEBUG Severity = 0; INFO; WARN; ERROR; FATAL; UNKNOWN) // 0..5
func SeverityLabel(s Severity) string            // "DEBUG".."FATAL", else "ANY"
func CoerceSeverity(v any) (Severity, error)     // int or "warn"/"FATAL"/…

// Formatter (logger/formatter.rb) — pure over injected clock + pid.
const DefaultDatetimeFormat = "%Y-%m-%dT%H:%M:%S.%6N"
type Inspector func(msg any) string              // host msg.inspect
type Exception struct { Message, Class string; Backtrace []string }
type Formatter struct { DatetimeFormat string; Inspect Inspector }
func (f *Formatter) Format(severityLabel string, t time.Time, pid int, progname string, msg any) string

// Logger (logger.rb) minus the IO device.
type Clock func() time.Time
type Logger struct {
	Level     Severity
	Progname  string
	Formatter *Formatter
	Sink      func(string) // the host IO device; nil = no device
	Now       Clock        // injected clock; defaults to time.Now
	Pid       func() int   // injected pid;   defaults to os.Getpid
}
func New(sink func(string)) *Logger
func (l *Logger) Add(severity Severity, message any, progname string) bool // alias Log
func (l *Logger) Write(msg string) int            // Logger#<< (raw, returns n or -1)
func (l *Logger) Debug/Info/Warn/Error/Fatal/Unknown(message any, progname string) bool
func (l *Logger) DebugQ/InfoQ/WarnQ/ErrorQ/FatalQ() bool
func (l *Logger) SetLevel(v any) error            // Logger#level=

// Rotation policy (logger/log_device.rb + logger/period.rb) — pure decisions.
type Period string // Daily, Weekly, Monthly, Now, Everytime
const (DefaultShiftSize = 1048576; DefaultShiftAge = 7; DefaultPeriodSuffix = "%Y%m%d")
func ShouldRotateBySize(currentSize, shiftSize int64, shiftAge int) bool
func ShouldRotateByPeriod(now, nextRotate time.Time) bool
type ShiftMove struct { From, To string }
func ShiftAgeSequence(filename string, shiftAge int) []ShiftMove
func PeriodAgeFile(filename string, periodEnd time.Time, periodSuffix string, exists func(string) bool) string
func NextRotateTime(now time.Time, period Period) (time.Time, error)
func PreviousPeriodEnd(now time.Time, period Period) (time.Time, error)
func ParsePeriod(s string) (Period, error)

What rbgo binds (the sink stays host-side)

Concern Where it lives
Severity numbers + labels + coercion this library
Default-format line bytes (msg2str, ts) this library
Level gating (Add / predicates) this library
Rotation decision + rename plan this library
Wall clock / process id host (injected via Now/Pid)
The IO device (open / write / rename) host (the Sink + the renames)

Tests & coverage

The suite pairs deterministic, ruby-free tests (which alone hold coverage at 100%, so the qemu cross-arch and Windows lanes pass the gate) with a differential MRI oracle: the default-format line, the datetime_format override, the Exception coercion, end-to-end level gating, the full severity label range, and the daily/weekly/monthly rotation schedule are each generated here and compared byte-for-byte against the system ruby. The oracle scripts $stdout.binmode and stub Process.pid/Time.now to fixed values so the bytes are stable, and skip themselves where ruby is absent.

COVERPKG=$(go list ./... | paste -sd, -)
go test -race -coverpkg="$COVERPKG" -coverprofile=cover.out ./...
go tool cover -func=cover.out | tail -1   # 100.0%

License

BSD-3-Clause — see LICENSE. Copyright the go-ruby-logger/logger authors.

WebAssembly

Being pure Go (CGO=0), this library also compiles to WebAssembly — both GOOS=js GOARCH=wasm (browser / Node.js) and GOOS=wasip1 GOARCH=wasm (WASI). CI builds both targets on every push, alongside the six 64-bit native/qemu arches.

GOOS=js     GOARCH=wasm go build ./...   # browser / Node
GOOS=wasip1 GOARCH=wasm go build ./...   # WASI (wasmtime, wasmer, wasmedge, …)

Documentation

Overview

Package logger is a pure-Go (CGO-free) port of the deterministic core of MRI 4.0.5's stdlib Logger (the logger-1.7.0 gem): the severity model, the default Formatter, level gating, and the LogDevice rotation *policy*.

It is the compute core that go-embedded-ruby's rbgo binds: rbgo wires the IO sink (the $stdout / File that receives the formatted bytes), the wall clock, and the process id; everything that is a pure decision — which bytes a log line is, whether a message is gated out by the level, and whether (and to what filename) a log file should rotate — lives here, with no IO and no Ruby runtime, so it is testable byte-for-byte against the `ruby` binary.

Index

Constants

View Source
const DefaultDatetimeFormat = "%Y-%m-%dT%H:%M:%S.%6N"

DefaultDatetimeFormat is MRI's Logger::Formatter::DatetimeFormat, the strftime pattern used when datetime_format is unset.

View Source
const DefaultPeriodSuffix = "%Y%m%d"

DefaultPeriodSuffix is MRI's default @shift_period_suffix ("%Y%m%d").

View Source
const DefaultShiftAge = 7

DefaultShiftAge is MRI's default integer @shift_age (keep 7 old files).

View Source
const DefaultShiftSize = 1048576

DefaultShiftSize is MRI's default @shift_size (1 MiB).

Variables

This section is empty.

Functions

func NextRotateTime

func NextRotateTime(now time.Time, period Period) (time.Time, error)

NextRotateTime is a pure port of Logger::Period.next_rotate_time: it returns the next instant at which a calendar rotation of cadence period becomes due, relative to now. The arithmetic is performed in now's own location, so a host that wires local time gets MRI's Time.mktime behaviour. "now"/"everytime" return now unchanged. An unrecognised period is an error.

func PeriodAgeFile

func PeriodAgeFile(filename string, periodEnd time.Time, periodSuffix string, exists func(string) bool) string

PeriodAgeFile returns the name a period rotation would rename the current log to, mirroring Logger::LogDevice#shift_log_period. The base name is "<filename>.<suffix>", where suffix is periodEnd formatted with the period suffix; if that name is in the host-supplied taken set, MRI appends ".1", ".2", … (up to 99) until a free name is found. exists reports whether a candidate name is already taken (the host's FileTest.exist?).

func PreviousPeriodEnd

func PreviousPeriodEnd(now time.Time, period Period) (time.Time, error)

PreviousPeriodEnd is a pure port of Logger::Period.previous_period_end: it returns 23:59:59 on the last day of the period preceding now, used to name the rotated file. "now"/"everytime" return now unchanged; an unknown period errors.

func SeverityLabel

func SeverityLabel(s Severity) string

SeverityLabel returns the textual label MRI uses for a severity, matching Logger#format_severity: SEV_LABEL[severity] || 'ANY'. Any value outside 0..5 yields "ANY".

func ShouldRotateByPeriod

func ShouldRotateByPeriod(now, nextRotate time.Time) bool

ShouldRotateByPeriod reports whether a calendar rotation is due, mirroring the period branch of check_shift_log: now >= @next_rotate_time.

func ShouldRotateBySize

func ShouldRotateBySize(currentSize, shiftSize int64, shiftAge int) bool

ShouldRotateBySize reports whether a size-based (integer shift_age) rotation is due. It mirrors Logger::LogDevice#check_shift_log's integer branch:

@filename && (@shift_age > 0) && (@dev.stat.size > @shift_size)

The filesize is injected (the host stat'd the device); this is the pure decision only.

Types

type Clock

type Clock func() time.Time

Clock supplies the current instant. The default is time.Now; tests inject a fixed instant so the formatted lines are deterministic.

type Exception

type Exception struct {
	Message   string   // exception.message
	Class     string   // exception.class.to_s
	Backtrace []string // exception.backtrace (nil if the exception has none)
}

Exception is the message shape Logger::Formatter#msg2str special-cases. A host passes one of these when the logged object is a Ruby exception; the formatter renders "<message> (<class>)\n<backtrace>" exactly as MRI does.

type Formatter

type Formatter struct {
	// DatetimeFormat overrides the strftime pattern for the timestamp; empty
	// means use DefaultDatetimeFormat (MRI's @datetime_format || DatetimeFormat).
	DatetimeFormat string
	// Inspect renders an arbitrary message value as msg.inspect would; see
	// Inspector. May be nil.
	Inspect Inspector
}

Formatter is a pure port of MRI's Logger::Formatter. It holds only the optional datetime_format override and the message Inspector; the clock, pid, severity-label, progname and message are all supplied per call, so Format is a deterministic function of its inputs.

func (*Formatter) Format

func (f *Formatter) Format(severityLabel string, t time.Time, pid int, progname string, msg any) string

Format renders one log line byte-for-byte as MRI's Logger::Formatter#call would, given the severity label, the (injected) timestamp, the (injected) pid, the progname and the message:

sprintf("%.1s, [%s #%d] %5s -- %s: %s\n",
        severity, format_datetime(time), pid, severity, progname, msg2str(msg))

type Inspector

type Inspector func(msg any) string

Inspector turns a non-String, non-Exception message into the text MRI's msg.inspect would produce. The host wires this from its own object model (Array#inspect, Hash#inspect, Integer#to_s, ...); the library never reaches into a Ruby object itself. A nil Inspector falls back to Go's %#v / fmt.Sprint only for the Go primitive forms a ruby-free test feeds it.

type Logger

type Logger struct {
	// Level is the threshold below which Add drops a message (MRI's @level).
	Level Severity
	// Progname is the default program name (MRI's @progname).
	Progname string
	// Formatter renders each line; if nil, a zero-value default Formatter is used
	// (MRI's @formatter || @default_formatter).
	Formatter *Formatter
	// Sink receives each formatted line and each raw << write — this is the host
	// IO device. A nil Sink models MRI's @logdev.nil? (Add returns true and
	// writes nothing).
	Sink func(string)
	// Now supplies the timestamp for each line; defaults to time.Now.
	Now Clock
	// Pid supplies the process id stamped into each line; defaults to os.Getpid
	// via PidFunc. Modeled as a function so tests inject a fixed pid.
	Pid func() int
}

Logger is a pure port of MRI's Logger class minus the IO device. It decides what bytes each call would emit and whether a call is gated by the level, then hands the formatted line to the host Sink (rbgo's IO device). It never writes to a file or stream itself.

func New

func New(sink func(string)) *Logger

New returns a Logger writing formatted lines to sink, with the default Formatter, DEBUG level, and the real clock/pid wired. Pass a nil sink to model a logger with no device.

func (*Logger) Add

func (l *Logger) Add(severity Severity, message any, progname string) bool

Add mirrors Logger#add. severity defaults to UNKNOWN when negative (MRI's `severity ||= UNKNOWN`, modeled here with the sentinel -1); when there is no sink (MRI's @logdev.nil?) or the severity is below the level, it formats nothing and returns true. Otherwise it formats the line — picking up the default progname when progname is empty — and writes it to the sink, then returns true. Add always returns true, exactly as MRI does.

func (*Logger) Debug

func (l *Logger) Debug(message any, progname string) bool

Debug logs message at DEBUG (Logger#debug). The remaining severity helpers mirror their MRI counterparts.

func (*Logger) DebugQ

func (l *Logger) DebugQ() bool

DebugQ mirrors Logger#debug?: level <= DEBUG. The predicates report whether a message at that severity would currently be emitted.

func (*Logger) Error

func (l *Logger) Error(message any, progname string) bool

Error logs message at ERROR (Logger#error).

func (*Logger) ErrorQ

func (l *Logger) ErrorQ() bool

ErrorQ mirrors Logger#error?: level <= ERROR.

func (*Logger) Fatal

func (l *Logger) Fatal(message any, progname string) bool

Fatal logs message at FATAL (Logger#fatal).

func (*Logger) FatalQ

func (l *Logger) FatalQ() bool

FatalQ mirrors Logger#fatal?: level <= FATAL.

func (*Logger) Info

func (l *Logger) Info(message any, progname string) bool

Info logs message at INFO (Logger#info).

func (*Logger) InfoQ

func (l *Logger) InfoQ() bool

InfoQ mirrors Logger#info?: level <= INFO.

func (*Logger) Log

func (l *Logger) Log(severity Severity, message any, progname string) bool

Log is MRI's alias for Add (`alias log add`).

func (*Logger) SetLevel

func (l *Logger) SetLevel(v any) error

SetLevel mirrors Logger#level=, coercing a string or integer like MRI's Severity.coerce before assigning.

func (*Logger) Unknown

func (l *Logger) Unknown(message any, progname string) bool

Unknown logs message at UNKNOWN (Logger#unknown).

func (*Logger) Warn

func (l *Logger) Warn(message any, progname string) bool

Warn logs message at WARN (Logger#warn).

func (*Logger) WarnQ

func (l *Logger) WarnQ() bool

WarnQ mirrors Logger#warn?: level <= WARN.

func (*Logger) Write

func (l *Logger) Write(msg string) int

Write mirrors Logger#<<: it writes msg to the sink with no formatting and returns the number of bytes written, or -1 when there is no sink (MRI returns nil; -1 is the Go-idiomatic "no device" signal).

type Period

type Period string

Period is a calendar rotation cadence, MRI's :shift_age string/symbol form ("daily"/"weekly"/"monthly", plus "now"/"everytime").

const (
	Daily     Period = "daily"
	Weekly    Period = "weekly"
	Monthly   Period = "monthly"
	Now       Period = "now"
	Everytime Period = "everytime"
)

The rotation periods MRI's Logger::Period accepts.

func ParsePeriod

func ParsePeriod(s string) (Period, error)

ParsePeriod coerces a host-supplied shift_age string/symbol into a Period, accepting the same spellings MRI's case statements do.

type Severity

type Severity int

Severity is a log level, matching MRI's Logger::Severity numeric model.

DEBUG=0 INFO=1 WARN=2 ERROR=3 FATAL=4 UNKNOWN=5

A Severity is just an int; the named constants below mirror the Ruby ones so a host can pass MRI's integers straight through.

const (
	DEBUG   Severity = 0 // Low-level information, mostly for developers.
	INFO    Severity = 1 // Generic (useful) information about system operation.
	WARN    Severity = 2 // A warning.
	ERROR   Severity = 3 // A handleable error condition.
	FATAL   Severity = 4 // An unhandleable error that results in a program crash.
	UNKNOWN Severity = 5 // An unknown message that should always be logged.
)

The MRI Logger::Severity constants (logger/severity.rb).

func CoerceSeverity

func CoerceSeverity(v any) (Severity, error)

CoerceSeverity mirrors Logger::Severity.coerce: an Integer passes through unchanged; a string (case-insensitively) maps via LEVELS, and anything else is an error ("invalid log level: ..."). It accepts an int or a string, the two forms a host level= would hand through.

type ShiftMove

type ShiftMove struct {
	From string
	To   string
}

ShiftMove is one rename a rotation performs: From is renamed to To. From may not exist (the host skips those, as MRI's `if FileTest.exist?` does).

func ShiftAgeSequence

func ShiftAgeSequence(filename string, shiftAge int) []ShiftMove

ShiftAgeSequence returns the rename moves a size-based rotation performs, in order, mirroring Logger::LogDevice#shift_log_age:

(@shift_age-3).downto(0) { |i| rename "f.i" -> "f.i+1" if exist }
rename "f" -> "f.0"

Each move is {From, To}; the host applies them (renaming only those whose From exists), then opens a fresh @filename. For a shift_age of n, the moves cover suffixes n-3 .. 0 plus the base file, keeping at most n-1 numbered backups. A shift_age of 3 or less yields just the base -> "filename.0" move.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL