Skip to content

Commit b32274a

Browse files
sashaarosasha
andauthored
Add FirstMatch routing strategy (#47)
* move RoutableHandler to separate file routable_handler.go * first match handler * move RoutableHandler back to router.go * fix * add First Match Routing section to README.md * make IsMatch method private * rename methods AttrIs -> AttrValueIs, AttrKeyTypeIs -> AttrKindIs * transmit firstMatch value in Add method * transmit skipPredicates forward while clone RoutableHandler * fix stop lose handler attributes * FirstMatch to clone RoutableHandler attributes to prevent double matching * FirstMatch wrap Handle under try * stop double clone slice attrs & groups in WithAttr & withGroup * fix & test --------- Co-authored-by: sasha <sasha@MacBook-Air-sasha.local>
1 parent cb917e6 commit b32274a

7 files changed

Lines changed: 444 additions & 19 deletions

File tree

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
- **🔄 Fanout**: Distribute logs to multiple handlers in parallel
1717
- **🛣️ Router**: Conditionally route logs based on custom criteria
18+
- **🎯 First Match**: Route logs to the first matching handler only
1819
- **🔄 Failover**: High-availability logging with automatic fallback
1920
- **⚖️ Load Balancing**: Distribute load across multiple handlers
2021
- **🔗 Pipeline**: Transform and filter logs with middleware chains
@@ -224,6 +225,60 @@ func recordMatchRegion(region string) func(ctx context.Context, r slog.Record) b
224225
- Level-based routing (errors to Slack, info to console)
225226
- Business logic routing (user actions vs system events)
226227

228+
### First Match Routing: `Router().FirstMatch()`
229+
230+
Route logs to the **first matching handler only**, unlike regular routing which sends to all matching handlers. Perfect for priority-based routing where you want exactly one handler to receive each log.
231+
232+
```go
233+
import (
234+
slogmulti "github.com/samber/slog-multi"
235+
slogslack "github.com/samber/slog-slack"
236+
"log/slog"
237+
)
238+
239+
func main() {
240+
queryChannel := slogslack.Option{Level: slog.LevelDebug, WebhookURL: "xxx", Channel: "db-queries"}.NewSlackHandler()
241+
requestChannel := slogslack.Option{Level: slog.LevelError, WebhookURL: "xxx", Channel: "service-requests"}.NewSlackHandler()
242+
influxdbChannel := slogslack.Option{Level: slog.LevelInfo, WebhookURL: "xxx", Channel: "influxdb-metrics"}.NewSlackHandler()
243+
fallbackChannel := slogslack.Option{Level: slog.LevelError, WebhookURL: "xxx", Channel: "logs"}.NewSlackHandler()
244+
245+
logger := slog.New(
246+
slogmulti.Router().
247+
Add(queryChannel, slogmulti.AttrKindIs("query", slog.KindString, "args", slog.KindAny)).
248+
Add(requestChannel, slogmulti.AttrKindIs("method", slog.KindString, "body", slog.KindAny)).
249+
Add(influxdbChannel, slogmulti.AttrValueIs("scope", "influx")).
250+
Add(fallbackChannel). // Catch-all for everything else
251+
FirstMatch(). // ← Enable first-match routing
252+
Handler(),
253+
)
254+
255+
// Goes to queryChannel only (stops at first match)
256+
logger.Debug("Executing SQL query", "query", "SELECT * FROM users WHERE id = ?", "args", []int{1})
257+
258+
// Goes to requestChannel only (stops at first match)
259+
logger.Error("Incoming request failed", "method", "POST", "body", "{'name':'test'}")
260+
261+
// Goes to fallbackChannel (no other handlers matched)
262+
logger.Error("An unexpected error occurred")
263+
}
264+
```
265+
266+
#### Built-in Predicates
267+
268+
**Level predicates:**
269+
- `LevelIs(levels ...slog.Level)` - Match specific log levels
270+
- `LevelIsNot(levels ...slog.Level)` - Exclude specific log levels
271+
272+
**Message predicates:**
273+
- `MessageIs(msg string)` - Exact message match
274+
- `MessageIsNot(msg string)` - Message doesn't match
275+
- `MessageContains(part string)` - Message contains substring
276+
- `MessageNotContains(part string)` - Message doesn't contain substring
277+
278+
**Attribute predicates:**
279+
- `AttrValueIs(key, value, ...)` - Check attributes have exact values
280+
- `AttrKindIs(key, kind, ...)` - Check attributes have specific types
281+
227282
### Failover: `slogmulti.Failover()`
228283

229284
Ensure logging reliability by trying multiple handlers in order until one succeeds. Perfect for high-availability scenarios.

examples/firstmatch/example.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package main
2+
3+
import (
4+
"context"
5+
6+
"log/slog"
7+
8+
slogmulti "github.com/samber/slog-multi"
9+
slogslack "github.com/samber/slog-slack"
10+
)
11+
12+
func main() {
13+
queryChannel := slogslack.Option{Level: slog.LevelDebug, WebhookURL: "xxx", Channel: "db queries"}.NewSlackHandler()
14+
requestChannel := slogslack.Option{Level: slog.LevelError, WebhookURL: "xxx", Channel: "service requests"}.NewSlackHandler()
15+
influxdbChannel := slogslack.Option{Level: slog.LevelInfo, WebhookURL: "xxx", Channel: "influxdb metrics"}.NewSlackHandler()
16+
fallbackChannel := slogslack.Option{Level: slog.LevelError, WebhookURL: "xxx", Channel: "logs"}.NewSlackHandler()
17+
18+
logger := slog.New(
19+
slogmulti.Router().
20+
Add(influxdbChannel, slogmulti.AttrValueIs("scope", "influx")).
21+
Add(queryChannel, slogmulti.AttrKindIs("query", slog.KindString, "args", slog.KindAny)).
22+
Add(requestChannel, slogmulti.AttrKindIs("method", slog.KindString, "body", slog.KindAny)).
23+
Add(fallbackChannel).
24+
FirstMatch().
25+
Handler(),
26+
)
27+
28+
logger.Debug("Executing SQL query", "query", "SELECT * FROM users WHERE id = ?", "args", []int{1})
29+
logger.Error("Incoming request failed", "method", "POST", "body", "{'name':'test'}")
30+
logger.Error("An unexpected error occurred")
31+
32+
influxLogger := logger.With("scope", "influx")
33+
34+
// influx.NewClient(influxLogger) ...
35+
influxLogger.Info("InfluxDB client initialized")
36+
}

examples/firstmatch/go.mod

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module github.com/samber/slog-multi/examples/firstmatch
2+
3+
go 1.21
4+
5+
require (
6+
github.com/samber/slog-multi v1.0.0
7+
github.com/samber/slog-slack v1.0.0
8+
)
9+
10+
require (
11+
github.com/gorilla/websocket v1.4.2 // indirect
12+
github.com/samber/lo v1.51.0 // indirect
13+
github.com/samber/slog-common v0.19.0 // indirect
14+
github.com/slack-go/slack v0.12.1 // indirect
15+
golang.org/x/text v0.22.0 // indirect
16+
)
17+
18+
replace github.com/samber/slog-multi => ../../

firstmatch.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package slogmulti
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"slices"
7+
8+
"github.com/samber/lo"
9+
)
10+
11+
// Ensure FirstMatchHandler implements the slog.Handler interface at compile time
12+
var _ slog.Handler = (*FirstMatchHandler)(nil)
13+
14+
type FirstMatchHandler struct {
15+
handlers []*RoutableHandler
16+
}
17+
18+
func FirstMatch(handlers ...*RoutableHandler) *FirstMatchHandler {
19+
return &FirstMatchHandler{handlers: lo.Map(handlers, func(h *RoutableHandler, _ int) *RoutableHandler {
20+
return &RoutableHandler{
21+
predicates: h.predicates,
22+
handler: h.handler,
23+
groups: slices.Clone(h.groups),
24+
attrs: slices.Clone(h.attrs),
25+
skipPredicates: true, // prevent double matching
26+
}
27+
})}
28+
}
29+
30+
// Enabled checks if any of the underlying handlers are enabled for the given log level.
31+
// This method implements the slog.Handler interface requirement.
32+
// See FanoutHandler.WithAttrs for details.
33+
func (h *FirstMatchHandler) Enabled(ctx context.Context, l slog.Level) bool {
34+
for i := range h.handlers {
35+
if h.handlers[i].Enabled(ctx, l) {
36+
return true
37+
}
38+
}
39+
40+
return false
41+
}
42+
43+
// Handle distributes a log record to the first matching handler.
44+
// This method implements the slog.Handler interface requirement.
45+
//
46+
// The method:
47+
// 1. Iterates through each child handler.
48+
// 2. Checks if the handler's predicates match the record.
49+
// 3. If a match is found, it checks if the handler is enabled for the record's level.
50+
// 4. If enabled, it forwards the record to that handler and returns.
51+
// 5. If no handlers match, it returns nil.
52+
func (h *FirstMatchHandler) Handle(ctx context.Context, r slog.Record) error {
53+
for i := range h.handlers {
54+
record, ok := h.handlers[i].isMatch(ctx, r)
55+
if ok {
56+
if h.handlers[i].Enabled(ctx, record.Level) {
57+
return try(func() error {
58+
return h.handlers[i].Handle(ctx, r)
59+
})
60+
}
61+
62+
return nil // Handler matched but is not enabled; do not proceed further
63+
}
64+
}
65+
66+
return nil
67+
}
68+
69+
// WithAttrs creates a new FirstMatchHandler with additional attributes added to all child handlers.
70+
// This method implements the slog.Handler interface requirement.
71+
// See FanoutHandler.WithAttrs for details.
72+
func (h *FirstMatchHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
73+
handlers := lo.Map(h.handlers, func(h *RoutableHandler, _ int) *RoutableHandler {
74+
return h.WithAttrs(slices.Clone(attrs)).(*RoutableHandler)
75+
})
76+
return newFirstMatch(handlers...)
77+
}
78+
79+
// WithGroup creates a new FirstMatchHandler with a group name applied to all child handlers.
80+
// This method implements the slog.Handler interface requirement.
81+
// See FanoutHandler.WithGroup for details.
82+
func (h *FirstMatchHandler) WithGroup(name string) slog.Handler {
83+
// https://cs.opensource.google/go/x/exp/+/46b07846:slog/handler.go;l=247
84+
if name == "" {
85+
return h
86+
}
87+
88+
handlers := lo.Map(h.handlers, func(h *RoutableHandler, _ int) *RoutableHandler {
89+
return h.WithGroup(name).(*RoutableHandler)
90+
})
91+
return newFirstMatch(handlers...)
92+
}
93+
94+
func newFirstMatch(handlers ...*RoutableHandler) *FirstMatchHandler {
95+
return &FirstMatchHandler{handlers: handlers}
96+
}

firstmatch_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package slogmulti
2+
3+
import (
4+
"bytes"
5+
"log/slog"
6+
"strings"
7+
"testing"
8+
)
9+
10+
var remoteTimeReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
11+
if a.Key == slog.TimeKey {
12+
return slog.Attr{}
13+
}
14+
return a
15+
}
16+
17+
func TestFirstMatch(t *testing.T) {
18+
t.Parallel()
19+
20+
t.Run("routes to first matching handler", func(t *testing.T) {
21+
queryBuf := bytes.NewBufferString("")
22+
23+
queryH := slog.NewTextHandler(queryBuf, &slog.HandlerOptions{
24+
Level: slog.LevelInfo,
25+
ReplaceAttr: remoteTimeReplaceAttr,
26+
})
27+
28+
commonBuf := bytes.NewBufferString("")
29+
commonH := slog.NewTextHandler(commonBuf, &slog.HandlerOptions{
30+
Level: slog.LevelDebug,
31+
ReplaceAttr: remoteTimeReplaceAttr,
32+
})
33+
34+
handler := Router().
35+
Add(queryH, AttrKindIs("query", slog.KindString, "args", slog.KindAny)).
36+
Add(commonH).
37+
FirstMatch().
38+
Handler()
39+
40+
logger := slog.New(handler).With("user_id", 123)
41+
// Test 1: Log matching first handler should only go to queryBuf
42+
logger.Info("get user by id", "query", "SELECT * FROM users id = ?", "args", []int{1})
43+
// Test 2: Debug level filtered by queryH, should not match any handler
44+
logger.Debug("get users", "query", "SELECT * FROM users", "args", []int{})
45+
// Test 3: Log not matching first handler should go to commonBuf
46+
logger.Warn("cache miss", "key", "user_1")
47+
48+
if strings.TrimSpace(queryBuf.String()) != `level=INFO msg="get user by id" user_id=123 query="SELECT * FROM users id = ?" args=[1]` {
49+
t.Fatalf("query log buffer did not match")
50+
}
51+
52+
if strings.TrimSpace(commonBuf.String()) != `level=WARN msg="cache miss" user_id=123 key=user_1` {
53+
t.Fatalf("common log buffer did not match")
54+
}
55+
})
56+
57+
t.Run("stops at first match", func(t *testing.T) {
58+
buf1, buf2, buf3 := bytes.NewBufferString(""), bytes.NewBufferString(""), bytes.NewBufferString("")
59+
60+
h1 := slog.NewTextHandler(buf1, &slog.HandlerOptions{
61+
ReplaceAttr: remoteTimeReplaceAttr,
62+
})
63+
h2 := slog.NewTextHandler(buf2, &slog.HandlerOptions{
64+
ReplaceAttr: remoteTimeReplaceAttr,
65+
})
66+
h3 := slog.NewTextHandler(buf3, &slog.HandlerOptions{
67+
Level: slog.LevelDebug,
68+
ReplaceAttr: remoteTimeReplaceAttr,
69+
})
70+
71+
handler := Router().
72+
Add(h1, AttrValueIs("type", "error")).
73+
Add(h2, AttrValueIs("type", "error")). // Also matches, but should not receive
74+
Add(h3). // Fallback handler
75+
FirstMatch().
76+
Handler()
77+
78+
logger := slog.New(handler).With("user_id", 123)
79+
logger.Info("test", "type", "error")
80+
logger.Debug("other_log", "type", "not_error")
81+
82+
if buf1.String() != "level=INFO msg=test user_id=123 type=error\n" {
83+
t.Errorf("expected buf1 to contain log")
84+
}
85+
if buf2.Len() != 0 {
86+
t.Errorf("expected buf2 to NOT contain log (should stop at first match)")
87+
}
88+
if buf3.String() != "level=DEBUG msg=other_log user_id=123 type=not_error\n" {
89+
t.Errorf("expected buf3 to contain log")
90+
}
91+
})
92+
}

0 commit comments

Comments
 (0)