├── go.sum
├── go.mod
├── gcp
├── basic.png
├── correlated.png
├── init
│ └── init.go
├── setup_test.go
├── trace_test.go
├── setup.go
├── README.md
└── trace.go
├── slogtest
├── another_test.go
├── slogtest_test.go
└── slogtest.go
├── .chainguard
└── source.yaml
├── examples
├── logger
│ ├── main_test.go
│ └── main.go
└── handler
│ └── main.go
├── .github
├── dependabot.yml
└── workflows
│ └── go.yml
├── .gitignore
├── handler_test.go
├── slag
└── flag.go
├── example_test.go
├── handler.go
├── README.md
├── log.go
├── logger_test.go
├── logger.go
└── LICENSE
/go.sum:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/chainguard-dev/clog
2 |
3 | go 1.24.4
4 |
--------------------------------------------------------------------------------
/gcp/basic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chainguard-dev/clog/HEAD/gcp/basic.png
--------------------------------------------------------------------------------
/gcp/correlated.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chainguard-dev/clog/HEAD/gcp/correlated.png
--------------------------------------------------------------------------------
/slogtest/another_test.go:
--------------------------------------------------------------------------------
1 | package slogtest_test
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/chainguard-dev/clog"
7 | )
8 |
9 | func fn(ctx context.Context) { clog.FromContext(ctx).With("foo", "bar").Infof("hello from fn") }
10 |
--------------------------------------------------------------------------------
/.chainguard/source.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Chainguard, Inc.
2 | # SPDX-License-Identifier: Apache-2.0
3 |
4 | spec:
5 | authorities:
6 | - keyless: {}
7 | - key:
8 | # Allow commits signed by GitHub.
9 | kms: https://github.com/web-flow.gpg
10 |
--------------------------------------------------------------------------------
/examples/logger/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/chainguard-dev/clog"
7 | "github.com/chainguard-dev/clog/slogtest"
8 | )
9 |
10 | func TestFoo(t *testing.T) {
11 | ctx := slogtest.TestContextWithLogger(t)
12 |
13 | for _, tc := range []string{"a", "b"} {
14 | t.Run(tc, func(t *testing.T) {
15 | clog.FromContext(ctx).Infof("hello world")
16 | })
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Chainguard, Inc.
2 | # SPDX-License-Identifier: Apache-2.0
3 |
4 | version: 2
5 | updates:
6 | - package-ecosystem: gomod
7 | directory: "/"
8 | schedule:
9 | interval: "weekly"
10 | open-pull-requests-limit: 10
11 | groups:
12 | all:
13 | update-types:
14 | - "minor"
15 | - "patch"
16 | - package-ecosystem: "github-actions"
17 | directory: "/"
18 | schedule:
19 | interval: "weekly"
20 | open-pull-requests-limit: 10
21 |
--------------------------------------------------------------------------------
/gcp/init/init.go:
--------------------------------------------------------------------------------
1 | package init
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 |
7 | "github.com/chainguard-dev/clog"
8 | "github.com/chainguard-dev/clog/gcp"
9 | )
10 |
11 | // Set up structured logging at Info+ level.
12 | func init() {
13 | level := slog.LevelInfo
14 | if e, ok := os.LookupEnv("LOG_LEVEL"); ok {
15 | if err := level.UnmarshalText([]byte(e)); err != nil {
16 | clog.Fatalf("slog: invalid log level: %v", err)
17 | }
18 | }
19 | slog.SetDefault(slog.New(gcp.NewHandler(level)))
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 |
--------------------------------------------------------------------------------
/examples/handler/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "os"
7 |
8 | "github.com/chainguard-dev/clog"
9 | )
10 |
11 | func init() {
12 | slog.SetDefault(slog.New(clog.NewHandler(slog.NewTextHandler(os.Stdout, nil))))
13 | }
14 |
15 | func main() {
16 | ctx := context.Background()
17 | ctx = clog.WithValues(ctx, "foo", "bar")
18 |
19 | // Use slog package directly
20 | slog.InfoContext(ctx, "hello world", slog.Bool("baz", true))
21 |
22 | // glog / zap style (note: can't pass additional attributes)
23 | clog.Errorf("hello %s", "world")
24 | }
25 |
--------------------------------------------------------------------------------
/gcp/setup_test.go:
--------------------------------------------------------------------------------
1 | package gcp
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "testing"
7 | )
8 |
9 | func TestHandler(t *testing.T) {
10 | ctx := context.Background()
11 | l := slog.New(NewHandler(slog.LevelInfo))
12 | l.With("level", "INFO").Log(ctx, slog.LevelInfo, "hello world") // okay
13 | l.With("level", "INFO").Log(ctx, slog.LevelWarn, "hello world") // weird, but okay (info)
14 |
15 | // These should not panic.
16 | l.With("level", nil).Log(ctx, slog.LevelInfo, "hello world")
17 | l.With("level", 123).Log(ctx, slog.LevelInfo, "hello world")
18 | l.With("level", map[string]string{}).Log(ctx, slog.LevelInfo, "hello world")
19 | }
20 |
--------------------------------------------------------------------------------
/examples/logger/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "log/slog"
7 | "os"
8 |
9 | "github.com/chainguard-dev/clog"
10 | "github.com/chainguard-dev/clog/slag"
11 | )
12 |
13 | func main() {
14 | var level slag.Level
15 | flag.Var(&level, "log-level", "log level")
16 | flag.Parse()
17 |
18 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: &level})))
19 |
20 | log := clog.NewLogger(slog.Default()).With("a", "b")
21 | ctx := clog.WithLogger(context.Background(), log)
22 |
23 | // Grab logger from context and use
24 | clog.FromContext(ctx).With("foo", "bar").Debugf("hello debug world")
25 | clog.FromContext(ctx).With("info", true).Infof("hello info world")
26 | clog.FromContext(ctx).With("warn", 42).Warnf("hello warn world")
27 |
28 | // Package level context loggers are also aware
29 | clog.ErrorContext(ctx, "hello error world")
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a golang project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
3 |
4 | name: Go
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | permissions: {}
13 |
14 | jobs:
15 |
16 | build:
17 | permissions:
18 | contents: read # for actions/checkout to fetch code
19 | runs-on: ubuntu-latest
20 | steps:
21 | - name: Harden the runner (Audit all outbound calls)
22 | uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3
23 | with:
24 | egress-policy: audit
25 |
26 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
27 |
28 | - name: Set up Go
29 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
30 | with:
31 | go-version-file: './go.mod'
32 |
33 | - name: Build
34 | run: go build -v ./...
35 |
36 | - name: Test
37 | run: go test -v ./...
38 |
--------------------------------------------------------------------------------
/handler_test.go:
--------------------------------------------------------------------------------
1 | package clog
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "log/slog"
8 | "reflect"
9 | "testing"
10 | )
11 |
12 | func TestContextHandler(t *testing.T) {
13 | ctx := context.Background()
14 | ctx = WithValues(ctx, "foo", "bar")
15 | ctx2 := WithValues(ctx,
16 | "a", "b",
17 | "c", "d",
18 | )
19 | ctx = WithValues(ctx, "b", 1)
20 |
21 | for _, tc := range []struct {
22 | ctx context.Context
23 | want map[string]any
24 | }{
25 | {ctx, map[string]any{
26 | "b": float64(1),
27 | "foo": "bar",
28 | }},
29 | {ctx2, map[string]any{
30 | "foo": "bar",
31 | "a": "b",
32 | "c": "d",
33 | }},
34 | } {
35 | t.Run("", func(t *testing.T) {
36 | b := new(bytes.Buffer)
37 | log := slog.New(NewHandler(slog.NewJSONHandler(b, testopts)))
38 | log.InfoContext(tc.ctx, "")
39 |
40 | tc.want["level"] = "INFO"
41 | tc.want["msg"] = ""
42 |
43 | var got map[string]any
44 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
45 | t.Fatal(err)
46 | }
47 |
48 | if !reflect.DeepEqual(tc.want, got) {
49 | t.Errorf("want %v, got %v", tc.want, got)
50 | }
51 | })
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/slogtest/slogtest_test.go:
--------------------------------------------------------------------------------
1 | package slogtest_test
2 |
3 | import (
4 | "log/slog"
5 | "testing"
6 |
7 | "github.com/chainguard-dev/clog"
8 | "github.com/chainguard-dev/clog/slogtest"
9 | )
10 |
11 | func TestSlogTest(t *testing.T) {
12 | ctx := slogtest.Context(t)
13 |
14 | clog.FromContext(ctx).With("foo", "bar").Infof("hello world")
15 | clog.FromContext(ctx).With("bar", "baz").Infof("me again")
16 | clog.FromContext(ctx).With("baz", true).Infof("okay last one")
17 |
18 | clog.FromContext(ctx).Debug("hello debug")
19 | clog.FromContext(ctx).Info("hello info")
20 | clog.FromContext(ctx).Warn("hello warn")
21 | clog.FromContext(ctx).Error("hello error")
22 |
23 | fn(ctx)
24 | }
25 |
26 | // TestSlogTestTContext tests the use of t.Context() in Go 1.24+.
27 | func TestSlogTestTContext(t *testing.T) {
28 | ctx := t.Context()
29 | slog.SetDefault(slog.New(slogtest.TestLogger(t).Handler()))
30 |
31 | clog.FromContext(ctx).With("foo", "bar").Infof("hello world")
32 | clog.FromContext(ctx).With("bar", "baz").Infof("me again")
33 | clog.FromContext(ctx).With("baz", true).Infof("okay last one")
34 |
35 | clog.FromContext(ctx).Debug("hello debug")
36 | clog.FromContext(ctx).Info("hello info")
37 | clog.FromContext(ctx).Warn("hello warn")
38 | clog.FromContext(ctx).Error("hello error")
39 |
40 | fn(ctx)
41 | }
42 |
--------------------------------------------------------------------------------
/slag/flag.go:
--------------------------------------------------------------------------------
1 | // Package slag provides a method for setting the log level from the command line.
2 | //
3 | // func main() {
4 | // var level slag.Level
5 | // flag.Var(&level, "log-level", "log level")
6 | // flag.Parse()
7 | // slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: &level})))
8 | // }
9 | //
10 | // See [./examples/logger](./examples/logger) for a full example.
11 | //
12 | // This allows the log level to be set from the command line:
13 | //
14 | // $ ./myprogram -log-level=debug
15 | //
16 | // The slag.Level type is a wrapper around slog.Level that implements the flag.Value interface,
17 | // as well as Cobra's pflag.Value interface.
18 | //
19 | // func main() {
20 | // var level slag.Level
21 | // cmd := &cobra.Command{
22 | // Use: "myprogram",
23 | // ...
24 | // }
25 | // cmd.PersistentFlags().Var(&level, "log-level", "log level")
26 | // cmd.Execute()
27 | // }
28 | package slag
29 |
30 | import "log/slog"
31 |
32 | type Level slog.Level
33 |
34 | func (l *Level) Set(s string) error {
35 | var ll slog.Level
36 | if err := ll.UnmarshalText([]byte(s)); err != nil {
37 | return err
38 | }
39 | *l = Level(ll)
40 | return nil
41 | }
42 | func (l *Level) String() string { return slog.Level(*l).String() }
43 | func (l *Level) Level() slog.Level { return slog.Level(*l) }
44 |
45 | // Implements https://pkg.go.dev/github.com/spf13/pflag#Value
46 | func (l *Level) Type() string { return "string" }
47 |
--------------------------------------------------------------------------------
/example_test.go:
--------------------------------------------------------------------------------
1 | package clog_test
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "os"
7 |
8 | "github.com/chainguard-dev/clog"
9 | "github.com/chainguard-dev/clog/slogtest"
10 | )
11 |
12 | func ExampleHandler() {
13 | log := slog.New(clog.NewHandler(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
14 | // Remove time for repeatable results
15 | ReplaceAttr: slogtest.RemoveTime,
16 | })))
17 |
18 | ctx := context.Background()
19 | ctx = clog.WithValues(ctx, "foo", "bar")
20 | log.InfoContext(ctx, "hello world", slog.Bool("baz", true))
21 |
22 | // Output:
23 | // level=INFO msg="hello world" baz=true foo=bar
24 | }
25 |
26 | func ExampleLogger() {
27 | log := clog.NewLogger(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
28 | // Remove time for repeatable results
29 | ReplaceAttr: slogtest.RemoveTime,
30 | })))
31 | log = log.With("a", "b")
32 | ctx := clog.WithLogger(context.Background(), log)
33 |
34 | // Grab logger from context and use
35 | // Note: this is a formatter aware method, not an slog.Attr method.
36 | clog.FromContext(ctx).With("foo", "bar").Infof("hello %s", "world")
37 |
38 | // Package level context loggers are also aware
39 | clog.ErrorContext(ctx, "asdf", slog.Bool("baz", true))
40 |
41 | // Output:
42 | // level=INFO msg="hello world" a=b foo=bar
43 | // level=ERROR msg=asdf a=b baz=true
44 | }
45 |
46 | func ExampleFromContext_preserveContext() {
47 | log := clog.NewLogger(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
48 | // Remove time for repeatable results
49 | ReplaceAttr: slogtest.RemoveTime,
50 | }))).With("foo", "bar")
51 | ctx := clog.WithLogger(context.Background(), log)
52 |
53 | // Previous context values are preserved when using FromContext
54 | clog.FromContext(ctx).Info("hello world")
55 |
56 | // Output:
57 | // level=INFO msg="hello world" foo=bar
58 | }
59 |
--------------------------------------------------------------------------------
/gcp/trace_test.go:
--------------------------------------------------------------------------------
1 | package gcp
2 |
3 | import (
4 | "log/slog"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/chainguard-dev/clog"
10 | )
11 |
12 | func TestTrace(t *testing.T) {
13 | // This ensures the metadata server is not called at all during tests.
14 | md := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15 | t.Fatalf("metadata server called")
16 | }))
17 | defer md.Close()
18 | t.Setenv("GCE_METADATA_HOST", md.URL)
19 |
20 | slog.SetDefault(slog.New(NewHandler(slog.LevelDebug)))
21 | for _, c := range []struct {
22 | name string
23 | env string
24 | wantTrace string
25 | }{
26 | {"no env set", "", ""},
27 | {"env set", "my-project", "projects/my-project/traces/traceid"},
28 | } {
29 | t.Run(c.name, func(t *testing.T) {
30 | t.Setenv("GOOGLE_CLOUD_PROJECT", c.env)
31 |
32 | // Set up a server that logs a message with trace context added.
33 | slog.SetDefault(slog.New(NewHandler(slog.LevelDebug)))
34 | h := WithCloudTraceContext(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35 | ctx := r.Context()
36 | clog.InfoContext(ctx, "hello world")
37 |
38 | // TODO: This doesn't propagate the trace context to the logger.
39 | //clog.FromContext(ctx).Info("hello world")
40 |
41 | if r.Header.Get("traceparent") == "" {
42 | t.Error("got empty trace context header, want non-empty")
43 | }
44 |
45 | traceCtx := TraceFromContext(ctx)
46 | if traceCtx != c.wantTrace {
47 | t.Fatalf("got %s, want %s", traceCtx, c.wantTrace)
48 | }
49 | }))
50 | srv := httptest.NewServer(h)
51 | defer srv.Close()
52 |
53 | // Send a request to the server with a trace context header.
54 | req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
55 | if err != nil {
56 | t.Fatal(err)
57 | }
58 | req.Header.Set("traceparent", "00-traceid-spanid-01")
59 | if _, err := http.DefaultClient.Do(req); err != nil {
60 | t.Fatal(err)
61 | }
62 | })
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/gcp/setup.go:
--------------------------------------------------------------------------------
1 | package gcp
2 |
3 | import (
4 | "context"
5 | "io"
6 | "log/slog"
7 | "os"
8 | )
9 |
10 | // LevelCritical is an extra log level supported by Cloud Logging.
11 | const LevelCritical = slog.Level(12)
12 |
13 | // Handler that outputs JSON understood by the structured log agent.
14 | // See https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields
15 | type Handler struct {
16 | handler slog.Handler
17 | }
18 |
19 | // NewHandler returns a new Handler that writes to stderr.
20 | func NewHandler(level slog.Level) *Handler {
21 | return NewHandlerForWriter(os.Stderr, level)
22 | }
23 |
24 | // NewHandlerForWriter returns a new Handler that writes to the given writer.
25 | func NewHandlerForWriter(w io.Writer, level slog.Level) *Handler {
26 | return &Handler{handler: slog.NewJSONHandler(w, &slog.HandlerOptions{
27 | AddSource: true,
28 | Level: level,
29 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
30 | if a.Key == slog.MessageKey {
31 | a.Key = "message"
32 | } else if a.Key == slog.SourceKey {
33 | a.Key = "logging.googleapis.com/sourceLocation"
34 | } else if a.Key == slog.LevelKey {
35 | a.Key = "severity"
36 | level, ok := a.Value.Any().(slog.Level)
37 | if ok && level == LevelCritical {
38 | a.Value = slog.StringValue("CRITICAL")
39 | }
40 | }
41 | return a
42 | },
43 | })}
44 | }
45 |
46 | func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
47 | return h.handler.Enabled(ctx, level)
48 | }
49 |
50 | func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
51 | if trace := TraceFromContext(ctx); trace != "" {
52 | rec = rec.Clone()
53 | // Add trace ID to the record so it is correlated with the request log
54 | // See https://cloud.google.com/trace/docs/trace-log-integration
55 | rec.Add("logging.googleapis.com/trace", slog.StringValue(trace))
56 | }
57 |
58 | return h.handler.Handle(ctx, rec)
59 | }
60 |
61 | func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
62 | return &Handler{handler: h.handler.WithAttrs(attrs)}
63 | }
64 |
65 | func (h *Handler) WithGroup(name string) slog.Handler {
66 | return &Handler{handler: h.handler.WithGroup(name)}
67 | }
68 |
--------------------------------------------------------------------------------
/handler.go:
--------------------------------------------------------------------------------
1 | package clog
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | )
7 |
8 | var (
9 | ctxKey = key{}
10 | )
11 |
12 | type key struct{}
13 | type ctxVal map[string]any
14 |
15 | // With returns a new context with the given values.
16 | // Values are expected to be key-value pairs, where the key is a string.
17 | // e.g. WithValues(ctx, "foo", "bar", "baz", 1)
18 | // If a value already exists, it is overwritten.
19 | // If an odd number of arguments are provided, With panics.
20 | func WithValues(ctx context.Context, args ...any) context.Context {
21 | if len(args)%2 != 0 {
22 | panic("non-even number of arguments")
23 | }
24 |
25 | values := ctxVal{}
26 |
27 | // Copy existing values
28 | for k, v := range get(ctx) {
29 | values[k] = v
30 | }
31 |
32 | for i := 0; i < len(args); i++ {
33 | key, ok := args[i].(string)
34 | if !ok {
35 | panic("non-string key")
36 | }
37 | i++
38 | if i >= len(args) {
39 | break
40 | }
41 | value := args[i]
42 | values[key] = value
43 | }
44 | return context.WithValue(ctx, ctxKey, values)
45 | }
46 |
47 | func get(ctx context.Context) ctxVal {
48 | if value, ok := ctx.Value(ctxKey).(ctxVal); ok {
49 | return value
50 | }
51 | return nil
52 | }
53 |
54 | // Handler is a slog.Handler that adds context values to the log record.
55 | // Values are added via [WithValues].
56 | type Handler struct {
57 | h slog.Handler
58 | }
59 |
60 | // NewHandler configures a new context aware slog handler.
61 | // If h is nil, the default slog handler is used.
62 | func NewHandler(h slog.Handler) Handler {
63 | return Handler{h}
64 | }
65 |
66 | func (h Handler) inner() slog.Handler {
67 | if h.h == nil {
68 | return slog.Default().Handler()
69 | }
70 | return h.h
71 | }
72 |
73 | func (h Handler) Enabled(ctx context.Context, level slog.Level) bool {
74 | return h.inner().Enabled(ctx, level)
75 | }
76 |
77 | func (h Handler) Handle(ctx context.Context, r slog.Record) error {
78 | values := get(ctx)
79 | for k, v := range values {
80 | r.Add(k, v)
81 | }
82 | return h.inner().Handle(ctx, r)
83 | }
84 |
85 | func (h Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
86 | return Handler{h.inner().WithAttrs(attrs)}
87 | }
88 |
89 | func (h Handler) WithGroup(name string) slog.Handler {
90 | return Handler{h.inner().WithGroup(name)}
91 | }
92 |
--------------------------------------------------------------------------------
/slogtest/slogtest.go:
--------------------------------------------------------------------------------
1 | // Package slogtest provides utilities for emitting test logs using clog.
2 | //
3 | // func TestExample(t *testing.T) {
4 | // ctx := slogtest.Context(t)
5 | // clog.FromContext(ctx).With("foo", "bar").Info("hello world")
6 | // }
7 | //
8 | // This produces the following test output:
9 | //
10 | // === RUN TestExample
11 | // slogtest.go:24: level=INFO source=/path/to/example_test.go:13 msg="hello world" foo=bar
12 | //
13 | // This package is intended to be used in tests only.
14 | //
15 | // In Go 1.24, *testing.T etc added `t.Context()` methods, which return a
16 | // context.Context to be used in tests. You can use `clog.FromContext(t.Context())`
17 | // to get a logger in tests instead, and configure the default logger to get the
18 | // same logging behavior as `slogtest.Context(t)`.
19 | package slogtest
20 |
21 | import (
22 | "context"
23 | "io"
24 | "log/slog"
25 | "strings"
26 | "testing"
27 |
28 | "github.com/chainguard-dev/clog"
29 | )
30 |
31 | var _ io.Writer = &logAdapter{}
32 |
33 | type logAdapter struct{ l Logger }
34 |
35 | func (l *logAdapter) Write(b []byte) (int, error) {
36 | l.l.Log(strings.TrimSuffix(string(b), "\n"))
37 | return len(b), nil
38 | }
39 |
40 | var _ Logger = (*testing.T)(nil)
41 | var _ Logger = (*testing.B)(nil)
42 | var _ Logger = (*testing.F)(nil)
43 |
44 | type Logger interface {
45 | Log(args ...any)
46 | Context() context.Context
47 | }
48 |
49 | // TestLogger gets a logger to use in unit and end to end tests.
50 | // This logger is configured to log at debug level.
51 | func TestLogger(t Logger) *clog.Logger {
52 | return clog.New(slog.NewTextHandler(&logAdapter{l: t}, &slog.HandlerOptions{
53 | Level: slog.LevelDebug,
54 | AddSource: true,
55 | ReplaceAttr: RemoveTime,
56 | }))
57 | }
58 |
59 | // TestLoggerWithOptions gets a logger to use in unit and end to end tests.
60 | func TestLoggerWithOptions(t Logger, opts *slog.HandlerOptions) *clog.Logger {
61 | return clog.New(slog.NewTextHandler(&logAdapter{l: t}, opts))
62 | }
63 |
64 | // Context returns a context with a logger to be used in tests.
65 | func Context(t Logger) context.Context {
66 | return clog.WithLogger(t.Context(), TestLogger(t))
67 | }
68 |
69 | // TestContextWithLogger returns a context with a logger to be used in tests
70 | //
71 | // Deprecated: Use Context instead.
72 | func TestContextWithLogger(t Logger) context.Context { return Context(t) }
73 |
74 | // RemoveTime removes the top-level time attribute.
75 | // It is intended to be used as a ReplaceAttr function,
76 | // to make example output deterministic.
77 | //
78 | // This is taken from slog/internal/slogtest.RemoveTime.
79 | func RemoveTime(groups []string, a slog.Attr) slog.Attr {
80 | if a.Key == slog.TimeKey && len(groups) == 0 {
81 | return slog.Attr{}
82 | }
83 | return a
84 | }
85 |
--------------------------------------------------------------------------------
/gcp/README.md:
--------------------------------------------------------------------------------
1 | # `clog/gcp`: structured logging for Google Cloud using [`slog`](https://pkg.go.dev/log/slog)
2 |
3 | Contrary to the
4 | [documented "standard" approach for logging](https://cloud.google.com/logging/docs/setup/go),
5 | this doesn't use any third-party logging package for logging.
6 |
7 | Instead, it relies on Google Cloud's support for ingesting structured logs by
8 | simply printing JSON to stderr.
9 |
10 | This method of emitting structured logs is supported by:
11 |
12 | - [Cloud Run](https://cloud.google.com/run/docs/logging#using-json)
13 | - [Kubernetes Engine](https://cloud.google.com/logging/docs/structured-logging#special-payload-fields)
14 | - [Cloud Functions](https://cloud.google.com/functions/docs/monitoring/logging#writing_structured_logs)
15 | - [App Engine](https://cloud.google.com/logging/docs/structured-logging#special-payload-fields)
16 | (standard and flexible)
17 | - and in other products, using the
18 | [Cloud Logging agent](https://cloud.google.com/logging/docs/agent/logging) and
19 | [Ops agent](https://cloud.google.com/logging/docs/agent/ops-agent).
20 |
21 | ## Basic Usage
22 |
23 | To use this, underscore-import `gcp/init`, which will configure `slog` to use
24 | the GCP-optimized JSON handler for all log messages:
25 |
26 | Then when you use `slog`, all log messages will be output in JSON format to
27 | standard error, which is automatically ingested by Cloud Logging.
28 |
29 | ```go
30 | import _ "github.com/chainguard-dev/clog/gcp/init"
31 |
32 | ...
33 |
34 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
35 | slog.InfoContext(r.Context(), "my message",
36 | "mycount", 42,
37 | "mystring", "myvalue",
38 | )
39 | })
40 | ```
41 |
42 | This logs the message, with the additional structured logging fields in Cloud
43 | Logging:
44 |
45 |
 |
46 |
47 | ## Correlating Logs with Requests
48 |
49 | You can also use this to correlate log lines with the request that generated
50 | them, by associating the log message with the request's trace context header.
51 |
52 | ```go
53 | import "github.com/chainguard-dev/clog/gcp"
54 |
55 | ...
56 |
57 | http.Handle("/", gcp.WithCloudTraceContext(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58 | slog.InfoContext(r.Context(), "my message",
59 | "mycount", 42,
60 | "mystring", "myvalue",
61 | )
62 | })))
63 | ```
64 |
65 | This logs the message, associated with the request's trace, in Cloud Logging:
66 |
67 |  |
68 |
69 | Other logs with the same `trace` attribute are generated by the same incoming
70 | request.
71 |
72 | See https://cloud.google.com/trace/docs/trace-log-integration for more
73 | information.
74 |
75 | ## Critical Logging
76 |
77 | Cloud Logging supports a **CRITICAL** logging level, which doesn't map cleanly
78 | to `slog`'s built-in levels.
79 |
80 | To log at this level:
81 |
82 | ```go
83 | slog.Log(ctx, gcp.LevelCritical, "I have a bad feeling about this...")
84 | ```
85 |
86 | See `./cmd/example` for a deployable example.
87 |
88 | ---
89 |
90 | This repo is forked from https://github.com/remko/cloudrun-slog, which
91 | originated this idea and implementation.
92 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 👞 clog
2 |
3 | [](https://pkg.go.dev/github.com/chainguard-dev/clog)
4 |
5 | Context-aware [`slog`](https://pkg.go.dev/log/slog)
6 |
7 | `slog` was added in Go 1.21, so using this requires Go 1.21 or later.
8 |
9 | ## Usage
10 |
11 | ### Context Logger
12 |
13 | The context Logger can be used to use Loggers from the context. This is
14 | sometimes preferred over the [Context Handler](#context-handler), since this can
15 | make it easier to use different loggers in different contexts (e.g. testing).
16 |
17 | This approach is heavily inspired by
18 | [`knative.dev/pkg/logging`](https://pkg.go.dev/knative.dev/pkg/logging), but with [zero dependencies outside the standard library](https://github.com/chainguard-dev/clog/blob/main/go.mod) (compare with [`pkg/logging`'s deps](https://pkg.go.dev/knative.dev/pkg/logging?tab=imports)).
19 |
20 | ```go
21 | package main
22 |
23 | import (
24 | "context"
25 | "log/slog"
26 |
27 | "github.com/chainguard-dev/clog"
28 | )
29 |
30 | func main() {
31 | // One-time setup
32 | log := clog.New(slog.Default().Handler()).With("a", "b")
33 | ctx := clog.WithLogger(context.Background(), log)
34 |
35 | f(ctx)
36 | }
37 |
38 | func f(ctx context.Context) {
39 | // Grab logger from context and use.
40 | log := clog.FromContext(ctx)
41 | log.Info("in f")
42 |
43 | // Add logging context and pass on.
44 | ctx = clog.WithLogger(ctx, log.With("f", "hello"))
45 | g(ctx)
46 | }
47 |
48 | func g(ctx context.Context) {
49 | // Grab logger from context and use.
50 | log := clog.FromContext(ctx)
51 | log.Info("in g")
52 |
53 | // Package level context loggers are also aware
54 | clog.ErrorContext(ctx, "asdf")
55 | }
56 |
57 | ```
58 |
59 | ```sh
60 | $ go run .
61 | 2009/11/10 23:00:00 INFO in f a=b
62 | 2009/11/10 23:00:00 INFO in g a=b f=hello
63 | 2009/11/10 23:00:00 ERROR asdf a=b f=hello
64 | ```
65 |
66 | #### Testing
67 |
68 | The `slogtest` package provides utilities to make it easy to create loggers that
69 | will use the native testing logging.
70 |
71 | ```go
72 | func TestFoo(t *testing.T) {
73 | ctx := slogtest.TestContextWithLogger(t)
74 |
75 | for _, tc := range []string{"a", "b"} {
76 | t.Run(tc, func(t *testing.T) {
77 | clog.FromContext(ctx).Infof("hello world")
78 | })
79 | }
80 | }
81 | ```
82 |
83 | ```sh
84 | $ go test -v ./examples/logger
85 | === RUN TestLog
86 | === RUN TestLog/a
87 | === NAME TestLog
88 | slogtest.go:20: time=2023-12-12T18:42:53.020-05:00 level=INFO msg="hello world"
89 |
90 | === RUN TestLog/b
91 | === NAME TestLog
92 | slogtest.go:20: time=2023-12-12T18:42:53.020-05:00 level=INFO msg="hello world"
93 |
94 | --- PASS: TestLog (0.00s)
95 | --- PASS: TestLog/a (0.00s)
96 | --- PASS: TestLog/b (0.00s)
97 | PASS
98 | ok github.com/chainguard-dev/clog/examples/logger
99 | ```
100 |
101 | ### Context Handler
102 |
103 | The context Handler can be used to insert values from the context.
104 |
105 | ```go
106 | func init() {
107 | slog.SetDefault(slog.New(clog.NewHandler(slog.NewTextHandler(os.Stdout, nil))))
108 | }
109 |
110 | func main() {
111 | ctx := context.Background()
112 | ctx = clog.WithValues(ctx, "foo", "bar")
113 |
114 | // Use slog package directly
115 | slog.InfoContext(ctx, "hello world", slog.Bool("baz", true))
116 |
117 | // glog / zap style (note: can't pass additional attributes)
118 | clog.ErrorContextf(ctx, "hello %s", "world")
119 | }
120 | ```
121 |
122 | ```sh
123 | $ go run .
124 | time=2009-11-10T23:00:00.000Z level=INFO msg="hello world" baz=true foo=bar
125 | time=2009-11-10T23:00:00.000Z level=ERROR msg="hello world" foo=bar
126 | ```
127 |
128 | ### Google Cloud Platform support
129 |
130 | This package also provides a GCP-optimized JSON handler for structured logging and trace attribution.
131 |
132 | See [`./gcp/README.md`](./gcp/README.md) for details.
133 |
--------------------------------------------------------------------------------
/gcp/trace.go:
--------------------------------------------------------------------------------
1 | package gcp
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "log/slog"
8 | "net"
9 | "net/http"
10 | "os"
11 | "runtime"
12 | "strings"
13 | "sync"
14 | "time"
15 | )
16 |
17 | func insideTest() bool {
18 | // Ask runtime.Callers for up to 10 PCs, including runtime.Callers itself.
19 | pc := make([]uintptr, 10)
20 | n := runtime.Callers(0, pc)
21 | if n == 0 {
22 | slog.Debug("WithCloudTraceContext: no PCs available")
23 | return true
24 | }
25 | frames := runtime.CallersFrames(pc[:n])
26 | for {
27 | frame, more := frames.Next()
28 | if !more {
29 | break
30 | }
31 | if strings.HasPrefix(frame.Function, "testing.") &&
32 | strings.HasSuffix(frame.File, "src/testing/testing.go") {
33 | slog.Debug("WithCloudTraceContext: inside test", "function", frame.Function, "file", frame.File, "line", frame.Line)
34 | return true
35 | }
36 | }
37 | return false
38 | }
39 |
40 | var (
41 | projectID string
42 | lookupOnce sync.Once
43 | )
44 |
45 | // WithCloudTraceContext returns an http.handler that adds the GCP Cloud Trace
46 | // ID to the context. This is used to correlate the structured logs with the
47 | // request log.
48 | func WithCloudTraceContext(h http.Handler) http.Handler {
49 | // Get the project ID from the environment if specified
50 | fromEnv := os.Getenv("GOOGLE_CLOUD_PROJECT")
51 | if fromEnv != "" {
52 | projectID = fromEnv
53 | } else {
54 | lookupOnce.Do(func() {
55 | if insideTest() {
56 | slog.Debug("WithCloudTraceContext: inside test, not looking up project ID")
57 | return
58 | }
59 |
60 | // By default use the metadata IP; otherwise use the environment variable
61 | // for consistency with https://pkg.go.dev/cloud.google.com/go/compute/metadata#Client.Get
62 | host := "169.254.169.254"
63 | if h := os.Getenv("GCE_METADATA_HOST"); h != "" {
64 | host = h
65 | }
66 | req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://%s/computeMetadata/v1/project/project-id", host), nil)
67 | if err != nil {
68 | slog.Debug("WithCloudTraceContext: could not get GCP project ID from metadata server", "err", err)
69 | return
70 | }
71 | req.Header.Set("Metadata-Flavor", "Google")
72 | resp, err := (&http.Client{ // Timeouts copied from https://pkg.go.dev/cloud.google.com/go/compute/metadata#Get
73 | Transport: &http.Transport{
74 | Dial: (&net.Dialer{Timeout: 2 * time.Second}).Dial,
75 | },
76 | Timeout: 5 * time.Second,
77 | }).Do(req)
78 | if err != nil {
79 | slog.Debug("WithCloudTraceContext: could not get GCP project ID from metadata server", "err", err)
80 | return
81 | }
82 | if resp.StatusCode != http.StatusOK {
83 | slog.Debug("WithCloudTraceContext: could not get GCP project ID from metadata server", "code", resp.StatusCode, "status", resp.Status)
84 | return
85 | }
86 | defer resp.Body.Close()
87 | all, err := io.ReadAll(resp.Body)
88 | if err != nil {
89 | slog.Debug("WithCloudTraceContext: could not get GCP project ID from metadata server", "err", err)
90 | return
91 | }
92 | projectID = string(all)
93 | })
94 | }
95 |
96 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97 | if projectID != "" {
98 | var trace string
99 | traceHeader := r.Header.Get("traceparent")
100 | traceID := parseTraceFromW3CHeader(traceHeader)
101 | if traceID != "" {
102 | trace = fmt.Sprintf("projects/%s/traces/%s", projectID, traceID)
103 | }
104 | r = r.WithContext(WithTrace(r.Context(), trace))
105 | }
106 | h.ServeHTTP(w, r)
107 | })
108 | }
109 |
110 | type traceKey struct{}
111 |
112 | // WithTrace adds a trace information to the context.
113 | func WithTrace(ctx context.Context, trace string) context.Context {
114 | if trace == "" {
115 | return ctx
116 | }
117 | return context.WithValue(ctx, traceKey{}, trace)
118 | }
119 |
120 | // TraceFromContext retrieves the trace information from the context.
121 | func TraceFromContext(ctx context.Context) string {
122 | trace := ctx.Value(traceKey{})
123 | if trace == nil {
124 | return ""
125 | }
126 | return trace.(string)
127 | }
128 |
129 | func parseTraceFromW3CHeader(traceparent string) string {
130 | traceParts := strings.Split(traceparent, "-")
131 | if len(traceParts) > 1 {
132 | return traceParts[1]
133 | }
134 | return ""
135 | }
136 |
--------------------------------------------------------------------------------
/log.go:
--------------------------------------------------------------------------------
1 | package clog
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "os"
7 | )
8 |
9 | // Info calls Info on the default logger.
10 | func Info(msg string, args ...any) {
11 | wrap(context.Background(), DefaultLogger(), slog.LevelInfo, msg, args...)
12 | }
13 |
14 | // InfoContext calls InfoContext on the context logger.
15 | // If a Logger is found in the context, it will be used.
16 | func InfoContext(ctx context.Context, msg string, args ...any) {
17 | wrap(ctx, FromContext(ctx), slog.LevelInfo, msg, args...)
18 | }
19 |
20 | // Infof calls Infof on the default logger.
21 | func Infof(format string, args ...any) {
22 | wrapf(context.Background(), DefaultLogger(), slog.LevelInfo, format, args...)
23 | }
24 |
25 | // InfoContextf calls InfoContextf on the context logger.
26 | // If a Logger is found in the context, it will be used.
27 | func InfoContextf(ctx context.Context, format string, args ...any) {
28 | wrapf(ctx, FromContext(ctx), slog.LevelInfo, format, args...)
29 | }
30 |
31 | // Warn calls Warn on the default logger.
32 | func Warn(msg string, args ...any) {
33 | wrap(context.Background(), DefaultLogger(), slog.LevelWarn, msg, args...)
34 | }
35 |
36 | // WarnContext calls WarnContext on the context logger.
37 | // If a Logger is found in the context, it will be used.
38 | func WarnContext(ctx context.Context, msg string, args ...any) {
39 | wrap(ctx, FromContext(ctx), slog.LevelWarn, msg, args...)
40 | }
41 |
42 | // Warnf calls Warnf on the default logger.
43 | func Warnf(format string, args ...any) {
44 | wrapf(context.Background(), DefaultLogger(), slog.LevelWarn, format, args...)
45 | }
46 |
47 | // WarnContextf calls WarnContextf on the context logger.
48 | // If a Logger is found in the context, it will be used.
49 | func WarnContextf(ctx context.Context, format string, args ...any) {
50 | wrapf(ctx, FromContext(ctx), slog.LevelWarn, format, args...)
51 | }
52 |
53 | // Error calls Error on the default logger.
54 | func Error(msg string, args ...any) {
55 | wrap(context.Background(), DefaultLogger(), slog.LevelError, msg, args...)
56 | }
57 |
58 | // ErrorContext calls ErrorContext on the context logger.
59 | func ErrorContext(ctx context.Context, msg string, args ...any) {
60 | wrap(ctx, FromContext(ctx), slog.LevelError, msg, args...)
61 | }
62 |
63 | // Errorf calls Errorf on the default logger.
64 | func Errorf(format string, args ...any) {
65 | wrapf(context.Background(), DefaultLogger(), slog.LevelError, format, args...)
66 | }
67 |
68 | // ErrorContextf calls ErrorContextf on the context logger.
69 | func ErrorContextf(ctx context.Context, format string, args ...any) {
70 | wrapf(ctx, FromContext(ctx), slog.LevelError, format, args...)
71 | }
72 |
73 | // Debug calls Debug on the default logger.
74 | func Debug(msg string, args ...any) {
75 | wrap(context.Background(), DefaultLogger(), slog.LevelDebug, msg, args...)
76 | }
77 |
78 | // DebugContext calls DebugContext on the context logger.
79 | func DebugContext(ctx context.Context, msg string, args ...any) {
80 | wrap(ctx, FromContext(ctx), slog.LevelDebug, msg, args...)
81 | }
82 |
83 | // Debugf calls Debugf on the default logger.
84 | func Debugf(format string, args ...any) {
85 | wrapf(context.Background(), DefaultLogger(), slog.LevelDebug, format, args...)
86 | }
87 |
88 | // DebugContextf calls DebugContextf on the context logger.
89 | // If a Logger is found in the context, it will be used.
90 | func DebugContextf(ctx context.Context, format string, args ...any) {
91 | wrapf(ctx, FromContext(ctx), slog.LevelDebug, format, args...)
92 | }
93 |
94 | // Fatal calls Error on the default logger, then exits.
95 | func Fatal(msg string, args ...any) {
96 | wrap(context.Background(), DefaultLogger(), slog.LevelError, msg, args...)
97 | os.Exit(1)
98 | }
99 |
100 | // FatalContext calls ErrorContext on the context logger, then exits.
101 | func FatalContext(ctx context.Context, msg string, args ...any) {
102 | wrap(ctx, FromContext(ctx), slog.LevelError, msg, args...)
103 | os.Exit(1)
104 | }
105 |
106 | // Fatalf calls Errorf on the default logger, then exits.
107 | func Fatalf(format string, args ...any) {
108 | wrapf(context.Background(), DefaultLogger(), slog.LevelError, format, args...)
109 | os.Exit(1)
110 | }
111 |
112 | // FatalContextf calls ErrorContextf on the context logger, then exits.
113 | func FatalContextf(ctx context.Context, format string, args ...any) {
114 | wrapf(ctx, FromContext(ctx), slog.LevelError, format, args...)
115 | os.Exit(1)
116 | }
117 |
--------------------------------------------------------------------------------
/logger_test.go:
--------------------------------------------------------------------------------
1 | package clog
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "log/slog"
9 | "reflect"
10 | "testing"
11 | )
12 |
13 | var (
14 | testopts = &slog.HandlerOptions{
15 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
16 | // Ignore time to make testing easier.
17 | if a.Key == "time" {
18 | return slog.Attr{}
19 | }
20 | return a
21 | },
22 | }
23 | )
24 |
25 | func TestLogger(t *testing.T) {
26 | ctx := context.Background()
27 | b := new(bytes.Buffer)
28 | base := slog.New(NewHandler(slog.NewJSONHandler(b, testopts)))
29 | log := NewLogger(base).With("a", "b")
30 | log.InfoContext(ctx, "")
31 | t.Log(b.String())
32 |
33 | want := map[string]any{
34 | "level": "INFO",
35 | "msg": "",
36 | "a": "b",
37 | }
38 |
39 | var got map[string]any
40 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
41 | t.Fatal(err)
42 | }
43 | if !reflect.DeepEqual(want, got) {
44 | t.Errorf("want %v, got %v", want, got)
45 | }
46 | }
47 |
48 | func TestLoggerNilBase(t *testing.T) {
49 | log := NewLogger(nil)
50 | log.Info("")
51 | }
52 |
53 | func TestLoggerFromContext(t *testing.T) {
54 | b := new(bytes.Buffer)
55 | base := slog.New(NewHandler(slog.NewJSONHandler(b, testopts)))
56 | log := NewLogger(base).With("a", "b")
57 |
58 | ctx := WithLogger(context.Background(), log)
59 | FromContext(ctx).Info("")
60 |
61 | want := map[string]any{
62 | "level": "INFO",
63 | "msg": "",
64 | "a": "b",
65 | }
66 |
67 | t.Run("FromContext.Info", func(t *testing.T) {
68 | var got map[string]any
69 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
70 | t.Fatal(err)
71 | }
72 | if !reflect.DeepEqual(want, got) {
73 | t.Errorf("want %v, got %v", want, got)
74 | }
75 | })
76 |
77 | b.Reset()
78 |
79 | t.Run("clog.Info", func(t *testing.T) {
80 | InfoContext(ctx, "")
81 | var got map[string]any
82 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
83 | t.Fatal(err)
84 | }
85 | if !reflect.DeepEqual(want, got) {
86 | t.Errorf("want %v, got %v", want, got)
87 | }
88 | })
89 | }
90 |
91 | func TestLoggerPC(t *testing.T) {
92 | b := new(bytes.Buffer)
93 | log := NewLogger(slog.New(NewHandler(slog.NewJSONHandler(b, &slog.HandlerOptions{
94 | AddSource: true,
95 | ReplaceAttr: testopts.ReplaceAttr,
96 | }))))
97 |
98 | log.Info("")
99 | t.Log(b.String())
100 |
101 | var got struct {
102 | Source struct {
103 | File string `json:"file"`
104 | Function string `json:"function"`
105 | } `json:"source"`
106 | }
107 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
108 | t.Fatal(err)
109 | }
110 | // Knowing that the PC is from this test is good enough.
111 | want := fmt.Sprintf("github.com/chainguard-dev/clog.%s", t.Name())
112 | if got.Source.Function != want {
113 | t.Errorf("want %v, got %v", want, got)
114 | }
115 | }
116 |
117 | func TestWith(t *testing.T) {
118 | ctx := context.WithValue(context.Background(), "test", "test")
119 | log := NewLoggerWithContext(ctx, nil)
120 | withed := log.With("a", "b")
121 | if want := withed.ctx; want != ctx {
122 | t.Errorf("want %v, got %v", want, ctx)
123 | }
124 | withed = log.WithGroup("a")
125 | if want := withed.ctx; want != ctx {
126 | t.Errorf("want %v, got %v", want, ctx)
127 | }
128 | }
129 |
130 | func TestDefaultHandler(t *testing.T) {
131 | old := slog.Default()
132 | defer func() {
133 | slog.SetDefault(old)
134 | }()
135 |
136 | b := new(bytes.Buffer)
137 | slog.SetDefault(slog.New(slog.NewJSONHandler(b, testopts)))
138 |
139 | t.Run("Info", func(t *testing.T) {
140 | FromContext(WithValues(context.Background(), "a", "b")).Info("")
141 | want := map[string]any{
142 | "level": "INFO",
143 | "msg": "",
144 | "a": "b",
145 | }
146 | var got map[string]any
147 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
148 | t.Fatal(err)
149 | }
150 | if !reflect.DeepEqual(want, got) {
151 | t.Errorf("want %v, got %v", want, got)
152 | }
153 | })
154 |
155 | b.Reset()
156 |
157 | t.Run("InfoContext", func(t *testing.T) {
158 | // Set logger with original value
159 | ctx := WithValues(context.Background(), "a", "b")
160 | logger := FromContext(ctx)
161 |
162 | // Override value in request context - we expect this to overwrite the original value set in the logger
163 | logger.InfoContext(WithValues(ctx, "a", "c"), "")
164 |
165 | want := map[string]any{
166 | "level": "INFO",
167 | "msg": "",
168 | "a": "c",
169 | }
170 | var got map[string]any
171 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
172 | t.Fatal(err)
173 | }
174 | if !reflect.DeepEqual(want, got) {
175 | t.Errorf("want %v, got %v", want, got)
176 | }
177 | })
178 |
179 | b.Reset()
180 |
181 | t.Run("Debug - no log", func(t *testing.T) {
182 | logger := FromContext(context.Background())
183 | logger.Debug("asdf")
184 |
185 | if b.Len() != 0 {
186 | t.Errorf("want empty, got %q", b.String())
187 | }
188 | })
189 | }
190 |
191 | func TestContext(t *testing.T) {
192 | old := slog.Default()
193 | t.Cleanup(func() {
194 | slog.SetDefault(old)
195 | })
196 |
197 | b := new(bytes.Buffer)
198 | slog.SetDefault(slog.New(slog.NewJSONHandler(b, testopts)))
199 |
200 | msg := "hello world"
201 | want := map[string]any{
202 | "level": "INFO",
203 | "msg": msg,
204 | "a": "b",
205 | }
206 |
207 | ctx := WithValues(context.Background(), "a", "b")
208 | // These should all give the same output and be functionally equivalent.
209 | for _, tc := range []struct {
210 | name string
211 | fn func()
212 | }{
213 | {
214 | name: "clog.InfoContext",
215 | fn: func() {
216 | InfoContext(ctx, msg)
217 | },
218 | },
219 | {
220 | name: "clog.FromContext.Info",
221 | fn: func() {
222 | FromContext(ctx).Info(msg)
223 | },
224 | },
225 | {
226 | name: "clog.FromContext.InfoContext",
227 | fn: func() {
228 | FromContext(ctx).InfoContext(ctx, msg)
229 | },
230 | },
231 | } {
232 | t.Run(tc.name, func(t *testing.T) {
233 | b.Reset()
234 | tc.fn()
235 |
236 | var got map[string]any
237 | if err := json.Unmarshal(b.Bytes(), &got); err != nil {
238 | t.Fatal(err)
239 | }
240 | if !reflect.DeepEqual(want, got) {
241 | t.Errorf("want %v, got %v", want, got)
242 | }
243 | })
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/logger.go:
--------------------------------------------------------------------------------
1 | package clog
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 | "os"
8 | "runtime"
9 | "time"
10 | )
11 |
12 | // Logger implements a wrapper around [slog.Logger] that adds formatter functions (e.g. Infof, Errorf)
13 | type Logger struct {
14 | ctx context.Context
15 | slog.Logger
16 | }
17 |
18 | // DefaultLogger returns a new logger that uses the default [slog.Logger].
19 | func DefaultLogger() *Logger {
20 | return NewLogger(nil)
21 | }
22 |
23 | // NewLogger returns a new logger that wraps the given [slog.Logger] with the default context.
24 | func NewLogger(l *slog.Logger) *Logger {
25 | return NewLoggerWithContext(context.Background(), l)
26 | }
27 |
28 | // NewLoggerWithContext returns a new logger that wraps the given [slog.Logger].
29 | func NewLoggerWithContext(ctx context.Context, l *slog.Logger) *Logger {
30 | if l == nil {
31 | l = slog.New(NewHandler(slog.Default().Handler()))
32 | }
33 | return &Logger{
34 | ctx: ctx,
35 | Logger: *l,
36 | }
37 | }
38 |
39 | // New returns a new logger that wraps the given [slog.Handler].
40 | func New(h slog.Handler) *Logger {
41 | return NewWithContext(context.Background(), h)
42 | }
43 |
44 | // NewWithContext returns a new logger that wraps the given [slog.Handler] using the given context.
45 | func NewWithContext(ctx context.Context, h slog.Handler) *Logger {
46 | return NewLoggerWithContext(ctx, slog.New(NewHandler(h)))
47 | }
48 |
49 | // With calls [Logger.With] on the default logger.
50 | func With(args ...any) *Logger {
51 | return DefaultLogger().With(args...)
52 | }
53 |
54 | // With calls [Logger.With] on the logger.
55 | func (l *Logger) With(args ...any) *Logger {
56 | return NewLoggerWithContext(l.context(), l.Logger.With(args...))
57 | }
58 |
59 | // WithGroup calls [Logger.WithGroup] on the default logger.
60 | func (l *Logger) WithGroup(name string) *Logger {
61 | return NewLoggerWithContext(l.context(), l.Logger.WithGroup(name))
62 | }
63 |
64 | func (l *Logger) context() context.Context {
65 | if l.ctx == nil {
66 | return context.Background()
67 | }
68 | return l.ctx
69 | }
70 |
71 | // Info logs at LevelInfo with the given message and treats the args as key/value pairs to form log message attributes.
72 | func (l *Logger) Info(msg string, args ...any) {
73 | wrap(l.context(), l, slog.LevelInfo, msg, args...)
74 | }
75 |
76 | // Infof logs at LevelInfo with the given format and arguments.
77 | func (l *Logger) Infof(format string, args ...any) {
78 | wrapf(l.context(), l, slog.LevelInfo, format, args...)
79 | }
80 |
81 | // InfoContextf logs at LevelInfo with the given context, format, and arguments.
82 | func (l *Logger) InfoContextf(ctx context.Context, format string, args ...any) {
83 | wrapf(ctx, l, slog.LevelInfo, format, args...)
84 | }
85 |
86 | // Warn logs at LevelWarn with the given message and treats the args as key/value pairs to form log message attributes.
87 | func (l *Logger) Warn(msg string, args ...any) {
88 | wrap(l.context(), l, slog.LevelWarn, msg, args...)
89 | }
90 |
91 | // Warnf logs at LevelWarn with the given format and arguments.
92 | func (l *Logger) Warnf(format string, args ...any) {
93 | wrapf(l.context(), l, slog.LevelWarn, format, args...)
94 | }
95 |
96 | // WarnContextf logs at LevelWarn with the given context, format and arguments.
97 | func (l *Logger) WarnContextf(ctx context.Context, format string, args ...any) {
98 | wrapf(ctx, l, slog.LevelWarn, format, args...)
99 | }
100 |
101 | // Error logs at LevelError with the given message and treats the args as key/value pairs to form log message attributes.
102 | func (l *Logger) Error(msg string, args ...any) {
103 | wrap(l.context(), l, slog.LevelError, msg, args...)
104 | }
105 |
106 | // Errorf logs at LevelError with the given format and arguments.
107 | func (l *Logger) Errorf(format string, args ...any) {
108 | wrapf(l.context(), l, slog.LevelError, format, args...)
109 | }
110 |
111 | // ErrorContextf logs at LevelError with the given context, format and arguments.
112 | func (l *Logger) ErrorContextf(ctx context.Context, format string, args ...any) {
113 | wrapf(ctx, l, slog.LevelError, format, args...)
114 | }
115 |
116 | // Debug logs at LevelDebug with the given message and treats the args as key/value pairs to form log message attributes.
117 | func (l *Logger) Debug(msg string, args ...any) {
118 | wrap(l.context(), l, slog.LevelDebug, msg, args...)
119 | }
120 |
121 | // Debugf logs at LevelDebug with the given format and arguments.
122 | func (l *Logger) Debugf(format string, args ...any) {
123 | wrapf(l.context(), l, slog.LevelDebug, format, args...)
124 | }
125 |
126 | // DebugContextf logs at LevelDebug with the given context, format and arguments.
127 | func (l *Logger) DebugContextf(ctx context.Context, format string, args ...any) {
128 | wrapf(ctx, l, slog.LevelDebug, format, args...)
129 | }
130 |
131 | // Fatal logs at LevelError with the given message, then exits.
132 | func (l *Logger) Fatal(msg string, args ...any) {
133 | wrap(l.context(), l, slog.LevelError, msg, args...)
134 | os.Exit(1)
135 | }
136 |
137 | // Fatalf logs at LevelError with the given format and arguments, then exits.
138 | func (l *Logger) Fatalf(format string, args ...any) {
139 | wrapf(l.context(), l, slog.LevelError, format, args...)
140 | os.Exit(1)
141 | }
142 |
143 | // FatalContextf logs at LevelError with the given context, format and arguments, then exits.
144 | func (l *Logger) FatalContextf(ctx context.Context, format string, args ...any) {
145 | wrapf(ctx, l, slog.LevelError, format, args...)
146 | os.Exit(1)
147 | }
148 |
149 | // FatalContext logs at LevelError with the given context and message, then exits.
150 | func (l *Logger) FatalContext(ctx context.Context, msg string, args ...any) {
151 | wrap(ctx, l, slog.LevelError, msg, args...)
152 | os.Exit(1)
153 | }
154 |
155 | // Base returns the underlying [slog.Logger].
156 | func (l *Logger) Base() *slog.Logger {
157 | return &l.Logger
158 | }
159 |
160 | // Handler returns the underlying [slog.Handler].
161 | func (l *Logger) Handler() slog.Handler {
162 | return l.Logger.Handler()
163 | }
164 |
165 | func wrap(ctx context.Context, logger *Logger, level slog.Level, msg string, args ...any) {
166 | if !logger.Handler().Enabled(ctx, level) {
167 | return
168 | }
169 |
170 | var pcs [1]uintptr
171 | runtime.Callers(3, pcs[:]) // skip [Callers, Infof, wrapf]
172 | r := slog.NewRecord(time.Now(), level, msg, pcs[0])
173 | r.Add(args...)
174 | _ = logger.Handler().Handle(ctx, r)
175 | }
176 |
177 | // wrapf is like wrap, but uses fmt.Sprintf to format the message.
178 | // NOTE: args are passed to fmt.Sprintf, not as [slog.Attr].
179 | func wrapf(ctx context.Context, logger *Logger, level slog.Level, format string, args ...any) {
180 | if !logger.Handler().Enabled(ctx, level) {
181 | return
182 | }
183 |
184 | var pcs [1]uintptr
185 | runtime.Callers(3, pcs[:]) // skip [Callers, Infof, wrapf]
186 | r := slog.NewRecord(time.Now(), level, fmt.Sprintf(format, args...), pcs[0])
187 | _ = logger.Handler().Handle(ctx, r)
188 | }
189 |
190 | type loggerKey struct{}
191 |
192 | func WithLogger(ctx context.Context, logger *Logger) context.Context {
193 | return context.WithValue(ctx, loggerKey{}, logger.Logger)
194 | }
195 |
196 | func FromContext(ctx context.Context) *Logger {
197 | if logger, ok := ctx.Value(loggerKey{}).(slog.Logger); ok {
198 | return &Logger{
199 | ctx: ctx,
200 | Logger: logger,
201 | }
202 | }
203 | return NewLoggerWithContext(ctx, nil)
204 | }
205 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------