From 56feedf127c3e41837d9987805e86a2c7f1a904f Mon Sep 17 00:00:00 2001 From: Professor X Date: Tue, 16 Jan 2024 23:15:12 -0500 Subject: [PATCH 1/7] feat: add hooks log handler wrapper for new olog pkg --- pkg/app/app.go | 26 ++++++++++++ pkg/olog/hooks/app.go | 24 +++++++++++ pkg/olog/hooks/hooks.go | 68 +++++++++++++++++++++++++++++++ pkg/olog/hooks/hooks_test.go | 77 ++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 pkg/olog/hooks/app.go create mode 100644 pkg/olog/hooks/hooks.go create mode 100644 pkg/olog/hooks/hooks_test.go diff --git a/pkg/app/app.go b/pkg/app/app.go index 4c85e740..ee75e7da 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -7,6 +7,7 @@ package app import ( "fmt" + "log/slog" "os" "runtime/debug" "strings" @@ -197,3 +198,28 @@ func (d *Data) MarshalLog(addField func(key string, v interface{})) { addField("deployment.namespace", d.Namespace) } } + +// LogValue implements the log/slog package's LogValuer interface (found +// here: https://pkg.go.dev/log/slog#LogValuer). Returns a subset of the +// App.Info data as a map. +func (d *Data) LogValue() slog.Value { + d.mu.Lock() + defer d.mu.Unlock() + + attrs := make(map[string]string, 4) + + // App prefixes are removed as AppInfo func nests data under app key + // already. + if d.Name != "unknown" { + attrs["name"] = d.Name + attrs["service_name"] = d.Name + } + if d.Version != "" { + attrs["version"] = d.Version + } + if d.Namespace != "" { + attrs["deployment.namespace"] = d.Namespace + } + + return slog.AnyValue(attrs) +} diff --git a/pkg/olog/hooks/app.go b/pkg/olog/hooks/app.go new file mode 100644 index 00000000..88c88405 --- /dev/null +++ b/pkg/olog/hooks/app.go @@ -0,0 +1,24 @@ +// Copyright 2023 Outreach Corporation. All Rights Reserved. + +// Description: Implements a hook based pattern for automatically +// adding data to logs before the record is written. + +package hooks + +import ( + "context" + "log/slog" + + "github.com/getoutreach/gobox/pkg/app" +) + +// AppInfo provides a log hook which extracts and returns the gobox/pkg/app.Data +// as a nested attribute on log record. +func AppInfo(ctx context.Context, r slog.Record) ([]slog.Attr, error) { + info := app.Info() + if info == nil { + return []slog.Attr{}, nil + } + + return []slog.Attr{slog.Any("app", info)}, nil +} diff --git a/pkg/olog/hooks/hooks.go b/pkg/olog/hooks/hooks.go new file mode 100644 index 00000000..dc4af977 --- /dev/null +++ b/pkg/olog/hooks/hooks.go @@ -0,0 +1,68 @@ +// Copyright 2023 Outreach Corporation. All Rights Reserved. + +// Description: Implements a hook based pattern for automatically +// adding data to logs before the record is written. + +// Package olog/hooks implements a lightweight hooks interface for +// creating olog/slog compliant loggers. This package builds around +// the log handler and logger already built and used by the olog +// package, but wraps the underlying handler such that it can accept +// hook functions for augmenting the final log record. +package hooks + +import ( + "context" + "log/slog" + + "github.com/getoutreach/gobox/pkg/olog" +) + +// Logger creates a new slog instance that can be used for logging. The +// provided logger use the global handler provided by this package. See +// the documentation on the 'handler' global for more information. +// +// The logger will be automatically associated with the module and +// package that it was instantiated in. This is done by looking at the +// call stack. +// +// Note: As mentioned above, this logger is associated with the module +// and package that created it. So, if you pass this logger to another +// module or package, the association will NOT be changed. This +// includes the caller metadata added to every log line as well as +// log-level management. If a type has a common logging format that the +// other module or package should use, then a slog.LogValuer should be +// implemented on that type instead of passing a logger around. If +// trying to set attributes the be logged by default, this is not +// supported without retaining the original association. +func Logger(hooks ...LogHookFunc) *slog.Logger { + defaultHandler := olog.New().Handler() + hookedHandler := &handler{Handler: defaultHandler, hooks: hooks} + return olog.NewWithHandler(hookedHandler) +} + +// LogHookFunc defines a function which can be called prior to a log being +// emitted, allowing the caller to augment the attributes on a log by +// returning a slice of slog.Attr which will appended to the record. The +// caller may also return an error, which will be handled by the underlying +// log handler (slog.TextHandler or slog.JSONHandler). +type LogHookFunc func(context.Context, slog.Record) ([]slog.Attr, error) + +type handler struct { + hooks []LogHookFunc + slog.Handler +} + +// Handle performs the required Handle operation of the log handler interface, +// calling any provided hooks before calling the underlying embedded handler. +func (h *handler) Handle(ctx context.Context, r slog.Record) error { + for _, hook := range h.hooks { + attrs, err := hook(ctx, r) + if err != nil { + return err + } + + r.AddAttrs(attrs...) + } + + return h.Handler.Handle(ctx, r) +} diff --git a/pkg/olog/hooks/hooks_test.go b/pkg/olog/hooks/hooks_test.go new file mode 100644 index 00000000..6ffe10e7 --- /dev/null +++ b/pkg/olog/hooks/hooks_test.go @@ -0,0 +1,77 @@ +package hooks + +import ( + "context" + "log/slog" + "testing" + + "github.com/getoutreach/gobox/pkg/app" + "github.com/getoutreach/gobox/pkg/olog" + "github.com/google/go-cmp/cmp" +) + +func TestLogWithHook(t *testing.T) { + // Force JSON handler for valid unmarshaling used in the TestCapturer + olog.SetDefaultHandler(olog.JSONHandler) + + logCapture := olog.NewTestCapturer(t) + + // Create logger with test hook + logger := Logger(func(context.Context, slog.Record) ([]slog.Attr, error) { + return []slog.Attr{slog.Any("data", map[string]string{"foo": "bar"})}, nil + }) + + logger.Info("should appear") + + // Verify the right messages were logged. + expected := []olog.TestLogLine{ + { + Message: "should appear", + Level: slog.LevelInfo, + Attrs: map[string]any{ + "module": "github.com/getoutreach/gobox", + "modulever": "", + "data": map[string]any{"foo": "bar"}, + }, + }, + } + + if diff := cmp.Diff(expected, logCapture.GetLogs()); diff != "" { + t.Fatalf("unexpected log output (-want +got):\n%s", diff) + } +} + +func TestLogWithAppInfoHook(t *testing.T) { + // Force JSON handler for valid unmarshaling used in the TestCapturer + olog.SetDefaultHandler(olog.JSONHandler) + + logCapture := olog.NewTestCapturer(t) + + // Initialize test app info + app.SetName("ologHooksTest") + + // Create logger with test hook + logger := Logger(AppInfo) + + logger.Info("should appear") + + // Verify the right messages were logged. + expected := []olog.TestLogLine{ + { + Message: "should appear", + Level: slog.LevelInfo, + Attrs: map[string]any{ + "module": "github.com/getoutreach/gobox", + "modulever": "", + "app": map[string]any{ + "name": "ologHooksTest", + "service_name": "ologHooksTest", + }, + }, + }, + } + + if diff := cmp.Diff(expected, logCapture.GetLogs()); diff != "" { + t.Fatalf("unexpected log output (-want +got):\n%s", diff) + } +} From 90fb15930821fd9f7907a3b1617105e516919a0a Mon Sep 17 00:00:00 2001 From: Professor X Date: Wed, 17 Jan 2024 08:15:53 -0500 Subject: [PATCH 2/7] chore: updated comments in hooks pkg --- pkg/olog/hooks/hooks.go | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/pkg/olog/hooks/hooks.go b/pkg/olog/hooks/hooks.go index dc4af977..c4d8eb5c 100644 --- a/pkg/olog/hooks/hooks.go +++ b/pkg/olog/hooks/hooks.go @@ -18,22 +18,15 @@ import ( ) // Logger creates a new slog instance that can be used for logging. The -// provided logger use the global handler provided by this package. See -// the documentation on the 'handler' global for more information. +// provided logger uses a handler which wraps the global handler provided +// by the olog pkg, allowing hooks to be provided by the caller in order +// to automatically augment the attributes on the log record before it +// writes. See the [documentation](../README.md) on the olog pkg for more +// information. // -// The logger will be automatically associated with the module and -// package that it was instantiated in. This is done by looking at the -// call stack. -// -// Note: As mentioned above, this logger is associated with the module -// and package that created it. So, if you pass this logger to another -// module or package, the association will NOT be changed. This -// includes the caller metadata added to every log line as well as -// log-level management. If a type has a common logging format that the -// other module or package should use, then a slog.LogValuer should be -// implemented on that type instead of passing a logger around. If -// trying to set attributes the be logged by default, this is not -// supported without retaining the original association. +// All hooks provided will executed in the order in which they are provided +// and will overwrite and attributes written by the previous hook when a +// duplicate key is provided. func Logger(hooks ...LogHookFunc) *slog.Logger { defaultHandler := olog.New().Handler() hookedHandler := &handler{Handler: defaultHandler, hooks: hooks} From 705dc95a30b392da2563f41bf5b2574b768a7d86 Mon Sep 17 00:00:00 2001 From: Professor X Date: Wed, 17 Jan 2024 09:00:40 -0500 Subject: [PATCH 3/7] chore: ignore gocritic complaints about slog interface --- pkg/olog/hooks/app.go | 1 + pkg/olog/hooks/hooks.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pkg/olog/hooks/app.go b/pkg/olog/hooks/app.go index 88c88405..e7795f86 100644 --- a/pkg/olog/hooks/app.go +++ b/pkg/olog/hooks/app.go @@ -14,6 +14,7 @@ import ( // AppInfo provides a log hook which extracts and returns the gobox/pkg/app.Data // as a nested attribute on log record. +// nolint:gocritic // Why: this signature is inline with the slog pkg handler interface func AppInfo(ctx context.Context, r slog.Record) ([]slog.Attr, error) { info := app.Info() if info == nil { diff --git a/pkg/olog/hooks/hooks.go b/pkg/olog/hooks/hooks.go index c4d8eb5c..9b785d66 100644 --- a/pkg/olog/hooks/hooks.go +++ b/pkg/olog/hooks/hooks.go @@ -38,6 +38,7 @@ func Logger(hooks ...LogHookFunc) *slog.Logger { // returning a slice of slog.Attr which will appended to the record. The // caller may also return an error, which will be handled by the underlying // log handler (slog.TextHandler or slog.JSONHandler). +// nolint:gocritic // Why: this is the signature require by the slog handler interface type LogHookFunc func(context.Context, slog.Record) ([]slog.Attr, error) type handler struct { @@ -47,6 +48,7 @@ type handler struct { // Handle performs the required Handle operation of the log handler interface, // calling any provided hooks before calling the underlying embedded handler. +// nolint:gocritic // Why: this is the signature require by the slog handler interface func (h *handler) Handle(ctx context.Context, r slog.Record) error { for _, hook := range h.hooks { attrs, err := hook(ctx, r) From d9410cfe6e85f47f367f54acdf29e4126feec358 Mon Sep 17 00:00:00 2001 From: Devbase CI Date: Wed, 17 Jan 2024 09:19:18 -0500 Subject: [PATCH 4/7] fix: linting --- pkg/olog/hooks/hooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/olog/hooks/hooks.go b/pkg/olog/hooks/hooks.go index 9b785d66..c42efe62 100644 --- a/pkg/olog/hooks/hooks.go +++ b/pkg/olog/hooks/hooks.go @@ -3,7 +3,7 @@ // Description: Implements a hook based pattern for automatically // adding data to logs before the record is written. -// Package olog/hooks implements a lightweight hooks interface for +// Package hooks implements a lightweight hooks interface for // creating olog/slog compliant loggers. This package builds around // the log handler and logger already built and used by the olog // package, but wraps the underlying handler such that it can accept From 753eaed3fbcb26194431bcb79c53e45986c143b2 Mon Sep 17 00:00:00 2001 From: Professor X Date: Wed, 17 Jan 2024 09:59:11 -0500 Subject: [PATCH 5/7] chore: olog - updated README.md for hooks sub-package --- pkg/olog/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pkg/olog/README.md b/pkg/olog/README.md index cec5b76b..4424a2a8 100755 --- a/pkg/olog/README.md +++ b/pkg/olog/README.md @@ -209,6 +209,36 @@ func init() { ``` +## Logger Hooks + +To provide a mechanism with which to automatically add attributes to all logs (with access to context), a sub-package named `olog/hooks` has been provided. This package exposes a new `Logger` func which wraps the handler provided by the `olog` package and allows for hook functions to be provided by the caller which may return any number of `slog` attributes which will then be added to the final log record before it is written. + +```go +import ( + "context" + "log/slog" + + "github.com/getoutreach/gobox/pkg/olog/hooks" + "github.com/getoutreach/gobox/pkg/trace" +) + +var logger *slog.Logger + +func init() { + // Create custom hook func + traceHook := hooks.LogHookFunc(func(ctx context.Context, r slog.Record) ([]slog.Attr, error) { + return []slog.Attr{slog.String("traceId", trace.ID(ctx))}, nil + }) + + // Create a hooks logger with the provided AppInfo hook as well as + // the custom traceHook, assigning to our package logger instance. + logger = hooks.Logger( + hooks.AppInfo, + traceHook, + ) +} +``` + ## func [New]() ```go From 110f6e468069b332fcd9cfd07af403c20e2c9efc Mon Sep 17 00:00:00 2001 From: Professor X Date: Thu, 18 Jan 2024 13:59:02 -0500 Subject: [PATCH 6/7] fix: update AppInfo hook to use slog.GroupValue --- pkg/app/app.go | 12 ++++++------ pkg/olog/hooks/app.go | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index ee75e7da..dcf355a2 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -206,20 +206,20 @@ func (d *Data) LogValue() slog.Value { d.mu.Lock() defer d.mu.Unlock() - attrs := make(map[string]string, 4) + attrs := make([]slog.Attr, 0, 4) // App prefixes are removed as AppInfo func nests data under app key // already. if d.Name != "unknown" { - attrs["name"] = d.Name - attrs["service_name"] = d.Name + attrs = append(attrs, slog.String("name", d.Name)) + attrs = append(attrs, slog.String("service_name", d.Name)) } if d.Version != "" { - attrs["version"] = d.Version + attrs = append(attrs, slog.String("version", d.Version)) } if d.Namespace != "" { - attrs["deployment.namespace"] = d.Namespace + attrs = append(attrs, slog.String("deployment.namespace", d.Namespace)) } - return slog.AnyValue(attrs) + return slog.GroupValue(attrs...) } diff --git a/pkg/olog/hooks/app.go b/pkg/olog/hooks/app.go index e7795f86..8dcf4f6f 100644 --- a/pkg/olog/hooks/app.go +++ b/pkg/olog/hooks/app.go @@ -21,5 +21,10 @@ func AppInfo(ctx context.Context, r slog.Record) ([]slog.Attr, error) { return []slog.Attr{}, nil } - return []slog.Attr{slog.Any("app", info)}, nil + return []slog.Attr{ + // Manually assign the LogValue to an attribute. The slog.Group + // func doesn't really take an already existing GroupValue as + // Values are more meant to be used directly in log calls. + {Key: "app", Value: info.LogValue()}, + }, nil } From 75a3bf75982c7099cc697fc1c30bc6cb6b8951f7 Mon Sep 17 00:00:00 2001 From: Professor X Date: Tue, 30 Jan 2024 10:13:18 -0500 Subject: [PATCH 7/7] chore: refactor hooks into same olog package; move AppInfo hook into app pkg; update README --- pkg/app/app.go | 18 +++++++++ pkg/olog/README.md | 11 +++--- pkg/olog/hook_handler.go | 35 ++++++++++++++++ pkg/olog/hooks/app.go | 30 -------------- pkg/olog/hooks/hooks.go | 63 ----------------------------- pkg/olog/hooks/hooks_test.go | 77 ------------------------------------ pkg/olog/olog.go | 13 ++++++ pkg/olog/olog_test.go | 68 +++++++++++++++++++++++++++++++ 8 files changed, 140 insertions(+), 175 deletions(-) create mode 100644 pkg/olog/hook_handler.go delete mode 100644 pkg/olog/hooks/app.go delete mode 100644 pkg/olog/hooks/hooks.go delete mode 100644 pkg/olog/hooks/hooks_test.go diff --git a/pkg/app/app.go b/pkg/app/app.go index dcf355a2..f2080075 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -6,6 +6,7 @@ package app import ( + "context" "fmt" "log/slog" "os" @@ -223,3 +224,20 @@ func (d *Data) LogValue() slog.Value { return slog.GroupValue(attrs...) } + +// LogHook provides an olog compatible hook func which extracts and returns +// the app Data as a nested attribute on log record. +// nolint:gocritic // Why: this signature is inline with the olog pkg hook type +func LogHook(ctx context.Context, r slog.Record) ([]slog.Attr, error) { + info := Info() + if info == nil { + return []slog.Attr{}, nil + } + + return []slog.Attr{ + // Manually assign the LogValue to an attribute. The slog.Group + // func doesn't really take an already existing GroupValue as + // Values are more meant to be used directly in log calls. + {Key: "app", Value: info.LogValue()}, + }, nil +} diff --git a/pkg/olog/README.md b/pkg/olog/README.md index 4f7b70b4..5065f6cd 100755 --- a/pkg/olog/README.md +++ b/pkg/olog/README.md @@ -282,14 +282,15 @@ func doSomething(ctx context.Context, t *Thing) { ## Logger Hooks -To provide a mechanism with which to automatically add attributes to all logs (with access to context), a sub-package named `olog/hooks` has been provided. This package exposes a new `Logger` func which wraps the handler provided by the `olog` package and allows for hook functions to be provided by the caller which may return any number of `slog` attributes which will then be added to the final log record before it is written. +To provide a mechanism with which to automatically add attributes to all logs (with access to context), loggers can also be created with hook functions. This package exposes a `NewWithHooks` func which wraps the default olog handler and allows for hook functions to be provided by the caller. These hooks funcs may return any number of `slog` attributes which will then be added to the final log record before it is written. ```go import ( "context" "log/slog" - "github.com/getoutreach/gobox/pkg/olog/hooks" + "github.com/getoutreach/gobox/pkg/app" + "github.com/getoutreach/gobox/pkg/olog" "github.com/getoutreach/gobox/pkg/trace" ) @@ -297,14 +298,14 @@ var logger *slog.Logger func init() { // Create custom hook func - traceHook := hooks.LogHookFunc(func(ctx context.Context, r slog.Record) ([]slog.Attr, error) { + traceHook := olog.LogHookFunc(func(ctx context.Context, r slog.Record) ([]slog.Attr, error) { return []slog.Attr{slog.String("traceId", trace.ID(ctx))}, nil }) // Create a hooks logger with the provided AppInfo hook as well as // the custom traceHook, assigning to our package logger instance. - logger = hooks.Logger( - hooks.AppInfo, + logger = olog.NewWithHooks( + app.LogHook, traceHook, ) } diff --git a/pkg/olog/hook_handler.go b/pkg/olog/hook_handler.go new file mode 100644 index 00000000..9da196d4 --- /dev/null +++ b/pkg/olog/hook_handler.go @@ -0,0 +1,35 @@ +package olog + +import ( + "context" + "log/slog" +) + +// LogHookFunc defines a function which can be called prior to a log being +// emitted, allowing the caller to augment the attributes on a log by +// returning a slice of slog.Attr which will appended to the record. The +// caller may also return an error, which will be handled by the underlying +// log handler (slog.TextHandler or slog.JSONHandler). +// nolint:gocritic // Why: this is the signature require by the slog handler interface +type LogHookFunc func(context.Context, slog.Record) ([]slog.Attr, error) + +type hookHandler struct { + hooks []LogHookFunc + slog.Handler +} + +// Handle performs the required Handle operation of the log handler interface, +// calling any provided hooks before calling the underlying embedded handler. +// nolint:gocritic // Why: this is the signature require by the slog handler interface +func (h *hookHandler) Handle(ctx context.Context, r slog.Record) error { + for _, hook := range h.hooks { + attrs, err := hook(ctx, r) + if err != nil { + return err + } + + r.AddAttrs(attrs...) + } + + return h.Handler.Handle(ctx, r) +} diff --git a/pkg/olog/hooks/app.go b/pkg/olog/hooks/app.go deleted file mode 100644 index 8dcf4f6f..00000000 --- a/pkg/olog/hooks/app.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2023 Outreach Corporation. All Rights Reserved. - -// Description: Implements a hook based pattern for automatically -// adding data to logs before the record is written. - -package hooks - -import ( - "context" - "log/slog" - - "github.com/getoutreach/gobox/pkg/app" -) - -// AppInfo provides a log hook which extracts and returns the gobox/pkg/app.Data -// as a nested attribute on log record. -// nolint:gocritic // Why: this signature is inline with the slog pkg handler interface -func AppInfo(ctx context.Context, r slog.Record) ([]slog.Attr, error) { - info := app.Info() - if info == nil { - return []slog.Attr{}, nil - } - - return []slog.Attr{ - // Manually assign the LogValue to an attribute. The slog.Group - // func doesn't really take an already existing GroupValue as - // Values are more meant to be used directly in log calls. - {Key: "app", Value: info.LogValue()}, - }, nil -} diff --git a/pkg/olog/hooks/hooks.go b/pkg/olog/hooks/hooks.go deleted file mode 100644 index c42efe62..00000000 --- a/pkg/olog/hooks/hooks.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2023 Outreach Corporation. All Rights Reserved. - -// Description: Implements a hook based pattern for automatically -// adding data to logs before the record is written. - -// Package hooks implements a lightweight hooks interface for -// creating olog/slog compliant loggers. This package builds around -// the log handler and logger already built and used by the olog -// package, but wraps the underlying handler such that it can accept -// hook functions for augmenting the final log record. -package hooks - -import ( - "context" - "log/slog" - - "github.com/getoutreach/gobox/pkg/olog" -) - -// Logger creates a new slog instance that can be used for logging. The -// provided logger uses a handler which wraps the global handler provided -// by the olog pkg, allowing hooks to be provided by the caller in order -// to automatically augment the attributes on the log record before it -// writes. See the [documentation](../README.md) on the olog pkg for more -// information. -// -// All hooks provided will executed in the order in which they are provided -// and will overwrite and attributes written by the previous hook when a -// duplicate key is provided. -func Logger(hooks ...LogHookFunc) *slog.Logger { - defaultHandler := olog.New().Handler() - hookedHandler := &handler{Handler: defaultHandler, hooks: hooks} - return olog.NewWithHandler(hookedHandler) -} - -// LogHookFunc defines a function which can be called prior to a log being -// emitted, allowing the caller to augment the attributes on a log by -// returning a slice of slog.Attr which will appended to the record. The -// caller may also return an error, which will be handled by the underlying -// log handler (slog.TextHandler or slog.JSONHandler). -// nolint:gocritic // Why: this is the signature require by the slog handler interface -type LogHookFunc func(context.Context, slog.Record) ([]slog.Attr, error) - -type handler struct { - hooks []LogHookFunc - slog.Handler -} - -// Handle performs the required Handle operation of the log handler interface, -// calling any provided hooks before calling the underlying embedded handler. -// nolint:gocritic // Why: this is the signature require by the slog handler interface -func (h *handler) Handle(ctx context.Context, r slog.Record) error { - for _, hook := range h.hooks { - attrs, err := hook(ctx, r) - if err != nil { - return err - } - - r.AddAttrs(attrs...) - } - - return h.Handler.Handle(ctx, r) -} diff --git a/pkg/olog/hooks/hooks_test.go b/pkg/olog/hooks/hooks_test.go deleted file mode 100644 index 6ffe10e7..00000000 --- a/pkg/olog/hooks/hooks_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package hooks - -import ( - "context" - "log/slog" - "testing" - - "github.com/getoutreach/gobox/pkg/app" - "github.com/getoutreach/gobox/pkg/olog" - "github.com/google/go-cmp/cmp" -) - -func TestLogWithHook(t *testing.T) { - // Force JSON handler for valid unmarshaling used in the TestCapturer - olog.SetDefaultHandler(olog.JSONHandler) - - logCapture := olog.NewTestCapturer(t) - - // Create logger with test hook - logger := Logger(func(context.Context, slog.Record) ([]slog.Attr, error) { - return []slog.Attr{slog.Any("data", map[string]string{"foo": "bar"})}, nil - }) - - logger.Info("should appear") - - // Verify the right messages were logged. - expected := []olog.TestLogLine{ - { - Message: "should appear", - Level: slog.LevelInfo, - Attrs: map[string]any{ - "module": "github.com/getoutreach/gobox", - "modulever": "", - "data": map[string]any{"foo": "bar"}, - }, - }, - } - - if diff := cmp.Diff(expected, logCapture.GetLogs()); diff != "" { - t.Fatalf("unexpected log output (-want +got):\n%s", diff) - } -} - -func TestLogWithAppInfoHook(t *testing.T) { - // Force JSON handler for valid unmarshaling used in the TestCapturer - olog.SetDefaultHandler(olog.JSONHandler) - - logCapture := olog.NewTestCapturer(t) - - // Initialize test app info - app.SetName("ologHooksTest") - - // Create logger with test hook - logger := Logger(AppInfo) - - logger.Info("should appear") - - // Verify the right messages were logged. - expected := []olog.TestLogLine{ - { - Message: "should appear", - Level: slog.LevelInfo, - Attrs: map[string]any{ - "module": "github.com/getoutreach/gobox", - "modulever": "", - "app": map[string]any{ - "name": "ologHooksTest", - "service_name": "ologHooksTest", - }, - }, - }, - } - - if diff := cmp.Diff(expected, logCapture.GetLogs()); diff != "" { - t.Fatalf("unexpected log output (-want +got):\n%s", diff) - } -} diff --git a/pkg/olog/olog.go b/pkg/olog/olog.go index dfe0b2f0..a1863186 100644 --- a/pkg/olog/olog.go +++ b/pkg/olog/olog.go @@ -114,3 +114,16 @@ func getMetadata() (metadata, error) { func NewWithHandler(h slog.Handler) *slog.Logger { return slog.New(h) } + +// NewWithHooks returns a new slog.Logger, allowing hooks to be provided +// by the caller in order to automatically augment the attributes on the +// log record before it writes. +// +// All hooks provided will be executed in the order in which they are provided +// and will overwrite any attributes written by the previous hook when a +// duplicate key is provided. +func NewWithHooks(hooks ...LogHookFunc) *slog.Logger { + defaultHandler := New().Handler() + hookedHandler := &hookHandler{Handler: defaultHandler, hooks: hooks} + return slog.New(hookedHandler) +} diff --git a/pkg/olog/olog_test.go b/pkg/olog/olog_test.go index 96c5e972..3d661a68 100644 --- a/pkg/olog/olog_test.go +++ b/pkg/olog/olog_test.go @@ -1,9 +1,11 @@ package olog import ( + "context" "log/slog" "testing" + "github.com/getoutreach/gobox/pkg/app" "github.com/google/go-cmp/cmp" ) @@ -34,3 +36,69 @@ func TestLogLevelByModule(t *testing.T) { t.Fatalf("unexpected log output (-want +got):\n%s", diff) } } + +func TestLogWithHook(t *testing.T) { + // Force JSON handler for valid unmarshaling used in the TestCapturer + SetDefaultHandler(JSONHandler) + + logCapture := NewTestCapturer(t) + + // Create logger with test hook + logger := NewWithHooks(func(context.Context, slog.Record) ([]slog.Attr, error) { + return []slog.Attr{slog.Any("data", map[string]string{"foo": "bar"})}, nil + }) + + logger.Info("should appear") + + // Verify the right messages were logged. + expected := []TestLogLine{ + { + Message: "should appear", + Level: slog.LevelInfo, + Attrs: map[string]any{ + "module": "github.com/getoutreach/gobox", + "modulever": "", + "data": map[string]any{"foo": "bar"}, + }, + }, + } + + if diff := cmp.Diff(expected, logCapture.GetLogs()); diff != "" { + t.Fatalf("unexpected log output (-want +got):\n%s", diff) + } +} + +func TestLogWithAppInfoHook(t *testing.T) { + // Force JSON handler for valid unmarshaling used in the TestCapturer + SetDefaultHandler(JSONHandler) + + logCapture := NewTestCapturer(t) + + // Initialize test app info + app.SetName("ologHooksTest") + + // Create logger with test hook + logger := NewWithHooks(app.LogHook) + + logger.Info("should appear") + + // Verify the right messages were logged. + expected := []TestLogLine{ + { + Message: "should appear", + Level: slog.LevelInfo, + Attrs: map[string]any{ + "module": "github.com/getoutreach/gobox", + "modulever": "", + "app": map[string]any{ + "name": "ologHooksTest", + "service_name": "ologHooksTest", + }, + }, + }, + } + + if diff := cmp.Diff(expected, logCapture.GetLogs()); diff != "" { + t.Fatalf("unexpected log output (-want +got):\n%s", diff) + } +}