Skip to content

Commit 8fe0a1c

Browse files
committed
docs(go): add Section 7 - Logging (slog default + backend decision matrix)
- 210-go.mdc: add a dedicated "Logging" section covering the modern Go logging story. Default for new code is log/slog (stdlib since 1.21); JSON handler with AddSource and slog.LevelVar for runtime level control; pass *slog.Logger via DI - Three calling conventions ordered by safety: loose key-value (error-prone, compiles cleanly with mistyped keys / odd argument counts), context-accepting *Context variants (recommended; required for trace correlation), LogAttrs typed-only (safest, compile-time type-checked) - otelslog bridge for first-class OpenTelemetry logs that carry the active span trace and span IDs (requires *Context call variants with an active span) - LogValuer pattern for centralized redaction of sensitive types so call sites cannot leak by accident - Decision matrix for when to swap the slog backend (only with profiling evidence): slog stdlib default; phuslu/log as backend for ~2.7x throughput; zerolog native for max throughput; zap native for extensibility/test observers; charmbracelet/log for CLI output; do not start new code on logrus - Footguns documented: zerolog as slog backend re-encodes WithAttrs per call (~46x slower than zerolog native); zerolog chain without Msg/Send drops + leaks pooled Event; zap.SugaredLogger adds 1 alloc per call; slog ships no TRACE/FATAL by default - Library landscape one-liner table with stars + benchmark numbers (phuslu/log ~25 ns; zerolog ~25 ns; zap ~51 ns; slog ~101 ns; logrus ~9 us; charmbracelet ~16 us) - Add sloglint to the CI Gates (Non-Negotiable) section so the loose key-value form cannot silently produce malformed entries - Renumber subsequent sections (8-17) since the Logging section is inserted at 7. Fixes a pre-existing duplicate "## 8" between Troubleshooting and CI/CD Integration along the way - skills/go-rust-systems/SKILL.md: add a "Go Logging - Default Stack" section mirroring the rule's calling-convention guidance, otelslog bridge, LogValuer redaction, decision matrix, footguns, and the required CI lint. Cross-link to the rule for full rationale - Source: https://www.dash0.com/guides/golang-logging-libraries (April 2026 benchmarks, captured here without external dependency) Closes the gap between code samples that already used slog and the absence of any explicit "use slog as default; here is when and why to swap" guidance.
1 parent bc988b2 commit 8fe0a1c

2 files changed

Lines changed: 212 additions & 10 deletions

File tree

rules/210-go.mdc

Lines changed: 146 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,7 @@ staticcheck ./...
14351435
golangci-lint run
14361436
go test -race ./...
14371437
govulncheck ./...
1438+
sloglint -no-mixed-args -static-msg ./... # see Section 7 - Logging
14381439
```
14391440

14401441
### CI Gates (Hardening Extension - Recommended)
@@ -1624,7 +1625,142 @@ func (c *Cache) Get(key string) (interface{}, bool) {
16241625

16251626
---
16261627

1627-
## 7. Troubleshooting Guide
1628+
## 7. Logging
1629+
1630+
**Default:** application code uses `log/slog` (stdlib since Go 1.21). Initialize with the JSON handler; pass `*slog.Logger` via dependency injection (no globals after `slog.SetDefault()` in `main`).
1631+
1632+
```go
1633+
opts := &slog.HandlerOptions{
1634+
AddSource: true,
1635+
Level: slog.LevelInfo,
1636+
}
1637+
logger := slog.New(slog.NewJSONHandler(os.Stderr, opts))
1638+
slog.SetDefault(logger)
1639+
```
1640+
1641+
This gives structured JSON, source attribution, runtime level control via `slog.LevelVar` (goroutine-safe), and zero external dependencies.
1642+
1643+
### Three calling conventions, ordered by safety
1644+
1645+
```go
1646+
// 1) Loosely typed key-value (convenient, ERROR-PRONE)
1647+
// Odd argument count, mistyped key, or swapped key/value compile cleanly
1648+
// and produce silently malformed output at runtime.
1649+
slog.Info("request", "method", "GET", "status", 200)
1650+
1651+
// 2) Context-accepting (RECOMMENDED default in handlers/services)
1652+
// Required for trace correlation via the otelslog bridge.
1653+
slog.InfoContext(ctx, "request",
1654+
slog.String("method", "GET"),
1655+
slog.Int("status", 200),
1656+
)
1657+
1658+
// 3) Typed attributes only (SAFEST; catches type errors at compile time)
1659+
logger.LogAttrs(ctx, slog.LevelInfo, "request",
1660+
slog.String("method", "GET"),
1661+
slog.Int("status", 200),
1662+
)
1663+
```
1664+
1665+
Use `LogAttrs` in hot paths and anywhere correctness matters; loose key-value form is acceptable in scripts and tests where the calls are easy to eyeball. Always run `sloglint` (see CI Gates) so the loose form does not silently rot in long-running services.
1666+
1667+
### Trace correlation via `otelslog`
1668+
1669+
Use the `go.opentelemetry.io/contrib/bridges/otelslog` handler to route slog through the OpenTelemetry Logs SDK. Logs become first-class OTel signals that carry the active span's trace/span IDs, enabling automatic log <-> trace correlation in your backend.
1670+
1671+
```go
1672+
import (
1673+
"go.opentelemetry.io/contrib/bridges/otelslog"
1674+
"go.opentelemetry.io/otel/log/global"
1675+
)
1676+
1677+
logger := otelslog.NewLogger(
1678+
"service-name",
1679+
otelslog.WithLoggerProvider(global.GetLoggerProvider()),
1680+
)
1681+
slog.SetDefault(logger)
1682+
```
1683+
1684+
Trace correlation only fires when the call accepts a context with an active span. Use `*Context` variants:
1685+
1686+
```go
1687+
// Correlated (context carries the span):
1688+
logger.InfoContext(ctx, "processing order", slog.String("order_id", id))
1689+
1690+
// NOT correlated (no context, no span):
1691+
logger.Info("processing order", slog.String("order_id", id))
1692+
```
1693+
1694+
### Redaction via `LogValuer`
1695+
1696+
Implement `slog.LogValuer` on sensitive types so they cannot leak by accident. Centralized redaction beats relying on every call site to remember.
1697+
1698+
```go
1699+
type APIKey string
1700+
1701+
func (APIKey) LogValue() slog.Value { return slog.StringValue("REDACTED") }
1702+
```
1703+
1704+
Same pattern for emails, JWT tokens, PII fields, and PCI numbers - one method per type, one source of truth for the redacted form.
1705+
1706+
### When to swap the slog backend (only with evidence)
1707+
1708+
Default is `slog.NewJSONHandler` and it is fast enough for the vast majority of services (~101 ns/op, zero allocs in steady state). Only swap when profiling shows logging as a bottleneck. Decision matrix:
1709+
1710+
| Need | Choice |
1711+
|---|---|
1712+
| OpenTelemetry trace correlation | slog + `otelslog` bridge (default for new services running on OTel) |
1713+
| Profiler shows logging is a hot path; want to keep slog API | `phuslu/log` as slog backend (~38 ns/op, ~2.7x stdlib) or `zap` adapter (~70 ns/op) |
1714+
| Maximum throughput; willing to use library API | `zerolog` native API (~25 ns/op, zero allocs); pleasant chained API; built-in sampling for high-throughput log dedup |
1715+
| Extensibility, advanced cores, test observers | `zap` native (`zapcore.Core` composition, `zaptest/observer`, `AtomicLevel`); ~51 ns/op typed, ~82 ns/op sugared |
1716+
| CLI tool, human reads the terminal | `charmbracelet/log` as slog backend (intelligent coloring, icons, color-downsampling for SSH/file pipes) |
1717+
| Existing logrus codebase | Migrate hot paths to slog; leave the rest until you get to it. **Do not start new code on logrus** - in maintenance mode, ~15x slower than slog, ~50x slower than zerolog |
1718+
1719+
**Footguns to remember:**
1720+
1721+
- `zerolog` natively is fast, but its `slog.Handler` bridge re-encodes `WithAttrs` on every `Handle()` call - ~46x slower than its native API. If you want zerolog speed, use its native API; do not run zerolog *as* a slog backend.
1722+
- `zerolog` API: forgetting `.Msg()` / `.Send()` at the end of a chain silently drops the entry AND leaks a pooled `Event` object. Use `sloglint` analogue or code review.
1723+
- `zap.SugaredLogger` adds one allocation per call from the variadic `interface{}` boxing - small but compounds under load.
1724+
- `slog` does not include `TRACE` or `FATAL` levels by default. Define your own (`slog.Level(-8)` for trace) or accept the standard set.
1725+
1726+
### Library landscape (one-liner per option)
1727+
1728+
| Library | Stars | When |
1729+
|---|---|---|
1730+
| **`log/slog`** (stdlib) | n/a | Default for new code. Frontend the ecosystem aligned on. |
1731+
| **`zerolog`** (~12k) | ~25 ns/op native, zero allocs | Maximum throughput when you can use the library API directly. |
1732+
| **`zap`** (~24k) | ~51 ns/op typed | Production tooling: AtomicLevel, zapcore composition, zaptest/observer. |
1733+
| **`phuslu/log`** (~840) | ~25 ns/op native, ~38 ns/op as slog backend | Fastest available; small community + single maintainer is the trade-off. No OTel bridge yet. |
1734+
| **`logrus`** (~25k) | ~9 us/op | Maintenance mode; do not start new code on it. |
1735+
| **`charmbracelet/log`** (Charm TUI) | ~16 us/op | CLI / TUI tooling where humans read the output. Implements `slog.Handler`. |
1736+
1737+
### Required CI lint
1738+
1739+
Add `sloglint` to the CI gate so the loosely-typed key-value form cannot silently produce malformed log entries.
1740+
1741+
```bash
1742+
go install github.com/go-simpler/sloglint/cmd/sloglint@latest
1743+
sloglint -kv-only -no-mixed-args ./... # enforce typed attrs in hot paths
1744+
```
1745+
1746+
Reasonable defaults:
1747+
1748+
- `-no-mixed-args` - reject mixing `slog.Attr` and key-value in the same call
1749+
- `-kv-only` or `-attr-only` - pick a house style and enforce it
1750+
- `-static-msg` - reject computed message strings (the message is the search key in observability tools)
1751+
1752+
### Practical defaults summary
1753+
1754+
- **Always-on:** `log/slog` + JSON handler + `otelslog` bridge if the service runs on OpenTelemetry; pass `*slog.Logger` via DI.
1755+
- **Always-on:** `LogAttrs(ctx, ...)` for hot paths and any sensitive-data path; loose key-value only in scripts and tests.
1756+
- **Always-on:** `LogValuer` on every sensitive type.
1757+
- **Always-on:** `sloglint` in CI.
1758+
- **Conditionally:** swap to `phuslu/log` or `zap` backend only when profiling shows logging is the bottleneck.
1759+
- **Never:** start new code on logrus; bridge zerolog *into* slog (use zerolog natively if you want its speed).
1760+
1761+
---
1762+
1763+
## 8. Troubleshooting Guide
16281764

16291765
### Debug Logging
16301766

@@ -1886,7 +2022,7 @@ func (c *Cache) Set(key string, obj *LargeObject) {
18862022

18872023
---
18882024

1889-
## 8. CI/CD Integration
2025+
## 9. CI/CD Integration
18902026

18912027
### GitHub Actions Example
18922028

@@ -1920,7 +2056,7 @@ jobs:
19202056

19212057
---
19222058

1923-
## 9. Advanced Patterns
2059+
## 10. Advanced Patterns
19242060

19252061
### Fan-Out, Fan-In
19262062

@@ -2000,7 +2136,7 @@ func (rl *RateLimiter) Wait(ctx context.Context) error {
20002136

20012137
---
20022138

2003-
## 10. AWS & Cloud Integration
2139+
## 11. AWS & Cloud Integration
20042140

20052141
### AWS SDK for Go (v2)
20062142

@@ -2185,7 +2321,7 @@ func ValidateRegion(region string) error {
21852321
}
21862322
```
21872323

2188-
## 11. CLI Development
2324+
## 12. CLI Development
21892325

21902326
### Using cobra (Recommended)
21912327

@@ -2352,7 +2488,7 @@ func outputResult(result interface{}, format string) error {
23522488
}
23532489
```
23542490

2355-
## 12. Design Patterns (Expanded)
2491+
## 13. Design Patterns (Expanded)
23562492

23572493
### Factory Pattern
23582494

@@ -2501,7 +2637,7 @@ func (eb *EventBus) Publish(ctx context.Context, event Event, data interface{})
25012637
}
25022638
```
25032639

2504-
## 13. Generics (Expanded Coverage)
2640+
## 14. Generics (Expanded Coverage)
25052641

25062642
### Generic Functions
25072643

@@ -2620,7 +2756,7 @@ func (r *UserRepository) Create(ctx context.Context, user *User) error {
26202756
}
26212757
```
26222758

2623-
## 14. Deployment & Distribution
2759+
## 15. Deployment & Distribution
26242760

26252761
### Building Binaries
26262762

@@ -2755,7 +2891,7 @@ func printVersion() {
27552891
}
27562892
```
27572893

2758-
## 15. Documentation
2894+
## 16. Documentation
27592895

27602896
- **Package Docs**: Capture purpose in `doc.go`
27612897
- **Exported Items**: Every exported item gets a comment
@@ -2787,7 +2923,7 @@ func GetUser(ctx context.Context, id string) (*User, error) {
27872923

27882924
---
27892925

2790-
## 16. Comprehensive Example Application
2926+
## 17. Comprehensive Example Application
27912927

27922928
Complete example demonstrating idiomatic Go patterns:
27932929

skills/go-rust-systems/SKILL.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,72 @@ fn process_user(id: &str) -> anyhow::Result<User> {
205205
}
206206
```
207207

208+
## Go Logging - Default Stack
209+
210+
**Default for new code:** `log/slog` (stdlib since 1.21) with the JSON handler. The ecosystem has aligned behind slog as the frontend; backends can be swapped without touching log statements if profiling shows logging is a bottleneck.
211+
212+
```go
213+
opts := &slog.HandlerOptions{AddSource: true, Level: slog.LevelInfo}
214+
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, opts)))
215+
```
216+
217+
### Calling conventions, ordered by safety
218+
219+
```go
220+
slog.Info("request", "method", "GET", "status", 200) // loose - error-prone
221+
slog.InfoContext(ctx, "request", slog.String("method", "GET"), ...) // recommended (enables trace correlation)
222+
logger.LogAttrs(ctx, slog.LevelInfo, "request", slog.String(...), ...) // safest (typed; compile-time)
223+
```
224+
225+
### Trace correlation via `otelslog`
226+
227+
```go
228+
import "go.opentelemetry.io/contrib/bridges/otelslog"
229+
230+
slog.SetDefault(otelslog.NewLogger("svc", otelslog.WithLoggerProvider(global.GetLoggerProvider())))
231+
// MUST use *Context variants for span correlation:
232+
logger.InfoContext(ctx, "processing", slog.String("order_id", id))
233+
```
234+
235+
### Redaction via `LogValuer`
236+
237+
```go
238+
type APIKey string
239+
func (APIKey) LogValue() slog.Value { return slog.StringValue("REDACTED") }
240+
```
241+
242+
Centralizes redaction so call sites cannot leak by accident.
243+
244+
### Decision matrix - when to swap backend
245+
246+
| Need | Choice | Notes |
247+
|---|---|---|
248+
| Default | `slog` + `JSONHandler` | ~101 ns/op, zero allocs; fast enough for almost everyone |
249+
| OTel correlation | `slog` + `otelslog` bridge | Default if service runs on OpenTelemetry |
250+
| Profiler shows logging is hot | `phuslu/log` as slog backend | ~38 ns/op (~2.7x stdlib); single maintainer is the trade-off |
251+
| Maximum throughput, library API OK | `zerolog` native | ~25 ns/op, zero allocs; built-in sampling |
252+
| Extensibility, advanced cores, test observers | `zap` native | `zapcore.Core` composition, `zaptest/observer`, `AtomicLevel` |
253+
| CLI / TUI, human reads terminal | `charmbracelet/log` as slog backend | Coloring, icons, color downsampling |
254+
| Existing logrus | Migrate hot paths to slog | Don't start new code on logrus (maintenance mode, ~15x slower than slog) |
255+
256+
**Footguns:**
257+
258+
- `zerolog` *as* slog backend re-encodes `WithAttrs` per `Handle()` call - ~46x slower than its native API. Use zerolog natively; don't bridge.
259+
- `zerolog` chain without terminal `.Msg()` / `.Send()` silently drops the entry AND leaks the pooled `Event`.
260+
- `zap.SugaredLogger` adds 1 alloc per call from variadic boxing.
261+
- `slog` ships no TRACE / FATAL levels by default.
262+
263+
### Required CI lint
264+
265+
```bash
266+
go install github.com/go-simpler/sloglint/cmd/sloglint@latest
267+
sloglint -no-mixed-args -static-msg ./...
268+
```
269+
270+
For the full rationale, library landscape, and benchmark numbers, see Section 7 (Logging) in `rules/210-go.mdc`.
271+
272+
---
273+
208274
## Detailed References
209275

210276
- **Go Patterns**: See [references/go-patterns.md](references/go-patterns.md) for concurrency, interfaces, testing

0 commit comments

Comments
 (0)