Skip to content

Commit e44c955

Browse files
authored
Merge pull request #21 from veqryn/http-middleware
Http middleware
2 parents 6bdf1a0 + 6601b62 commit e44c955

13 files changed

+648
-117
lines changed

README.md

+127-2
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,17 @@ whenever a new log line is written.
2626

2727
In that same workflow, the `HandlerOptions` and `AttrExtractor` types let us
2828
extract any custom values from a context and have them automatically be
29-
prepended or appended to all log lines using that context. For example, the
30-
`slogotel.ExtractTraceSpanID` extractor will automatically extract the OTEL
29+
prepended or appended to all log lines using that context. By default, there are
30+
extractors for anything added via `Prepend` and `Append`, but this repository
31+
contains some optional Extractors that can be added:
32+
* `slogotel.ExtractTraceSpanID` extractor will automatically extract the OTEL
3133
(OpenTelemetry) TraceID and SpanID, and add them to the log record, while also
3234
annotating the Span with an error code if the log is at error level.
35+
* `sloghttp.ExtractAttrCollection` extractor will automatically add to the log
36+
record any attributes added by `sloghttp.With` after the `sloghttp.AttrCollection`
37+
http middleware. This allows other middlewares to log with attributes that would
38+
normally be out of scope, because they were added by a later middleware or the
39+
final http handler in the chain.
3340

3441
#### Logger in Context Workflow:
3542

@@ -68,6 +75,7 @@ import (
6875
```
6976

7077
## Usage
78+
[Examples in repo](examples/)
7179
### Logger in Context Workflow
7280
```go
7381
package main
@@ -443,6 +451,123 @@ func newTraceProvider(exp sdktrace.SpanExporter) *sdktrace.TracerProvider {
443451
}
444452
```
445453

454+
#### Slog Attribute Collection HTTP Middleware and Extractor
455+
```go
456+
package main
457+
458+
import (
459+
"log/slog"
460+
"net/http"
461+
"os"
462+
463+
slogctx "github.com/veqryn/slog-context"
464+
sloghttp "github.com/veqryn/slog-context/http"
465+
)
466+
467+
func init() {
468+
// Create the *slogctx.Handler middleware
469+
h := slogctx.NewHandler(
470+
slog.NewJSONHandler(os.Stdout, nil), // The next or final handler in the chain
471+
&slogctx.HandlerOptions{
472+
// Prependers will first add the any sloghttp.With attributes,
473+
// then anything else Prepended to the ctx
474+
Prependers: []slogctx.AttrExtractor{
475+
sloghttp.ExtractAttrCollection, // our sloghttp middleware extractor
476+
slogctx.ExtractPrepended, // for all other prepended attributes
477+
},
478+
},
479+
)
480+
slog.SetDefault(slog.New(h))
481+
}
482+
483+
func main() {
484+
slog.Info("Starting server. Please run: curl localhost:8080/hello?id=24680")
485+
486+
// Wrap our final handler inside our middlewares.
487+
// AttrCollector -> Request Logging -> Final Endpoint Handler (helloUser)
488+
handler := sloghttp.AttrCollection(
489+
httpLoggingMiddleware(
490+
http.HandlerFunc(helloUser),
491+
),
492+
)
493+
494+
// Demonstrate the sloghttp middleware with a http server
495+
http.Handle("/hello", handler)
496+
err := http.ListenAndServe(":8080", nil)
497+
if err != nil {
498+
panic(err)
499+
}
500+
}
501+
502+
// This is a stand-in for a middleware that might be capturing and logging out
503+
// things like the response code, request body, response body, url, method, etc.
504+
// It doesn't have access to any of the new context objects's created within the
505+
// next handler. But it should still log with any of the attributes added to our
506+
// sloghttp.Middleware, via sloghttp.With.
507+
func httpLoggingMiddleware(next http.Handler) http.Handler {
508+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
509+
// Add some logging context/baggage before the handler
510+
r = r.WithContext(sloghttp.With(r.Context(), "path", r.URL.Path))
511+
512+
// Call the next handler
513+
next.ServeHTTP(w, r)
514+
515+
// Log out that we had a response. This would be where we could add
516+
// things such as the response status code, body, etc.
517+
518+
// Should also have both "path" and "id", but not "foo".
519+
// Having "id" included in the log is the whole point of this package!
520+
slogctx.Info(r.Context(), "Response", "method", r.Method)
521+
/*
522+
{
523+
"time": "2024-04-01T00:06:11Z",
524+
"level": "INFO",
525+
"msg": "Response",
526+
"path": "/hello",
527+
"id": "24680",
528+
"method": "GET"
529+
}
530+
*/
531+
})
532+
}
533+
534+
// This is our final api endpoint handler
535+
func helloUser(w http.ResponseWriter, r *http.Request) {
536+
// Stand-in for a User ID.
537+
// Add it to our middleware's context
538+
id := r.URL.Query().Get("id")
539+
540+
// sloghttp.With will add the "id" to to the middleware, because it is a
541+
// synchronized map. It will show up in all log calls up and down the stack,
542+
// until the request sloghttp middleware exits.
543+
ctx := sloghttp.With(r.Context(), "id", id)
544+
545+
// The regular slogctx.With will add "foo" only to the Returned context,
546+
// which will limits its scope to the rest of this function (helloUser) and
547+
// any functions called by helloUser and passed this context.
548+
// The original caller of helloUser and all the middlewares will NOT see
549+
// "foo", because it is only part of the newly returned ctx.
550+
ctx = slogctx.With(ctx, "foo", "bar")
551+
552+
// Log some things.
553+
// Should also have both "path", "id", and "foo"
554+
slogctx.Info(ctx, "saying hello...")
555+
/*
556+
{
557+
"time": "2024-04-01T00:06:11Z",
558+
"level": "INFO",
559+
"msg": "saying hello...",
560+
"path": "/hello",
561+
"id": "24680",
562+
"foo": "bar"
563+
}
564+
*/
565+
566+
// Response
567+
_, _ = w.Write([]byte("Hello User #" + id))
568+
}
569+
```
570+
446571
### slog-multi Middleware
447572
This library has a convenience method that allow it to interoperate with [github.com/samber/slog-multi](https://github.com/samber/slog-multi),
448573
in order to easily setup slog workflows such as pipelines, fanout, routing, failover, etc.

attrs.go

+6-45
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"log/slog"
66
"slices"
77
"time"
8+
9+
"github.com/veqryn/slog-context/internal/attr"
810
)
911

1012
// Prepend key for context.valueCtx
@@ -24,9 +26,9 @@ func Prepend(parent context.Context, args ...any) context.Context {
2426

2527
if v, ok := parent.Value(prependKey{}).([]slog.Attr); ok {
2628
// Clip to ensure this is a scoped copy
27-
return context.WithValue(parent, prependKey{}, append(slices.Clip(v), argsToAttrSlice(args)...))
29+
return context.WithValue(parent, prependKey{}, append(slices.Clip(v), attr.ArgsToAttrSlice(args)...))
2830
}
29-
return context.WithValue(parent, prependKey{}, argsToAttrSlice(args))
31+
return context.WithValue(parent, prependKey{}, attr.ArgsToAttrSlice(args))
3032
}
3133

3234
// ExtractPrepended is an AttrExtractor that returns the prepended attributes
@@ -50,9 +52,9 @@ func Append(parent context.Context, args ...any) context.Context {
5052

5153
if v, ok := parent.Value(appendKey{}).([]slog.Attr); ok {
5254
// Clip to ensure this is a scoped copy
53-
return context.WithValue(parent, appendKey{}, append(slices.Clip(v), argsToAttrSlice(args)...))
55+
return context.WithValue(parent, appendKey{}, append(slices.Clip(v), attr.ArgsToAttrSlice(args)...))
5456
}
55-
return context.WithValue(parent, appendKey{}, argsToAttrSlice(args))
57+
return context.WithValue(parent, appendKey{}, attr.ArgsToAttrSlice(args))
5658
}
5759

5860
// ExtractAppended is an AttrExtractor that returns the appended attributes
@@ -64,44 +66,3 @@ func ExtractAppended(ctx context.Context, _ time.Time, _ slog.Level, _ string) [
6466
}
6567
return nil
6668
}
67-
68-
// Turn a slice of arguments, some of which pairs of primitives,
69-
// some might be attributes already, into a slice of attributes.
70-
// This is copied from golang sdk.
71-
func argsToAttrSlice(args []any) []slog.Attr {
72-
var (
73-
attr slog.Attr
74-
attrs []slog.Attr
75-
)
76-
for len(args) > 0 {
77-
attr, args = argsToAttr(args)
78-
attrs = append(attrs, attr)
79-
}
80-
return attrs
81-
}
82-
83-
// This is copied from golang sdk.
84-
const badKey = "!BADKEY"
85-
86-
// argsToAttr turns a prefix of the nonempty args slice into an Attr
87-
// and returns the unconsumed portion of the slice.
88-
// If args[0] is an Attr, it returns it.
89-
// If args[0] is a string, it treats the first two elements as
90-
// a key-value pair.
91-
// Otherwise, it treats args[0] as a value with a missing key.
92-
// This is copied from golang sdk.
93-
func argsToAttr(args []any) (slog.Attr, []any) {
94-
switch x := args[0].(type) {
95-
case string:
96-
if len(args) == 1 {
97-
return slog.String(badKey, x), nil
98-
}
99-
return slog.Any(x, args[1]), args[2:]
100-
101-
case slog.Attr:
102-
return x, args[1:]
103-
104-
default:
105-
return slog.Any(badKey, x), args[1:]
106-
}
107-
}

ctx_test.go

+11-4
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,27 @@ import (
66
"errors"
77
"log/slog"
88
"testing"
9+
10+
"github.com/veqryn/slog-context/internal/test"
911
)
1012

1113
func TestCtx(t *testing.T) {
12-
t.Parallel()
13-
1414
buf := &bytes.Buffer{}
1515
h := slog.NewJSONHandler(buf, &slog.HandlerOptions{
1616
AddSource: false,
1717
Level: slog.LevelDebug,
1818
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
1919
// fmt.Printf("ReplaceAttr: key:%s valueKind:%s value:%s nilGroups:%t groups:%#+v\n", a.Key, a.Value.Kind().String(), a.Value.String(), groups == nil, groups)
2020
if groups == nil && a.Key == slog.TimeKey {
21-
return slog.Time(slog.TimeKey, defaultTime)
21+
return slog.Time(slog.TimeKey, test.DefaultTime)
2222
}
2323
return a
2424
},
2525
})
2626

2727
// Confirm FromCtx retrieves default if nothing stored in ctx yet
2828
l := slog.New(h)
29-
slog.SetDefault(l)
29+
slog.SetDefault(l) // Can cause issues in tests run in parallel, so don't use it in other tests, just this test
3030
if FromCtx(nil) != l {
3131
t.Error("Expecting default logger retrieved")
3232
}
@@ -58,6 +58,13 @@ func TestCtx(t *testing.T) {
5858
}
5959

6060
// Test with wrappers
61+
buf.Reset()
62+
Log(ctx, slog.LevelDebug-10, "ignored")
63+
LogAttrs(ctx, slog.LevelDebug-10, "ignored")
64+
if buf.String() != "" {
65+
t.Errorf("Expected:\n%s\nGot:\n%s\n", "", buf.String())
66+
}
67+
6168
buf.Reset()
6269
Debug(ctx, "main message", "main1", "arg1", "main1", "arg2")
6370
expectedDebug := `{"time":"2023-09-29T13:00:59Z","level":"DEBUG","msg":"main message","with1":"arg1","with1":"arg2","with2":"arg1","with2":"arg2","group1":{"with4":"arg1","with4":"arg2","with5":"arg1","with5":"arg2","main1":"arg1","main1":"arg2"}}

examples/go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.21
44

55
require (
66
github.com/veqryn/slog-context v0.6.0
7-
github.com/veqryn/slog-context/otel v0.5.1
7+
github.com/veqryn/slog-context/otel v0.6.0
88
go.opentelemetry.io/otel v1.24.0
99
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0
1010
go.opentelemetry.io/otel/sdk v1.24.0

examples/go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
1313
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
1414
github.com/veqryn/slog-context v0.6.0 h1:RV0DL6SIXwTjKu5hUfZlreitiQ47HG6BA7G/aQINK9A=
1515
github.com/veqryn/slog-context v0.6.0/go.mod h1:E+qpdyiQs2YKRxFnX1JjpdFE1z3Ka94Kem2q9ZG6Jjo=
16-
github.com/veqryn/slog-context/otel v0.5.1 h1:X/UmUK+YJHTun7TVwSfOTAkfbhlpPCr7W6WjfHDF3qw=
17-
github.com/veqryn/slog-context/otel v0.5.1/go.mod h1:C+F2oB2BnozIuBHw7cvenou3fpDJqfOvMvKNA7RS5jA=
16+
github.com/veqryn/slog-context/otel v0.6.0 h1:LwVMzfKMFhVpIKPASwa4IkzgL8+/bmyrFWJYrBIkpf0=
17+
github.com/veqryn/slog-context/otel v0.6.0/go.mod h1:0l6vQ7IqZHKq1MH7pyhNlbdUutBcuiGYJluAPLQ33Nk=
1818
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
1919
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
2020
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8=

handler_test.go

+14-6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"testing"
1010
"time"
11+
12+
"github.com/veqryn/slog-context/internal/test"
1113
)
1214

1315
type logLine struct {
@@ -21,7 +23,7 @@ type logLine struct {
2123
func TestHandler(t *testing.T) {
2224
t.Parallel()
2325

24-
tester := &testHandler{}
26+
tester := &test.Handler{}
2527
h := NewHandler(tester, nil)
2628

2729
ctx := Prepend(nil, "prepend1", "arg1", slog.String("prepend1", "arg2"))
@@ -35,8 +37,8 @@ func TestHandler(t *testing.T) {
3537

3638
l := slog.New(h)
3739

38-
l = l.With("with1", "arg1", "with1", "arg2")
39-
l = l.WithGroup("group1")
40+
l = l.With("with1", "arg1", "with1", "arg2").With()
41+
l = l.WithGroup("group1").WithGroup("")
4042
l = l.With("with2", "arg1", "with2", "arg2")
4143

4244
l.InfoContext(ctx, "main message", "main1", "arg1", "main1", "arg2")
@@ -59,7 +61,7 @@ func TestHandler(t *testing.T) {
5961
}
6062

6163
// Check the source location fields
62-
tester.source = true
64+
tester.Source = true
6365
b, err = tester.MarshalJSON()
6466
if err != nil {
6567
t.Fatal(err)
@@ -73,15 +75,15 @@ func TestHandler(t *testing.T) {
7375

7476
if unmarshalled.Source.Function != "github.com/veqryn/slog-context.TestHandler" ||
7577
!strings.HasSuffix(unmarshalled.Source.File, "slog-context/handler_test.go") ||
76-
unmarshalled.Source.Line != 42 {
78+
unmarshalled.Source.Line != 44 {
7779
t.Errorf("Expected source fields are incorrect: %#+v\n", unmarshalled)
7880
}
7981
}
8082

8183
func TestHandlerMultipleAttrExtractor(t *testing.T) {
8284
t.Parallel()
8385

84-
tester := &testHandler{}
86+
tester := &test.Handler{}
8587
h := NewMiddleware(&HandlerOptions{
8688
Prependers: []AttrExtractor{
8789
ExtractPrepended,
@@ -95,6 +97,9 @@ func TestHandlerMultipleAttrExtractor(t *testing.T) {
9597
}
9698
return nil
9799
},
100+
func(_ context.Context, _ time.Time, _ slog.Level, _ string) []slog.Attr {
101+
return []slog.Attr{}
102+
},
98103
},
99104
Appenders: []AttrExtractor{
100105
ExtractAppended,
@@ -108,6 +113,9 @@ func TestHandlerMultipleAttrExtractor(t *testing.T) {
108113
}
109114
return nil
110115
},
116+
func(_ context.Context, _ time.Time, _ slog.Level, _ string) []slog.Attr {
117+
return nil
118+
},
111119
},
112120
})(tester)
113121

0 commit comments

Comments
 (0)