Skip to content
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

Merged
merged 31 commits into from
Jan 19, 2017
Merged

Add SugaredLogger #185

merged 31 commits into from
Jan 19, 2017

Conversation

akshayjshah
Copy link
Contributor

@akshayjshah akshayjshah commented Nov 21, 2016

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:

BenchmarkLogrusAddingFields-4                         	  100000	     10219 ns/op	    5726 B/op	      77 allocs/op
BenchmarkLogrusWithAccumulatedContext-4               	  200000	      7760 ns/op	    3919 B/op	      61 allocs/op
BenchmarkLogrusWithoutFields-4                        	 1000000	      2317 ns/op	    1321 B/op	      25 allocs/op

BenchmarkZapAddingFields-4                            	 1000000	      1287 ns/op	     768 B/op	       4 allocs/op
BenchmarkZapWithAccumulatedContext-4                  	 5000000	       341 ns/op	      64 B/op	       2 allocs/op
BenchmarkZapWithoutFields-4                           	 5000000	       363 ns/op	      64 B/op	       2 allocs/op

BenchmarkZapSugarAddingFields-4                       	  500000	      4160 ns/op	    1443 B/op	      36 allocs/op
BenchmarkZapSugarWithAccumulatedContext-4             	 3000000	       408 ns/op	      80 B/op	       3 allocs/op
BenchmarkZapSugarWithoutFields-4                      	 5000000	       400 ns/op	      80 B/op	       3 allocs/op

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.

@jcorbin
Copy link
Contributor

jcorbin commented Nov 21, 2016

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:

  1. adding sugared methods for adding fields in a less verbose way
  2. providing it a way back to dynamic message string building

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).

@anuptalwalkar
Copy link

@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.

@mikluko
Copy link
Contributor

mikluko commented Dec 24, 2016

Any progress on that? Is there anything I can assist with?

@akshayjshah akshayjshah changed the base branch from master to dev January 12, 2017 01:18
@akshayjshah akshayjshah requested a review from jcorbin January 13, 2017 01:03
@akshayjshah akshayjshah added this to the 1.0.0 milestone Jan 13, 2017
Copy link
Contributor

@jcorbin jcorbin left a 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())}
Copy link
Contributor

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.
Copy link
Contributor

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.

Copy link
Contributor Author

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)...)}
Copy link
Contributor

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.

Copy link
Contributor

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)...)

Copy link
Contributor Author

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 {
Copy link
Contributor

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)

Copy link
Contributor Author

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.

Copy link
Collaborator

@prashantv prashantv left a 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 {
Copy link
Collaborator

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{})

Copy link
Contributor Author

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.

Copy link
Collaborator

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.

Copy link
Contributor Author

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.

Copy link
Collaborator

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{}) {
Copy link
Collaborator

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{}

Copy link
Contributor Author

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)
}
Copy link
Collaborator

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 an interface{}
  • 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 as interface{} where all objects are treated the same, or it takes a message string and some set of key-value pairs (possibly using interface{}).

Copy link
Contributor Author

@akshayjshah akshayjshah Jan 14, 2017

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.

Copy link
Collaborator

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 library log package. This pattern is very different to the SugaredLogger, since a log line like log.Info("dial failed", hostname, err) will just append everything together with logrus or log, but with the SugaredLogger, it will treat hostname 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.

Copy link
Contributor Author

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 the log15 API, I know of three libraries that have strongly-typed string arguments as messages - log15, logxi, and apex/log. The standard library, glog, logrus, go-kit, go-logging, xlog, seelog, go-playground/log, sasbury/logging, logsip, and spacelog 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 accept interface{}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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why no ArrayMarshaler here?

Copy link
Contributor Author

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:
Copy link
Collaborator

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

Copy link
Contributor Author

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."
Copy link
Collaborator

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 {
Copy link
Collaborator

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.

Copy link
Contributor Author

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])
Copy link
Collaborator

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.

Copy link
Contributor Author

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.

@akshayjshah
Copy link
Contributor Author

akshayjshah commented Jan 14, 2017

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 interface{} where possible are worth the cost. This makes logging errors in production and arbitrary things in development much simpler.

The other reasonable option is logger.Info(string, map[string]interface{}), possibly with zap.Map or zap.SugaredFields offered as a type alias for map[string]interface{}. I find the wordiness of this API and the intrusiveness of the zap package quite unappealing.

An intermediate position is logger.Error(string, keysAndVals ...interface{}). I'm not wholly opposed to this, but I do really hate logger.Error(err.Error()) all over the place.

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.
@prashantv
Copy link
Collaborator

An intermediate position is logger.Error(string, keysAndVals ...interface{}). I'm not wholly opposed to this, but I do really hate logger.Error(err.Error()) all over the place.

Yeah this is the approach I'd prefer. Agreed that logger.Error(err.Error()) is not great, but it's something that the compiler will complain about, and would be better done as logger.Error("some context", "error", err).

Supporting fmt.Stringer for a message doesn't seem that useful, and I don't think anything other than a string makes sense for field keys.

Using interface{} has a few issues:

  • messages can now be nil, which seems like a bug.
  • harder to catch places where the message is accidentally replaced with some other random object (e.g., a bad merge)

Are there may other logging libraries that accept the equivalent of interface{} for messages and keys and stringify it? The only ones I can think of accept interface{} when accepting a list of objects, and they're either all merged using fmt.Sprint or use fmt.Sprintf with a template.

E.g., This logger looks very similar to log15, which allows passing context as ...interface{}, but it expects messages to be strings, and that field keys are strings:
https://github.com/inconshreveable/log15/blob/master/format.go#L146

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.

@ansel1
Copy link
Contributor

ansel1 commented Jan 17, 2017

logxi functions look like Info(s string, args ...interface{}), and generally require args to be balanced pairs of strings/interface{}. But it handles the special case of an args with a length of 1 (a single logged "thing" with no key). It's a compromise somewhere between Info(err) and Info("error occurred", "error", err):

Info("error occurred", err)  // or...
Info("user struct", user)

The key for a "bare" arg defaults to an underscore, so the entry ends up looking like:

Error occurred     _=error message

@akshayjshah
Copy link
Contributor Author

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 Error() themselves, it's needlessly irritating. This is the same reason logging libraries provide formatting helpers, instead of forcing users to call fmt.Sprintf directly.

The same is true regarding field names. Sure, we can log a DPanic when we encounter a non-string key, but why wouldn't we then make a best effort to also something useful? We're guaranteed that we won't affect any other fields, so I don't see the harm. It's certainly better than dropping a field.

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 DPanic, I don't find the safety concerns compelling.

Let's discuss this in person and come to a conclusion tomorrow.

@prashantv
Copy link
Collaborator

Your main argument for supporting message as an interface{} is that a large number of existing loggers allow it. Almost every library you listed has a significant difference from SugaredLogger, they accept ...interface{} and stringify all objects and output a space-separated list, for all of the following: glog, logrus, go-logging, xlog, seelog, go-playground/log, sasburry/logging, logsip, spacelog

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 SugaredLogger interface. Given the significant difference in how arguments are handled, I don't think it makes any sense to use the existing interfaces as support for the "message should be interface{}" argument. The existing interfaces support the idea that all ...interface{} arguments should be treated the same.

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 SugaredLogger, but it also doesn't have the concept of a message.

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.

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.

@akshayjshah
Copy link
Contributor Author

As I said earlier, I don't think further back-and-forth in these comments is helpful. Let's discuss this in person.

@akshayjshah akshayjshah force-pushed the sugar branch 2 times, most recently from c1aa84b to 2482f20 Compare January 18, 2017 23:31
Compromise on the type of the message: for the context-adding methods, accept
only strings, but also provide Sprint-style methods that take ...interface{}.
Copy link
Collaborator

@prashantv prashantv left a 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")

@akshayjshah akshayjshah merged commit 64245ad into dev Jan 19, 2017
@akshayjshah akshayjshah deleted the sugar branch January 19, 2017 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

6 participants