Files
Paul Lorenz ff8ae7cd45 Add common/logging package: AsyncHandler, level helpers, and chain composition. Fixes #3904
- adds a new common/logging package with custom slog.Level constants for
  Trace (-8), Fatal (12), and Panic (16) extending slog's four standard
  levels, plus LevelName and ParseLevel as the single source of truth for
  canonical lowercase wire names (warn and warning both accepted)
- adds AsyncOptions (QueueSize, BlockThreshold, SummaryInterval) with
  Validate, defaults of 4096 / Warn / 5s, and AddFlags / OptionsFromFlags
  bindings so the package can wire into cobra via spf13/pflag alone
- adds AsyncHandler, a bounded async slog.Handler that hands records to a
  single drain goroutine and onto a downstream handler under a shared
  mutex; records at or above the block threshold block (with a closeNotify
  escape so shutdown cannot deadlock), records below it drop when the
  queue is full and bump a per-level atomic counter
- the drain emits a drop-summary record on each SummaryInterval tick when
  any per-level counter is non-zero, and also counts downstream errors in
  a drain_errors counter that appears in the same summary line; downstream
  errors are also logged once to os.Stderr to avoid slog recursion
- Close signals shutdown and returns immediately; the drain final-flushes
  records that beat the close, emits a final summary if drops occurred,
  and closes drainDone for tests
- SyncEmit bypasses the queue and writes through the downstream handler
  synchronously under the same downstreamMu the drain uses, so fatal/panic
  records are durable before the process exits
- adds boundHandler, which prepends bound attrs to every record flowing
  through it before delegating to its parent; WithAttrs returns a new
  boundHandler whose parent is the receiver's parent (not the receiver
  itself), so a chain of slog.Logger.With calls produces sibling
  boundHandlers at the same chain depth rather than stacking
  wrapper-on-wrapper
- adds groupedHandler, which wraps record attrs in slog.Group(name, ...)
  before delegating; a subsequent WithAttrs creates a boundHandler whose
  parent is the groupedHandler, so the attrs land inside the group
- AsyncHandler.WithAttrs and WithGroup are the real chain entry points;
  empty attrs and empty group names return the receiver so no-op
  slog.Logger.With() and WithGroup("") allocate nothing
- covers the lot with -race tests: level round-trip and offset fallback,
  defaults validity and bad-value rejection, flag round-trip, async normal
  flow, drop-on-full with summary attrs, block at the threshold, Close
  idempotent + non-blocking + unblocks Handle, Handle racing Close never
  panics, SyncEmit synchronous and serialized with the drain, drain-error
  counting, the four worked examples from the design doc for the chain
  (with-then-group, group-then-with, nested groups, basic with-attrs),
  no-nesting on repeated WithAttrs, sibling-loggers-do-not-leak-attrs,
  WithGroup("") and WithAttrs(nil) as no-ops on all three handler types,
  and Enabled delegation through the chain
2026-06-12 19:34:17 -04:00

81 lines
2.4 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 (
"log/slog"
"testing"
"github.com/stretchr/testify/require"
)
func TestLevelNameRoundTrip(t *testing.T) {
for _, lvl := range []slog.Level{
LevelTrace,
slog.LevelDebug,
slog.LevelInfo,
slog.LevelWarn,
slog.LevelError,
LevelFatal,
LevelPanic,
} {
name := LevelName(lvl)
parsed, err := ParseLevel(name)
require.NoError(t, err, "round-trip should succeed for %v", lvl)
require.Equal(t, lvl, parsed)
}
}
func TestLevelNameCanonical(t *testing.T) {
require.Equal(t, "trace", LevelName(LevelTrace))
require.Equal(t, "debug", LevelName(slog.LevelDebug))
require.Equal(t, "info", LevelName(slog.LevelInfo))
require.Equal(t, "warn", LevelName(slog.LevelWarn))
require.Equal(t, "error", LevelName(slog.LevelError))
require.Equal(t, "fatal", LevelName(LevelFatal))
require.Equal(t, "panic", LevelName(LevelPanic))
}
// TestLevelNameOffsetFallback proves a non-canonical level value (e.g.
// slog.LevelDebug+1) emits valid lowercase output via slog.Level.String,
// rather than collapsing to an empty string or panicking. slog renders
// offsets relative to the nearest standard level, so the exact string depends
// on slog's own conventions; we just need it to be non-empty and lowercase.
func TestLevelNameOffsetFallback(t *testing.T) {
require.Equal(t, "debug+1", LevelName(slog.LevelDebug+1))
require.Equal(t, "error+1", LevelName(slog.LevelError+1))
}
func TestParseLevelAcceptsCaseAndWarningAlias(t *testing.T) {
lvl, err := ParseLevel("DEBUG")
require.NoError(t, err)
require.Equal(t, slog.LevelDebug, lvl)
lvl, err = ParseLevel("warning")
require.NoError(t, err)
require.Equal(t, slog.LevelWarn, lvl)
lvl, err = ParseLevel(" Trace ")
require.NoError(t, err)
require.Equal(t, LevelTrace, lvl)
}
func TestParseLevelRejectsUnknown(t *testing.T) {
_, err := ParseLevel("bogus")
require.Error(t, err)
}