TTYHandler silently discards log.With attributes, so diagnostics differ between terminal and CI #97
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
internal/logexports aWithfunction whose attributes vanish whenstdout is a terminal and appear when it is not. Found while checking
whether a defect reported against
simplelogapplied here — vaultik doesnot use that library, but the same defect shape exists in our own handler.
The code
internal/log/tty_handler.go:116-124:Both discard their argument and return the receiver unchanged. Note the
doc comments assert the opposite of what the code does — "returns a new
handler with the given attributes" is false.
This is reachable through an exported API.
internal/log/log.go:181:The environment inversion
log.go:73-78selects the handler by TTY-ness:log.WithattributesTTYHandlerslog.NewJSONHandlerSo the interactive path — the one a developer uses while debugging — is
the one that loses the fields, and CI is fine. That is the inverse of the
simplelogbug, where a local TTY run looked healthy and containersbroke. Both are the same underlying hazard: behavior that differs by
environment, in the component you use to diagnose behavior.
WithGroupdiscards group names on the same path.Current impact: latent, but it is an exported footgun
grepfinds no caller oflog.With(...)outside the package today, sonothing is losing fields right now. The defect is that the package
offers the function, and the first person to reach for it will be
someone adding context to a hard bug — which is exactly when silently
missing fields cost the most, and exactly when they will be hardest to
notice, because the same code prints correctly in CI.
Related: the lint remediation in #61 blanked unused parameters to satisfy
revive. That did not cause this — the parameters were already unused —but it removed the compiler-visible hint that something was ignored.
Worth knowing when reading similar signatures elsewhere.
Definition of done
TTYHandler.WithAttrsretains the attributes and emits them on everysubsequent record, and
WithGroupapplies grouping — or, ifimplementing grouping properly is judged out of scope,
WithGroupdocuments honestly what it does and a follow-up is filed. Do not leave
a doc comment claiming behavior the code does not have.
slogpermits a handler to be shared and derived from concurrently, somutating
hin place would be a data race.log.With("key", "value")attribute appears inTTYHandler output. It must fail against the current implementation —
verify by reverting the fix and watching it fail, not by assuming.
for the same logger, so the two paths cannot drift again. This is the
test that would have caught the original defect.
// Simplified for nowcomments removed or replaced with somethingtrue.
script/cibuildexits 0, verified per #93 (expectedokcount, zero(cached)markers, plausible wall time).Out of scope
The logger writing to stdout at all (#82) and the
--cronsuppressionsemantics (#84, #87). This issue is only about attributes being dropped.
Implementation plan
Implementing this together with
issue #82 on one branch —
that one moves the logger's sink and this one fixes the handler, and
both edit the same construction site in
internal/log.TTYHandlerstateTTYHandlergains two fields and one changes type:attrs []slog.Attr— attributes accumulated throughWithAttrs,stored with their group path already folded into the key.
groups []string— the open group path, so attributes arriving later(on a record, or through a further
WithAttrs) get qualified.mubecomes*sync.Mutex. Derived handlers share the receiver'swriter, so they must share one mutex; a value mutex would give each
derived handler its own lock and stop serialising writes to the same
stream.
NewTTYHandlerallocates it.WithAttrs/WithGroupBoth
clone()the receiver — copying theattrsandgroupsslices,not just reslicing them, so two handlers derived from one parent cannot
append into a shared backing array — and return the clone. The receiver
is never written to, which is what makes concurrent derivation safe.
WithAttrs(nil)andWithGroup("")return the receiver unchanged, perthe
slog.Handlercontract.Grouping
Grouping is implemented rather than deferred, using dotted keys:
WithGroup("db").With("rows", 3)rendersdb.rows=3. That is theconventional flattening for a line-oriented format that has nowhere to
nest, and it round-trips against
slog.JSONHandler's nested objectunder the same flattening rule, which is what makes item 4 below
testable.
slog.KindGroupvalues on a record are flattened the sameway. Empty attrs are dropped and an empty-key group is inlined, both
per the
slog.Handlercontract.So the doc comments will describe exactly this, and the
// Simplified for nowmarkers are removed rather than reworded.Tests (new
internal/logtest files)log.With("key", "value")through the exported package-levelAPI, with the package logger pointed at a
TTYHandlerover abuffer, asserting the attribute appears in the output. Written as an
in-package test so it can set the package logger; that is the only
way to exercise
Withitself rather than a hand-builtslog.Logger.With/WithGroupderivation chain and the identical record, then their emitted
attribute sets compared after flattening JSON's nesting to dotted
keys. This is the drift guard.
attributes, assert neither derived handler sees the other's, and
assert the parent still emits none. Plus a concurrent-derivation
test, since
script/testalready runs-race.WithGroupqualification and empty-group/empty-attr handling.Item 3 of the definition of done — the test must fail against the
current implementation — will be verified by reverting
tty_handler.goto the current
return hbodies, running the suite, and recording theobserved failures in the PR body. Not asserted, observed.
Verification
make check(which runs-race) andscript/cibuild.Implemented in
PR #107 (branch
fix-log-stdout), together withissue #82.
Against the definition of done, point by point.
1.
WithAttrsretains attributes;WithGroupapplies grouping.Both done, grouping implemented rather than deferred.
TTYHandlergainedan
attrs []slog.Attrfield (keys already qualified when added) and agroups []stringpath applied to attributes arriving later. Grouping isrendered as a dotted key prefix —
WithGroup("db").With("rows", 3)emits
db.rows=3— because this format is a single line with nowhere tonest. Record-level
slog.KindGroupvalues flatten the same way, emptyattrs are dropped, and an empty-key group is inlined, per the
slog.Handlercontract.WithGroup("")andWithAttrs(nil)return thereceiver, also per the contract. No follow-up issue needed.
2. New handler, not a mutated receiver. Both derive through a
clone()that copies theattrsandgroupsslices rather thanreslicing them, so two concurrent derivations cannot append over each
other's attribute. The receiver is never written to.
mubecame a*sync.Mutexso a handler and everything derived from it keep sharingone lock — a value mutex would have given each derived handler its own
and stopped serializing writes to the stream they have in common.
Verified under
-race:script/testrunsgo test -race, andTestTTYHandlerConcurrentDerivationhas sixteen goroutines derivingfrom and writing through one handler.
3. A test asserting
log.With("key","value")reaches TTYHandleroutput, verified to fail against the current implementation.
TestWithAttributesReachTTYOutputgoes through the exported package-levelWith— an in-package test, because pointing the package logger at abuffer is the only way to exercise
Withitself rather than ahand-built
slog.Logger;//nolint:testpackagecarries that reason.Verified by observation, not assumption:
internal/log/tty_handler.gowas reverted to its
mainversion with the new tests in place andmake testrun. Result:Every other package stayed green, so the failures are the handler and
nothing else. The handler was then restored and the suite went green.
4. A test asserting TTY and JSON emit the same attribute set.
TestTTYHandlerMatchesJSONHandlerAttributesbuilds both handlers overbuffers, applies an identical
With/WithGroupderivation chain and anidentical record to each, and compares the emitted attribute sets after
flattening
JSONHandler's nested groups to the same dotted keys. Sevencases: record attributes only, handler attributes, accumulated handler
attributes, a group qualifying later attributes, nested groups,
attributes straddling a group, and an inline
slog.Groupvalue on therecord. Six of the seven fail against the unfixed handler, as listed
above.
5.
// Simplified for nowremoved. Both are gone, and the doccomments describe what the code does — including
WithGroupnaming thedotted-prefix rendering explicitly.
6.
script/cibuildexits 0, verified perissue #93. Exit 0, 140s
wall. The check layers executed rather than replaying from cache —
make fmt-check2.4s,make lint40.4s,make test58.5s under afresh
CHECK_EPOCH— with 14oklines and zero(cached)markers.The 10
CACHEDlayers are dependency and module layers, which is theintended behaviour.
make checkis green on the host as well.