Search by

sugarcraft / sugar-log

detain

PHP port of charmbracelet/log — minimal, colorful leveled logging with structured human-readable output, text/JSON/logfmt formatters, and stdlog adapter.

Package info

github.com/sugarcraft/sugar-log

pkg:composer/sugarcraft/sugar-log

Statistics

Installs: 0

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-11-03 17:18 UTC

This package is auto-updated.

Last update: 2026-11-05 01:02:12 UTC


README

sugar-log

CI codecov Packagist Version License PHP

SugarLog

Formerly published as sugarcraft/candy-log (renamed 2026-10).

sugar-log — a minimal, colorful leveled logging library for PHP 8.3+.

Features

  • Leveled logging — Debug, Info, Warn, Error, Fatal levels
  • Colorful human-readable output — styled when the destination is a color-capable terminal (honors NO_COLOR, force with CLICOLOR_FORCE; plain bytes on files and pipes by design)
  • Multiple formatters — TextFormatter (default), JsonFormatter, LogfmtFormatter
  • Structured key/value pairs — pass arbitrary context with every log call
  • Sub-loggers — with([...]) creates a child logger with persistent fields (a channel analogue: give each subsystem its own withPrefix()/with() child)
  • Per-field styling — the Styles instance property values maps individual field names to their styles; keys styles the generic key and value roles
  • Threshold-friendly levels — integer values (-4/0/4/8/12, charmbracelet/log-aligned deltas — not RFC 5424 severities) for easy threshold filtering
  • stdlog adapter — StandardLogAdapter offers a Go-style call pattern (print/printLn/fatal/panic) over a sugar-log logger — a shape, not an interface: it implements no external contract (see Stdlog adapter)
  • PSR-3 bridge — PsrBridge implements Psr\Log\LoggerInterface; the eight severity methods never throw (a failed write lands as an internal notice, not an exception), while log() rejects unknown level strings with InvalidArgumentException as PSR-3 itself prescribes
  • Record integrity — colliding context keys are namespaced (ctx_), logfmt values escape \ and ", control bytes are neutralised (see Record integrity)
  • Error-stream routing — optional second stream for severe records (threshold defaults to Error) without moving everything else off STDOUT
  • Hook system — register callbacks per log level via HookRegistry::onLevel()
  • Configurable log-part ordering — PartsOrder DTO controls which parts appear and in what sequence

Install

composer require sugarcraft/sugar-log

Stability: this package requires its sugarcraft/ siblings at dev-master. A Composer project left on the default stable minimum-stability cannot resolve them — add "minimum-stability": "dev", "prefer-stable": true to the consuming root (or pin the released sugarcraft/* tags once cut). The monorepo publishes all libs together, so sibling versions always line up.

Quick Start

use SugarCraft\Log\Logger;
use SugarCraft\Log\Level;

$log = Logger::new(level: Level::Debug); // default threshold is Info; Debug opts in
$log->debug('preheat cycle', ['stage' => 1]);
$log->info('Starting oven', ['degree' => 375]);
$log->warn('Almost ready', ['batch' => 2]);
$log->error('Temperature too low', ['err' => 'underheated']);

Every Logger constructor call also takes formatter:/stream:/prefix: — see Output and streams.

Levels

Levels carry charmbracelet/log-aligned integer deltas (Debug -4 through Fatal 12 — deliberately not RFC 5424 severities) — use ->value for threshold comparisons. A record is emitted when its level value is at or above the logger's minLevel (default Info, hence the Debug opt-in above; print() and its formatted sibling printf() are the two methods that bypass the filter):

use SugarCraft\Log\Level;
use SugarCraft\Log\Logger;

Level::Debug->value; // -4
Level::Info->value;  //  0
Level::Warn->value;  //  4
Level::Error->value;  //  8
Level::Fatal->value;  // 12

$log = Logger::new(); // threshold: Info
$log->info('info message');
$log->warn('warn message');
$log->error('error message');

try {
    $log->fatal('fatal message'); // writes the line, then throws RuntimeException
} catch (RuntimeException) {
    // the port's stop-signal: Go's log.Fatal would exit(1) right here
}

$log->print('always prints'); // bypasses the level FILTER — the label still renders

Static Facade

Log is a process-shared façade over one lazily-created default Logger — the package-level function style of charmbracelet/log:

use SugarCraft\Log\Log;

Log::info('baking started', ['temp' => 375]);
Log::warn('timer almost up');
Log::error('power cut');
// The shared logger's threshold is Info, so Log::debug('...') is dropped
// until it is lowered: Log::default()->setMinLevel(Level::Debug);

try {
    Log::fatal('out of flour'); // writes, then throws RuntimeException
} catch (RuntimeException) {
    // handle, or exit deliberately — this is where Go's Fatal would stop
}

$fh = fopen('php://stderr', 'w');
Log::default()->setOutput($fh); // reach the shared logger itself
fclose($fh);

Swap the shared logger wholesale with Log::setLogger(Logger::new(...)), and Log::reset() returns to the lazily-created default.

Structured Fields

use SugarCraft\Log\Logger;

$log = Logger::new();
$log->info('Baking cookies', [
    'flour' => '2 cups',
    'butter' => true,
    'temp' => 375,
]);

// Child logger with persistent fields
$baker = $log->with(['user' => 'chef', 'session' => 'am']);
$baker->info('Batch started'); // also has user + session

Formatters

use SugarCraft\Log\Formatter\TextFormatter;
use SugarCraft\Log\Formatter\JsonFormatter;
use SugarCraft\Log\Formatter\LogfmtFormatter;
use SugarCraft\Log\Logger;

$log = Logger::new(formatter: new JsonFormatter());   // structured output
$log = Logger::new(formatter: new LogfmtFormatter()); // logfmt key=value
$log = Logger::new(formatter: new TextFormatter());   // human line (the default)

JsonFormatter and LogfmtFormatter take an optional second constructor argument, $timeFormat — a DateTime format string; it defaults to DateTimeInterface::ATOM because machine parsers pin timestamps to that shape, so change it deliberately, not casually. Inside a text line, array context values render as [a b c]: elements joined positionally, associative keys not shown — use JSON or logfmt when key names matter.

Styling

Styles are applied only when the destination can render them. A Logger consults sugar-colorprofile's Probe and — for each write — the stream it is about to use: TextFormatter output reaches a file or a piped stream as plain bytes, never as baked-in ANSI escapes. Set NO_COLOR to force plain output on terminals, or CLICOLOR_FORCE=1 to opt styling back in for captured output (FORCE_COLOR is not a knob this fleet reads). A standalone new TextFormatter(useColors: true) still emits SGR by contract — the gate lives in the Logger, not in the formatter.

Override level styles via Logger::styles():

Shared singleton: Logger::styles() returns the process-wide Styles::default() instance. Mutating it changes every logger that was not given its own Styles. For per-logger styling, build a fresh Styles, start from Styles::snapshot() — a private copy (mutable arrays fresh, Style value-objects shared) safe to edit without touching the process default — or hand one to the formatter, and install it with setStyles():

use SugarCraft\Core\Util\Color;
use SugarCraft\Log\Level;
use SugarCraft\Log\Logger;
use SugarCraft\Log\Styles;
use SugarCraft\Style\Style;

$log = Logger::new();
$styles = new Styles();
$styles->levels[Level::Error->value] = Style::new()->foreground(Color::parse('red'))->bold();
$log->setStyles($styles);

Per-field styles

Styles::keys maps the log parts (time, level, prefix, caller, message) and the field-pair roles (key, value) to individual Style objects. Part roles ship as do-nothing styles (raw bytes, no SGR); the formatter falls back to the matching property ($timestamp, $prefix, $caller, $message) or the level colour map when a part style is unset:

use SugarCraft\Core\Util\Color;
use SugarCraft\Log\Logger;
use SugarCraft\Log\Styles;
use SugarCraft\Style\Style;

$log = Logger::new();
$styles = new Styles();
$styles->keys['time']    = Style::new()->foreground(Color::parse('cyan'));
$styles->keys['level']   = Style::new()->foreground(Color::parse('magenta')); // wins over the level map
$styles->keys['message'] = Style::new()->italic();
$styles->keys['value']   = Style::new()->foreground(Color::parse('green'));   // splits k=v into segments
$styles->values['batch'] = Style::new()->foreground(Color::parse('magenta')); // per-field override
$log->setStyles($styles);

High-contrast environments

The shipped defaults put Debug, the caller tag and generic keys on bright-black — roughly 2.5-3:1 against the default background, marginal for long reading sessions. Labels always carry text (color is never the only channel), but where contrast matters, override the roles explicitly:

use SugarCraft\Core\Util\Color;
use SugarCraft\Log\Level;
use SugarCraft\Log\Logger;
use SugarCraft\Log\Styles;
use SugarCraft\Style\Style;

$log = Logger::new();
$styles = Styles::snapshot();
$styles->levels[Level::Debug->value] = Style::new()->foreground(Color::parse('white'));
$styles->levels[Level::Info->value]  = Style::new()->foreground(Color::parse('cyan'))->bold(); // pure blue is the other marginal one
$styles->keys['key']                 = Style::new()->foreground(Color::parse('white'));
$styles->caller                      = Style::new()->foreground(Color::parse('white'))->bold();
$log->setStyles($styles);

Without a value style, each context pair is rendered as one key=value run using keys['key']; setting keys['value'] (or a per-field values[$key]) renders key and value as separate styled segments around a bare =.

Level text alignment

Styles::padLevelText($label) right-pads a level label to 5 characters for column-aligned log output:

use SugarCraft\Log\Styles;

Styles::padLevelText('INFO');  // "INFO "
Styles::padLevelText('DEBUG'); // "DEBUG"

Set the padLevelText property on a Styles instance to make TextFormatter apply the padding to every rendered level label:

use SugarCraft\Log\Logger;
use SugarCraft\Log\Styles;

$log = Logger::new();
$styles = new Styles();
$styles->padLevelText = true; // renders "INF  " / "DEBUG" instead of "INF" / "DEBUG"
$log->setStyles($styles);

Output and streams

By default every level is written to STDOUT. This deliberately deviates from charmbracelet/log, which writes to os.Stderr; the single-stream default is kept because existing consumers rely on it. Two knobs change where records go:

use SugarCraft\Log\Formatter\TextFormatter;
use SugarCraft\Log\Logger;
use SugarCraft\Log\Level;

// 1. Custom stream — send a logger anywhere (immutable: returns a new Logger)
$log = Logger::new();
$fh  = fopen('/tmp/app.log', 'a');   // plain bytes on files: styling is auto-dropped
$out = $log->withOutput($fh);         // create with 0600 (umask) in real deployments

// 2. Error-stream routing — severe records go to a second stream
$err = fopen('php://stderr', 'w');
$log = Logger::new(errorStream: $err);                              // Error+Fatal → STDERR
$log = Logger::new(errorStream: $err, errorStreamAt: Level::Warn); // Warn and above → STDERR
$one = $log->withErrorStream(null);                                // back to a single stream

Routing is decided per record when it is emitted; Fatal still throws RuntimeException after writing. Constructor and setOutput()/ withOutput()/setErrorStream()/withErrorStream() reject non-resource streams with an InvalidArgumentException. A record that cannot be written (closed or unwritable handle) never throws: it is retried once after a flush and the loss is announced as a sanitized [sugar-log] log write failed: notice on STDERR. File streams are buffered by PHP — call $log->flush() before hard-exit paths (SIGKILL, posix_kill, exit() under a fatal framework) so nothing still in the buffer is lost.

Record integrity

Untrusted data can never forge or reshape a record:

  • Sanitisation — text reaching TextFormatter and LogfmtFormatter passes through Sanitize::untrusted(), stripping ESC-based injection (terminal repositioning, hyperlinks, image markers) before anything is written; JsonFormatter sweeps the same control bytes from message, prefix, caller and every context key and value before encoding (json_encode escapes C0 bytes but passes C1 bytes through verbatim), and substitutes invalid UTF-8 instead of letting it fail the encode.
  • logfmt escaping — backslash and double-quote inside values are escaped (\\, \"), newline/CR/tab become \n/\r/\t, and a value is quoted whenever it contains whitespace, =, ", or \.
  • Reserved keys — context keys colliding with the record envelope (level, msg, time, caller, prefix, _encode_error) are renamed with a repeated ctx_ prefix in both JSON and logfmt output (level → ctx_level, ctx_level → ctx_ctx_level). Decoded records therefore always carry the real level, time and message no matter what a context array contains.
  • Hostile shapes — reference cycles in context terminate at a depth cap ([...]), non-finite floats become 'INF'/'-INF'/'NAN', a throwing __toString() is contained (the object reports its class name), and if an encode fails anyway the JSON fallback drops only the offending field — the envelope and every other entry survive, flagged by _encode_error.

Panic Handlers

use SugarCraft\Log\Log;

// Install a panic handler that catches uncaught exceptions and fatal errors,
// restores the terminal from altscreen mode, and prints a panic report —
// styled when STDERR is a color-capable terminal (or CLICOLOR_FORCE=1),
// plain ANSI-free text everywhere else.
Log::installPanicHandler();

// Restore terminal state manually (exit altscreen, show cursor).
// Called automatically by the panic handler, but safe to call directly.
Log::restoreTerminal();

The panic handler catches uncaught exceptions and fatal PHP errors (E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR), restores the terminal to a usable state, and prints a banner with the error class, message, and backtrace — colorized only when the destination supports it. The exception message is treated as untrusted input: terminal-control bytes are swept and newlines flattened, so a message that embeds user data can never forge extra report rows or emit escape sequences into whoever displays it, and redactPaths substrings are stripped from it as they are from frames and locals. Re-installing replaces the previous sugar-log handler instead of stacking. Internal frames with no file/line render honestly as ?:?, and the report ends with a neutral footer identifying sugar-log for issue filing. showLocals dumps call arguments only for traces that carry them — PHP's engine omits args from exception traces, so for plain uncaught exceptions there are no locals to show; the knob is live for manually injected frame arrays.

PSR-3 Bridge

PsrBridge wraps a Logger instance and implements Psr\Log\LoggerInterface: all nine methods (emergency, alert, critical, error, warning, notice, info, debug, log) are real interface methods, so the bridge satisfies a LoggerInterface type-hint anywhere a PSR-3 logger is expected. The eight severity methods never throw — a hostile __toString(), a formatter that raises, or the RuntimeException that backs Fatal are all contained and announced as sanitized notices on STDERR, never propagated into the caller. Only log(), which takes the PSR-3 spec's mixed $level, may raise: an unrecognized or non-string level is an InvalidArgumentException, as PSR-3 itself prescribes for that method:

use SugarCraft\Log\Logger;
use SugarCraft\Log\PsrBridge;
use Psr\Log\LogLevel;

$logger = new Logger();
$psr = new PsrBridge($logger);

// All PSR-3 methods available
$psr->warning('Something is off', ['detail' => 'temperature rising']);
$psr->log(LogLevel::ERROR, 'Operation failed', ['code' => 500]);

Level mapping, in one place: emergency/alert/critical → Fatal (the record carries the FTL label and routes to the error stream, but the bridge swallows the RuntimeException that backs Fatal — Go's exit is not portable here); error → Error; warning → Warn; notice and info → Info; debug → Debug.

The bridge also fires registered hooks after the record is written (see Hook System), enabling middleware-style interceptors. A hook that throws is contained per-handler the same way — one hostile callback can never cancel the walk or eat the log line.

Hook System

The hook system lets you register callbacks that fire whenever a log entry is emitted at or above a given level. Hooks receive the Level, PSR-3 level string, message, and context — useful for dispatching to external services, enriching context, or filtering.

Note: Hooks fire only via PsrBridge — they do not fire when calling Logger->info() etc. directly. For the plain Logger API, hooks are not invoked. To use hooks, wrap your Logger in a PsrBridge and call methods on the bridge.

use SugarCraft\Log\Logger;
use SugarCraft\Log\Level;
use SugarCraft\Log\Hook\HookRegistry;
use SugarCraft\Log\PsrBridge;

$logger = new Logger();
$hooks = new HookRegistry();

// Register a callback for all Warn-and-above entries
$id = $hooks->onLevel(Level::Warn, function (Level $level, string $psrLevel, string $message, array $context) {
    // Dispatch to external service, enrich context, etc.
    file_put_contents('/tmp/warn.log', "[{$level->label()}] {$message}\n", FILE_APPEND);
});

// Wire the registry into the bridge — this is how hooks fire
$psr = new PsrBridge($logger, $hooks);
$psr->warning('Something is off'); // the hook above runs after the record lands

// Unregister when done ($id came from onLevel())
$hooks->remove($id);

// The registry can also be fired directly by an integration:
$hooks->fire(Level::Warn, 'warning', 'Something is off', []);

HookRegistry::onLevel(Level, callable) returns a registration ID. HookRegistry::fire(Level, psrLevel, message, context) dispatches to all handlers whose minimum level is met, and remove($id) unregisters a handler (unknown ids are ignored).

The Hook interface is also available for structured implementations:

use SugarCraft\Log\Hook\Hook;
use SugarCraft\Log\Level;

final class MetricsHook implements Hook
{
    public function onLevel(Level $level, string $psrLevel, string $message, array $context): void
    {
        // Ship metrics to your observability platform
    }
}

Parts Order

PartsOrder is a config DTO that controls which log-parts appear and in what sequence when formatting. It ships with three named presets:

use SugarCraft\Log\PartsOrder;

// Default: all six parts in canonical order
PartsOrder::default();   // [timestamp, level, prefix, caller, message, fields]

// Syslog-friendly: omits prefix and caller
PartsOrder::syslog();     // [timestamp, level, message, fields]

// Message-first: message comes before level and timestamp
PartsOrder::messageFirst(); // [message, level, timestamp, fields]

// Custom ordering
$order = new PartsOrder([PartsOrder::PART_MESSAGE, PartsOrder::PART_LEVEL, PartsOrder::PART_FIELDS]);

// Query whether a part is included
$order->has(PartsOrder::PART_CALLER); // false for syslog(), true for default()

Named part constants: PART_TIMESTAMP, PART_LEVEL, PART_PREFIX, PART_CALLER, PART_MESSAGE, PART_FIELDS. The constructor rejects unknown or duplicated parts with an InvalidArgumentException naming the offenders — a typo'd 'msg' would otherwise silently delete the message from every rendered line. PartsOrder is honored only by TextFormatter; JSON and logfmt ignore it (a JSON object has no member order, a logfmt record is fixed-shape).

Caller Information

Logger::setReportCaller(true) is the normal switch — the logger then tags each line automatically. Underneath, CallerFormatter::find() walks the call stack (skipping at most 20 frames) and returns "basename:line" of the first frame outside the log package, or null when the call site is not found within that budget:

use SugarCraft\Log\CallerFormatter;

$caller = CallerFormatter::find(); // e.g. "my-script.php:42"
use SugarCraft\Log\Logger;

$log = Logger::new(reportCaller: true);
$log->info('who called me'); // line carries a <file:line> tag

Stdlog adapter

StandardLogAdapter mirrors the shape of Go's log.Logger methods — print()/printLn()/fatal()/panic() — over a sugar-log Logger. Be honest about the boundary: PHP has no *log.Logger interface and no net/http, so the adapter implements no contract; it is a call-pattern bridge (hand it to code written against that method roster):

use SugarCraft\Log\Level;
use SugarCraft\Log\Logger;
use SugarCraft\Log\StandardLogAdapter;

$logger = Logger::new(level: Level::Debug);
$stdlog = new StandardLogAdapter($logger);
$stdlog->printLn('GET /health');     // lands like Logger::print(): INF label, bypasses the minLevel filter
try {
    $stdlog->fatal('listen :80 failed'); // writes the record, then throws RuntimeException
} catch (RuntimeException) {
    // Go's Fatal would exit(1) here
}

An adapter built with a forced level above Info keeps the bypass only for the un-forced path: once a level is forced, print() goes through the minLevel filter like any ordinary call at that level.

Sample output

Three formatters, three records, no color (what a file sink or pipe receives):

07:15:04 WRN bakery <baker.php:88> Almost ready batch=2 oven=convection
{"level":"ERROR","msg":"Temperature too low","time":"2026-11-03T07:15:04+00:00","caller":"oven.php:31","prefix":"bakery","err":"underheated"}
time=2026-11-03T07:15:04+00:00 level=INFO msg="Starting oven" prefix=bakery degree=375

Translations

Error-path prose — the two messages thrown by Logger (logger.fatal, logger.invalid_stream) — is resolved through sugar-core's Lang lookup in en plus 15 locales (exact → base → en → raw). Log content (level labels, timestamps, field text) is never translated. Selection is the host application's duty: sugar-log never changes locale on its own — set it through sugar-core's i18n entry point at process start, and the two error-path messages resolve accordingly.

License

MIT

Credits & inspiration

Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.