diff --git a/internal/composer/debug/debug.go b/internal/composer/debug/debug.go new file mode 100644 index 0000000..31b78db --- /dev/null +++ b/internal/composer/debug/debug.go @@ -0,0 +1,33 @@ +package debug + +import "sync/atomic" + +// nolint:gochecknoglobals // global provider follows the OTEL provider pattern. +var global = newGlobal() + +type globalState struct { + ptr atomic.Pointer[Debugger] +} + +func newGlobal() *globalState { + g := &globalState{} + var d Debugger = noopDebuggerSingleton + g.ptr.Store(&d) + return g +} + +// GetDebugger returns the global Debugger. +// If no debugger has been set, a no-op Debugger is returned. +// GetDebugger is safe for concurrent use and is designed to be called on every +// FlowComponent invocation with minimal overhead. +func GetDebugger() Debugger { + return *global.ptr.Load() +} + +// SetDebugger replaces the global Debugger. +// This should be called once during application initialization before any +// FlowComponent begins handling requests. +// SetDebugger is safe for concurrent use. +func SetDebugger(d Debugger) { + global.ptr.Store(&d) +} diff --git a/internal/composer/debug/debug_test.go b/internal/composer/debug/debug_test.go new file mode 100644 index 0000000..089629d --- /dev/null +++ b/internal/composer/debug/debug_test.go @@ -0,0 +1,74 @@ +package debug_test + +import ( + "context" + "testing" + "time" + + "github.com/trebent/kerberos/internal/composer/debug" +) + +func TestGetDebugger_defaultIsNoop(t *testing.T) { + d := debug.GetDebugger() + if d == nil { + t.Fatal("expected non-nil Debugger") + } + + // The default noop debugger must not panic. + action := d.StartAction(context.Background(), "test-component") + if action == nil { + t.Fatal("expected non-nil Action") + } + + action.End(debug.Outcome{StatusCode: 200, Duration: time.Millisecond}) +} + +func TestSetDebugger_replacesGlobal(t *testing.T) { + original := debug.GetDebugger() + t.Cleanup(func() { debug.SetDebugger(original) }) + + called := false + debug.SetDebugger(&spyDebugger{onStartAction: func() { called = true }}) + + debug.GetDebugger().StartAction(context.Background(), "component"). + End(debug.Outcome{StatusCode: 200}) + + if !called { + t.Fatal("expected custom debugger to be called") + } +} + +func TestSetDebugger_nilNoPanic(t *testing.T) { + original := debug.GetDebugger() + t.Cleanup(func() { debug.SetDebugger(original) }) + + // Setting a nil Debugger should not panic; retrieving and calling it may + // panic depending on usage, but the set operation itself must be safe. + debug.SetDebugger(nil) +} + +// spyDebugger is a test helper that records StartAction calls. +type spyDebugger struct { + onStartAction func() +} + +func (s *spyDebugger) StartAction(_ context.Context, _ string) debug.Action { + if s.onStartAction != nil { + s.onStartAction() + } + return &spyAction{} +} + +type spyAction struct{} + +func (s *spyAction) End(_ debug.Outcome) {} + +// BenchmarkNoopDebugger verifies that the noop path causes zero heap allocations. +func BenchmarkNoopDebugger(b *testing.B) { + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + action := debug.GetDebugger().StartAction(ctx, "test-component") + action.End(debug.Outcome{StatusCode: 200, Duration: time.Millisecond}) + } +} diff --git a/internal/composer/debug/debugger.go b/internal/composer/debug/debugger.go new file mode 100644 index 0000000..d9a13eb --- /dev/null +++ b/internal/composer/debug/debugger.go @@ -0,0 +1,29 @@ +package debug + +import ( + "context" + "time" +) + +// Outcome describes the result of a single FlowComponent invocation. +type Outcome struct { + // StatusCode is the HTTP response status code produced by the component. + StatusCode int + // Duration is the time the component took to process the request. + Duration time.Duration +} + +// Action represents a single flow component debug recording session. +// End must be called exactly once after the component finishes processing. +type Action interface { + // End records the outcome of the flow component invocation. + End(outcome Outcome) +} + +// Debugger records diagnostic information about FlowComponent invocations. +// Implementations must be safe for concurrent use. +type Debugger interface { + // StartAction begins recording a single flow component invocation. + // The returned Action must have its End method called when the component finishes. + StartAction(ctx context.Context, componentName string) Action +} diff --git a/internal/composer/debug/noop.go b/internal/composer/debug/noop.go new file mode 100644 index 0000000..5bf0b54 --- /dev/null +++ b/internal/composer/debug/noop.go @@ -0,0 +1,24 @@ +package debug + +import "context" + +// nolint:gochecknoglobals // singleton instances avoid allocations on the noop hot-path. +var ( + noopDebuggerSingleton = &noopDebugger{} + noopActionSingleton = &noopAction{} +) + +var ( + _ Debugger = noopDebuggerSingleton + _ Action = noopActionSingleton +) + +type noopDebugger struct{} + +func (n *noopDebugger) StartAction(_ context.Context, _ string) Action { + return noopActionSingleton +} + +type noopAction struct{} + +func (n *noopAction) End(_ Outcome) {}