-
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 WithLazy Logger method #1319
Conversation
34182f2
to
2d6ccc7
Compare
f36dab4
to
2892baa
Compare
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.
My internal comment was meant as "we should report this before making internal changes", so you went further than I thought we would here - thanks!
It does look surprisingly clean, I thought it'd be more painful.
zapcore/deferred_with.go
Outdated
}) | ||
} | ||
|
||
func (d *deferredWithCore) With(fields []Field) 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.
from internal discussion
I still wonder if this couldn't do something like
func (d *deferredWithCore) With(fields []Field) Core {
return &deferredWithCore{
Core: core,
fields: d.fields+fields, // paraphrasing, likely as a new copy
}
it should be cheaper to both create, but also use later on?
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.
Yeah I like it, its even more performant on the Benchmark5WithsDeferredUsed, I just need to think through it a bit more carefully, is it correct in all cases?
goos: darwin
goarch: arm64
pkg: go.uber.org/zap
Benchmark5WithsUsed-10 496836 2543 ns/op
Benchmark5WithsNotUsed-10 570384 2251 ns/op
Benchmark5WithsDeferredUsed-10 770275 1456 ns/op
Benchmark5WithsDeferredNotUsed-10 2621470 464.9 ns/op
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.
Seems to be, added another test TestLoggerChainedWith
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.
wait, so you totally gave up on this? What's the reasoning?
It could still have worked, you just needed to return a new core, right?
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 haven't given up.
I wish to write the tests that show the difference in behaviour first with the sync.Once core, and then port in the "mway" style pattern in zapfx context_core.go once I have confidence in the tests. Just been busy with day to day tasks :)
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 updated the tests - TestLoggerDeferredWithCapture
They are still using the "sync.Once" new core pattern, please check if the behaviour looks correct. Then I will try the mway pattern.
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.
Using your pattern, there seems to be a regression
--- FAIL: TestLoggerDeferredWithCapture (0.00s)
--- FAIL: TestLoggerDeferredWithCapture/deferred_logger_evaluates_at_time_of_logging,_and_stops_deferring_on_children (0.00s)
logger_test.go:221:
Error Trace: /Users/qjeremy/zap/logger_test.go:221
Error: Not equal:
expected: zap.res{A:[]int{2}, B:[]int{2}, C:[]int{2}, D:[]int{3}, E:[]int{4}}
actual : zap.res{A:[]int{4}, B:[]int{4}, C:[]int{2}, D:[]int{4}, E:[]int{4}}
Diff:
--- Expected
+++ Actual
@@ -2,6 +2,6 @@
A: ([]int) (len=1) {
- (int) 2
+ (int) 4
},
B: ([]int) (len=1) {
- (int) 2
+ (int) 4
},
@@ -11,3 +11,3 @@
D: ([]int) (len=1) {
- (int) 3
+ (int) 4
},
Test: TestLoggerDeferredWithCapture/deferred_logger_evaluates_at_time_of_logging,_and_stops_deferring_on_children
7ffacf5
to
ce273cd
Compare
Codecov Report
@@ Coverage Diff @@
## master #1319 +/- ##
=======================================
Coverage 98.41% 98.42%
=======================================
Files 52 53 +1
Lines 3471 3493 +22
=======================================
+ Hits 3416 3438 +22
Misses 46 46
Partials 9 9
📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more |
e3cba1e
to
33743dc
Compare
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 With state tracking isn't right.
Separately from that, you can probably drop the synchronization and the overhead of the atomic sync.Once if you do something like @mway's context core.
As a reminder, the contract for writing with Cores is:
if ce := core.Check(e, ce); ce != nil { ce.Write(fields) }
Where core.Check
is responsible for adding the core to the CheckedEntry with CheckedEntry.AddCore, and ce.Write
eventually calls Core.Write
on all added cores.
@mway's context core takes advantage of the fact that Core.Write
will never be called directly, only after being added to the CheckedEntry with AddCore. It does so by splitting the core into two: the bit that just tracks With
s and everything else, and the writer bit that is used with Check and Write.
So the way that works out is something like this:
type myCore struct{ /* some state */ }
func (*myCore) With(fields []Field) Core { return &myCore{...} }
func (c *myCore) Check(e Entry, ce *CheckedEntry) *CheckedEntry {
ce.AddCore(&myCoreWriter{c: c.wrappedCore, /* stuff */})
// This is the important bit.
// The core added to the CheckedEntry
// is a completely different object that has the relevant state
// computed.
// You can even pool relevant things here,
// and release them in myCoreWriter.Write.
}
type myCoreWriter struct{ /* state */ }
func (*myCoreWriter) Write(fields []Field) {
// ...
}
The Write method on myCore panics because that's invalid usage that breaks the contract. The non-Write methods on myCoreWriter delegate or panic as necessary.
zapcore/deferred_with.go
Outdated
d.fields = allFields | ||
return d |
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 isn't right. With
should return a new core, not modify the original core.
This will break if, e.g.
parent := // ...
child1 := parent.With([]Field{f1, f2})
child2 := parent.With([]Field{f3, f4})
Lines 81 to 85 in 5da8736
func (c *ioCore) With(fields []Field) Core { | |
clone := c.clone() | |
addFields(clone.enc, fields) | |
return clone | |
} |
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.
interestingly its not breaking, which surprised me (see the cross talk test)
I'll have to have a look into why later with the debugger
EDIT: Oh I see, its because the test case is testing the Logger, not the Core, so the With's are isolated, but I agree, it is not correct.
Also, API-wise, I'm happy to be convinced otherwise, but my gut says this shouldn't be an Option. |
Yeah, this was a bit unclear. And you can implicitly end up with some loggers behaving differently than others. On the other hand, having it per-logger means you can write We were wondering - what's the exact reason for not deferring always? Would that be a very strongly impossible option? |
The contract right of For example, here's a convoluted test that passes today: func Example_modifyAfterWith() {
logger := zap.NewExample()
defer logger.Sync()
var x int
arr := zapcore.ArrayMarshalerFunc(func(enc zapcore.ArrayEncoder) error {
enc.AppendInt(x)
return nil
})
logger = logger.With(zap.Array("a", arr))
x = 1
logger = logger.With(zap.Array("b", arr))
x = 2
logger.Info("hello", zap.Array("c", arr))
// Output:
// {"level":"info","msg":"hello","a":[0],"b":[1],"c":[2]}
} If you always defer Withs (by adding the option to the logger), you'll get:
|
Do you think then that perhaps we express in documentation and tests that this difference in behaviour will result and the consumer can then decide whether it is the right approach for them? |
I mean the inspiration for this was for 99% cases, this greatly reduced heap allocations across the application, I tested this simply by doing
At the same time however, a global change to the top level logger may have unexpected behaviour as you mention with the way that Withs are evaluated. However, the most common case of this is an internal zapfx.Context(...) use case at Uber which is very expensive to pre-evaluate |
Bump, whats the verdict on this. I've provided and implemented a menu of choices and there is no consensus reached and it looks like this PR might otherwise end up on the graveyard. 1/ Logger level option: we all agreed its wrong. 2/ "Breaking" change (adding zap/Logger.WithLazy). Can't realise benefits immediately. EDIT: adding a method to a struct is a breaking change, but it is a "weaker" breaking change and it seems that the history shows it is permitted without bumping the major version (e.g. #1273) in this project 3/ /exp/ Wrapper implementing WithLazy for immediate use. Rather cumbersome to consume in client code due to the enclosing type. 4/ With level option e.g. logger.WithOption(LazyFields(....))). Not breaking, but needs to be supported forever. Can realise benefits immediately. Syntax is clunkier. Maybe not too hard in practice. 5/ Internal fork of our (Uber) own OSS project, but this seems absurd |
Sorry I'm late to the game here. Looked through the different options @jquirke put up, and IMO the best option here is 1505868 (adding WithLazy method to Logger). As I chatted offline with Jeremy, this level of change is something we've done in the past and is compatible enough for us to accept. As for putting in exp/ i don't see much benefit added from doing so, since the API seems pretty solid. For other things we put in exp/ it was either because it depended on experimental packages itself (i.e. slog) or only for a temporary solution to bake things in a bit. Thoughts? @abhinav @mway @prashantv |
Cool. Switched to the WithLazy implementation without exp (2) |
Agreed. |
f8116ee
to
a4d3109
Compare
One common performance problem we see is that Loggers and their children may be composed with one or more With() invocations, but the logger/s is/are not used in most invocations of the function. For example, an author might initialise a logger with some function-scoped contexttual information at the start of a function: ``` l := logger.With(zap.String("tripID", trip.ID)) ``` but never use 'l' except in seldom taken branches. However, in the majority cases the expensive with operation has been performed which can result in the wasted effort (in terms of alloc, CPU) of cloning (e.g of the JSON encoder). This commit adds a new method on Logger, WithLazy, which defers all of this expense until the time of first use. This creates considerable performance improvement for the cases where a logger is not regularly used. This does require an explicit opt in, because the author must be aware there is the possibility of objects living longer than the scope of the method wherein WithLazy is called. Additionally, cases such as logging objects where the object is modified between the WithLazy and logging that will reflect the state of the field at log time, rather than With time, which is something authors need to be aware of. ``` % go test -bench=Benchmark5With goos: darwin goarch: arm64 pkg: go.uber.org/zap/exp/lazylogger Benchmark5WithsUsed-10 527385 2390 ns/op Benchmark5WithsNotUsed-10 563468 2246 ns/op Benchmark5WithLazysUsed-10 475064 2642 ns/op Benchmark5WithLazysNotUsed-10 2255786 527.4 ns/op PASS ``` Furthermore, one core microservice in Uber has seen a reduction of 10% heap allocation by simply using this feature.
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'm a bit busy this week, so I can't give it a full review. I've pushed a couple minor tweaks.
I don't know if the tests already cover this but I want to make sure at least the following are handled, specifically with regards to evaluation of fields.
WithLazy.With
WithLazy.WithLazy
This makes for slightly more readable code.
Excessive detail in var name.
Test and assert behaviour of .With.WithLazy, .WithLazy.WithLazy, .WithLazy.With combinations.
Added test for With.WithLazy, WithLazy.With, WithLazy.WithLazy |
Split the doc strings into multiple paragraphs, adjust the wording.
Not a problem! Pushed it back. |
The table tests hurts the readability of the tests a little bit, but we can address these later. Thanks! |
As per internal Uber discussion