skip to content

As the owner of a Go fleet's slog defaults, how do you choose between NewJSONHandler and NewTextHandler, and whether AddSource is on?

level: principalimportance: should knowfreq 28%

answer

  1. decide by who reads the stream
  2. everything in the binary inherits one choice
  3. the decision should live in one place
  4. a per-handler boolean has no middle setting
  5. only the binary's entry point owns the global

basics

~20 s

Decide by consumer: JSON wherever a collector parses the stream, text only where a human reads it. Put the choice in one shared setup function called from each main, and forbid libraries from calling slog.SetDefault.

solid answer

~40 s

The default handler is inherited by every package in the binary, including dependencies that can only reach `slog.Default()`, so this is a fleet decision rather than a per-service one. I standardise on `slog.NewJSONHandler` to `os.Stdout` for anything a collector ingests, and allow `slog.NewTextHandler` only where the reader is a person at a terminal — local runs, CLIs, tests — selected by environment in one shared setup helper every `main` calls, so the choice lives in one place and rolls with a dependency bump. `AddSource` I keep off by default: it is a per-handler boolean, it costs a frame resolution per record, and its value collapses when message strings are already unique. The non-negotiable rules are that only `main` calls `slog.SetDefault`, first, before anything logs, and that libraries take a `*slog.Logger`.

code

go · 13 lines
go
// package platformlog
func Setup(env string, addSource bool) *slog.Logger {
	opts := &slog.HandlerOptions{AddSource: addSource}
	var h slog.Handler
	if env == "local" {
		h = slog.NewTextHandler(os.Stderr, opts)
	} else {
		h = slog.NewJSONHandler(os.Stdout, opts)
	}
	l := slog.New(h)
	slog.SetDefault(l) // callers still get a logger to inject
	return l
}

go deeper

for a junior

Know that the handler choice is made once in main and that JSON is the usual production default because something machine-readable consumes the stream, while text is for reading locally.

for a middle

Explain what each option costs: JSON preserves nested values a collector can query, text flattens them, and AddSource is a per-handler boolean that pays a frame resolution on every record it writes.

for a senior

Show you would put the choice in one shared setup function called first in main, so startup records are not stranded on the built-in handler and the format can be changed without editing every service.

for a principal

Own it as an interface with consumers: name who inherits the default, what breaks when the format changes, how the migration is staged and ended, and the review rules — only main calls SetDefault, libraries take a logger.

## Why this is a decision somebody owns `slog.SetDefault` writes one process-wide pointer. Every package-level `slog.Info` in the binary — yours, and any dependency's — resolves through it. That makes the handler choice a one-way door in the same sense an exported API is: once a collector, an alerting rule and three dashboards parse a format, changing it is a coordinated migration, not a code change. The organisational consequence follows directly: a team that picks its own format is not making a local choice, they are making one the observability owner has to support, and that is the ground on which a platform owner can legitimately overrule them. ## The format Decide by *consumer*. - **A collector reads it → JSON.** `slog.NewJSONHandler` preserves nested values, which is the difference between a queryable field and a string somebody regexes at 3am. `slog.NewTextHandler` flattens anything without a `TextMarshaler` through `fmt`, so structure supplied by a caller is gone by the time it lands. - **A person reads it directly → text.** Local development, a CLI, a test binary. Nobody should have to pipe their own service through a formatter to read a stack of key-value pairs. Make it an environment decision, not a per-service one: one shared internal setup function — `NewLogger(cfg)` returning a `*slog.Logger`, or `Setup(cfg)` that also calls `SetDefault` — chooses the handler from configuration and every service's `main` calls it. Two properties fall out. First, the fleet has one grammar, so the collector parses one thing. Second, changing the fleet's default becomes a version bump of one module, which is a rollout you can stage, rather than 40 pull requests you have to chase. What you must not permit is two formats in one stream. A process that emits JSON from its own code and plain text from a dependency's startup path gives the collector lines it cannot parse — which brings us to ordering. ## Ordering, and the trap that eats startup logs Until `slog.SetDefault` runs, `slog.Default()` is the built-in handler that writes plain text through the `log` package to standard error. So every record emitted before that call — package `init` functions, config parsing, an imported package's own startup message — is in the wrong format, and often on the wrong stream. Those are exactly the records you need when a deploy fails to come up. So: `slog.SetDefault` is the first meaningful statement of `main`, before flags, before config, before any constructor runs, and "no logging from package `init`" is a review rule. The person onboarding to the codebase should be able to read the first ten lines of `main` and know with certainty where every log line goes; if the answer is "it depends when it was emitted", the wiring is wrong. ## AddSource `HandlerOptions.AddSource` is a single boolean on the handler, so it is on for every record or for none — there is no "errors only" setting to hide behind. The tradeoff: - **Cost:** the program counter is recorded either way; `AddSource` adds resolving it into function/file/line for each record the handler writes, plus three more fields on the wire and in storage. On a service logging tens of thousands of records a second that is worth benchmarking rather than assuming. - **Value:** high when the same message string appears in several places (`"retrying"`, `"failed"`); near zero when messages are already unique. My default is off, with unique message strings as the cheaper substitute, and on where a team can demonstrate ambiguity they cannot design away. And if it goes on, require a test asserting the reported source is a real call site — a logging wrapper silently makes every record name the wrapper, which is worse than no source at all because it looks authoritative. ## Who is allowed to call SetDefault Exactly one caller: the `main` package of a binary. A library that calls it overrides a decision its importer already made, and which one wins depends on initialisation order — unreproducible by construction. Libraries take a `*slog.Logger`, usually a field on a config struct that defaults to `slog.Default()` when the caller leaves it nil. That keeps the global as a *fallback for code that cannot be given a logger*, not as the mechanism your own code relies on. Setting the default at all is still worth it, for exactly that reason: dependencies and standard-library-adjacent code have no other way to reach your sink. So the posture is both — set the default once in `main` so nothing is stranded, and inject an explicit logger through your own code so tests can substitute one and so a request-scoped logger can carry attributes. ## How the change actually ships If you are moving a fleet from text to JSON, the format change breaks whatever parses it. Ship the shared setup module with the new default behind configuration, roll it service by service with the collector accepting both during the window, then flip the configuration default and remove the old path. Name the owner of the migration and the date the old parser is deleted; a compatibility window with no end date becomes permanent. ## Interview framing What is being tested is whether you treat a logging default as an interface with consumers. Say who inherits the choice, what it costs to change, where the decision physically lives in the code, and which rules you would enforce in review.

  • A team argues for text output in production because it is easier to read during an incident. How do you answer?
    Agree with the goal and reject the mechanism. Readability during an incident is a tooling problem — a formatter over the JSON stream gives them the same view without making every consumer parse two grammars. The cost they are proposing to move is not theirs: it lands on the collector, on shared alert rules, and on anyone querying a nested field that text output has already flattened.
  • If you set the default in main anyway, why also pass a *slog.Logger explicitly through your own code?
    Because the global is a fallback for code you cannot hand a logger to, not an argument-passing mechanism. Explicit injection lets a test substitute a capturing handler without touching process state, lets a request-scoped logger carry bound attributes down a call chain, and makes a package's dependencies visible in its constructor rather than hidden in an import.
  • How would you enforce the rule that only main calls slog.SetDefault?
    Make it cheap to comply and visible when violated: ship the shared setup function so the right thing is one call, then add a check in review or in CI that flags `slog.SetDefault` outside a `main` package. It is a grep-level rule, which is the kind worth automating because the failure it prevents — import-order-dependent logging — is nearly impossible to debug after the fact.
  • When would you accept AddSource on for the whole fleet?
    When message strings are demonstrably ambiguous across packages and the teams cannot fix that by naming, and when a benchmark on the busiest service shows the per-record frame resolution is noise against its budget. I would still require a test asserting the reported source is a genuine call site, because any logging wrapper in the path makes every record name the wrapper.

saying these in an interview costs you the question

  • Treats the log format as each team's local preference
  • Lets a shared library call slog.SetDefault on import
  • Turns AddSource on fleet-wide without measuring the cost
  • Emits two formats into the same output stream
  • Sets the default late in main and strands startup records
  • Plans a format migration with no end date for the old parser