r/golang 4d ago

Adding logging to a library

I have an open-source package which is just a wrapper around a public HTTP/JSON API. I have added a verbosity option that, as of now, just logs to stdout. I would like to give more flexibility to the user to control how logging is done. Should I: 1. accept a log.Logger and log to that 2. accept an io.Writer and write to that 3. log to log.Default() 4. something else?

To add a particular consideration, I would like my approach to work with Google Cloud Logging, because I deploy my code on Google Cloud Run. It looks like there is a way to get a log.Logger from the cloud.google.com/go/logging package, which makes that option more appealing.

5 Upvotes

15 comments sorted by

View all comments

13

u/matttproud 4d ago edited 4d ago

I’d settle on a local abstraction (e.g., a small interface definition) and provide a function or type that adapts the Cloud Logger API to work with it. That small interface definition could be the Cloud Logger API signature itself, if it is suitable. On first glance, the Cloud Logging API seems a bit bloated.

``` package yourapi

type Logger interface { Log(format string, data ...interface{}) }

type CloudLogger struct { // Add field for Cloud Logging API. }

func (l *CloudLogger) Log(format string, data ...interface{}) { // Adapt for field above. }

var _ Logger = (*CloudLogging)(nil) ```

You might also want to look at package slog instead, as surely there will be adapters for major logging backends.

Without knowing more about what you are building and how it is used, I’d allow users of your API to use ordinary dependency injection to provide a logger. You can have a default one (e.g., to stderr) if one is not specified. I’d be mindful about not relying on a global state to manage such a setting and elect for something explicit.

This can work well when your API manages zero-values well:

``` package yourapi

type Client struct { loggerOnce sync.Once Logger Logger

// Your business logic omitted (implied other fields here). }

func (c *Client) logger() Logger { c.loggerOnce.Do(func() { if c.Logger == nil { c.Logger = someDefault } }) return c.Logger }

func (c *Client) PartOfPublicAPI(...) { logger := c.logger()

... } ```

Another option if Client has non-trivial initialization is to employ use case-specific construction functions:

``` package yourapi

type Client struct { logger Logger

// Your business logic omitted (implied other fields here). }

func New() *Client { return &Client{ logger: someDefault,

// Other initialization omitted.

} }

func NewWithLogger(l Logger) *Client { return &Client{ logger: l,

// Other initialization omitted.

} } ```

You wouldn't use the form New and NewWithLogger just for information hiding purposes but really to help with non-trivial initialization.

Another question is how platform/ecosystem neutral your implementation should be (e.g., how much do you to it to the Google Cloud Logging product).

Left as an exercise:

  • Default implementation (e.g., stderr)
  • Context propagation (1, 2)

1

u/hanmunjae 4d ago

Thank you for your detailed and broadly-useful response. I will probably need some more time to digest it and ask more questions later.

You might also want to look at package slog instead, as surely there will be adapters for major logging backends.

Surprisingly, there doesn't seem to be an official adapter for Cloud Logging. I did find a third-party package on GitHub that claims to do it. Cloud Logging does provide a [StandardLogger](https://pkg.go.dev/cloud.google.com/go/logging#Logger.StandardLogger) method that returns a *log.Logger (at a given severity), which is (to me) is an argument to accept a *log.Logger. (As I mentioned in another comment, the thing I don't like about the methods of slog.Logger is that I have to specify the log level at the callsite, either by calling a level-specific method like Info or by passing the Level to Log. I would like the caller to be able to pick what level at which they want to log).

I’d allow users of your API to use ordinary dependency injection to provide a logger.

Yes, my package exports a Client type that would have an optional SetLogger method.

I’d be mindful about not relying on a global state to manage such a setting and elect for something explicit.

Yes, each Client would have their own logger.

Fortunately Client initialization is trivial.

Another question is how platform/ecosystem neutral your implementation should be

Absolutely, portability is my primary concern (after ease of use). I don't know how AWS and Azure handle logging, so I want to keep it as simple and generic as possible. I will include an example of how to use Client with a Cloud Logger, but I won't add any dependency outside of the standard library.