├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── MIT-LICENSE ├── README.md ├── go.mod ├── main.go └── main_test.go /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v4 15 | with: 16 | go-version-file: go.mod 17 | - name: golangci-lint 18 | uses: golangci/golangci-lint-action@v3 19 | with: 20 | version: v1.55.2 21 | args: --timeout 3m 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | tags: 6 | branches: 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | go_version: ["1.21", "1.22"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: ${{ matrix.go_version }} 21 | - name: Test 22 | run: | 23 | go test . 24 | - name: Run benchmarks 25 | run: | 26 | go test -bench . 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | *.iml 13 | .idea/ 14 | .vscode/ 15 | 16 | # Sublime 17 | *.sublime-project 18 | *.sublime-workspace 19 | 20 | # OS or Editor folders 21 | .DS_Store 22 | .cache 23 | .project 24 | .settings 25 | .tmproj 26 | Thumbs.db 27 | 28 | log/*.log 29 | *.gz 30 | 31 | tmp/ 32 | dist/ 33 | 34 | .env 35 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Vladimir Dementyev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slog-spy 2 | 3 | Slog handler (or wrapper) to temporary deliver formatted verbose logs to an arbitrary target. Useful for providing diagnostic/troubleshooting logging functionality to your Go application. 4 | 5 | > [!NOTE] 6 | > This library has been extracted from [AnyCable](https://github.com/anycable/anycable-go). 7 | 8 | ## Install 9 | 10 | ```sh 11 | go get github.com/palkan/slog-spy 12 | ``` 13 | 14 | **Compatibility**: go >= 1.21 15 | 16 | ## Usage 17 | 18 | ```go 19 | import ( 20 | slogspy "github.com/palkan/slog-spy" 21 | "log/slog" 22 | ) 23 | 24 | func main() { 25 | handler := slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo}) 26 | 27 | // Create a spy handling by wrapping the default one 28 | spy := slogspy.NewSpy(handler) 29 | 30 | // Use it with your logger 31 | logger := slog.New(spy) 32 | 33 | // Start spy go routine to process logs in the background (when they're requested) 34 | go spyHandler.Run(myLogsConsumer) 35 | defer spyHandler.Shutdown(context.Background()) 36 | 37 | // your application logic 38 | 39 | // whenever you want to start consuming verbose logs via the spy 40 | spy.Watch() 41 | // don't forget to unwatch to disable the spy handler 42 | defer spy.Unwatch() 43 | } 44 | 45 | func myLogsConsumer(logs []byte) { 46 | // consume pre-formatted logs here 47 | } 48 | ``` 49 | 50 | You MAY call `spy.Watch()` multiple times (indicating that there are multiple consumers); you MUST call `spy.Unwatch()` the same number of times to deactivate the spy. The logs are streamed to the callback function as long as there is at least one consumer. 51 | 52 | ### Configuration 53 | 54 | By default, a spy handler uses a JSON handler to format the logs and produce the raw bytes. The output is buffered (to prevent too frequent consumer function calling). The buffer flushing is controlled by two parameters: max buffer size and flush interval. 55 | 56 | Here is how you can adjust all of the parameters mentioned above (with the defaults specified): 57 | 58 | ```go 59 | spy := slogspy.NewSpy( 60 | handler, 61 | slogspy.WithMaxBufSize(256 * 1024), 62 | slogspy.WithFlushInterval(250 * time.Millisecond), 63 | slogspy.WithPrinter(func(output io.Writer) slog.Handler { 64 | return slog.NewJSONHandler(output, &slog.HandlerOptions{Level: slog.LevelDebug}) 65 | }), 66 | ) 67 | ``` 68 | 69 | ## Benchmarks 70 | 71 | The spy handler in the idle state has no noticeable overhead. When it's active, the overhead is ~2x lower than when turning debug logs on for the base handler. Here are the numbers: 72 | 73 | ```sh 74 | BenchmarkSpy/active_spy 372.5 ns/op 75 | BenchmarkSpy/inactive_spy 8.380 ns/op 76 | BenchmarkSpy/no_spy 7.681 ns/op 77 | BenchmarkSpy/no_spy_mainLevel=debug 656.3 ns/op 78 | ``` 79 | 80 | The source code can be found in the `main_test.go` file. 81 | 82 | ### IgnorePC optimization 83 | 84 | You can improve the performance even more by disabling the caller information retrieval for log records: 85 | 86 | ```go 87 | //go:linkname IgnorePC log/slog/internal.IgnorePC 88 | var IgnorePC = true 89 | ``` 90 | 91 | ## Future enhancements 92 | 93 | - Support watching specific key-value attribute pairs (e.g., `spy.WatchAttrs("user_id", "42")`) 94 | 95 | ## License 96 | 97 | This project is [MIT](./MIT-LICENSE) licensed. 98 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/palkan/slog-spy 2 | 3 | go 1.22.2 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "log/slog" 8 | "sync/atomic" 9 | "time" 10 | ) 11 | 12 | const ( 13 | defaultMaxbufSize = 256 * 1024 // 256KB 14 | defaultFlushInterval = 250 * time.Millisecond 15 | ) 16 | 17 | type SpyOutput func(msg []byte) 18 | 19 | type SpyCommand int 20 | 21 | const ( 22 | SpyCommandRecord SpyCommand = iota 23 | SpyCommandFlush 24 | SpyCommandStop 25 | ) 26 | 27 | type Entry struct { 28 | record *slog.Record 29 | // printer keeps the reference to the current printer 30 | // to carry on log attributes and groups 31 | printer slog.Handler 32 | cmd SpyCommand 33 | } 34 | 35 | type SpyHandler struct { 36 | output SpyOutput 37 | 38 | active *atomic.Int64 39 | ch chan *Entry 40 | timer *time.Timer 41 | buf *bytes.Buffer 42 | 43 | // A log handler we use to format records 44 | printer slog.Handler 45 | maxBufSize int 46 | flushInterval time.Duration 47 | } 48 | 49 | var _ slog.Handler = (*SpyHandler)(nil) 50 | 51 | type SpyHandlerOption func(*SpyHandler) 52 | 53 | // WithMaxBufSize sets the maximum output buffer size for the SpyHandler. 54 | func WithMaxBufSize(size int) SpyHandlerOption { 55 | return func(h *SpyHandler) { 56 | h.maxBufSize = size 57 | } 58 | } 59 | 60 | // WithFlushInterval sets the max flush interval for the SpyHandler. 61 | func WithFlushInterval(interval time.Duration) SpyHandlerOption { 62 | return func(h *SpyHandler) { 63 | h.flushInterval = interval 64 | } 65 | } 66 | 67 | // WithPrinter allows to configure a custom slog.Handler used to format log records. 68 | func WithPrinter(printerBuilder func(io io.Writer) slog.Handler) SpyHandlerOption { 69 | return func(h *SpyHandler) { 70 | h.printer = printerBuilder(h.buf) 71 | } 72 | } 73 | 74 | // WithBacklogSize sets the size of the backlog channel used as a queue for log records. 75 | func WithBacklogSize(size int) SpyHandlerOption { 76 | return func(h *SpyHandler) { 77 | h.ch = make(chan *Entry, size) 78 | } 79 | } 80 | 81 | // NewSpyHandler creates a new SpyHandler with the provided options. 82 | func NewSpyHandler(opts ...SpyHandlerOption) *SpyHandler { 83 | buf := &bytes.Buffer{} 84 | h := &SpyHandler{ 85 | ch: make(chan *Entry, 2048), 86 | buf: buf, 87 | active: &atomic.Int64{}, 88 | printer: slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}), 89 | maxBufSize: defaultMaxbufSize, 90 | flushInterval: defaultFlushInterval, 91 | } 92 | 93 | for _, opt := range opts { 94 | opt(h) 95 | } 96 | 97 | return h 98 | } 99 | 100 | func (h *SpyHandler) Enabled(ctx context.Context, level slog.Level) bool { 101 | return h.active.Load() > 0 102 | } 103 | 104 | func (h *SpyHandler) Handle(ctx context.Context, r slog.Record) error { 105 | h.enqueueRecord(&r) 106 | 107 | return nil 108 | } 109 | 110 | func (h *SpyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 111 | newHandler := h.Clone() 112 | newHandler.printer = h.printer.WithAttrs(attrs) 113 | return newHandler 114 | } 115 | 116 | func (h *SpyHandler) WithGroup(name string) slog.Handler { 117 | newHandler := h.Clone() 118 | newHandler.printer = newHandler.printer.WithGroup(name) 119 | return newHandler 120 | } 121 | 122 | // Run starts a Go routine which publishes log messages in the background 123 | func (h *SpyHandler) Run(out SpyOutput) { 124 | h.output = out 125 | 126 | for entry := range h.ch { 127 | if entry.cmd == SpyCommandStop { 128 | if h.timer != nil { 129 | h.timer.Stop() 130 | } 131 | return 132 | } 133 | 134 | if entry.cmd == SpyCommandFlush { 135 | h.flush() 136 | continue 137 | } 138 | 139 | entry.printer.Handle(context.Background(), *entry.record) // nolint: errcheck 140 | 141 | if h.buf.Len() > h.maxBufSize { 142 | h.flush() 143 | } else { 144 | h.resetTimer() 145 | } 146 | } 147 | } 148 | 149 | func (h *SpyHandler) Shutdown(ctx context.Context) { 150 | h.ch <- &Entry{cmd: SpyCommandStop} 151 | } 152 | 153 | func (h *SpyHandler) Watch() { 154 | h.active.Add(1) 155 | } 156 | 157 | func (h *SpyHandler) Unwatch() { 158 | h.active.Add(-1) 159 | } 160 | 161 | // Clone returns a new SpyHandler with the same parent handler and buffers 162 | func (t *SpyHandler) Clone() *SpyHandler { 163 | return &SpyHandler{ 164 | output: t.output, 165 | active: t.active, 166 | ch: t.ch, 167 | buf: t.buf, 168 | maxBufSize: t.maxBufSize, 169 | flushInterval: t.flushInterval, 170 | } 171 | } 172 | 173 | func (h *SpyHandler) enqueueRecord(r *slog.Record) { 174 | // Make sure we don't block the main thread; it's okay to ignore the record if the channel is full 175 | select { 176 | case h.ch <- &Entry{record: r, cmd: SpyCommandRecord, printer: h.printer}: 177 | default: 178 | } 179 | } 180 | 181 | func (h *SpyHandler) resetTimer() { 182 | if h.timer != nil { 183 | h.timer.Stop() 184 | } 185 | h.timer = time.AfterFunc(h.flushInterval, h.sendFlush) 186 | } 187 | 188 | func (h *SpyHandler) sendFlush() { 189 | h.ch <- &Entry{cmd: SpyCommandFlush} 190 | } 191 | 192 | func (h *SpyHandler) flush() { 193 | if h.buf.Len() == 0 { 194 | return 195 | } 196 | 197 | msg := h.buf.Bytes() 198 | 199 | h.output(msg) 200 | 201 | h.buf.Reset() 202 | } 203 | 204 | type Spy struct { 205 | parent slog.Handler 206 | handler *SpyHandler 207 | } 208 | 209 | var _ slog.Handler = (*Spy)(nil) 210 | 211 | func NewSpy(parent slog.Handler, opts ...SpyHandlerOption) *Spy { 212 | handler := NewSpyHandler(opts...) 213 | 214 | return &Spy{ 215 | parent: parent, 216 | handler: handler, 217 | } 218 | } 219 | 220 | func (s *Spy) Enabled(ctx context.Context, level slog.Level) bool { 221 | if !s.handler.Enabled(ctx, level) { 222 | return s.parent.Enabled(ctx, level) 223 | } 224 | 225 | return true 226 | } 227 | 228 | func (s *Spy) Handle(ctx context.Context, r slog.Record) (err error) { 229 | if s.handler.Enabled(ctx, r.Level) { 230 | s.handler.Handle(ctx, r) // nolint: errcheck 231 | } 232 | 233 | if s.parent.Enabled(ctx, r.Level) { 234 | err = s.parent.Handle(ctx, r) 235 | } 236 | 237 | return 238 | } 239 | 240 | func (s *Spy) WithAttrs(attrs []slog.Attr) slog.Handler { 241 | return &Spy{ 242 | parent: s.parent.WithAttrs(attrs), 243 | handler: (s.handler.WithAttrs(attrs)).(*SpyHandler), 244 | } 245 | } 246 | 247 | func (s *Spy) WithGroup(name string) slog.Handler { 248 | return &Spy{ 249 | parent: s.parent.WithGroup(name), 250 | handler: (s.handler.WithGroup(name)).(*SpyHandler), 251 | } 252 | } 253 | 254 | func (s *Spy) Handler() slog.Handler { 255 | return s.parent 256 | } 257 | 258 | func (s *Spy) Run(out SpyOutput) { 259 | s.handler.Run(out) 260 | } 261 | 262 | func (s *Spy) Shutdown(ctx context.Context) { 263 | s.handler.Shutdown(ctx) 264 | } 265 | 266 | func (s *Spy) Watch() { 267 | s.handler.Watch() 268 | } 269 | 270 | func (s *Spy) Unwatch() { 271 | s.handler.Unwatch() 272 | } 273 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "testing" 10 | "time" 11 | _ "unsafe" 12 | ) 13 | 14 | //go:linkname IgnorePC log/slog/internal.IgnorePC 15 | var IgnorePC = true 16 | 17 | func BenchmarkSpy(b *testing.B) { 18 | handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}) 19 | spy := NewSpy(handler) 20 | configs := []struct { 21 | spy *Spy 22 | active bool 23 | ignorePC bool 24 | handlerDebug bool 25 | }{ 26 | {spy, true, false, false}, 27 | {spy, true, true, false}, 28 | {spy, false, false, false}, 29 | {spy, false, true, false}, 30 | {nil, false, false, false}, 31 | {nil, false, false, true}, 32 | {nil, false, true, false}, 33 | {nil, false, true, true}, 34 | } 35 | 36 | for _, config := range configs { 37 | spyDesc := "no spy" 38 | 39 | if config.spy != nil { 40 | spyDesc = "active spy" 41 | if !config.active { 42 | spyDesc = "inactive spy" 43 | } 44 | } 45 | 46 | desc := fmt.Sprintf("%s ignorePC=%t", spyDesc, config.ignorePC) 47 | 48 | if config.handlerDebug { 49 | desc += " mainLevel=debug" 50 | } 51 | 52 | b.Run(desc, func(b *testing.B) { 53 | if config.handlerDebug { 54 | handlerBuf := &bytes.Buffer{} 55 | handler = slog.NewTextHandler(handlerBuf, &slog.HandlerOptions{Level: slog.LevelDebug}) 56 | } 57 | 58 | var h slog.Handler = handler 59 | 60 | IgnorePC = config.ignorePC 61 | 62 | if config.spy != nil { 63 | spy := config.spy 64 | go spy.Run(func(msg []byte) { 65 | // immitate some work 66 | time.Sleep(10 * time.Millisecond) 67 | }) 68 | defer spy.Shutdown(context.Background()) 69 | 70 | if config.active { 71 | spy.Watch() 72 | defer spy.Unwatch() 73 | } 74 | 75 | h = spy 76 | } 77 | 78 | logger := slog.New(h) 79 | 80 | b.ResetTimer() 81 | 82 | for i := 0; i < b.N; i++ { 83 | logger.Debug("test", "key", 1, "key2", "value2", "key3", 3.14) 84 | } 85 | }) 86 | } 87 | } 88 | 89 | func TestSpy__Handle(t *testing.T) { 90 | mainBuf := &bytes.Buffer{} 91 | buf := &bytes.Buffer{} 92 | 93 | done := make(chan struct{}) 94 | 95 | output := func(msg []byte) { 96 | buf.Write(msg) 97 | 98 | if bytes.Contains(msg, []byte("done")) { 99 | close(done) 100 | } 101 | } 102 | 103 | handler := slog.NewTextHandler(mainBuf, &slog.HandlerOptions{Level: slog.LevelInfo}) 104 | spy := NewSpy(handler) 105 | 106 | logger := slog.New(spy) 107 | 108 | go spy.Run(output) 109 | defer spy.Shutdown(context.Background()) 110 | 111 | logger.Debug("never") 112 | logger.Info("only-main") 113 | 114 | spy.Watch() 115 | logger.Debug("only-spy") 116 | 117 | spy.Watch() 118 | logger.Info("both") 119 | 120 | spy.Unwatch() 121 | logger.Debug("still-spying") 122 | 123 | spy.Unwatch() 124 | logger.Debug("never-again") 125 | 126 | spy.Watch() 127 | logger.Debug("done") 128 | 129 | select { 130 | case <-done: 131 | case <-time.After(1 * time.Second): 132 | t.Fatal("timed out to receive done message") 133 | } 134 | 135 | assertBufferContains(t, mainBuf, "only-main") 136 | assertBufferContains(t, buf, "only-spy") 137 | assertBufferContains(t, mainBuf, "both") 138 | assertBufferContains(t, buf, "both") 139 | assertBufferContains(t, buf, "still-spying") 140 | assertBufferContainsNot(t, buf, "never") 141 | } 142 | 143 | func assertBufferContains(t *testing.T, buf *bytes.Buffer, expected string) { 144 | t.Helper() 145 | 146 | if !bytes.Contains(buf.Bytes(), []byte(expected)) { 147 | t.Errorf("expected buffer to contain %s, got %s", expected, buf.String()) 148 | } 149 | } 150 | 151 | func assertBufferContainsNot(t *testing.T, buf *bytes.Buffer, expected string) { 152 | t.Helper() 153 | 154 | if bytes.Contains(buf.Bytes(), []byte(expected)) { 155 | t.Errorf("expected buffer to not contain %s, got %s", expected, buf.String()) 156 | } 157 | } 158 | --------------------------------------------------------------------------------