golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
120.68k stars 17.33k forks source link

log/slog: structured, leveled logging #56345

Closed jba closed 10 months ago

jba commented 1 year ago

We propose a new package providing structured logging with levels. Structured logging adds key-value pairs to a human-readable output message to enable fast, accurate processing of large amounts of log data.

See the design doc for details.

jaloren commented 1 year ago

@jba you are quite right about passing attrs in. I no longer have concerns about that.

I personally have a strong preference for emitting syslog messages and using fluent api builder for attributes. I wrote a POC to test out how easy both would be to implement. Turns out writing a handler is really easy and adding a logging library that overlays a fluent api on the frontend is also quite easy.

https://github.com/jaloren/go-syslog

Which is to say, if people want a fluent api, I think they can trivially create one on top of slog which is what I will likely do since I don't have a problem space that is so sensitive to allocations. I wrote a simple benchmark that compared allocations between the fluent API and slog. The fluent api allocated 5 more than slog. This may not work for someone in a very high performance setting with lots of debug statements but that works just fine for my purposes.

I wish there was some way to include a fluent api in the logging library but I understand the concerns around performance and minimizing API surface.

shivanshuraj1333 commented 1 year ago

We're also doing some work to improve klog to support structured logging in kubernetes, under wg-structured-logging. The proposal looks interesting to me!! Would be interested in picking up some of the work...

LeGEC commented 1 year ago

doc improvement : specify in the documentation that (*logger).With (resp (*logger).WithGroup) call handler.WithAttrs (resp handler.WithGroup) on the logger's handler.

It is ok to consider that this implementation point is part of the slog.Logger contract, right ? (or is this point still up for discussion ?)

seankhliao commented 1 year ago

Having used slog in a few projects, I keep getting (unpleasantly) surprised by the default level for the builtin handlers being Info. I added a bunch of debug logs, but they never show up... because I needed to go change the handler to use the options variant.

Merovius commented 1 year ago

@seankhliao I would absolutely expect the default level to be Info. I'd argue that that's basically the definition of that level.

rezkam commented 1 year ago

Having used slog in a few projects, I keep getting (unpleasantly) surprised by the default level for the builtin handlers being Info. I added a bunch of debug logs, but they never show up... because I needed to go change the handler to use the options variant.

I expect it to be Info. Many other go logger packages like zap and logrus also have Info as their default logger.

AndrewHarrisSPU commented 1 year ago

Having used slog in a few projects, I keep getting (unpleasantly) surprised by the default level for the builtin handlers being Info. I added a bunch of debug logs, but they never show up... because I needed to go change the handler to use the options variant.

It seems like, if there's a global/default logger, a global/default reference level would also make sense? HandlerOptions could be made to observe that - if the Level field is nil, take whatever reference Leveler the global/default is using, or export that Leveler from slog, something like this?

For handler configuration more generally, a sketch of a fluent/chaining API: https://go.dev/play/p/sX6WbVq8l4R - there are tradeoffs, the fluent/chaining style isn't common in the standard library ... usually I think fluent/chaining is a bad idea (in any language, and especially Go), but it seemed interesting here

jellevandenhooff commented 1 year ago

Thanks for this proposal. I've played around some with the experimental package and it's pleasant to use.

One thing I found surprising is that a slog.Value doesn't work using the two-argument style logging API. The fragment slog.Log("logging an object", "object", slog.GroupValue(slog.String("name", "bob"))) logs a pointer to the value instead of the value itself. I ran into this when creating a small helper function returning a GroupValue to describe an object.

When logging another string containing JSON-encoded data, I found myself wishing that the output included that data without escaping. But I can also see how passing through raw JSON (not created with explicit groups) might cause problems in downstream log processing.

As a third-party package author, I would like guidance on the where to get my logger from. For example, if I want to add structured logging to a net/http-like library, should I add a *Logger to a http.Client? Or should I get a logger using FromContext? For logging request-related information in a http.Server, I could similarly imagine storing a *Logger on the request, or passing it in the request's context. Though that scenario is a little more awkward, as a *someapp.Server that a http.Server passes requests to might already have its own logger.

ancientlore commented 1 year ago

Apologies if these have been mentioned before. I found some limitations in renaming keys while exploring slog.

  1. Shouldn't there be a definition const ErrKey = "err" since that is actually a standard key?
  2. There doesn't seem to be a way to know what group an slog.Attr belongs to in a ReplaceAttr function. This means all instances are replaced, even if you don't want that behavior. See https://go.dev/play/p/rnrc4XnIvDC for an example. Perhaps this could be handled with a bool return when a group is processed, so that we could avoid processing entries in the group.
  3. In our use case, the error in slog.Error needs to be in a subgroup. This is not too hard to achieve in ReplaceAttr; however, you can't add to that group later. This doesn't impact the text handler but causes the JSON handler to duplicate the key. See https://go.dev/play/p/n4NWbHHBGws for an example. I think this also happens when using WithAttrs and adding to the group later. The append-only nature of things makes perfect sense for performance. I'm just wondering if the expectation is to create our own slog.Handler for such a use case, or if there are plans to handle this in slog.JSONHandler?
AndrewHarrisSPU commented 1 year ago

@ancientlore (3) is interesting! I think there's a reason ReplaceAttr isn't the right tool for the job here - to deduplicate all "err"-keyed attrs, one has to intercept them all before they are encoded to bytes, and the slog Handlers apply ReplaceAttr just before encoding. The TextHandler display is a bit illusory, the actual structure of things is still not what it looks like.

https://go.dev/play/p/mAUoNdQWyWn - this is capturing "err"-keyed attrs, and deduplicating by aggregating strings into a slice. Just a thought, it's rough. I'm fairly certain this deduplication step before encoding is essential.

ancientlore commented 1 year ago

@AndrewHarrisSPU thanks very much for the code snippet! I think the approach can work, although I am still curious whether or not the slog package will ultimately handle this or not.

tv42 commented 1 year ago

In https://github.com/golang/go/issues/56345#issuecomment-1304788506 @jba said

I at least would prefer using the attr alternative but that’s currently much harder to write.

Not really; you can write

logger.Warn("message", slog.String("name", "john doe"))

However, logger.Warn is func (l *Logger) Warn(msg string, args ...any). That any is footgun I don't want! I want to be forced to hand it Attrs, anything else is a mistake.

(Also, it seems that'll force it to allocate, to use an interface.)

I really don't want to ever review a log.Info("foo", "be", "careful", "counting", "these").

jba commented 1 year ago

doc improvement : specify in the documentation that Logger.WIth (resp Logger.WithGroup) call Handler.WithAttrs (resp Handler.WithGroup) on the logger's handler.

@LeGEC, this was done for With. https://go.dev/cl/451290 adds it for WithGroup.

jba commented 1 year ago

It seems like, if there's a global/default logger, a global/default reference level would also make sense? HandlerOptions could be made to observe that - if the Level field is nil, take whatever reference Leveler the global/default is using, or export that Leveler from slog, something like this?

@AndrewHarrisSPU, currently the built-in Handlers and their options are self-contained—they make no reference to global state. I think we should keep it that way. Think of it from the perspective of someone who is trying to avoid all global state. They can do so by avoiding the default logger and its global functions, always creating their own loggers and handlers. They wouldn't want global state smuggled back in by omitting an option.

jba commented 1 year ago

One thing I found surprising is that a slog.Value doesn't work using the two-argument style logging API.

@jellevandenhooff, This is a bug, fixed in https://go.dev/cl/451291.

AndrewHarrisSPU commented 1 year ago

@jba

They can do so by avoiding the default logger and its global functions, always creating their own loggers and handlers.

Sounds reasonable. Is this a fair summary of how to avoid global state?

Ways to stumble into default Logger:

Solution for the first three cases:

For the last case, something like: It's really on library code authors to pay attention, but application authors can use slog.SetDefault if they must.

08d2 commented 1 year ago

This is a huge API surface without any real production testing (AIUI). Perhaps it might be better to land it under golang.org/x for some time? Eg, like context, xerrors changes.

Huge +1 — the proposal asserts an enormous set of assumptions which are in my experience not generally applicable. The stdlib log infrastructure warrants improvement, but that improvement should be far more narrowly scoped than this.

deefdragon commented 1 year ago

@jba I have a quick question regarding this statement

OpenTelemetry also has the names TRACE and FATAL, which slog does not. But those OpenTelemetry levels can still be represented as slog Levels by using the appropriate integers.

I was unable to find anything in the discussion, but what was the driving onus behind not having a trace level? Was a lookup ever done to determine if trace is used that little that it is not worth adding to the base library?

Logrus and zerolog both have it, along with the mentioned OpenTelemetry, while Zap and glog both don't. From my spot checking, its about a 50-50 on if an import would have access to a trace call. While its simple enough to use a base Log call with -8, if there are enough places using trace, that pattern grows to be quite excessive, so again, I wonder if there is enough usage of it to where it should be added to slog.

(Regarding fatal, I do understand why its not a base level. On the one hand, for logging systems that push to remotes (an ELK stack for example), having the ability to force a flush before closing would be useful to make sure all messages are captured. On the other, flushing would likely be easy enough to put on the handler and its likely better practice to intentionally shut down vs using a single log call.)

jba commented 1 year ago

When logging another string containing JSON-encoded data, I found myself wishing that the output included that data without escaping. But I can also see how passing through raw JSON (not created with explicit groups) might cause problems in downstream log processing.

@jellevandenhooff, does json.RawMessage help? If not, can you give an example?

jba commented 1 year ago

As a third-party package author, I would like guidance on the where to get my logger from.

Speaking for myself, I generally use the context if one is passed down, HTTP request handlers being the prime example. It's also fine to add it to a struct that the package exports. For packages that have both, I would think about whether I want common attributes to be part of the dynamic call chain, or associated with the struct value.

jba commented 1 year ago

@ancientlore, good point about a constant for "err". Added in https://go.dev/cl/451293.

Regarding your other concerns, I think it's best if we keep ReplaceAttr simple, at least until we see significant demand for the kind of features you want. For now it would be best to define your own handler. If you just need to pre-process attributes, your handler can probably wrap a JSONHandler.

jba commented 1 year ago

Is this a fair summary of how to avoid global state?

  • using slog.Info, slog.Error calls
    • using slog.FromContext on a context with no logger
    • using the "log" package
    • importing any code that does any of the above

@AndrewHarrisSPU, looks right to me, once you add calling slog.Default.

To anticipate a question, yes, calling slog.FromContext on a context with no logger is the one case you can't easily catch statically. But we feel the convenience of being able to log from anywhere outweighs that.

rliebz commented 1 year ago

As a third-party package author, I would like guidance on the where to get my logger from.

Speaking for myself, I generally use the context if one is passed down, HTTP request handlers being the prime example. It's also fine to add it to a struct that the package exports. For packages that have both, I would think about whether I want common attributes to be part of the dynamic call chain, or associated with the struct value.

For application authors who leverage contexts, I think the difference in quality of life between third-party packages that do vs. don't leverage contexts is significant enough to warrant an endorsement in the documentation.

The obvious example is for tracing—any third-party library that uses a context to log will become part of the trace, and any log without the context will not. And even if folks remember to call .WithContext, it's not necessarily clear at this point what context-scoped modifications will be occurring for context-stored loggers.

It also seems application authors should as a precaution always use slog.SetDefault even if they plan on exclusively passing the logger through the context, because if it's unclear how third-party package authors will be logging, a prudent application author has to support the global default logger. Because while it's almost certainly more correct to use a context-passed logger or a struct-scoped logger than the global one as a third party, that may not be universally understood to be true if the slog docs don't suggest it.

If there's an explicit recommendation for third-party packages in the docs on supporting contexts and where to pull the logger from, then those concerns go away. Third party packages can be understood to implement that contract by default, and deviations from that become bugs worth fixing.

jba commented 1 year ago

@deefdragon, The fact that two major log packages omit TRACE was enough evidence for us to omit it. I believe it's better to leave it out now and add it later if necessary.

jellevandenhooff commented 1 year ago

When logging another string containing JSON-encoded data, I found myself wishing that the output included that data without escaping. But I can also see how passing through raw JSON (not created with explicit groups) might cause problems in downstream log processing.

@jellevandenhooff, does json.RawMessage help? If not, can you give an example?

Ah, yes, it does. Thank you! Though the ergonomics for text vs JSON logging aren't wonderful -- the text logger prints an array of raw bytes instead.

jellevandenhooff commented 1 year ago

As a third-party package author, I would like guidance on the where to get my logger from.

Speaking for myself, I generally use the context if one is passed down, HTTP request handlers being the prime example. It's also fine to add it to a struct that the package exports. For packages that have both, I would think about whether I want common attributes to be part of the dynamic call chain, or associated with the struct value.

Thinking about this some more, I wonder if the choice is necessary at all. Instead of storing the logger in the context, what if the context holds a set of Attrs (like the otel baggage)? Then the API could become something like:

// NewContextWithAttrs returns a new Context that includes the given arguments, converted to
// Attrs as in Logger.Log. The Attrs can be added to a Logger using WithContext
func NewContextWithAttrs(ctx context.Context, args ...any) context.Context

// WithContext returns a new Logger that includes the Attrs stored in
// the context, if any, from NewContextWithAttrs.
func (l *Logger) WithContext(ctx context.Context) *Logger

// WithContext invokes WithContext on the default Logger.
func WithContext(ctx context.Context) *Logger

Any code doing logging that also has access to a context can and should use WithContext to include any request-specific attributes.

I realize these functions can be added in a third-party package, though they would be less likely to be used if not in the standard library.

I believe this API satisfies the requirements to not add a context argument to every log output method. Code that would invoke slog.FromContext could instead invoke slog.WithContext (or slog.Default().WithContext). Code that has its own logger can merge the information into that logger using logger.WithContext. Any request-specific middleware can add its information using NewContextWithAttrs.

The downside is that passing a request-specific logger (with a request-specific Enabled-level, for example) won't work anymore.

deefdragon commented 1 year ago

@deefdragon, The fact that two major log packages omit TRACE was enough evidence for us to omit it. I believe it's better to leave it out now and add it later if necessary.

That feels a bit backwards to me as I would have assumed if we were going off of library inclusion, logrus alone including it would be enough to include it. Regardless, I feel the answer to this is raw data, so I dabbled in the search magics and came up with this count

Level Count [^1] Logrus Count [^2]
Trace 6,190 318
Debug 37,594 4,944
Info 57,539 4,180
Log 4,580 1
Print 52,362 225
Warn 21,955 2,867
Error 60,544 4,913
Fatal 57,255 1,496
Panic 6,206 167

Count calls analyses

The rough pattern here was observed consistently while fiddling with the exact regex.

Based on this, I think it could be argued either way as to if trace/panic should be included initially. They are not used anywhere near as much as Info etc, but they are being used more than a tenth of the time, which is still quite a bit.

What surprises me is the number of calls to Fatal. It is part of the standard log package, but that is a lot more use than I would expect. (perhaps due to a common patter in simple scripts/examples?). Regardless, it is used often enough that it may be worth considering adding a native way to flush the logs and close. We cant easily change the handler interface after it is released, and I feel flushing would best be done there given that's all we pass to create a new logger.

Logrus calls analyses

Added Logrus Calls regex column to attempt to account for scripts using log.Fatal(), skewing the data. The argument being that simple scripts are less likely to need a log.Fatal requiring a flush call.

Info and Error are just as used Debug calls increased in relative usage Warn stayed as part of the second group Fatal decreased, but only to becoming part of the second group

[^1]: These exact numbers were collected using this regex log(rus|ger)?\.Panicf?\( (replacing the level as needed) and grep.app. First time doing a code search like this so feel free to redo if I messed something up.

[^2]: To account for the potential data skew by calls to log.Fatal() in scripts, I did another search on logrus\.Fatalf?\( etc.

wzshiming commented 1 year ago

On go 1.18 vendor/golang.org/x/exp/slog/level.go:95:13: undefined: atomic.Int64

Maybe it's better to switch to an older interface, otherwise 1.18 isn't even compatible

Merovius commented 1 year ago

@wzshiming This issue is about a standard library package. It will be introduced in a future version of Go. So the existence of that package implies a later Go version than 1.18 anyways.

wzshiming commented 1 year ago

@Merovius I understand that, but it would be great if log/slog got more attempts and feedback before being included in the standard library package in the future, like the previous context and errors.

jba commented 1 year ago

Though the ergonomics for text vs JSON logging aren't wonderful -- the text logger prints an array of raw bytes instead.

@jellevandenhooff, https://go-review.googlesource.com/c/exp/+/453495 will fix that.

jba commented 1 year ago

@deefdragon, thanks for the data. Looks like it is reasonable to omit Trace. For Fatal, we decided not to complicate Handlers further by adding flushing. If, as we suspect, most log.Fatal uses are in CLI programs, then most of those uses wouldn't change to slog anyway—CLI tools are probably better off with the formatted output of log.

jba commented 1 year ago

@jellevandenhooff, regarding your alternative design for contexts and loggers: I think you'll find that people will now have to pass both Loggers and contexts around together. From there it's hard to resist adding the Logger to the context.

jfesler commented 1 year ago

If, as we suspect, most log.Fatal uses are in CLI programs, then most of those uses wouldn't change to slog anyway—CLI tools are probably better off with the formatted output of log

CLI users use libraries, too. The key value (imo) of any new log work, is to provide /the/ standardized interface that libraries can agree on. As someone who writes a lot of CLIs, I'd expect to adopt (or write) an slog handler that can still produce human friendly console output.

deefdragon commented 1 year ago

@deefdragon, thanks for the data. Looks like it is reasonable to omit Trace. For Fatal, we decided not to complicate Handlers further by adding flushing. If, as we suspect, most log.Fatal uses are in CLI programs, then most of those uses wouldn't change to slog anyway—CLI tools are probably better off with the formatted output of log.

Updated and added a second search set to attempt to account for excess usage of simple log.Fatal calls in scripts, CLI tools etc. that might be less inclined to use slog or require flushing.

Fatal remains rather prevalent. I really think we need to support Fatal&flushing the logs off the bat, and the only way I can see to do that reasonably is through the interface.

Regarding the choices of levels in general, would Level.String() not lock us in to a specific set of log levels (stuck having trace output as Warn-4)? Or would we be able to be changed what it can output, accounting for if we do add a trace or fatal level? I'm not sure how the compatibility promise would come into play in this case.

hherman1 commented 1 year ago

Why can’t Fatal (or just Flush) just be a feature of a specific handler which clis would like to use?

jellevandenhooff commented 1 year ago

@jellevandenhooff, regarding your alternative design for contexts and loggers: I think you'll find that people will now have to pass both Loggers and contexts around together. From there it's hard to resist adding the Logger to the context.

I don't entirely understand the problem you describe. The way I understand the slog.Logger API is that you'd store Loggers on long-lived structs (for servers, clients, others) if there is helpful logging context to add with every log from that struct, and use the global Default logger otherwise. That seems orthogonal to passing around contexts. Am I misunderstanding things?

If you can move the logger onto the context, that would simplify things, but I'm worried about the scenario where there is no clear logger to store in the context. A pattern I have worked with several times is with multiple sharded RPC servers running in a process. When handling RPCs they would log both a shard ID and a request ID. That is tricky with the context API as proposed:

type ServerShard struct {
    logger *slog.Logger

    // workaround: log attrs to include when using context logger
    logAttrs []any
}

func (s *ServerShard) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    logger := slog.FromContext(ctx)

    // issue:
    logger.Info("got request")   // this won't log server-specific attrs
    s.logger.Info("got request") // this won't log request ID

    // workaround 1: use context logger and store extra attrs on server
    logger.With(s.logAttrs...).Info("got request")

    // workaround 2: use server logger and manually add attrs from context
    s.logger.With("requestID", RequestID(ctx)).Info("got request")
}

(runnable code in https://go.dev/play/p/IJm1_tcZ8eY)

08d2 commented 1 year ago
func (s *ServerShard) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    logger := getLogger(ctx, s.logAttrs...)
    logger.Info("got request") // logs server-specific attrs and request ID

?

AndrewHarrisSPU commented 1 year ago

@jellevandenhooff I think what's hard here is there are a few different approaches that make sense. The relational algebra of joining segments of Attrs with disparate provenance is not hard to work out but the variations look and feel different enough in practice to be a little puzzling. But it should be solvable! Ultimately, I think what slog really must provide (and does) is a common key for finding a logger in a context.

The LogValuer interface is I think extremely useful here, and probably the most straightforward:

type ServerShard struct {
    id   int
    data []byte
}

func (s *ServerShard) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("component", "server"),
        slog.Int("id", s.id),
    )
}

func (s *ServerShard) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    log := slog.FromContext(ctx)

    log.Info("got request", "shard", s)

    ...
}

Another variation for extracting structure from a context involves some extension, turning a Handler into a LogValuer. It starts something like:

type BaggageHandler struct {
    slog.Handler
        prefix string
    as []Attrs
}

func (h *BaggageHandler) LogValue() slog.Value {
    return slog.GroupValue(h.as)
}

func BaggageFromContext(ctx context.Context) []slog.Attr {
    h := slog.FromContext(ctx).Handler()
    if v, ok := h.(slog.LogValuer); ok {
        return v.LogValue()
    }
    return nil
}

BaggageHandler or any similar LogValuer+Handler needs to also implement WithAttrs and WithGroup, but not Enabled or Handle. This is imperfect in that it requires the foresight to prepare a context with the right kind of Logger, but I think could have better perf implications in some scenarios. EDIT: I think the Record.Context field also has some potential, hmm...

ancientlore commented 1 year ago

Regarding your other concerns, I think it's best if we keep ReplaceAttr simple, at least until we see significant demand for the kind of features you want. For now it would be best to define your own handler. If you just need to pre-process attributes, your handler can probably wrap a JSONHandler.

@jba Thanks for reviewing all these comments. Because of the semantics of Handler and Record, wrapping a handler means creating a new Record and copying the attributes to make changes. This will have undesirable performance and memory impacts.

I would like to make one more plea for considering a change to the signature of ReplaceAttr. The main challenge with the current definition of ReplaceAttr is that slog descends into all of the attributes and groups (similar to filepath.Walk), but provides no context about where it is along the way. It is effectively not "group aware", even though grouping is supported by the package. It's currently not easy to avoid replacing all instances of an attribute with a given key (for instance, I only want to replace level but not matrix.level).

Looking at handler.go it doesn't seem that hard to at least provide the name of the open group...something like:

func ReplaceAttr(groupKey string, a Attr)

That's not a complete solution for multi-level groups, but it probably does provide enough context inside the ReplaceAttr function. This might be hard to change in the future without breaking code that uses it, so perhaps now is the right time to consider the topic.

08d2 commented 1 year ago

Is ReplaceAttr actually a core requirement?

ancientlore commented 1 year ago

Is ReplaceAttr actually a core requirement?

@08d2 I think it is, because the standard keys like "msg" could otherwise not be changed. At minimum those need to be customizable, or many people would not be able to use the slog package.

jba commented 1 year ago

I'd expect to adopt (or write) an slog handler that can still produce human friendly console output.

@jfesler, TextHandler is supposed to play that role.

jba commented 1 year ago

Regarding the choices of levels in general, would Level.String() not lock us in to a specific set of log levels (stuck having trace output as Warn-4)?

@deefdragon, you could always write a handler or ReplaceAttr function for that. I don't see a simple way of making Level.String general enough to support user-defined levels, and I'm not sure it's worth it—level names, or even whether to use strings rather than numbers, will always be a source of difference across systems.

jba commented 1 year ago

The way I understand the slog.Logger API is that you'd store Loggers on long-lived structs (for servers, clients, others) if there is helpful logging context to add with every log from that struct, and use the global Default logger otherwise. That seems orthogonal to passing around contexts.

@jellevandenhooff, I think @AndrewHarrisSPU gave you some good suggestions. I would add another. A ServerShard should hold a logger that already has all its attributes:

&ServerShard{logger: slog.Default().With("shardID", sid)}

When a request starts, add request attributes to that logger (creating a new one) and put it in the context. This could be done in a middleware function if the server struct has many handlers. Using your code:

func (s *ServerShard) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := slog.NewContext(r.Context(), s.logger.With("request", r))
        ...
}

(You probably don't want the whole request there, just the parts you want to log.)

Now functions called from this handler can use slog.FromContext(ctx). Those functions should take a context, to support cancellation and timeouts.

nfx commented 1 year ago

did someone already do a colored handler for slog in CLIs?

jellevandenhooff commented 1 year ago

Re. context and loggers. Thanks for your thoughts @AndrewHarrisSPU and @jba. Let me try and summarize:

Application code, specifically RPC handlers that have access to context, that want to log needs to decide which logger to use. Both loggers hold attributes that the RPC handler want to include with its logs. The suggestions, paraphrased, are:

My suggestion was to sidestep the inability to merge two loggers by storing attributes on the context instead of a logger. This approach removes the need to choose between loggers, and also removes the need to re-add the attributes from the other logger. If application code doesn't have a logger, a slog.FromContext(ctx) could extend the default logger, and if application does have its own logger, s.logger.WithContext(ctx) would extend its existing logger.

The only downside of storing attributes instead of a whole logger that I see is that an application that never want to use the default logger now has to store a logger everywhere, instead of relying on the logger being passed through the context. If that is a big concern, a solution could be to add a slog.WithDefaultLogger(ctx, logger) API.

Thanks again for working on this API that will be a big improve on the non-standardized status quo, and reading through these comments.

Edit:

Another difference of the logger-in-context API is a bit more subtle. Some RPC handler might augment its local logger, and then pass that logger on:

func handler(ctx context.Context, req *Request) (*Response, error) {
    logger := slog.FromContext(ctx)
    ...
    logger = logger.With("user", request.User)
    ctx = slog.NewContext(ctx, logger)
    // use logger
    // pass on ctx
    ...
}

This code would (with the attributes-in-context proposal) require more re-organization

func handler(ctx context.Context, req *Request) (*Response, error) {
    ctx = slog.NewContextWithAttrs(ctx, "user", request.User)
    logger := slog.FromContext(ctx)
    ...
    // use logger
    // pass on ctx   
    ...
}

and that transformation might not always be possible (or look good).

prochac commented 1 year ago

imo the context should contain only the log fields (here called attributes, or args), not the logger. The fields can be stored in context behind well-know key. That makes sense to me. And it should be the application that contains the logger, that can be easily sourced by these log fields. Because the logger contains, except the fields, also information how they will be logged, in what format, and to what destination. I find it strange that this information is being passed by *http.Request.

AndrewHarrisSPU commented 1 year ago

did someone already do a colored handler for slog in CLIs?

I've got a TTY device in a work-in-progress. Advent of Code is a nice test, there are some kinks and decisions to revisit ... the whole thing is an experiment to build more printf-style feel on top of slog.

I am finding the slog data model and and the Handler interface are doing really well for this. The things that require some effort are exactly the things that don't quite fit in a structured logging mindset, but are still plausible. Just doing some colored output would be a lot less involved.

jba commented 1 year ago

I would like to make one more plea for considering a change to the signature of ReplaceAttr. The main challenge with the current definition of ReplaceAttr is that slog descends into all of the attributes and groups (similar to filepath.Walk), but provides no context about where it is along the way. It is effectively not "group aware", even though grouping is supported by the package.

@ancientlore (and everyone else), how about the following changes:

The CL is at https://go.dev/cl/455155. The new godoc is

// ReplaceAttr is called to rewrite each non-group attribute before it is logged.
// The attribute's value has been resolved (see [Value.Resolve]).
// If ReplaceAttr returns an Attr with Key == "", the attribute is discarded.
//      
// The built-in attributes with keys "time", "level", "source", and "msg"
// are passed to this function, except that time and level are omitted
// if zero, and source is omitted if AddSourceLine is false.
//      
// The first argument is a list of groups that contain the Attr. It must not
// be retained or modified. ReplaceAttr is never called for Group attributes,
// only their contents. For example, the attribute list
//      
//     Int("a", 1), Group("g", Int("b", 2)), Int("c", 3)
//      
// results in consecutive calls to ReplaceAttr with the following arguments:
//      
//     nil, Int("a", 1)
//     []string{"g"}, Int("b", 2)
//     nil, Int("c", 3)
//      
// ReplaceAttr can be used to change the default keys of the built-in
// attributes, convert types (for example, to replace a `time.Time` with the
// integer seconds since the Unix epoch), sanitize personal information, or
// remove attributes from the output.
ReplaceAttr func(groups []string, a Attr) Attr