lg

package module
v2.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2022 License: MIT Imports: 1 Imported by: 0

README

Actions Status release GoDoc license

neilotoole/lg

lg is an exploration of a small, leveled, unstructured logging interface for enterprise applications. It is not suggested for production use.

TLDR

Log levels ERROR, WARN and DEBUG are appropriate for many enterprise applications.

Use this idiom with io.Closer:

func DoSomething(log lg.Log) error {
  f, err := os.Open("filename")
  if err != nil {
    return err
  }
  defer log.WarnIfFuncError(f.Close)

Installation

go get -u github.com/neilotoole/lg/v2

Quick Start

// Use uber/zap adapter with options
log := zaplg.NewWith(os.Stdout, "json", true, true, true, true, 0)

// Add a field to the log
log = log.With("request_id", 12345)

log.Debug("hello world")
log.Warnf("not %s at all", "good")
log.Error(err)

log.WarnIfError(f.Close())

// WarnIfFuncError typically used with defer
defer log.WarnIfFuncError(f.Close)

// Alternatively
defer log.WarnIfCloseError(f)

When testing:

func TestMe(t *testing.T) {
	log := testlg.New(t)
	log.Debug("Hello world") // directs output to t.Log
}

Overview

Contra Cheney, lg's primary thesis is that three log levels are appropriate for many enterprise applications, and that these levels should be ERROR, WARN, and DEBUG.

  • ERROR: a business operation failed, and the user experienced it. We've lost actual dollars because of this thing. Ops needs to look at it immediately.
  • WARN: the business operation didn't fail, but something fishy happened that should be diagnosed before it does start costing us money. Ops needs to look at it eventually, possibly passing it on to dev.
  • DEBUG: for developers, for after-the-fact diagnosis of problems. Sometimes DEBUG logs are the only practical way for devs to sherlock the disaster that occurred at our primary customer's installation, which of course cannot be recreated in tests because Mephisto himself couldn't conjure this production environment.

This exploration examines a specific issue in detail: how to handle an io.Closer error after the main business operation has succeeded. The conclusion is that this error is best logged at WARN level. The lg.Log interface provides convenience methods for this scenario.

Additionally, lg demonstrates the separation of a logging interface from concrete implementations. Note that lg itself doesn't perform rendering of log entries: this is left to a backing log library. Implementations can be found in lg/zaplg and lg/testlg. The testlg impl is used in conjunction with Go's testing framework. If using zap, testlg has benefits over zaptest.

lg does not address structured logging, the virtues of which are outside scope.

WarnIf methods

In addition to typical logging methods such as Debugf, the Log interface defines methods WarnIfError, WarnIfFuncError, and WarnIfCloseError.

TLDR

Do this:

  defer log.WarnIfFuncError(dataSource.Close)

Not this:

 defer func() {
   err := dataSource.Close()
   if err != nil {
     log.Warn(err)
   }
 }()

We'll use examples to work our way through the origin story of the WarnIf methods.

BusinessOperationV1

Let's start with this function:

// BusinessOperationV1 performs a business operation against
// an external API. If the business operation fails, a non-nil
// error is returned. If the business operation succeeds,
// a non-empty transaction receipt is returned.
//
// BusinessOperationV1 closes dataSource via defer, but ignores
// any error from Close.
func BusinessOperationV1(log lg.Log) (receipt string, err error) {
  dataSource, err := OpenBizData()
  if err != nil {
    return "", err
  }
  defer dataSource.Close() // Ignores any error from Close

  data, err := ioutil.ReadAll(dataSource)
  if err != nil {
    return "", err
  }

  return ExternalAPICall(data) // e.g. book a flight
}

BusinessOperationV1 calls OpenBizData which returns an io.ReadCloser (could be a file for example), reads data, and then invokes an external API, e.g. to book a flight. The ExternalAPICall function returns a transaction receipt or error, which BusinessOperationV1 returns.

We're concerned with the line defer dataSource.Close().

Although the Close method returns an error, it is not checked. Nothing is done with it. Now, being that all the data has already been read from dataSource, it is unlikely that the Close method will fail, but it could. What to do with a Close error? Assume that ExternalAPICall has succeeded, and BusinessOperationV1 can return the transaction receipt to the caller. Should BusinessOperationV1 return an error and no receipt?

A common judgment is that if the business operation succeeded, the error on Close is not worth failing the entire operation for. Plus, then we'd be in the position of potentially having to roll back the effect of ExternalAPICall, which simply may not be possible.

We could return the successful receipt and the Close error, but that's counter to the Go idiom that if an error is returned, the other return items should be the zero value.

In many cases, the usual handling is simply not to check the Close error (defer dataSource.Close() reads nicely as well). But this error should not be ignored. It is symptomatic of some underlying issue, and it should be investigated, even it's not causing a business operation to fail (yet).

A quick reminder of how lg chooses to define log levels:

  • ERROR: a business operation failed.
  • WARN: the business operation succeeded, but something worrying happened.
  • DEBUG: for devs, for after-the-fact diagnosis of problems.

This Close error seems an ideal candidate to log at WARN level.

BusinessOperationV2

This is the next iteration of our function:

// BusinessOperationV2 closes dataSource in a defer,
// and logs at WARN level if an error results from Close.
func BusinessOperationV2(log lg.Log) (receipt string, err error) {
  dataSource, err := OpenBizData()
  if err != nil {
    return "", err
  }
  defer func() {
    closeErr := dataSource.Close()
    if closeErr != nil {
      log.Warnf(closeErr.Error())
    }
  }()
    
  // rest of function omitted 

This achieves our goals. The Close error is logged at WARN level. We could call it a day here and go home. However, there's no question that:

defer func() {
  closeErr := dataSource.Close()
  if closeErr != nil {
    log.Warn(closeErr)
  }
}()

is less pleasant to read than:

defer dataSource.Close()

We can do better.

BusinessOperationV3

In BusinessOperationV3, we make the defer tidier by using Log.WarnIfError.

// WarnIfError is no-op if err is nil; if non-nil, err
// is logged at WARN level.
WarnIfError(err error)

Here's how it looks:

// BusinessOperationV3 uses WarnIfError to make the defer statement
// more succinct.
func BusinessOperationV3(log lg.Log) (receipt string, err error) {
  dataSource, err := OpenBizData()
  if err != nil {
    return "", err
  }
  defer func() {
    log.WarnIfError(dataSource.Close())
  }()

  // rest of function omitted   

That defer looks significantly cleaner now. We could even write it on one line:

defer func() { log.WarnIfError(dataSource.Close()) }()
BusinessOperationV4

But we can get cleaner yet. Here's Log.WarnIfFuncError:

// WarnIfFuncError is no-op if fn is nil; if fn is non-nil,
// fn is executed and if fn's error is non-nil, that error
// is logged at WARN level.
WarnIfFuncError(fn func() error)

In practice, this reads nicely:

// BusinessOperationV4 uses WarnIfFuncError to make the defer statement
// yet more succinct.
func BusinessOperationV4(log lg.Log) (receipt string, err error) {
  dataSource, err := OpenBizData()
  if err != nil {
    return "", err
  }
  defer log.WarnIfFuncError(dataSource.Close)
  
  // rest of function omitted 

As a variation when dataSource can be nil, we could use WarnIfCloseError:

// WarnIfCloseError is no-op if c is nil; if c is non-nil,
// c.Close is executed and if Close's error is non-nil,
// that error is logged at WARN level.
//
// WarnIfCloseError is preferred to WarnIfFuncError when c may be nil.
//
//  var c io.Closer = nil
//  log.WarnIfCloseError(c)    // ok
//  log.WarnIfFuncError(c.Close) // panic
WarnIfCloseError(c io.Closer)

And invoke like so:

defer log.WarnIfCloseError(dataSource)

testlg adapter for testing

Package testlg provides a lg.Log implementation that can output its log entries to testing.T. This test:

func TestNew(t *testing.T) {
  log := testlg.New(t)
  log.Debugf("hello")
  log.Warnf("hola")
  log.Errorf("jambo")
}

outputs:

=== RUN   TestNew
--- PASS: TestNew (0.00s)
    testlg_test.go:22: 22:12:15.668336 DEBUG  hello
    testlg_test.go:23: 22:12:15.668469 WARN   hola
    testlg_test.go:24: 22:12:15.668474 ERROR  jambo
Prefer testlg to zaptest

If you're using Uber's zap as your logging impl, you'll have noticed that pkg zaptest provides an adapter for use with testing. Alas, zaptest has one ugly drawback: it causes testing to output incorrect caller information. The test output from TestZapTestVsTestLg demonstrates the issue (edited for brevity):

=== RUN   TestZapTestVsTestLg
--- PASS: TestZapTestVsTestLg (0.00s)
    zaplg_test.go:68: zaptest -- Observe the clashing caller info reported by the testing framework (misleading) vs zap itself (desired)
    logger.go:130:  DEBUG  zaplg/zaplg_test.go:70  misleading caller info
    zaplg_test.go:74: testlg -- Observe the concurring caller info reported by the testing framework and zap itself
    zaplg_test.go:79:  DEBUG  zaplg/zaplg_test.go:79  accurate caller info

Note line 4, where testing reports logger.go:130 as the caller, while zap itself reports the correct caller (zaplg/zaplg_test.go:70). In contrast, testlg causes testing to accurately report the caller.

Acknowledgements

Documentation

Overview

Package lg is an exploration of a small, leveled, unstructured logging interface for enterprise developers. It is not suggested for production use.

This pkg does not itself perform logging. A concrete impl must be used, e.g. zaplg.New. Use testlg.New to adapt lg to output to a testing.T.

Example (Zap)

Demonstrate use with uber/zap.

package main

import (
	"os"

	"github.com/neilotoole/lg/v2/zaplg"
)

func main() {
	// Default setup
	// log := zaplg.New()

	// With options
	log := zaplg.NewWith(os.Stdout, "text", false, true, true, true, 0)
	log.Debug("Hello", "World")
}
Output:

DEBUG	lg/example_test.go:16:Example_zap	HelloWorld

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Log

type Log interface {
	// Debug logs at DEBUG level.
	Debug(a ...any)

	// Debugf logs at DEBUG level.
	Debugf(format string, a ...any)

	// Warn logs at WARN level.
	Warn(a ...any)

	// Warnf logs at WARN level.
	Warnf(format string, a ...any)

	// WarnIfError is no-op if err is nil; if non-nil, err
	// is logged at WARN level.
	WarnIfError(err error)

	// WarnIfFuncError is no-op if fn is nil; if fn is non-nil,
	// fn is executed and if fn's error is non-nil, that error
	// is logged at WARN level.
	WarnIfFuncError(fn func() error)

	// WarnIfCloseError is no-op if c is nil; if c is non-nil,
	// c.Close is executed and if Close's error is non-nil,
	// that error is logged at WARN level.
	//
	// WarnIfCloseError is preferred to WarnIfFuncError
	// when c may be nil.
	//
	//  var c io.Closer = nil
	//  log.WarnIfCloseError(c)      // ok
	//  log.WarnIfFuncError(c.Close) // panic
	WarnIfCloseError(c io.Closer)

	// Error logs at ERROR level.
	Error(a ...any)

	// Errorf logs at ERROR level.
	Errorf(format string, a ...any)

	// With returns a child Log instance that has a structured
	// field key with val.
	With(key string, val any) Log
}

Log is a logging interface that adds WarnIf methods to the basic Debug, Warn and Error methods. The methods of Log are safe for concurrent use.

Style note: Being that Log is an interface, idiomatically the type name should be Logger. But the sense is that Orwell's sixth rule prevails here. It's not that lg.Logger is barbaric in and of itself, but a thousand appearances of lg.Logger vs lg.Log constitutes a horde.

func AddCallerSkip

func AddCallerSkip(log Log, skip int) Log

AddCallerSkip adds caller skip to log. If the log impl does not support additional caller skip, log is returned unchanged.

func Discard

func Discard() Log

Discard returns a Log whose methods are no-op.

Directories

Path Synopsis
Package testlg implements a lg.Log that directs output to the testing framework.
Package testlg implements a lg.Log that directs output to the testing framework.
Package zaplg adapts Uber's zap logging library for use with the lg interface.
Package zaplg adapts Uber's zap logging library for use with the lg interface.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL