Note: Always-applicable rules (documentation, code patterns) live in
.claude/rules/. This file is high-level project context.
LogLayer for Go is a Go port of the TypeScript loglayer library: a transport-agnostic structured logging library with a fluent API for messages, fields, metadata, and errors.
Module path: go.loglayer.dev
GitHub: github.com/loglayer/loglayer-go
Docs: VitePress site under docs/
loglayer-go/
├── docs/ VitePress documentation site
│ ├── .vitepress/ VitePress config (sidebar lives here)
│ └── src/ Markdown source
│ ├── logging-api/ Per-method API guides
│ ├── transports/ Per-transport guides + _partials/
│ └── ... Top-level pages (index, intro, configuration, etc.)
├── transport/ BaseTransport / BaseConfig / helpers / transporttest
├── transports/ Built-in transports
│ ├── axiom/ Wraps github.com/axiomhq/axiom-go
│ ├── blank/ Delegates to user-supplied function (template for new transports)
│ ├── console/ Plain fmt.Println-style
│ ├── pretty/ Colorized terminal output (uses fatih/color)
│ ├── structured/ JSON-per-line
│ ├── testing/ In-memory capture for tests
│ ├── zerolog/ Wraps github.com/rs/zerolog
│ ├── zap/ Wraps go.uber.org/zap
│ ├── phuslu/ Wraps github.com/phuslu/log (always exits on Fatal)
│ ├── logrus/ Wraps github.com/sirupsen/logrus
│ └── charmlog/ Wraps github.com/charmbracelet/log
├── loglayer.go LogLayer struct + processLog dispatcher
├── builder.go LogBuilder fluent chain
├── level.go LogLevel + per-level state
├── types.go Config, Fields, Data, Metadata aliases
├── fields.go WithFields / WithoutFields / mute methods
├── from_context.go NewContext / FromContext / MustFromContext
├── mock.go loglayer.NewMock for silent mocking
└── *_test.go Tests + benchmarks
-
Metadata is
any:WithMetadataaccepts any value; the transport decides serialization.loglayer.Metadatais a type alias formap[string]anyfor the common case. -
Fields is
map[string]any: notany, because fields support keyed mutation, inheritance, and nesting that only make sense over keys. (Renamed from "Context" to avoid clashing with Go'scontext.Context.) -
Fatal exits by default: matches Go convention (stdlib
log.Fatal, zerolog, zap, etc.). SetConfig.DisableFatalExit: trueto opt out.loglayer.NewMock()does this automatically. -
No
Loggerinterface in core: Go convention is "consumer defines the interface." Application code accepts the concrete*loglayer.LogLayer;loglayer.NewMock()returns the same type for test injection. -
Multi-module layout: the main
go.loglayer.devmodule hosts only the framework core (loglayer/builder/dispatch/plugin/level/etc.), the sharedtransport/package (BaseTransport, helpers, transporttest, benchtest), theutils/*helpers, andinternal/lltest(a private capture transport so main's own tests don't have to requiretransports/testing). Every transport, plugin, and integration ships as its own independently-versioned Go module;monorel.toml's[packages]map is the canonical list.The split is the whole point: a breaking change in any single sub-module bumps only that sub-module's major version, so
go.loglayer.devitself never has to migrate to/v2because of a downstream rename. Pre-split, every breaking refactor cascaded into a main-module path migration.Each sub-module has its own
go.modwith areplace go.loglayer.dev => ../..(and any relevant sibling) directive for development. Ago.workat the repo root letsgoplsandgo test ./...see every module from one place; CI usesscripts/foreach-module.shwhich runs each module in isolation and is unaffected.
After any code change:
go build ./...
go test ./...For benchmarks:
go test -bench=. -benchmem -run=^$ ./...For docs:
cd docs && bun run docs:buildFor livetests (integration tests against real third-party SDKs):
# OTel: build-tag-gated, lives in the main module (cheap deps).
go test -tags=livetest -race ./transports/otellog/ ./plugins/oteltrace/
# Datadog dd-trace-go: lives in its own module so dd-trace-go's heavy
# transitive closure doesn't pollute the main module's dependency graph.
cd plugins/datadogtrace/livetest && go test -race ./...go test -tags=livetest ./transports/axiom/
Two patterns are in use, picked by dependency weight:
- Build-tag gating in the main module (
//go:build livetest): used when the SDK adds only a handful of indirect deps. The OTel SDK fits here (~4 added go.sum lines on top of the OTel API we already need). - Separate test module: used when the SDK pulls in a heavy transitive
closure that we don't want exposed to users of the plugin. The Datadog
dd-trace-go SDK fits here (would have added 250+ go.sum lines to the
main module). The test module imports the parent via a
replacedirective and is opt-in bycd-ing into it.
Add new livetests for any package that talks to a third-party SDK whose contract you need to verify in-process. Use the build-tag pattern when the dep cost is small; use a separate module when it isn't.
Pre-commit and pre-push hooks are managed by lefthook.
Config lives in lefthook.yml at the repo root.
Install once after cloning:
go install github.com/evilmartians/lefthook@latest
lefthook installgo install puts binaries in $(go env GOPATH)/bin (default ~/go/bin).
Make sure that directory is on your PATH so git hooks can find lefthook
when they fire. If you only have ~/.local/bin or similar on PATH, a
symlink works too:
ln -sf ~/go/bin/lefthook ~/.local/bin/lefthookWithout this, the hook script will print Can't find lefthook in PATH and
exit 0, silently skipping the checks (lefthook intentionally fails open so a
missing install doesn't break commits).
What runs:
- commit-msg: lints the commit message against
@conventional-commits/parserfor git-history hygiene. Hard-fails ifbunisn't on PATH ornode_modulesis missing; install bun (https://bun.sh) and runbun install. - pre-commit (parallel):
gofmt -lon staged Go files (fails if anything needs formatting; rungofmt -w <file>to fix),go vet ./..., andstaticcheck ./.... Hard-fails ifstaticcheckisn't on PATH; install once withgo install honnef.co/go/tools/cmd/staticcheck@latest. - pre-push:
go test -race -count=1 ./...across every module viascripts/foreach-module.sh test. The script parallelizes per-module test runs across CPUs; override withPARALLEL=1for serial output.
Hook commands fail closed: missing tooling blocks the commit/push so the
local checks actually run. Skip a single commit/push with --no-verify if
you genuinely need to. (Note: this only applies to the steps inside
lefthook.yml. The git hook script lefthook generates still fails open if
lefthook itself isn't installed; the line below explains why and how to
recover.)
Skip a hook for one command with git commit --no-verify or
git push --no-verify. Don't make this a habit; the hooks are deliberately
fast. The pre-push test step parallelizes the per-module test runs across
CPUs (override with PARALLEL=1 to force serial when debugging a single
module's output), so a typical run finishes in well under 10 seconds on a
multi-core box.
Releases are managed by monorel, a changesets-style release tool built specifically for the layout this repo uses (bare vX.Y.Z for the root, <path>/vX.Y.Z for sub-modules). The release signal is explicit per-PR: .changeset/<name>.md files declare which packages release at what bump level. Don't git tag manually.
Install monorel once:
go install monorel.disaresta.com/cmd/monorel@latest- Main module tags as
v1.X.Y. Sub-modules tag astransports/otellog/v1.X.Y,plugins/oteltrace/v1.X.Y(Go module convention). Configured inmonorel.tomlat the repo root. - Standard SemVer:
:majorbump,:minorbump,:patchbump per changeset frontmatter. The maximum bump across all changesets naming a package wins. - Per-package
CHANGELOG.mdis maintained bymonorel releasefrom v1.0.0 forward. New entries land above the first##heading in Keep-a-Changelog format; existing entries (release-please-formatted, for the historical period before the migration) stay verbatim. - User-facing release notes also land in
docs/src/whats-new.mdfor the docs site. Currently maintained manually; follow the Keep a Changelog shape. - A changeset can name multiple packages with different bumps. Use
monorel add --package "<name>:<level>"to write one (or hand-roll the file). Package keys match the[packages."<key>"]lines inmonorel.toml:go.loglayer.devfor the root,<path>for sub-modules (e.g.transports/zerolog,plugins/oteltrace). - Conventional commits are still required for PR titles + commit
messages, but only as a hygiene check.
pr-title.ymland the lefthook commit-msg hook validate the format; the parser doesn't drive any release decision. A release happens iff a PR contributes a.changeset/*.mdfile to main.
Every transport, plugin, and integration ships as its own Go module. There is no "bundle it in main first, split later" path — split from day one so the eventual breaking change is local to that module's tag namespace.
To add <path> (e.g. transports/foo or plugins/bar):
-
Create the directory and code as usual.
-
Add
<path>/go.modwith:module go.loglayer.dev/<path> go 1.25.0 replace go.loglayer.dev => ../.. require go.loglayer.dev v0.0.0-00010101000000-000000000000
The module path is
go.loglayer.dev/<path>with no/v2suffix, even though the core dependency isgo.loglayer.dev/v2. The sub-module ships at v1.0.0 independently; it only gets a/v2in its own path when it breaks its own API after v1.x.x. (Seetransports/datadog— started attransports/datadog/v1.0.0, later bumped totransports/datadog/v2.0.0when the core went v2 and the wrapper shape changed.)Adjust the
replacedepth (../..fortransports/foo,../../..forplugins/foo/livetest, etc.). If the package depends on other split sub-modules (e.g.plugins/plugintest), add correspondingreplaceandrequirelines following the existing siblings as a template. -
Register the module in
monorel.tomlwith a[packages."<path>"]block following the existing siblings as a template (tag_prefix,path,changelogall set to the path-derived values). -
Add the path to
scripts/foreach-module.sh(ALL_MODULES,SHIPPED_MODULES, and thetestop's hardcoded list). -
Add the path to
go.work'suseblock. -
Important: For fresh modules (new transports, plugins, or integrations), use
:majorfor the initial release to establish v1.0.0. Subsequent releases will bump to:minorfor new features or:patchfor bug fixes. -
Run
bash scripts/foreach-module.sh tidyto settle indirect deps andbash scripts/foreach-module.sh testto confirm. -
Open the PR. No release happens from this PR —
monorel.tomlregisters the package but registration alone doesn't trigger a release. -
Cut the first release in a follow-up PR by adding a changeset:
monorel add --package "<path>:major" --message "Initial release."
When that follow-up PR merges, the always-open release PR updates with
<path>/v1.0.0(or:minor→v0.1.0,:patch→v0.0.1); merging that release PR creates the tag.
Tags are created by monorel release from changesets scoped to the module. The post-merge tag form is <path>/v<X.Y.Z>. CI runs go test and the other checks per-module via scripts/foreach-module.sh. Adding the new module to that script's arrays in step 4 is what makes CI / pre-push pick it up.
The release-please-era gotchas (release-as initial-version dance, squash-merge stripping Release-As: footers, exclude-paths completeness) are gone. Changesets are explicit per-PR file artifacts; nothing is inferred from commit messages, so none of those failure modes can recur.
.github/workflows/:
- ci.yml: build, gofmt, vet, test -race, staticcheck, govulncheck.
Matrix tests Go 1.25 and 1.26. Calls
scripts/foreach-module.shfor the per-module operations so the same checks run locally. - docs.yml: build vitepress docs on PR (verify clean), deploy to
GitHub Pages on main push, also called by
release.ymlafter a monorel-driven release (workflow_call sidesteps GitHub's anti-recursion forGITHUB_TOKEN-created releases). - release.yml: triggered on every push to
main(or viaworkflow_dispatch). Runsmonorel autovia thedisaresta-org/monorel/ci/github@v1action, which detects whether HEAD is a release-PR merge: on a regular feature merge it upserts the always-open release PR; on thechore(release):merge it tags, pushes, and publishes per-package GitHub Releases. After the action completes on achore(release):push,deploy-docsruns viaworkflow_call. - pr-title.yml: validates that PR titles follow Conventional Commits for git-history hygiene. Allowed types match the scoped-commit convention above.
To cut a release:
- Land changes on
mainvia PRs that include a.changeset/<name>.mdfile when a release is desired. Usemonorel add --package "<name>:<level>"to author one, or hand-roll the file. - The release workflow's
monorel autostep updates the always-open release PR after each push tomain. The PR body shows the rendered plan (per-packagefrom/toversions plus the changelog body for each). - Edit
docs/src/whats-new.mdto add the user-facing summary if relevant — monorel doesn't touch this file. - Merge the release PR. The release workflow's
monorel autostep detects thechore(release):merge, creates per-package tags from the merge commit'smonorel-Release:trailers, pushes them, and creates one GitHub Release per tag.
scripts/agent-vulncheck.sh runs govulncheck across all modules and
emits a compact summary of findings. It's wired up as a Claude Code
SessionStart hook in .claude/settings.json, so any agent working
in the repo sees current findings as session context. CI runs the same
scan as a separate job (govulncheck in ci.yml).
Findings come in three flavors and only some are addressable here:
- Standard library vulns (
crypto/x509@go1.25→go1.25.9): fixed by the operator upgrading their Go install. The repo can bump thegodirective in go.mod to require a patched version, which forces downstream users onto it. Worth doing when patches accumulate but trades adoption for urgency. Don't bump on every advisory. - Direct dependency vulns: usually fixable with
go get -u <module>followed bygo mod tidy. Pre-push tests catch regressions. - Indirect / unreachable vulns: govulncheck reports these as "imports" but doesn't show a call path. Typically false positives for our use case; ignore unless they migrate to the "Your code is affected" section on a future scan.
The hook never blocks. If a finding warrants action, an agent or human surfaces it in a PR; otherwise the noise floor stays advisory.
Every method on *LogLayer is safe to call from any goroutine, including
concurrently with emission. There is no setup-only category.
How each class achieves safety:
- Emission methods (
Info,Warn,Error,Debug,Fatal,WithMetadata,WithError,WithContext,Raw,MetadataOnly,ErrorOnly): read-only on logger state. - Returns-new (
WithFields,WithoutFields,Child,WithPrefix,WithGroupon*LogLayer,WithContexton*LogLayer): build a new logger; receiver untouched. - Read-only (
GetFields,GetLoggerInstance,IsLevelEnabled): no state change. - Level mutators (
SetLevel,EnableLevel,DisableLevel,EnableLogging,DisableLogging): backed by anatomic.Uint32bitmap. Mirrorszap.AtomicLevel. Designed for live runtime toggling (SIGUSR1, admin endpoints flipping debug on, etc.). - Transport mutators (
AddTransport,RemoveTransport,SetTransports): publish a new immutable transport set viaatomic.Pointer[transportSet]. Concurrent mutators on the same logger serialize via an internal mutex (slow path); the dispatch hot path only loads the pointer. - Mute toggles (
MuteFields,UnmuteFields,MuteMetadata,UnmuteMetadata): backed byatomic.Boolstate on*LogLayer. Construction values come fromConfig.MuteFields/Config.MuteMetadataand are latched into the atomic state inbuild(). - Plugin mutators (
AddPlugin,RemovePlugin): publish a new immutablepluginSetviaatomic.Pointer[pluginSet]. Same pattern as transports; serialized bypluginMu. The dispatch hot path only loads. - Group mutators (
AddGroup,RemoveGroup,EnableGroup,DisableGroup,SetGroupLevel,SetActiveGroups,ClearActiveGroups): publish a new immutablegroupSetviaatomic.Pointer[groupSet]. Same pattern, serialized bygroupMu.
The contract above is verified by concurrency_test.go under -race,
including a runtime-level-toggle test and a transport hot-swap test.
This section records architectural perf changes that were tried, measured, and reverted. Don't redo them without new evidence.
Hypothesis: pooling *LogBuilder would eliminate one allocation per
WithMetadata / WithError call.
Result: net negative. Map metadata went from 200 ns / 3 allocs to 217 ns / 3 allocs, struct metadata 75 ns / 2 allocs to 87 ns / 2 allocs.
Why: the Go compiler already inlines newLogBuilder (cost 5) into the
caller, and escape analysis stack-allocates the resulting *LogBuilder when
the chain is consumed inline. There was no allocation to save. sync.Pool.Get
plus the defer pool.Put added overhead with zero alloc savings.
Don't try again unless Go's compiler regresses on inlining or the LogBuilder shape grows beyond what fits a stack frame. Verify by running:
go build -gcflags='-m' . | grep -E "newLogBuilder|LogBuilder.*moved to heap"The builder should NOT appear in the "moved to heap" output for a
log.WithMetadata(...).Info(...) call site.
Hypothesis: pooling the assembled Data map saves an alloc per log with
fields or error.
Result: would force a contract change. Transports today are free to retain
params.Data (the testing transport stores it in LogLine.Data without
copying; future async/batching transports would do the same). Pooling means
"transports must not retain or must clone first," which silently breaks every
custom transport.
Don't try again unless there's a clean contract migration path (e.g. a
params.RetainData() helper transports must call before they retain).
A cached pointer to transports[0] for the single-transport fast path. Saved
the loop-iteration cost (~2 ns) but had to be re-synced across New, Child,
AddTransport, RemoveTransport, SetTransports. Marginal speedup
didn't justify the maintenance burden.
Don't reintroduce unless single-transport dispatch becomes a measured bottleneck.
- Level state as
[6]boolarray instead ofmap[LogLevel]bool: ~10% faster onBenchmarkLoglayer_SimpleMessage(42 → 38 ns) and improves thread-safety story. - Lazy
Dataallocation inprocessLog: skip themake(Data, ...)when there are no fields and no error. SimpleMessage went from 2 allocs to 1. - Sized
Datamap (make(Data, len(fields)+1)instead of a fixed2): trivial, avoids map-grow on fields-heavy logs. cfg := &l.configinstead of value copy inprocessLog: avoids copying the entire Config struct (multiple fields incl. function pointers) on every log call. ~5% faster on SimpleMessage (38 → 36 ns) and proportional wins on metadata paths.
These exist in upstream loglayer but are not in the Go v1:
- Field managers (TS calls these "context managers": Linked / Isolated / Default. Fields behave as a flat copied map here)
- Log level managers (LinkedLogLevelManager / etc.; level state is per-instance, copied on
Child()) - Async lazy evaluation (the sync form ships as
loglayer.Lazy; async has no Go equivalent forPromise<T>) - Mixins (the
useLogLayerMixinaugmentation pattern)
If you're asked to add one of these, propose the design first, do not silently start implementing.
Never commit directly to main. Always create a feature branch from current main, work on it, then open a PR that targets main. This applies to all changes: features, bug fixes, docs updates, configuration tweaks.
The full workflow is in .claude/rules/git-workflow.md:
- Rebase your branch onto current main before opening a PR
- Always run code review (
/superpowers:requesting-code-review) before committing final work - Documentation changes need a separate review with framing "act as a senior Go developer encountering this for the first time"