Skip to content

Commit

Permalink
L 0
Browse files Browse the repository at this point in the history
  • Loading branch information
scott-cotton committed Jun 11, 2022
1 parent 89d47b4 commit 8f740b4
Show file tree
Hide file tree
Showing 32 changed files with 1,942 additions and 0 deletions.
142 changes: 142 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,143 @@
# L

L is a minimalist project-aware structured logger for Go.

## Why?

There are indeed already too many logging libraries out there. It makes a lot
of sense to continue using them if they serve you well, and maybe even if they
don't given the effort of changing something as low level as logging.

However, logging has a evolved a lot since the standard library logging
interface. First, structured logging has become the norm for newer
applications, perhaps due to the nature of logging in cloud distributed systems
such as Kubernetes. Second, as observed by
[capnslog](https://github.com/coreos/capnslog), logging should serve the project
it uses well: the main entry point should determine the logging configuration of
its imports, and moreover do so in a way that is runtime configurable.

Unfortunately, all the libraries out there seem to use the idea of the global
logger, where this state can be changed, and often is, inside library code.


L provides

- Efficient structured logging, with reasonable syntax for creating nested structures
incrementally.
- Formatters and a simple formatting interface.
- The ability for the main entry point to determine and set up configuration
for all Loggers in all packages it depends on.
- The ability for a package to define its own default configuration.
- Support for _hooks_ aka MiddleWare.
- An admin interface for manipulating labels in a Go process.

## Working with L

To use L in some package, simply import L and call L.New(cfg) with
your desired configuration. This should normally done at the top level
of each package once.

## Basic structured logging

Structured logging is about providing structured output, which is
typically fairly unstructured actually and refers to anything that
can be viewed as a `map[string]any`, where `any` is any ground type
or another `map[string]any` or a slice of values, i.e. Go's representation
of free form json objects.

```go
// log a field {"k": 77}
var myL = L.New(L.NewConfig())

myL.Dict().Field("k", 77).Log()

// add a bunch of stuff in a chain
myL.Dict().Field("k", 3).Field("mini", "kube").Field("who", "me").Log()

// add a tree structure '{"k": 3, "k2": [ "a", 4 ] }'
myL.Dict().Field("k", 3).Set(
"k2",
myL.Array().Str("a").Int(4)).
Log()

// slower, but easier on the eyes
myL.Fmt(`{"k": %d, %q: [ "%s", %d ] }`, 3, "key", "a", 4)

```

## Custom Log project-native types

L provides a means to avoid copying your native types at
all their points they are used and logged, as this clearly is tedious.

The idea is simple: define a json Unmarshaler for a type and then call
```
v := &T{...}
obj.JSON(v)
```

Note that there are libraries to autogenerate marshalers, and also
one can easily enough define an alternate marshaler for logging specific purposes.

The distinction that L makes here is that if `v` does not implement json.Marshaler,
then the program will fail to compile. The restriction helps to guarantee that logging
is fast.



### Overriding the configuration at the entry point

The entry point can set the configuration for all
imported packages, transitively by calling `L.Apply(...)`:
```
import "github.com/scott-cotton/L"
L.Apply(MyAppConfig())
```

In this context, the meaning of the labels is as follows:
a label key is a regular expression which
- a label value is an indication of whether or not labels matched
with the corresponding key should be kept. It can contain package names
- deletion takes precedence over insertion, so setting the value to
false in the label map will always delete the corresponding matched
keys.

This allows for the application of labels accross all packages following
a project policy.

Applying a config allows setting middleware for pre- and post-processing.
nil values for the middleware, .E, .F, and .W attributes will not overwrite
package-specified ones.


### Levelled Logging

Traditionally, levelled logging uses an interface where the level is made explicit
by a function call at each log site. For example, we often see

```
log.Info().Msg("hello")
```

or similar. Logging can happen at very high frequency in terms of lines of code,
leading to a whole lot ".\<level\>()" repeats.

Another problem with existing levelled logging options is that one must choose
the levels for a whole project, making inter-project dependencies difficult when
different projects use different levels.

L proposes to treat this differently. L uses labels which can be turned on or
off at the call site and at main entry points, together with middle ware to
have efficient levelled logging.

The idea is that for a project which wants to define its own levels, the project
defines a project specific level key and associated integer values.

[this test file](levels_test.go) contains a full working example.

This mechanism is dynamic. At runtime, you can set the logging to a given
level. For example, you can automatically increase the level if the frequency
of errors goes above some threshold.

L provides more general middleware for filtering logging.
26 changes: 26 additions & 0 deletions bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package L_test

import (
"io"
"testing"

"github.com/scott-cotton/L"
)

func BenchmarkBasic(b *testing.B) {
L := L.New(&L.Config{
Labels: map[string]int{},
W: io.Discard,
//F: &L.CLI{Fields: []string{"key3", "key0"}},
F: L.JSONFmter(),
E: L.EPanic,
})
b.StartTimer()
for i := 0; i < b.N; i++ {
L.Dict().
Field("key0", 22).
Field("key2", false).
Field("key3", "hello susan").
Log()
}
}
135 changes: 135 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package L

import (
"io"
"os"
"runtime"
"strings"
)

// Config is the configuration of a logger.
type Config struct {
// Labels represents the set of labels associated with
// a logger.
Labels map[string]int

// Pre is a sequence of Middlewares to pre-process
// loggable objects.
Pre []Middleware
// Post is a sequence of Middlewares to post-process
// loggable objects. Post middlewares are called
// after .Log() and before .F.Fmt().
Post []Middleware
// W is the writer for this logger.
W io.Writer
// F is a Fmter for the logger.
F Fmter
// E is a handler for any errors which occur during
// formatting.
E func(Logger, *Config, error)

pkg string
}

// NewConfig returns a *Config with the associated labels. The labels are
// subject to the following expansion rules
//
// - a label starting with a colon ':' is prefixed with package name
func NewConfig(labels ...string) *Config {
pc, _, _, _ := runtime.Caller(1)
fn := runtime.FuncForPC(pc).Name()
i := strings.LastIndexByte(fn, byte('.'))
pkg := fn
if i != -1 {
pkg = fn[:i]
}
c := &Config{
Labels: map[string]int{},
W: os.Stderr,
E: EPanic,
pkg: pkg,
}
for _, lbl := range labels {
key := lbl
if lbl != "" && lbl[0] == '.' {
key = pkg + lbl
}
c.Labels[key] = 0
}
return c
}

// Clone clones the configuration c.
func (c *Config) Clone() *Config {
res := &Config{}
*res = *c
res.Pre = append([]Middleware{}, c.Pre...)
res.Post = append([]Middleware{}, c.Post...)
res.Labels = make(map[string]int, len(c.Labels))
res.pkg = c.pkg
for k, v := range c.Labels {
res.Labels[k] = v
}
return res
}

// Apply applies the configuration o to c. Fields
// are copied over if they are not nil in o, otherwise
// left untouched. Labels in o should not include
// the package name, but if they start with '.', they
// are expanded with the package name of 'c' when
// copied to c's Labels.
//
// if a label in 'c', with any package name stripped,
// is not in o, then it is removed from c.
func (c *Config) Apply(o *Config) {
if o.E != nil {
c.E = o.E
}
if o.W != nil {
c.W = o.W
}
if o.F != nil {
c.F = o.F
}
if o.Pre != nil {
c.Pre = append([]Middleware{}, o.Pre...)
}
if o.Post != nil {
c.Post = append([]Middleware{}, o.Post...)
}
if o.Labels == nil {
return
}
if c.Labels == nil {
c.Labels = make(map[string]int, len(o.Labels))
}
for k := range c.Labels {
if _, ok := o.Labels[c.Unlocalize(k)]; !ok {
delete(c.Labels, k)
}
}
for k, v := range o.Labels {
c.Labels[c.Localize(k)] = v
}
}

func (c *Config) Unlocalize(label string) string {
if label != "" && label[0] == '.' {
return c.pkg + label
}
return label
}

func (c *Config) Localize(label string) string {
if strings.HasPrefix(label, c.pkg+".") {
return label[len(c.pkg)+1:]
}
return label
}

// CurrentRootConfig retrieves a clone of the configuration
// from the last call to Apply, if any.
func CurrentRootConfig() *Config {
return root.ReadConfig()
}
28 changes: 28 additions & 0 deletions config_e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package L_test

import (
"bytes"
"fmt"

"github.com/scott-cotton/L"
)

var eW = bytes.NewBuffer(nil)
var cfg = &L.Config{
W: eW,
F: L.JSONFmter(),
E: func(l L.Logger, _ *L.Config, e error) {
fmt.Printf("logger couldn't log obj: %s", e.Error())
},
}

var eL = L.New(cfg)

func Example_configE() {
// make a mistake and don't create real json.
eL.Str("hello").Str("again").Log()
fmt.Printf("%s\n", eW.String())

// Output:
// logger couldn't log obj: invalid character ',' after top-level value
}
4 changes: 4 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Package L provides minimalist, efficient, project-aware structured logging.
//
//
package L
21 changes: 21 additions & 0 deletions e.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package L

import "os"

// EPanic is a Config.E that panics when there is an error.
func EPanic(_ Logger, _ *Config, e error) {
panic(e)
}

// EFatal is a Config.E that calls ELog and then exits.
func EFatal(l Logger, c *Config, e error) {
ELog(l, c, e)
os.Exit(7)
}

// ELog is a Config.E that safely logs the error 'e' in a dict with key '"LE"'.
func ELog(l Logger, _ *Config, e error) {
ev := l.Dict()
ev.Field("LE", e.Error())
l.Log(ev)
}
8 changes: 8 additions & 0 deletions fmt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package L

import "io"

// a Fmter formats logs
type Fmter interface {
Fmt(w io.Writer, d []byte) error
}
7 changes: 7 additions & 0 deletions fmt_table_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package L

import "testing"

func TestTableFmter(t *testing.T) {

}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/scott-cotton/L

go 1.18
Loading

0 comments on commit 8f740b4

Please sign in to comment.