Skip to content

feat(observe/log): add a slog.Handler adapter - #665

Draft
thierrycoopman wants to merge 1 commit into
mainfrom
claude/slog-adapter-logging-dd4b6a
Draft

feat(observe/log): add a slog.Handler adapter#665
thierrycoopman wants to merge 1 commit into
mainfrom
claude/slog-adapter-logging-dd4b6a

Conversation

@thierrycoopman

Copy link
Copy Markdown
Contributor

What

Adds logging.NewSlogHandler(logging.Logger) slog.Handler to pkg/observe/log, alongside the existing logrus and zap adapters.

slog.SetDefault(slog.New(logging.NewSlogHandler(logger)))

Existing slog call sites then gain the platform logger's formatting, level control and trace correlation with no changes.

Why

pkg/service builds a logging.Logger from --debug and --json-formatting-logger, installs the OTel hook that stamps trace_id/span_id, and injects it into fx — but a service whose own code calls slog.Default() never touches any of it. slog.Default() stays Go's stock stderr text handler.

Measured in one consumer (formancehq/connectivity-plugins): 58 non-test files logging via slog.Default(). Every one of those lines ignored --json-formatting-logger, ignored --debug, and carried no trace_id — requests were traced, but their logs could not be joined to the trace. That consumer wrote an adapter locally; slog is the stdlib default and new services will keep hitting this, so it belongs here.

Design notes

  • Context binding. Handle calls logger.WithContext(ctx) before emitting. That is what lets the existing logrus traceHook read the active span — without it there is no trace correlation, which is most of the point. A context with no recording span adds no trace fields rather than empty ones.
  • Enabled consults the underlying logger, so --debug actually gates slog debug calls instead of every record being built and then dropped. slog.LevelWarn maps to InfoLevel for that check — deliberately permissive, since the backing logger filters again at emit, so an over-generous answer costs a wasted field build, never a suppressed line getting through.
  • Groups flatten to dotted keys (G.H.key) rather than nested maps. Logger carries flat fields, and flat scalar keys are what log search backends index well. Worth a second opinion if you'd rather have nested maps.
  • clone() copies the attrs slice. slog branches handlers, and two branches appending into a shared backing array overwrite each other's attributes.
  • A nil Logger discards rather than panicking — a logging call is the worst place to take a process down.

⚠️ Breaking change: Logger gains Warn/Warnf

A slog Warn had nowhere correct to go. The interface declared Trace/Debug/Info/Error (+f variants) but not Warn, while LogrusLogger and ZapLogger both already implemented Warn/Warnf — the omission from the interface looks accidental. Routing warnings to Error would turn every warning into an alert; routing them to Info loses the severity. So the interface now declares both, with the rationale in the source.

Out-of-tree implementations of logging.Logger must add Warn(args ...any) and Warnf(fmt string, args ...any). In-tree adapters already had them; only the generated mock and three test doubles needed updating.

No WarnLevel was added to Level: nothing gates on warn, and it would renumber ErrorLevel.

This repo has no automated semantic-release, so the BREAKING CHANGE: footer on the commit is documentary. Flagging for a versioning call per the usual process for interface breaks.

Testing

testing/slogtest, the stdlib's own conformance suite — the strongest available check, covering group nesting, empty-attr elision, inline groups and LogValuer resolution.

It passes with exactly one deviation, allow-listed by exact message rather than skipping the suite, so every other case stays enforced and a second deviation fails the test:

a Handler should ignore a zero Record.Time

This one cannot pass. Emission goes through logging.Logger, whose signature is Info(args ...any) — there is no way to pass or suppress a timestamp, and the backing logger always stamps its own. That is correct behaviour in production and unreachable in practice: slog.Logger always sets Record.Time from the clock, so only a hand-built Record has a zero one. The test also fails if the deviation ever stops occurring, so the allow-list cannot go stale.

Also covered: trace correlation end to end against a real recorded span (sdktrace with AlwaysSample, asserting trace_id/span_id match span.SpanContext()); the no-span case; level gating with and without --debug; warn landing at warn and not error; the full level mapping; and WithAttrs/WithGroup branch independence.

I mutation-checked the suite rather than trusting a green run — sharing the attrs slice instead of cloning makes the branch test report right where left was expected, and dropping either the inline-group or the empty-Attr handling produces a slogtest failure. The tests bite.

just pre-commit is clean (0 issues); go vet ./... and the tests for pkg/observe/..., pkg/transport/httpclient, pkg/workflow/temporal and pkg/authn/licence pass under -race.

Not included

pkg/observe/log stays Layer 1 — stdlib plus existing dependencies, no fx.

This only takes effect once a service calls slog.SetDefault(...) itself. Wiring it into pkg/service startup would be a Layer 3 change and is out of scope here; happy to follow up if you want it on by default.

🤖 Generated with Claude Code

pkg/observe/log shipped adapters for logrus and zap but none for the
stdlib's log/slog, so any service whose own code calls slog.Default() was
silently disconnected from the platform logger: pkg/service builds a
logging.Logger from --debug and --json-formatting-logger and installs the
OTel hook that stamps trace_id/span_id, but slog.Default() stayed Go's
stock stderr text handler. Measured in connectivity-plugins: 58 non-test
files logging via slog.Default(), every line ignoring both flags and
carrying no trace_id, so requests were traced but their logs could not be
joined to the trace.

NewSlogHandler closes that gap with no call-site changes:

    slog.SetDefault(slog.New(logging.NewSlogHandler(logger)))

Handle binds the logger to the call's context via WithContext, which is
what lets the existing logrus OTel hook read the active span; Enabled
consults the underlying logger so --debug actually gates slog debug calls;
groups are flattened into dotted keys, since Logger carries flat fields and
flat scalars index better downstream; a nil Logger discards rather than
panicking.

Tested with the stdlib's own conformance suite, testing/slogtest. It passes
with exactly one deviation, allow-listed by exact message so every other
case stays enforced and a second deviation fails the test: "a Handler
should ignore a zero Record.Time" cannot pass, because emission goes
through Logger's Info(args ...any) signature, which has no way to carry or
suppress a timestamp, and the backing logger always stamps its own.

Logger gains Warn and Warnf. A slog Warn otherwise had nowhere correct to
go: Error turns every warning into an alert, Info loses the severity. Both
bundled adapters already implemented these methods, so the omission from
the interface looks accidental. No WarnLevel is added to Level -- nothing
gates on warn, and it would renumber ErrorLevel.

BREAKING CHANGE: logging.Logger now declares Warn(args ...any) and
Warnf(fmt string, args ...any). In-tree adapters already had them;
out-of-tree implementations of the interface must add both.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: adac9d5f-db5d-4f72-ae66-ecd211e2b393

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/slog-adapter-logging-dd4b6a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.28713% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 42.20%. Comparing base (5944fc7) to head (be2ea80).

Files with missing lines Patch % Lines
pkg/observe/log/logging_generated.go 0.00% 19 Missing ⚠️
pkg/observe/log/adapter_slog.go 92.30% 3 Missing and 3 partials ⚠️
pkg/observe/log/logging.go 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #665      +/-   ##
==========================================
+ Coverage   40.99%   42.20%   +1.20%     
==========================================
  Files         191      192       +1     
  Lines        8481     8582     +101     
==========================================
+ Hits         3477     3622     +145     
+ Misses       4803     4753      -50     
- Partials      201      207       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gfyrag gfyrag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The need for a slog.Handler adapter is well demonstrated: current slog.Default() call sites bypass the platform logger’s formatting, debug gating, and trace correlation. The adapter itself is carefully implemented: context binding, handler cloning, group flattening, level mapping, and the slogtest coverage all look sound. The targeted package tests and current CI pass.

The blocker is that this opt-in adapter unnecessarily makes the existing v5 Logger interface source-incompatible (inline comment). A service that never uses slog can stop compiling solely because one of its mocks/adapters lacks two new methods.

Please keep the feature non-breaking, for example by using a private optional warning capability in the slog adapter and falling back for older implementations, or reserve the interface expansion for a new major version.

One need/rollout question remains: this PR deliberately does not wire the handler into pkg/service, so merging it alone changes nothing for the 58 cited call sites. Which concrete consumer will install it first, and should that wiring be part of the acceptance criterion or an explicitly linked follow-up? Once the v5 compatibility issue is removed, I see no other blocker.

Infof(fmt string, args ...any)
// Warnf, like Warn, exists so adapters that receive a warning from an
// upstream API have somewhere correct to put it.
Warnf(fmt string, args ...any)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the slog adapter does not require a v5-wide interface break. Adding Warn/Warnf makes every out-of-tree Logger implementation, test double, and generated mock fail to compile, including consumers that never opt into slog. The bundled loggers already expose these methods, so the adapter can detect a private optional interface such as interface { Warn(...any) } and use it when available, with a documented fallback for legacy implementations. Otherwise this change belongs in a new major version. Please preserve source compatibility for /v5.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants