I read an argument for only two log levels: INFO
and ERROR
.
I responded essentially saying:
- I lightly agree that we could get away with two log levels.
- I strongly disagree that we only need two ways to log.
Like the blog post's author, most of systems that I've worked on have had poor logging practices. But unlike the blog post's author, I strongly suspect that's because most logging APIs offer poor affordances. That is to say, their design makes it easy to do the wrong thing.
What follows will be a thought experiment for an alternative logging API design that, in an imaginary world, would be harder to misuse.
But, first, let's review what I mean by "most logging APIs."
https://logging.apache.org/log4j/2.x/javadoc/log4j-api/org/apache/logging/log4j/Logger.html
https://docs.python.org/3/howto/logging.html
https://docs.rs/tracing/latest/tracing/index.html
https://pkg.go.dev/log https://pkg.go.dev/log/slog
- Too many levels, no guidance on when to use them.
- Limit support for structured logging (contexts, spans, events)
- Logs aren't events
- Hard (or impossible) to compose the conditions for when to log
- log4j and friends do this in global filters; IMHO policy is easier to maintain and less magic when located close to the mechanism
- Raw logs of what's on the wire (debug)
- Deprecation (warning)
- is an error recoverable or not? (error / fatal)
- stack traces (debug or trace if it's available)
- eisenhower priority (actionable: urgent = error, not urgent = warning, not unactionabe: urgent = info, not urgent = debug)
- what module is logging
- excessive logging (sampling)
- sensitive information (redaction)
- optional logging depending on success (request-scoped circular buffer)
# Structured logging
logger = Logger.new()
.record(key=value) # Custom keys
.record(log.tags.TAG=true) # Well-known keys
# Good affordances
def Logger.important(self):
return self.record(log.tags.IMPORTANT=True)
def Logger.sampled(self):
return self.record(
log.tags.SAMPLE_KEY=key(prefix=caller),
log.tags.SAMPLE_RATE=0.1,
)
def Logger.error(self, error=None):
return self.record(
log.tags.ERROR=True,
log.tags.ERROR_DETAIL=error,
)
# Example uses
logger = Logger.new()
logger.log("A normal log")
logger.important().log("way too much detail for normal purposes")
logger.important().error(e).log("handled an error")
with logger.sampled() as logger:
logger.log("got a request on a high QPS endpoint")
try:
something()
except e:
logger.error(e).log("could not handle this error")
raise e