-
Notifications
You must be signed in to change notification settings - Fork 1.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add SugaredLogger #185
Add SugaredLogger #185
Conversation
This is great, I think it may even deduplicate effort between uberfx and other ongoing internal work. My only thoughts on a high level about its API are: it seems like we have strictly two things going on here:
I'd really like to see us keep those concerns split (e.g.SugaredLogger embedded by FormattedLogger?) My primary concerns are around making it clear to other core zap pieces when they can and cannot assume that message is a manageable cardinality (e.g. the sampler would at worst degrade to no weighting, but we could do something even more graceful like degrade to an LRU counts table). |
@jcorbin I agree with not duplicating this effort. UberFx's current interface is conforming with SugarLogger. Once this PR is ready for merge, My plan is to cleanup UberFx log and embed sugar logger. That way we will get unified experience whether we are using UberFx or zap. |
Any progress on that? Is there anything I can assist with? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is okay and all, my skepticism just dies hard ;-)
// WithStack adds a complete stack trace to the logger's context, using the key | ||
// "stacktrace". | ||
func (s *SugaredLogger) WithStack() *SugaredLogger { | ||
return &SugaredLogger{core: s.core.With(Stack())} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned offline (followingf WIP brain dump in #183) I think that we can do better wrt caller(s) across all of zap. For this method, if everything works out, I'd like to shift it to:
// WithCallers adds a complete structured stack trace to a logger's context, using the
// key "callers".
func (s *SugaredLogger) WithCallers() *SugaredLogger {
return &SugaredLogger{core: s.core.With(Callers())
}
|
||
// Desugar unwraps a SugaredLogger, exposing the original Logger. | ||
func Desugar(s *SugaredLogger) Logger { | ||
// TODO: decrement caller skip. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can't naively just mutate a field on a struct tho if/when Logger
becomes *Logger
; I think we have to "return a copy with a lower skip" as a first approximation. As an optimization then, we may need to hold two *Logger
: original, and mine (the one we actually use here); we then either return the original, or fault one in with a lower level of skip.
A corollary of all this applies to the logger passed into Sugar above: we can't just mutate, because it still needs to be correct for the caller to use directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO is to use whatever WithOptions
thing we end up settling on.
// Object("user", User{name: "alice"}), | ||
// ) | ||
func (s *SugaredLogger) With(args ...interface{}) *SugaredLogger { | ||
return &SugaredLogger{core: s.core.With(sweetenFields(args, s.core)...)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pulling my thread along about original, mine *Logger
: I guess then that With
should only with mine
and set s.original = nil
, leaving it up to an eventual desugar to fault in if someone wants to unwrap a contextified sugared logger.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I feel that this method should just be "sugar" for WithFields
: return s.WithFields(sweetenFields(args, s.core)...)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, that's reasonable.
} | ||
} | ||
|
||
func sweetenFields(args []interface{}, errLogger Logger) []zapcore.Field { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, this is so close to being a more useful func ZipAny(...interface{}) ([]zapcore.Field, error)
.
Towards a counterfactual then, how bad would it be to not have this sugaredlogger, when logging sites could be:
log.Info("stuff", zap.ZipAny(
"this", 2,
"andThen", 3,
"andFurthermore", map[string]string{"cantStop": "meNow!"},
)...)
log.Debug(fmt.Sprintf("I just %s", "can't stop myself"))
In the real "approved" case, there's only one "zap" in play, and in the regrettable case, they clearly know what they've bought into...
(this counterfactual up against my prior "how really does this (un)wrapping work" meme)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Happy to consider ZipAny
later on; in general, though, I think that there's an important place for a logger that makes these semi-magical behaviors the default. In contexts where performance isn't a primary concern, I'd like to have as little noise as possible around logging sites.
The sugared logger benchmarks were artificially good because we weren't logging anything :/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really don't like the idea of messages and field names be interface{}
.
// Debug logs a message and some key-value pairs at DebugLevel. Keys and values | ||
// are treated as they are in the With method. | ||
func (s *SugaredLogger) Debug(msg interface{}, keysAndValues ...interface{}) { | ||
if ce := s.core.Check(DebugLevel, sweetenMsg(msg)); ce != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why duplicate these check + call to Write for each level? A single helper (that will probably be inlined) should clean up this implementation:
func (s *SugaredLogger) log(level zap.Level, msg interface{}, keysAndValues []interface{})
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That breaks the caller skipping.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The caller skipping should allow skipping n frames, not just one. It seems a little arbitrary to say a logger can only skip a single call frame.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problem isn't the number of frames skipped, it's the incongruence between the number of frames skipped when calling CheckedEntry.Write
and logger.Info
. Putting another layer of indirection in here dries up the logger, but prevents us from exposing a checking API. I'd intended to expose such an API; until that happens, I'll add the extra indirection back in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahh, that makes sense. One other option is to have the sugar logger maintain 2 loggers internally, one with +2 skip, and one with +1 skip, and have the checking API use the one with less skip.
Definitely not ideal though. Considering this and @jcorbin's comment on #247 (review), I wonder if per-call site skip makes sense to consider.
|
||
// Debug logs a message and some key-value pairs at DebugLevel. Keys and values | ||
// are treated as they are in the With method. | ||
func (s *SugaredLogger) Debug(msg interface{}, keysAndValues ...interface{}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is msg
an interface{}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because we don't have sum types?
return str | ||
} | ||
return fmt.Sprint(msg) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really dislike this "message is an interface" pattern.
- You eat an extra allocation on every single log converting the
string
to aninterface{}
- Apart from
error
, I don't see many use cases for supporting arbitrary types as messages - No other logging library I know allows the message to be an
interface{}
. Either it only takes objects asinterface{}
where all objects are treated the same, or it takes a messagestring
and some set of key-value pairs (possibly usinginterface{})
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In order:
- That's true. However, if you're using the sugared logger, I don't think a single allocation is breaking the bank.
fmt.Stringer
is the other obvious interface we should support, but yes, that's probably it.logger.Error(err)
is so common that this seems worthwhile to me. This also makes debug-level logging much easier in development, since you can easily log arbitrary structs.- Can you clarify this last point a bit? Many Go loggers expect the message(s) to be
interface{}
: logrus, log15, and go-kit/kit/log all do this. Those libraries make different choices about how to represent key-value pairs; go-kit models them as...interface{}
too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- I agree that a single allocation isn't a huge deal, but it is another drawback of this approach.
- I'm not sure I agree that a
Stringer
is useful. Can you provide use cases from real world code where users are calling.String()
and passing it as a message right now? - There is no single message that is passed as a
interface{}
.- logrus supports a list of
interface{}
that it then puts together, which is more similar to the standard librarylog
package. This pattern is very different to the SugaredLogger, since a log line likelog.Info("dial failed", hostname, err)
will just append everything together withlogrus
orlog
, but with theSugaredLogger
, it will treathostname
as a key. Considering how differently these work, I'm not sure it's a good comparison. - log15 expects messages to be strings, see the Logger type.
- The
go-kit/log
Logger doesn't even take a message, it only takes key-value pairs.
- logrus supports a list of
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Excellent - glad we agree that, in this context, the perf impact of a single
interface{}
conversion isn't something to be concerned about. - Appeals to data are great. Typically, the onus of proof falls on the minority position - in this case, that's the argument for compile-time safety at log sites. Of course, any quantification of this is difficult because the problem is circular: developers don't explicitly stringify their messages because they overwhelmingly choose logging libraries that do so for them.
- It seems to me that the strong consensus in logging, both in Go and in other languages, is to sacrifice some degree of compile-time type-checking for convenience. This is why
Sprint
-style interfaces are so common in Go. After your (accurate) correction regarding thelog15
API, I know of three libraries that have strongly-typedstring
arguments as messages -log15
,logxi
, andapex/log
. The standard library,glog
,logrus
,go-kit
,go-logging
,xlog
,seelog
,go-playground/log
,sasbury/logging
,logsip
, andspacelog
are all built around loosely-typed APIs. I'm quite confident that the majority of libraries I don't know about are also loosely-typed. They're loosely-typed because it's relatively cheap to acceptinterface{}
s instead of strings, it's convenient, and the consequences of passing un-printable or otherwise problematic values are not particularly dire.
// necessary. | ||
func Any(key string, value interface{}) zapcore.Field { | ||
switch val := value.(type) { | ||
case zapcore.ObjectMarshaler: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why no ArrayMarshaler
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just on oversight, since I was working on this before that branch landed.
return Uintptr(key, val) | ||
case string: | ||
return String(key, val) | ||
case error: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggest putting this next to fmt.Stringer
to group interfaces together
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure.
"go.uber.org/zap/zapcore" | ||
) | ||
|
||
const oddNumberErrMsg = "Passed an odd number of keys and values to SugaredLogger, ignoring last." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: underscore prefix for unexported global which we seem to be using elsewhere in zap.
} | ||
} | ||
|
||
func sweetenFields(args []interface{}, errLogger Logger) []zapcore.Field { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this function not on *Sugar
. The only field in Sugar
is a logger, and this function takes that as an argument.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, can move it.
|
||
fields := make([]zapcore.Field, len(args)/2) | ||
for i := range fields { | ||
key := sweetenMsg(args[2*i]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should enforce that keys are strings.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've had a number of very long discussions about this, and I'm generally convinced that there's utility here. We already have a logger that's type-safe, low-allocation, and fast. For the sugared logger, I think there's value in supporting error
and fmt.Stringer
in addition to strings. Given the limitations of the type system and reasonable APIs, eating an allocation and accepting interface{}
seems like a reasonable tradeoff.
In summary: we've had this discussion with a number of people, in a number of different contexts. For the sugared logger, I think that the improved ergonomics of accepting The other reasonable option is An intermediate position is We can, of course, always just offer multiple options. There's not much code required. |
Keep the sugared API wholly separate from the unsugared API: don't expose a way to add plan `zapcore.Field`s to the context, and don't plan to expose a checked entry API. This cleans up the sugared logger implementation. Since the sugared logger is a concrete type and not an interface, we can always add these curlicues back later.
Yeah this is the approach I'd prefer. Agreed that Supporting Using
Are there may other logging libraries that accept the equivalent of E.g., This logger looks very similar to log15, which allows passing context as I don't think the value of supporting automatic conversion of any type to strings is worth the potential for issues that the compiler can't help with. |
logxi functions look like
The key for a "bare" arg defaults to an underscore, so the entry ends up looking like:
|
To me, the point under debate here is how type-safe it's necessary for the sugared logger to be, and how much we're willing to sacrifice in ergonomics for that safety. My broad point, which is firmly borne out by the spectrum of existing logging libraries, is that type safety just isn't a priority for typical logging APIs. In the absolute worst case, we'll end up logging a message with a handful of oddly-named fields, which is certainly not the end of the world. Given that, I don't see why we wouldn't make a best effort to support a wide variety of types. Errors are the most obvious case: while it's obviously feasible for users to call The same is true regarding field names. Sure, we can log a It's also worth remembering that we already have a fully type-safe, performance-optimized logger. Anyone who cares deeply about log site correctness or shaving off a single allocation here or there will already be using that logger. I plan on using that logger most of the time. The sugared logger is explicitly intended to provide ergonomics and ease of use. Since the effect of passing problematic messages or keys is (a) minimal, and (b) easily caught in development with Let's discuss this in person and come to a conclusion tomorrow. |
Your main argument for supporting message as an There is no special "message", or "field key" positional argument in any these interfaces, all arguments are treated exactly the same. It's fine to reorder the objects, and still have valid output. The same is definitely not true for the Most of the logging libraries that do treat messages and key names separately use a string on the other hand (log15, logxi, apex/log, go-playground/log. The go-kit logger treats keys and values in a similar way to
Structured logging is also not a priority based on existing libraries and existing code. However, the API we're debating is intended for structured logging, and this interface is more error-prone for structured logging. Existing logging library interfaces are not error-prone for their use case of logging a list of objects. |
As I said earlier, I don't think further back-and-forth in these comments is helpful. Let's discuss this in person. |
c1aa84b
to
2482f20
Compare
Compromise on the type of the message: for the context-adding methods, accept only strings, but also provide Sprint-style methods that take ...interface{}.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, I was worried about having too many methods, but the separation is definitely growing on me ("Just log all these things", "Format these", and "I want structure")
This PR carries forward the work that @akabos did in #147. Once complete, this will resolve #138.
Relevant benchmarks for logrus, the regular zap logger, and the sugared logger:
By these benchmarks, we're doing pretty well - the sugared logger is similar to logrus in developer ergonomics, but is much, much faster and lower-allocation. However, the sugared field-adding API does introduce quite a bit of allocation overhead.