mirror of
https://github.com/openziti/ziti.git
synced 2026-09-10 00:35:41 +00:00
53a7dea7f3
- adds Registry (exported, for tests) tracking the global *slog.LevelVar, a
per-name override map of *slog.LevelVar, a logger cache, and the root
handler; For(name) returns a cached *slog.Logger whose handler chain binds
channel: name as the first attr (panics on empty); SetGlobalLevel /
SetNamedLevel / ClearNamedLevel mutate the registry under a write lock;
ClearNamedLevel deliberately tracks the live global rather than
snapshotting, so a later global change still reaches the previously-
overridden name
- adds namedHandler, the chain node that gates Enabled on the registry-
resolved level (override or live global) and forwards Handle to the
registry's root; WithAttrs / WithGroup compose with the existing
boundHandler / groupedHandler so For(name).WithGroup(...).With(...) works
- adds a package-level default Registry behind an atomic.Pointer; Configure
swaps the default and clears the logger cache (the new Registry starts
empty); the package-level For / SetGlobalLevel / etc. panic if Configure
has not been called
- adds slogBridge, the logrus.Hook that copies each logrus.Entry into a
slog.Record and dispatches via RootHandler.Handle; Fatal and Panic levels
route through SyncEmit instead so they're durable before logrus exits the
process
- adds Install (and the testable InstallTo) which Configure's the default
Registry, sets the global slog level, redirects logrus output to
io.Discard, replaces its formatter with noopFormatter, sets its level to
the mapped equivalent, and registers slogBridge as a hook
- adds bidirectional level mappings between logrus.Level and slog.Level for
the seven canonical names; non-canonical slog levels bucket into the
canonical level whose value they most recently exceeded
- adds SyncEmit, the package-level entry point that type-asserts the root to
*AsyncHandler and routes through its SyncEmit when possible, falling back
to Handle (already synchronous for non-async handlers)
- adds ReplaceAttr, the slog.HandlerOptions.ReplaceAttr callback that
coerces JSON output into the pfxlog shape: lowercase level via LevelName,
nested source attr suppressed at the top level
- adds sourceFlattener, the chain wrapper that decodes the record's PC into
flat file and func attrs; bridged records arrive with PC == 0 and the
bridge derives their file/func attrs from logrus's already-resolved
Entry.Caller (its symbolized PC does not re-decode reliably, so the bridge
does not forward it), and sourceFlattener passes those records through
- adds BuildHandler, which constructs the production chain (AsyncHandler ->
sourceFlattener -> JSONHandler) over a caller-supplied io.Writer
- exposes Registry.Root so the package-level RootHandler / SyncEmit can
reach the underlying handler without exporting the field
- covers everything under -race: For panics on empty, caches loggers,
GlobalLevel gates Debug below Info, per-name override lets Debug through
one logger while a peer still filters, clear reverts to the live global
(and reflects a later global change), level changes affect previously-
created loggers, the composed
For("router.link").WithGroup("g").With("k","v").Info(...) produces
{msg, channel:"router.link", g:{k:"v", x:1}} through the async queue,
Configure replaces the default with a fresh cache, concurrent For +
SetNamedLevel + ClearNamedLevel + SetGlobalLevel don't race, level
round-trip and non-canonical bucketing, bridge async-for-non-fatal vs
synchronous-for-fatal-and-panic, InstallTo invariants and pre-filter for
below-level records, SyncEmit fallback for a non-async root, ReplaceAttr
level rename + source suppression + group isolation, sourceFlattener PC=0
pass-through, BuildHandler end-to-end pfxlog-shape JSON, and custom-level
rendering
458 lines
14 KiB
Go
458 lines
14 KiB
Go
/*
|
|
Copyright NetFoundry Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package logging
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// recordingHandler captures every record passed to Handle so tests can
|
|
// inspect what made it through the async sink.
|
|
type recordingHandler struct {
|
|
mu sync.Mutex
|
|
records []slog.Record
|
|
}
|
|
|
|
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
|
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.records = append(h.records, r)
|
|
return nil
|
|
}
|
|
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
|
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
|
|
|
func (h *recordingHandler) count() int {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return len(h.records)
|
|
}
|
|
|
|
func (h *recordingHandler) snapshot() []slog.Record {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return append([]slog.Record(nil), h.records...)
|
|
}
|
|
|
|
// blockingHandler holds Handle until release is closed, signaling the first
|
|
// entry into Handle via entered. It's used to deterministically park the
|
|
// drain inside a downstream call so the queue fills up.
|
|
type blockingHandler struct {
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
once sync.Once
|
|
inner *recordingHandler
|
|
}
|
|
|
|
func newBlockingHandler() *blockingHandler {
|
|
return &blockingHandler{
|
|
entered: make(chan struct{}),
|
|
release: make(chan struct{}),
|
|
inner: &recordingHandler{},
|
|
}
|
|
}
|
|
|
|
func (h *blockingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
|
func (h *blockingHandler) Handle(ctx context.Context, r slog.Record) error {
|
|
h.once.Do(func() { close(h.entered) })
|
|
<-h.release
|
|
return h.inner.Handle(ctx, r)
|
|
}
|
|
func (h *blockingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
|
func (h *blockingHandler) WithGroup(string) slog.Handler { return h }
|
|
|
|
// erroringHandler returns a fixed error from every Handle call. Used to
|
|
// exercise the drain's error-counting path.
|
|
type erroringHandler struct {
|
|
err error
|
|
count atomic.Int64
|
|
}
|
|
|
|
func (h *erroringHandler) Enabled(context.Context, slog.Level) bool { return true }
|
|
func (h *erroringHandler) Handle(context.Context, slog.Record) error {
|
|
h.count.Add(1)
|
|
return h.err
|
|
}
|
|
func (h *erroringHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
|
func (h *erroringHandler) WithGroup(string) slog.Handler { return h }
|
|
|
|
func makeRecord(level slog.Level, msg string) slog.Record {
|
|
return slog.NewRecord(time.Now(), level, msg, 0)
|
|
}
|
|
|
|
func TestNewAsyncHandlerValidatesOptions(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
_, err := NewAsyncHandler(rec, AsyncOptions{}) // zero values invalid
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestNewAsyncHandlerRejectsNilDownstream(t *testing.T) {
|
|
_, err := NewAsyncHandler(nil, DefaultOptions())
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestAsyncHandlerNormalFlow(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
h, err := NewAsyncHandler(rec, DefaultOptions())
|
|
require.NoError(t, err)
|
|
|
|
for i := 0; i < 5; i++ {
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, fmt.Sprintf("msg %d", i))))
|
|
}
|
|
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
|
|
require.Equal(t, 5, rec.count())
|
|
}
|
|
|
|
// TestAsyncHandlerDropsSubThresholdWhenFull parks the drain inside the
|
|
// downstream so the queue fills, then verifies that sub-threshold records
|
|
// drop and bump the per-level counter without blocking the caller.
|
|
func TestAsyncHandlerDropsSubThresholdWhenFull(t *testing.T) {
|
|
block := newBlockingHandler()
|
|
opts := DefaultOptions()
|
|
opts.QueueSize = 2
|
|
opts.SummaryInterval = time.Hour
|
|
h, err := NewAsyncHandler(block, opts)
|
|
require.NoError(t, err)
|
|
|
|
// Send one record; wait for the drain to enter the downstream.
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelDebug, "m1")))
|
|
<-block.entered
|
|
|
|
// Fill the queue (capacity 2).
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelDebug, "m2")))
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelDebug, "m3")))
|
|
|
|
// Sub-threshold records now drop without blocking.
|
|
for i := 0; i < 5; i++ {
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelDebug, "dropped")))
|
|
}
|
|
require.Equal(t, int64(5), h.dropCounts[dropIdx(slog.LevelDebug)].Load())
|
|
|
|
// Release the downstream, close, and verify only the original three got
|
|
// through. Filter the close-flush drop-summary out of the count.
|
|
close(block.release)
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
|
|
nonSummary := 0
|
|
for _, r := range block.inner.snapshot() {
|
|
if r.Message != "logging queue full, message drop summary" {
|
|
nonSummary++
|
|
}
|
|
}
|
|
require.Equal(t, 3, nonSummary)
|
|
}
|
|
|
|
// TestAsyncHandlerBlocksAtThreshold proves a record at the block threshold
|
|
// parks Handle until the drain frees a slot, instead of dropping.
|
|
func TestAsyncHandlerBlocksAtThreshold(t *testing.T) {
|
|
block := newBlockingHandler()
|
|
opts := DefaultOptions()
|
|
opts.QueueSize = 2
|
|
opts.SummaryInterval = time.Hour
|
|
h, err := NewAsyncHandler(block, opts)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "m1")))
|
|
<-block.entered
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "m2")))
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "m3")))
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelWarn, "warn1")))
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
t.Fatal("Handle on Warn returned but should have blocked")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(block.release)
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Handle on Warn never returned after the downstream was released")
|
|
}
|
|
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
require.Equal(t, 4, block.inner.count())
|
|
}
|
|
|
|
// TestAsyncHandlerCloseUnblocksHandle proves a Handle call parked on the
|
|
// blocking arm of select returns when Close fires, instead of deadlocking.
|
|
func TestAsyncHandlerCloseUnblocksHandle(t *testing.T) {
|
|
block := newBlockingHandler()
|
|
defer close(block.release)
|
|
opts := DefaultOptions()
|
|
opts.QueueSize = 1
|
|
opts.SummaryInterval = time.Hour
|
|
h, err := NewAsyncHandler(block, opts)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "m1")))
|
|
<-block.entered
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "m2")))
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
_ = h.Handle(context.Background(), makeRecord(slog.LevelWarn, "warn"))
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
t.Fatal("Handle should be blocked at this point")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
require.NoError(t, h.Close())
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Close did not unblock Handle")
|
|
}
|
|
}
|
|
|
|
func TestAsyncHandlerCloseIdempotent(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
h, err := NewAsyncHandler(rec, DefaultOptions())
|
|
require.NoError(t, err)
|
|
require.NoError(t, h.Close())
|
|
require.NoError(t, h.Close())
|
|
}
|
|
|
|
func TestAsyncHandlerHandleAfterCloseReturnsNil(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
h, err := NewAsyncHandler(rec, DefaultOptions())
|
|
require.NoError(t, err)
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "after-close")))
|
|
}
|
|
|
|
// TestAsyncHandlerHandleRacingCloseNoPanic spawns many producers and a
|
|
// concurrent Close. The handler must never panic and must drain cleanly.
|
|
func TestAsyncHandlerHandleRacingCloseNoPanic(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
h, err := NewAsyncHandler(rec, DefaultOptions())
|
|
require.NoError(t, err)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 200; j++ {
|
|
_ = h.Handle(context.Background(), makeRecord(slog.LevelInfo, "x"))
|
|
}
|
|
}()
|
|
}
|
|
|
|
time.Sleep(time.Millisecond) // let producers ramp
|
|
require.NoError(t, h.Close())
|
|
wg.Wait()
|
|
<-h.drainDone
|
|
}
|
|
|
|
// TestAsyncHandlerSummaryEmittedOnTick seeds the drop counters directly and
|
|
// waits for the next ticker emission. Using direct counter seeding avoids
|
|
// timing flakes around when the queue actually fills.
|
|
func TestAsyncHandlerSummaryEmittedOnTick(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
opts := DefaultOptions()
|
|
opts.SummaryInterval = 20 * time.Millisecond
|
|
h, err := NewAsyncHandler(rec, opts)
|
|
require.NoError(t, err)
|
|
|
|
h.dropCounts[dropIdx(slog.LevelDebug)].Add(7)
|
|
h.dropCounts[dropIdx(slog.LevelInfo)].Add(3)
|
|
|
|
require.Eventually(t, func() bool { return rec.count() >= 1 }, time.Second, 5*time.Millisecond)
|
|
|
|
var summary *slog.Record
|
|
for _, r := range rec.snapshot() {
|
|
if r.Message == "logging queue full, message drop summary" {
|
|
r := r
|
|
summary = &r
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, summary, "expected drop-summary record")
|
|
|
|
attrs := map[string]any{}
|
|
summary.Attrs(func(a slog.Attr) bool {
|
|
attrs[a.Key] = a.Value.Any()
|
|
return true
|
|
})
|
|
require.Equal(t, int64(7), attrs["debug"])
|
|
require.Equal(t, int64(3), attrs["info"])
|
|
require.NotContains(t, attrs, "trace", "zero-count levels must not appear")
|
|
require.Contains(t, attrs, "since")
|
|
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
}
|
|
|
|
// TestSyncEmitDeliversSynchronously proves SyncEmit calls downstream.Handle
|
|
// on the caller's goroutine, bypassing the queue.
|
|
func TestSyncEmitDeliversSynchronously(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
h, err := NewAsyncHandler(rec, DefaultOptions())
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
_ = h.Close()
|
|
<-h.drainDone
|
|
}()
|
|
|
|
require.NoError(t, h.SyncEmit(context.Background(), makeRecord(LevelFatal, "fatal!")))
|
|
require.Equal(t, 1, rec.count())
|
|
require.Equal(t, "fatal!", rec.snapshot()[0].Message)
|
|
}
|
|
|
|
// TestSyncEmitSerializesWithDrain holds the drain inside the downstream, then
|
|
// fires a SyncEmit. SyncEmit must wait for the in-flight drain dispatch to
|
|
// finish (they share downstreamMu), so the queued record lands before the
|
|
// SyncEmit record.
|
|
func TestSyncEmitSerializesWithDrain(t *testing.T) {
|
|
block := newBlockingHandler()
|
|
h, err := NewAsyncHandler(block, DefaultOptions())
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "queued")))
|
|
<-block.entered
|
|
|
|
syncDone := make(chan struct{})
|
|
go func() {
|
|
defer close(syncDone)
|
|
require.NoError(t, h.SyncEmit(context.Background(), makeRecord(LevelFatal, "sync")))
|
|
}()
|
|
|
|
// SyncEmit must be blocked waiting for downstreamMu held by the drain.
|
|
select {
|
|
case <-syncDone:
|
|
t.Fatal("SyncEmit returned while the drain was still inside Handle")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(block.release)
|
|
<-syncDone
|
|
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
|
|
recs := block.inner.snapshot()
|
|
require.Equal(t, 2, len(recs))
|
|
require.Equal(t, "queued", recs[0].Message)
|
|
require.Equal(t, "sync", recs[1].Message)
|
|
}
|
|
|
|
// TestSyncEmitFlushesQueuedRecords proves SyncEmit drains the records already
|
|
// sitting in the queue before writing its own record, so the buffered context
|
|
// leading up to a fatal/panic survives a process exit that happens right after
|
|
// the call. The drain goroutine is stopped first (Close, then drainDone) so the
|
|
// flush runs in isolation with no concurrent consumer racing for the queue;
|
|
// records are staged directly because Handle no-ops post-Close.
|
|
func TestSyncEmitFlushesQueuedRecords(t *testing.T) {
|
|
rec := &recordingHandler{}
|
|
opts := DefaultOptions()
|
|
opts.QueueSize = 8
|
|
h, err := NewAsyncHandler(rec, opts)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
require.Equal(t, 0, rec.count(), "no records should have been written before staging")
|
|
|
|
h.queue <- queuedRecord{ctx: context.Background(), record: makeRecord(slog.LevelInfo, "q1")}
|
|
h.queue <- queuedRecord{ctx: context.Background(), record: makeRecord(slog.LevelWarn, "q2")}
|
|
|
|
require.NoError(t, h.SyncEmit(context.Background(), makeRecord(LevelFatal, "fatal")))
|
|
|
|
recs := rec.snapshot()
|
|
got := make([]string, len(recs))
|
|
for i, r := range recs {
|
|
got[i] = r.Message
|
|
}
|
|
require.Equal(t, []string{"q1", "q2", "fatal"}, got)
|
|
}
|
|
|
|
// TestAsyncHandlerCountsDrainErrors emits records into a handler that errors
|
|
// on every call and verifies the drain counts the errors and survives. The
|
|
// assertions run before Close so the close-flush summary doesn't perturb
|
|
// the error counter (the summary is itself sent through the downstream and
|
|
// would otherwise add one more error).
|
|
func TestAsyncHandlerCountsDrainErrors(t *testing.T) {
|
|
errH := &erroringHandler{err: errors.New("boom")}
|
|
opts := DefaultOptions()
|
|
opts.SummaryInterval = time.Hour
|
|
h, err := NewAsyncHandler(errH, opts)
|
|
require.NoError(t, err)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
require.NoError(t, h.Handle(context.Background(), makeRecord(slog.LevelInfo, "x")))
|
|
}
|
|
|
|
require.Eventually(t, func() bool { return errH.count.Load() == 3 }, time.Second, time.Millisecond)
|
|
require.Equal(t, int64(3), h.drainErrors.Load())
|
|
|
|
require.NoError(t, h.Close())
|
|
<-h.drainDone
|
|
}
|
|
|
|
func TestDropIdxBucketing(t *testing.T) {
|
|
tests := []struct {
|
|
level slog.Level
|
|
want int
|
|
}{
|
|
{LevelTrace, 0},
|
|
{LevelTrace - 1, 0},
|
|
{slog.LevelDebug, 1},
|
|
{slog.LevelDebug + 1, 1},
|
|
{slog.LevelInfo, 2},
|
|
{slog.LevelWarn, 3},
|
|
{slog.LevelError, 4},
|
|
{LevelFatal, 5},
|
|
{LevelPanic, 6},
|
|
{LevelPanic + 100, 6},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(LevelName(tt.level), func(t *testing.T) {
|
|
require.Equal(t, tt.want, dropIdx(tt.level))
|
|
})
|
|
}
|
|
}
|