Skip to content

goss: replace global logging with injectable structured records - #1119

Draft
dukelion wants to merge 9 commits into
goss-org:masterfrom
dukelion:feature/1093-structured-logging
Draft

goss: replace global logging with injectable structured records#1119
dukelion wants to merge 9 commits into
goss-org:masterfrom
dukelion:feature/1093-structured-logging

Conversation

@dukelion

@dukelion dukelion commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • replace the process-global prefix logger with injected log/slog loggers
  • add a TRACE level, structured record schemas, and CLI-owned UTC rendering
  • propagate logging through config, system, output, validation, add, and serve paths
  • keep nil-loggers silent for library consumers and document embedding/migration behavior
  • add behavioral, propagation, compatibility, subprocess, and static-invariant coverage

Closes #1093.

Discussion points

  • util.Config.LogLevel is removed; callers configure levels on their handler
  • system.New gains variadic options and util.OutputConfig gains Logger
  • direct GossConfig.Merge calls become silent because that API has no logger
  • structured attributes can contain command output, matcher values, response bodies, and executed command text; the documentation describes filtering and retention considerations
  • gorelease -base=v0.4.10 recommends v0.5.0 because of the intentional API incompatibilities

Verification

Passing locally:

  • go vet ./...
  • go mod verify
  • golangci-lint v2.12.2 run --timeout 5m ./...
  • .venv/bin/zensical build --strict
  • isolated repeated serve matrix under -race -shuffle=on

Known discussion issue:

  • the full go test -race -shuffle=on ./... can fail under heavy local parallel load when command-backed serve tests reach the inherited 10-second command timeout; the timeout path returns before cmd.Run finishes and the race detector then reports concurrent buffer access. This draft leaves the existing production timeout implementation unchanged, but the new command-backed tests should be serialized or moved to non-subprocess fixtures before merge.

Scope

The final documentation-only commit repairs three pre-existing broken anchors required for the strict documentation build.


📚 Documentation preview 📚: https://goss--1119.org.readthedocs.build/en/1119/

goss is moving from the process-global log package to log/slog with a
logger supplied by the caller. This is the first step: the level and the
helpers the rest of the migration builds on, with no call site converted
yet.

LevelTrace is a constant below slog.LevelDebug, so a logger configured at
DEBUG suppresses trace records. Standard handlers have no name for it and
would render "DEBUG-4", so ReplaceTraceLevel carries the exact
slog.HandlerOptions.ReplaceAttr signature and rewrites just the built-in
level attribute. An embedder installs the same function rather than
reimplementing goss's convention.

Trace and TraceContext build the slog.Record by hand instead of calling
Logger.Log, so the source position on the record is the caller's own.
Both consult Enabled before doing that work and both route through
LoggerOrDiscard, which is the guard every later derivation uses:
util.Config is documented as usable as a composite literal, so its logger
will be nil in ordinary embedded use, and a nil *slog.Logger panics on
every method that matters. There is deliberately no slog.Default()
fallback; silence is the correct default for library records.

Refs: goss-org#1093
Adds the injection points the call-site conversions will read from, with
no call site converted yet, so this commit changes no behaviour.

util.Config gains a Logger field and a WithLogger option; util.OutputConfig
gains the same field for the outputs/ sites. NewConfig deliberately does
not default it. Resolving a level or a destination in the constructor would
silently disable logging for the hand-built literals that util/config.go
documents as the embedding pattern, and any default would be a destination
the embedder did not choose.

system.New becomes variadic over a new Option type with system.WithLogger,
so every existing positional call keeps compiling. The System's logger is
read back through an unexported guarded accessor rather than the field,
because a System is constructible as a literal and New is callable without
options, leaving the field nil in both cases.

Refs: goss-org#1093
Under slog the level belongs to the handler, so the CLI resolves
--log-level to one and injects the resulting logger through the runtime
config. The library will interpret no level string once the last reader of
util.Config.LogLevel is gone; until then the normalised value is still
written to that field, because setLogLevel remains a reader on the
validate, serve and add paths.

newRuntimeConfigFromCLI now returns an error, which makes an unsupported
level fail consistently. Today validate, serve and add reject one through
setLogLevel while render and autoadd accept it silently and carry on,
because they never called that function.

Every executable action is built through one wrapper that runs the
unchanged platform gate, constructs the runtime config, and returns its
error without invoking the operation. Alpha rejection therefore keeps
winning when both the platform and the level are unacceptable. The
operations the actions call are held in an injected set, so the tests drive
the real app, flags and actions without replacing an Action or exiting the
test process. alphaRejected and alphaMessage split the platform decision
and its diagnostic out of fatalAlphaIfNeeded, which makes the darwin and
windows behaviour testable from any host while the log-and-exit stays where
it was.

The handler renders TRACE through the exported hook and normalises record
timestamps to UTC. UTC is applied by a handler wrapper rather than
ReplaceAttr because ReplaceAttr cannot tell slog's own time field from a
user attribute that happens to be named "time".

Refs: goss-org#1093
Two log sites had no route to a config, for different reasons, and both are
now reported through the injected logger.

The duplicate-resource warning was emitted from mergeType, five frames below
the entry point, with no config or logger in any of them, and the chain
crosses the exported GossConfig.Merge. Only mergeType knows whether the
destination map already held the key, so detection stays there and an
unexported callback carries the report up to the operation root that does
have a logger. Merge keeps its signature and passes no callback, so a direct
call is silent rather than writing to the process logger; getGossConfig and
RenderJSON, whose callers all have a config, build the callback from it.
getGossConfig now takes that config instead of three of its fields.

The empty-configuration message was blocked by something else entirely:
WriteJSON returns nil whether it wrote the file or declined to, so its two
callers could not tell which had happened. An unexported writeJSON reports
that, leaving the exported contract exactly as it was, and both add roots
warn through their own logger and still return nil.

The message also becomes filterable in the process. It carries no level
prefix today, so the prefix filter emits it at every level including ERROR.

Refs: goss-org#1093
Every serve site now emits through the config's logger, with its data in
attributes rather than formatted into the message.

The per-request record keeps its response-body gate, which is load-bearing:
attaching the body unconditionally would put every check's result into the
log of every successful probe. What changes is that the body is now the raw
response under its own key, without the " - " prefix that used to join it to
the message.

The cache-miss message loses cacheMissLogFormat entirely. Its "[DEBUG]"
prefix existed so the old filter could match on it, and a constant carrying
a level and a format verb is exactly what a structured record replaces. Both
tests that counted it, the cache regression test and the concurrent cold-cache
dedupe test, now count records instead of substrings, and neither needs the
process logger to do it.

Serve builds its http.Server through newServer so that http.Server's own
errors reach the configured logger at ERROR. That bridge hands over one
preformatted message with no structure to recover, so it is the single
record goss emits that is not built from attributes. Production still passes
a nil handler, which keeps DefaultServeMux exactly as it was; the parameter
is there so a test can run a real server without registering on a
process-global mux.

processAndEnsureCached takes a context, which gives TraceContext its callers,
and the redundant System construction it did is gone: validate() builds one
again two lines later.

Refs: goss-org#1093
This converts the last library call sites. Subject command output and the
per-result traces are the two highest-volume records goss produces, and both
now carry their data in attributes.

Command output records carry both the resource id, which is what the old
message identified them by, and the command that actually ran, which is a
different string whenever a resource sets an explicit exec. Reading the id
needs no repair here: the context key it is stored under is exported. The
line splitting is unchanged, including the empty record a trailing newline
produces, because anything counting records would notice that going away.

The two summary sites in each of json and rspecish collapse into one record
per Output call. They differed only in the word in front of an otherwise
identical payload, which is now a status attribute. json keeps the serialised
document it always logged; rspecish keeps its counts and duration, which were
its whole summary, minus the colour escapes the printed form carries.

Every remaining System construction and both OutputConfig literals pass the
logger, including the two places a System is rebuilt mid-operation: the
validate retry loop and serve's cache expiry. A missed one there would lose
logging for every attempt after the first, so a static test enumerates the
construction sites as well as driving them.

Exit codes and HTTP statuses are pinned across all nine outputers, passing
and failing, because two Output methods changed and neither may move a code.

The json outputer stops calling every result that did not fail a success.
That was survivable when the word sat in prose next to the result number and
is not survivable as an enum attribute named outcome, so skipped results now
say so, as they always have under rspecish. The trace test drives all three
outcomes through both outputers rather than one outcome through each, which
is what let the disagreement through.

Refs: goss-org#1093
Nothing reads them any more. logs.go held a logutils filter that matched a
"[LEVEL]" prefix on each message, which is incompatible with structured
records by construction, and setLogLevel, which reconfigured the process-wide
logger's writer and flags on three of the nine operations and never put them
back. An embedder who called goss had their own logging quietly rearranged.

util.Config.LogLevel goes with them. It is removed rather than deprecated
because its only remaining use was a keyed write in a composite literal: a
deprecation marker warns on reads, so an embedder would have got a clean
build and no logging, while removal gives them a compile error. It is not
coming back either. Under slog the level belongs to the handler, and
reintroducing the string would restore the two sources of truth that produced
the --debug and --log-level overlap.

--log-level now works under render and autoadd, where it was documented but
inert because neither called setLogLevel.

The static tests are the point of this commit as much as the deletions.
"No library code writes to a process logger" is a claim about every file, not
about the paths a test happens to drive, so the log import, the logutils
dependency, slog.Default, the removed field, level-string interpretation, the
handler's writer and the alpha gate's ordering are all checked across the
module. slog.Default in particular is invisible to sloglint's no-global
setting, so nothing else would catch a global logger returning that way.

Refs: goss-org#1093
sloglint is what keeps the conventions from decaying: constant messages,
snake_case keys, lowercased text and no global logger. util.Trace and
util.TraceContext wrap Log and LogAttrs, so the linter needs their message and
argument positions declared or it skips them, and skipping them would leave
every TRACE site unchecked. The two custom-funcs entries are the whole reason
the wrappers are safe to have.

The issue caps are raised because they are not off by default: golangci-lint
stops at 50 issues per linter and 3 per identical message, which it applies
silently. The first run of this configuration reported three violations in one
test file when there were five. A gate that reports part of the tree teaches
you to trust it anyway.

The new tests cover what the linter cannot. One pins the whole flag inventory,
including -l being bound both globally and by serve's --listen-addr, because
"adds no flag" is a claim about the entire command tree and this change
deliberately reuses the existing --log-level rather than adding --log-format.
Another checks that the seams introduced for testability have no test-only
branch, which is the failure mode dependency injection invites: newServer,
newApp, newCLIHandler, the alpha helpers, the operation set, the action
wrappers and the UTC handler all take injected values or pure inputs and
behave identically under test.

docs/logging.md documents levels, the record schema, embedding, TRACE hook
composition and what the records can contain. The sensitive-fields section is
not reassurance: output, actual, expected and response_body were message text
before and are queryable named fields now, nothing is redacted, no attribute
is capped, and readers need to know that before they ship goss records to an
index. The CLI page loses its claim that --log-level may appear after the
command name, which was never true and cost a previous investigation time:
--log-level is a parse error there and -l binds --listen-addr under serve.

Refs: goss-org#1093
The strict site build has been failing on these, so nothing else could be
checked by it. All three are links to anchors that do not exist: two to
#patterns, which was never a heading, and one to #global-options from a page
that does not have that section.

The pattern syntax they mean is documented under io.Readers, which is where
the "!foo" and "/regex/" forms are listed, and the global options are on the
CLI page. Nothing else changes: no text, and no other link.

Found while adding docs/logging.md, whose gate is that same build.
@dukelion

Copy link
Copy Markdown
Contributor Author

It turned out quite large and needs a bit more non-substantial polishing before the merge.
Posting it now primarily for discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant