Logger¶
pkg/logger provides a unified logging interface for all GTB packages. Every
component accepts logger.Logger rather than a concrete type, keeping the
framework backend-agnostic and fully testable.
Overview¶
All GTB packages receive a logger.Logger through the Props container.
logger.Logger's method set mirrors the standard library's *slog.Logger
exactly, so a *slog.Logger satisfies it directly — you can assign one straight
to Props.Logger. Several constructors are provided:
| Constructor | Returns | Best For |
|---|---|---|
NewCharm(w, opts...) |
slog-mirror Logger (also Leveller/Reformatter) |
CLI applications — coloured, styled terminal output; GTB's default |
NewCharmSlog(w, opts...) / NewCharmHandler(w, opts...) |
*slog.Logger / slog.Handler |
slog-native construction over GTB's Charm output |
NewSlog(handler) |
*slog.Logger |
Observability stacks — OpenTelemetry, Datadog, Zap, Zerolog |
NewNoop() |
discarding *slog.Logger |
Tests — discards all output |
NewBuffer() / NewCaptureHandler() |
in-memory capture | Tests — assert on captured records |
Why a Logger Interface?¶
Go's log/slog is the standard library logging boundary, and GTB embraces it:
logger.Logger mirrors *slog.Logger exactly, so any slog-compatible logger
drops in and structured, levelled logging is the norm across the framework. What
pkg/logger adds on top is CLI-shaped construction and testing:
- Coloured, styled terminal output —
slogproduces plain text or JSON;NewCharmgives CLI users the styled output they expect while still being a*slog.Loggerunder the hood - Testable by construction —
slogships no first-class test double.logger.NewNoop()discards output,NewBuffer()/NewCaptureHandler()capture records for assertions, andNewCharm(w, …)writes to anyio.Writeryou inject (e.g. abytes.Buffer) - Runtime level/format control — a bare
*slog.Loggerowns its level via its handler, but GTB's default logger also implementsLeveller/Reformatterso--debug,log.level, andlog.formatcan take effect after construction
Because the interface is just the *slog.Logger method set, backends are swapped
at the Props construction point in main.go — no other code changes, and you
can inject a plain *slog.Logger from anywhere in the ecosystem.
The Logger Interface¶
logger.Logger mirrors the *slog.Logger method set exactly — the levelled
methods (Debug/Info/Warn/Error and their …Context variants), Log /
LogAttrs, With / WithGroup, Enabled, and Handler. Nothing else is on
the interface, which is precisely why a *slog.Logger satisfies it:
Runtime level/format control is not on the interface (see
Dynamic Level Control below); process termination and
unlevelled user output are the command / go/output layers' job, not the
logging boundary's.
[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/logger for the full API definition.
Log Levels¶
const (
DebugLevel Level = iota // Most verbose
InfoLevel // Default
WarnLevel // Potentially harmful
ErrorLevel // Error conditions
FatalLevel // Fatal — terminates the process
)
Parse a level from a string (e.g., config or flag):
Output Formatters¶
const (
TextFormatter Formatter = iota // Human-readable (default for charmbracelet)
JSONFormatter // Machine-readable JSON
LogfmtFormatter // logfmt key=value pairs
)
Reformatting at runtime goes through the logger.SetFormatter(log, f) helper.
It succeeds (returning true) on GTB's Charm-backed logger, which implements
Reformatter; for a plain *slog.Logger it is a no-op returning false, since
the format is fixed by the slog.Handler at construction time.
Backends¶
charmbracelet (default for CLI)¶
Produces coloured, styled terminal output via charmbracelet/log.
This is the default for all GTB-generated CLI tools.
import (
"os"
"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
)
l := logger.NewCharm(os.Stderr,
logger.WithLevel(logger.InfoLevel),
logger.WithTimestamp(false), // disable timestamps for CLI output
logger.WithCaller(false), // disable caller location
logger.WithPrefix("myapp"),
)
CharmOption functions:
| Option | Effect |
|---|---|
WithLevel(level) |
Sets the initial log level |
WithTimestamp(bool) |
Show/hide timestamp in output |
WithCaller(bool) |
Show/hide caller file:line |
WithPrefix(string) |
Prepend a prefix to all messages |
NewCharm returns a logger.Logger; its Handler() method exposes the
backing slog.Handler, which you can wrap in slog.New for any library that
wants a *slog.Logger:
If you need slog-native construction over the same Charm output — for example to
hand a *slog.Logger or slog.Handler directly to another component — use
NewCharmSlog(w, opts...) or NewCharmHandler(w, opts...) instead.
slog (observability integration)¶
Wraps any slog.Handler — use this for OpenTelemetry, Datadog, structured
JSON pipelines, or any slog ecosystem library.
import (
"log/slog"
"gitlab.com/phpboyscout/go-tool-base/pkg/logger"
)
// Standard library JSON handler
jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
l := logger.NewSlog(jsonHandler)
// With Zap (using zapslog bridge)
// l := logger.NewSlog(zapslog.NewHandler(zapCore))
// With OpenTelemetry
// l := logger.NewSlog(otelslog.NewHandler(exporter))
NewSlog returns a plain *slog.Logger, which owns its level and format through
its handler. It does not implement Leveller/Reformatter, so
logger.SetLevel and logger.SetFormatter are no-ops (returning false) for
it. For runtime level control, build the handler at its most permissive level
and wrap it with NewLevelGate(handler, levelVar); mutate the shared
*slog.LevelVar to raise or lower the threshold at runtime.
noop (tests)¶
Discards all output with zero allocations. Use in tests where log output is irrelevant.
slog Interoperability¶
All backends expose an slog.Handler via l.Handler(). Use this when a
third-party library requires *slog.Logger:
Structured values, and why the Charm backend is wrapped¶
The Charm backend's handler is wrapped by logger.NewResolvingHandler. That
wrapper exists to work around a specific upstream defect, and it is meant to be
deleted, so it is worth writing down what it is for.
The defect¶
log/slog requires a handler to call Value.Resolve() on every attribute. That
is the call which turns a slog.LogValuer into the value it wants logged.
charmbracelet/log does this in one of its three formatters. Its JSON
formatter resolves structured values and renders them as nested objects; its
text and logfmt formatters do neither, because Handle passes the raw
slog.Value on and leaves each formatter to decide. The same record therefore
carries less information as text than as JSON:
text err="no config file found"
json {"err":{"msg":"no config file found","kind":"...","hint":["Run 'gtb init' ..."]}}
This became load-bearing when errorhandling v0.2.0 stopped taking errors apart
and began handing them to slog whole, expecting the handler to resolve them.
Everything an error carries, including the hints that tell a user what to do
next, arrives through LogValue. Against the text formatter it silently
disappeared.
Watch this issue¶
https://github.com/charmbracelet/log/issues/96
When it is fixed, delete pkg/logger/resolve.go and its wiring in NewCharm.
Nothing else depends on it.
The asymmetry looks accidental rather than deliberate: PR #127 ("support slog
attributes") touched json.go and the handler, not the two text formatters. A
patch exists and was deliberately not proposed upstream, because resolving
properly changes output for anyone pinning the old rendering, and that is the
maintainers' call to make. The findings are recorded on the issue, and the patch
is kept at ~/patches/charmbracelet-log/ in case they ask for it.
Why Charm was kept rather than swapped out¶
The obvious alternative was to drop Charm and standardise on a conformant
slog.Handler. The ecosystem was surveyed and none of the alternatives carried
the features GTB actually relies on:
| Candidate | Outcome |
|---|---|
lmittmann/tint |
Escapes multi-line values onto one line. A stack trace becomes unreadable |
golang-cz/devslog |
Renders multi-line, but with no gutter the value bleeds into the left margin |
phuslu/log |
Its only slog.Handler is JSON. No text handler exists |
coder/slog (cdr.dev/slog) |
Predates the standard library and implements its own slog.Sink, not log/slog.Handler |
phsym/console-slog |
Unmaintained since January 2024 |
dpotapov/slogpfx |
The only dedicated prefix handler, unmaintained since 2023 |
What Charm provides and the alternatives do not:
- Multi-line values with a gutter. A stack trace stays readable and stays visibly attached to its key, with the following attribute correctly outdented. Nothing else in the ecosystem does this.
- Prefixes, for labelling a logger by subsystem.
Helper(), which skips a wrapper frame in caller reporting the waytesting.TB.Helper()does.
One apparent requirement turned out not to be one: an adapter to the older
*log.Logger API is in the standard library as slog.NewLogLogger, and works
with any handler.
So Charm is kept. It is the most actively maintained of the options and the defect is one code path, against a wrapper of about fifty lines that is tested and reversible.
Integration with Props¶
The logger is injected through Props:
func NewMyCommand(p *props.Props) *cobra.Command {
return &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
p.Logger.Info("running", "args", args)
return nil
},
}
}
For packages that only need logging, declare the narrow provider interface:
type logProvider interface {
GetLogger() logger.Logger
}
func doWork(p logProvider) {
l := p.GetLogger()
l.Info("working")
}
Dynamic Level Control¶
SetLevel/SetFormatter are not interface methods — they are package helpers
that apply only when the logger implements the optional Leveller/Reformatter
capabilities. GTB's default NewCharm logger implements both:
type Leveller interface{ SetLevel(level slog.Level) }
type Reformatter interface{ SetFormatter(f logger.Formatter) }
Use the helpers to change the level at runtime, useful for toggling debug output in response to a signal or config change. They report whether the change was applied:
logger.SetLevel(l, slog.LevelDebug) // true on NewCharm; false (no-op) on a plain *slog.Logger
// ... do work
logger.SetLevel(l, slog.LevelInfo) // restore default
To branch on whether a level is active — for example to skip expensive
diagnostics — call Enabled on the interface itself:
Config-driven construction¶
A config-driven host does not hand-assemble CharmOptions. It unmarshals its
logging config section into the typed, config-system-agnostic logger.Config
(fields Level, Format, Timestamp, Caller) and bridges it to NewCharm
via Config.CharmOptions():
cfg := logger.Merge(logger.DefaultConfig(), decoded) // decoded from the host's config layer
l := logger.NewCharm(os.Stderr, cfg.CharmOptions()...)
DefaultConfig gives the package baseline (info level, text format, no
timestamp, no caller); Merge overlays the host's decoded section onto it
(empty string fields preserve the base; booleans take the overlay value); and
CharmOptions() renders the result as the WithLevel/WithFormatter/
WithTimestamp/WithCaller options NewCharm expects.
Why the bridge is the only route for two of the four fields¶
The four Config fields do not all have the same reach:
| Field | Construction | Runtime |
|---|---|---|
Level |
✓ (WithLevel) |
✓ (SetLevel / Leveller) |
Format |
✓ (WithFormatter) |
✓ (SetFormatter / Reformatter) |
Timestamp |
✓ (WithTimestamp) |
✗ — no runtime setter |
Caller |
✓ (WithCaller) |
✗ — no runtime setter |
Level and Format are reachable both at construction and at runtime, so a
host can build a logger however it likes and still let --debug, log.level,
and log.format take effect afterwards. Timestamp and Caller are
construction-time only — the Logger interface exposes no runtime setter for
them. A host that wants those two config-driven therefore must build its logger
from Config through CharmOptions(); there is no later hook to apply them, so
setting them after construction is impossible rather than merely inconvenient.
Contextual Logging¶
Add fields that appear on every subsequent log call with With (which returns a
*slog.Logger, itself a logger.Logger):
// Structured key-value fields
reqLogger := l.With("request_id", reqID, "user", userID)
reqLogger.Info("processing request")
// → INFO processing request request_id=abc123 user=matt
// A component/prefix is just another structured attribute
subLogger := l.With("component", "db")
subLogger.Error("connection failed", "host", host)
// → ERROR connection failed component=db host=postgres:5432
There is no WithPrefix on the interface — a prefix is carried as a structured
attribute via With. (A construction-time logger.WithPrefix(...) CharmOption
still exists to set a fixed prefix on a NewCharm logger.)
Testing¶
Use NewNoop() in all unit tests:
Mocks are available if you need to assert specific log calls:
import mock_logger "gitlab.com/phpboyscout/go-tool-base/mocks/pkg/logger"
func TestWithLogAssertions(t *testing.T) {
ml := mock_logger.NewMockLogger(t)
ml.EXPECT().Warn("low disk space", "free_gb", 1).Once()
// ...
}
Related Documentation¶
- Props — how Logger is injected via the Props container
- Interface Design — Logger interface in the interface hierarchy
- Error Catalogue —
ErrInvalidLevelfromParseLevel