Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 0
wool: higher-signal logging — omit-empty, FOCUS semantics, per-scope levels, SecretField (#17)#18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
c0b85c8288499b78d9ad0a23f55bFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -78,7 +78,11 @@ func (l *Log) String() string { | ||
| } | ||
| tokens = append(tokens, l.Message) | ||
| for _, f := range fields { | ||
| tokens = append(tokens, f.String()) | ||
| // Fields that render to nothing (empty/nil value) are noise — a bare | ||
| // `key=` carries no information — so drop them from the line entirely. | ||
| if s := f.String(); s != "" { | ||
| tokens = append(tokens, s) | ||
| } | ||
| } | ||
| return strings.Join(tokens, " ") | ||
| } | ||
| @@ -91,25 +95,56 @@ type LogField struct { | ||
| Value any `json:"value"` | ||
| } | ||
| // String renders the field as "key=value". It returns the empty string when | ||
| // the value renders to nothing (nil or empty), letting Log.String drop the | ||
| // field rather than emit a meaningless bare "key=". | ||
| func (f *LogField) String() string { | ||
| v := f.renderValue() | ||
| if v == "" { | ||
| return "" | ||
| } | ||
| return fmt.Sprintf("%s=%s", f.Key, v) | ||
| } | ||
| // renderValue formats the field value, preferring fmt.Stringer over %v so | ||
| // domain types control their own representation instead of being dumped as a | ||
| // raw Go struct. | ||
| func (f *LogField) renderValue() string { | ||
| if f.Value == nil { | ||
| return fmt.Sprintf("%s=nil", f.Key) | ||
| return "" | ||
| } | ||
| if stringer, ok := f.Value.(fmt.Stringer); ok { | ||
| return fmt.Sprintf("%s=%s", f.Key, stringer.String()) | ||
| // A typed-nil pointer (e.g. (*T)(nil)) still satisfies fmt.Stringer, but | ||
| // its String() may dereference the nil receiver and panic. Logging must | ||
| // never panic, so render a nil underlying value as empty (and let | ||
| // Log.String drop the field) rather than calling through. | ||
| if rv := reflect.ValueOf(f.Value); rv.Kind() == reflect.Pointer && rv.IsNil() { | ||
| return "" | ||
| } | ||
| return stringer.String() | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| if s, ok := f.Value.(string); ok { | ||
| return s | ||
| } | ||
| return fmt.Sprintf("%s=%v", f.Key, f.Value) | ||
| return fmt.Sprintf("%v", f.Value) | ||
| } | ||
| // Loglevel defines log severity. | ||
| type Loglevel int | ||
| // Levels are ordered by severity; a message is shown when its level is >= the | ||
| // effective log level (see Wool.LogLevel). FOCUS sits just above INFO and below | ||
| // WARN: it is a highlighted milestone. At the default INFO level FOCUS lines are | ||
| // shown (the highlight a user wants to see); running at FOCUS hides routine INFO | ||
| // chatter while keeping milestones, warnings and errors — the "signal only" | ||
| // view. FOCUS must stay above INFO so it is never accidentally filtered out by an | ||
| // INFO-level run. | ||
| const ( | ||
| DEFAULT Loglevel = iota | ||
| TRACE | ||
| DEBUG | ||
| FOCUS | ||
| INFO | ||
| FOCUS | ||
| WARN | ||
| ERROR | ||
| FATAL | ||
| @@ -120,14 +155,33 @@ var levelToString = map[Loglevel]string{ | ||
| DEFAULT: "DEFAULT", | ||
| TRACE: "TRACE", | ||
| DEBUG: "DEBUG", | ||
| FOCUS: "FOCUS", | ||
| INFO: "INFO", | ||
| FOCUS: "FOCUS", | ||
| WARN: "WARN", | ||
| ERROR: "ERROR", | ||
| FATAL: "FATAL", | ||
| FORWARD: "FORWARD", | ||
| } | ||
| // stringToLevel maps a lowercase level name to its Loglevel, for parsing | ||
| // per-scope overrides (see SetLogScopes / CODEFLY_LOG). | ||
| var stringToLevel = map[string]Loglevel{ | ||
| "trace": TRACE, | ||
| "debug": DEBUG, | ||
| "info": INFO, | ||
| "focus": FOCUS, | ||
| "warn": WARN, | ||
| "error": ERROR, | ||
| "fatal": FATAL, | ||
| } | ||
| // LevelFromString resolves a level name (case-insensitive, e.g. "debug") to a | ||
| // Loglevel. The second return is false for an unrecognized name. | ||
| func LevelFromString(s string) (Loglevel, bool) { | ||
| l, ok := stringToLevel[strings.ToLower(strings.TrimSpace(s))] | ||
| return l, ok | ||
| } | ||
| // String returns the human-readable name of the log level (e.g. "INFO"), | ||
| // or "L<n>" for an unknown value. Implements fmt.Stringer. | ||
| func (l Loglevel) String() string { | ||
| @@ -277,3 +331,39 @@ func InField(s string) *LogField { | ||
| func Writer() *LogField { | ||
| return &LogField{Key: "writer"} | ||
| } | ||
| // SecretField redacts the value at construction: the raw secret is dropped and | ||
| // never reaches any sink (console, file, gRPC, telemetry). The field always | ||
| // renders "****". This makes redaction a logging-layer guarantee rather than a | ||
| // convention each call site has to remember. | ||
| func SecretField(key string, _ any) *LogField { | ||
| return &LogField{Key: key, Value: "****"} | ||
| } | ||
| // sliceValue renders a slice as a bracketed, comma-separated list ("[a, b]"), | ||
| // or "none" when empty — instead of a raw "{1 [a b]}" %v struct dump. Elements | ||
| // that implement fmt.Stringer render via String(). | ||
| type sliceValue[T any] struct { | ||
| items []T | ||
| } | ||
| func (s sliceValue[T]) String() string { | ||
| if len(s.items) == 0 { | ||
| return "none" | ||
| } | ||
| parts := make([]string, len(s.items)) | ||
| for i, it := range s.items { | ||
| if str, ok := any(it).(fmt.Stringer); ok { | ||
| parts[i] = str.String() | ||
| } else { | ||
| parts[i] = fmt.Sprintf("%v", it) | ||
| } | ||
| } | ||
| return "[" + strings.Join(parts, ", ") + "]" | ||
| } | ||
| // SliceField formats a slice as a readable list (see sliceValue) so call sites | ||
| // can log domain collections without dumping internal struct layout. | ||
| func SliceField[T any](key string, items []T) *LogField { | ||
| return &LogField{Key: key, Value: sliceValue[T]{items: items}} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| package wool_test | ||
| import ( | ||
| "context" | ||
| "strings" | ||
| "sync" | ||
| "testing" | ||
| "github.com/codefly-dev/core/wool" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
| // capture is a LogProcessor that records every Log it receives, so tests can | ||
| // assert on what actually reached the sink after level filtering. | ||
| type capture struct { | ||
| mu sync.Mutex | ||
| logs []*wool.Log | ||
| } | ||
| func (c *capture) Process(msg *wool.Log) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| c.logs = append(c.logs, msg) | ||
| } | ||
| func (c *capture) messages() []string { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| var out []string | ||
| for _, l := range c.logs { | ||
| out = append(out, l.Message) | ||
| } | ||
| return out | ||
| } | ||
| func newWool(t *testing.T, level wool.Loglevel) (*wool.Wool, *capture) { | ||
| t.Helper() | ||
| cap := &capture{} | ||
| w := wool.Get(context.Background()).WithLogger(cap) | ||
| w.WithLoglevel(level) | ||
| return w, cap | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| func TestLogField_OmitsEmptyValues(t *testing.T) { | ||
| log := &wool.Log{ | ||
| Level: wool.INFO, | ||
| Message: "Found configurations", | ||
| Fields: []*wool.LogField{ | ||
| wool.Field("configurations", ""), | ||
| wool.Field("count", 3), | ||
| }, | ||
| } | ||
| s := log.String() | ||
| require.NotContains(t, s, "configurations=", | ||
| "an empty value must not render a bare key=") | ||
| require.Contains(t, s, "count=3") | ||
| } | ||
| // nilDerefStringer.String() dereferences its receiver, so calling it on a | ||
| // typed-nil pointer panics — exactly the case renderValue must guard against. | ||
| type nilDerefStringer struct{ s string } | ||
| func (n *nilDerefStringer) String() string { return n.s } | ||
| func TestField_TypedNilStringer_DoesNotPanic(t *testing.T) { | ||
| var ns *nilDerefStringer // typed nil that still satisfies fmt.Stringer | ||
| f := wool.Field("x", ns) | ||
| require.NotPanics(t, func() { _ = f.String() }) | ||
| require.Empty(t, f.String(), "a typed-nil stringer must render to nothing, not panic") | ||
| } | ||
| func TestSliceField_RendersList(t *testing.T) { | ||
| require.Equal(t, "endpoints=[a, b]", | ||
| wool.SliceField("endpoints", []string{"a", "b"}).String()) | ||
| require.Equal(t, "endpoints=none", | ||
| wool.SliceField("endpoints", []string{}).String()) | ||
| } | ||
| // stringerEndpoint exercises the fmt.Stringer branch of SliceField's element | ||
| // rendering — domain types control their own representation instead of %v. | ||
| type stringerEndpoint struct{ name string } | ||
| func (e stringerEndpoint) String() string { return e.name } | ||
| func TestSliceField_RendersStringerElements(t *testing.T) { | ||
| f := wool.SliceField("endpoints", []stringerEndpoint{{"tcp"}, {"grpc"}}) | ||
| require.Equal(t, "endpoints=[tcp, grpc]", f.String()) | ||
| } | ||
| func TestField_NilValueIsDropped(t *testing.T) { | ||
| require.Equal(t, "", wool.Field("k", nil).String(), | ||
| "a nil value must render to nothing so Log.String drops it") | ||
| } | ||
| func TestSecretField_NeverLeaksValue(t *testing.T) { | ||
| f := wool.SecretField("connection", "postgres://user:hunter2@host/db") | ||
| require.Equal(t, "connection=****", f.String()) | ||
| // The raw secret must not survive anywhere on the field — not just in the | ||
| // rendered string but in the Value that reaches structured sinks too. | ||
| require.NotEqual(t, "postgres://user:hunter2@host/db", f.Value) | ||
| require.NotContains(t, f.String(), "hunter2") | ||
| } | ||
| func TestFocus_OrdersAboveInfo(t *testing.T) { | ||
| require.Greater(t, wool.FOCUS, wool.INFO, | ||
| "FOCUS must outrank INFO so an INFO-level run still shows it") | ||
| } | ||
| func TestFocus_VisibleAtInfoLevel(t *testing.T) { | ||
| w, cap := newWool(t, wool.INFO) | ||
| w.Focus("milestone") | ||
| w.Info("routine") | ||
| require.Equal(t, []string{"milestone", "routine"}, cap.messages()) | ||
| } | ||
| func TestFocus_HidesRoutineInfoAtFocusLevel(t *testing.T) { | ||
| w, cap := newWool(t, wool.FOCUS) | ||
| w.Info("routine") // below FOCUS — filtered | ||
| w.Focus("milestone") // shown | ||
| w.Warn("careful") // above FOCUS — shown | ||
| require.Equal(t, []string{"milestone", "careful"}, cap.messages()) | ||
| } | ||
| func TestLevelFromString(t *testing.T) { | ||
| got, ok := wool.LevelFromString("Debug") | ||
| require.True(t, ok) | ||
| require.Equal(t, wool.DEBUG, got) | ||
| _, ok = wool.LevelFromString("nonsense") | ||
| require.False(t, ok) | ||
| } | ||
| func TestScopeLevels_OverridePerComponent(t *testing.T) { | ||
| t.Cleanup(func() { wool.SetLogScopes("") }) | ||
| wool.SetLogScopes("network=debug,*=warn") | ||
| // A network.* scope drops to DEBUG even though the catch-all is WARN. | ||
| netW := wool.Get(context.Background()).In("network.Runtime.GenerateNetworkMappings") | ||
| require.Equal(t, wool.DEBUG, netW.LogLevel()) | ||
| // Anything else falls back to the catch-all. | ||
| other := wool.Get(context.Background()).In("resources.Service.Save") | ||
| require.Equal(t, wool.WARN, other.LogLevel()) | ||
| } | ||
| func TestScopeLevels_FilterAtSink(t *testing.T) { | ||
| t.Cleanup(func() { wool.SetLogScopes("") }) | ||
| wool.SetLogScopes("network=debug,*=warn") | ||
| cap := &capture{} | ||
| base := wool.Get(context.Background()).WithLogger(cap) | ||
| base.In("network.Connect").Debug("dialing") // network scope at DEBUG — shown | ||
| base.In("resources.Load").Debug("loading") // catch-all WARN — filtered | ||
| base.In("resources.Load").Error("disk") // catch-all WARN — shown | ||
| require.Equal(t, []string{"dialing", "disk"}, cap.messages()) | ||
| } | ||
| func TestScopeLevels_LongestPrefixWins(t *testing.T) { | ||
| t.Cleanup(func() { wool.SetLogScopes("") }) | ||
| wool.SetLogScopes("network=warn,network.dns=trace") | ||
| dns := wool.Get(context.Background()).In("network.dns.Resolve") | ||
| require.Equal(t, wool.TRACE, dns.LogLevel()) | ||
| other := wool.Get(context.Background()).In("network.Connect") | ||
| require.Equal(t, wool.WARN, other.LogLevel()) | ||
| } | ||
| func TestScopeLevels_MatchOnSegmentBoundary(t *testing.T) { | ||
| t.Cleanup(func() { wool.SetLogScopes("") }) | ||
| wool.SetLogScopes("net=debug") | ||
| // A prefix must align with a scope segment: "net" matches "net.X" but not | ||
| // "network.X" — otherwise turning up one component leaks into its neighbors. | ||
| require.Equal(t, wool.DEBUG, | ||
| wool.Get(context.Background()).In("net.Dial").LogLevel()) | ||
| require.Equal(t, wool.GlobalLogLevel(), | ||
| wool.Get(context.Background()).In("network.Dial").LogLevel()) | ||
| // The "::" separator (used by some scopes) anchors too. | ||
| wool.SetLogScopes("RuntimeInstance=debug") | ||
| require.Equal(t, wool.DEBUG, | ||
| wool.Get(context.Background()).In("RuntimeInstance::Load").LogLevel()) | ||
| } | ||
| func TestScopeLevels_InstanceLevelTakesPrecedence(t *testing.T) { | ||
| t.Cleanup(func() { wool.SetLogScopes("") }) | ||
| wool.SetLogScopes("network=debug") | ||
| w := wool.Get(context.Background()).In("network.Connect") | ||
| w.WithLoglevel(wool.ERROR) | ||
| // An explicit per-instance level wins over a scope override — this is the | ||
| // contract custom processors rely on to receive every line. | ||
| require.Equal(t, wool.ERROR, w.LogLevel()) | ||
| } | ||
| func TestScopeLevels_IgnoresMalformedEntries(t *testing.T) { | ||
| t.Cleanup(func() { wool.SetLogScopes("") }) | ||
| // "=warn" has an empty name and must NOT become a catch-all. | ||
| wool.SetLogScopes("=warn,network=debug") | ||
| require.Equal(t, wool.GlobalLogLevel(), | ||
| wool.Get(context.Background()).In("resources.Load").LogLevel()) | ||
| require.Equal(t, wool.DEBUG, | ||
| wool.Get(context.Background()).In("network.Dial").LogLevel()) | ||
| } | ||
| func TestString_DropsEmptyFieldsEndToEnd(t *testing.T) { | ||
| w, cap := newWool(t, wool.INFO) | ||
| w.Info("done", wool.Field("a", ""), wool.Field("b", "x")) | ||
| require.Len(t, cap.logs, 1) | ||
| line := cap.logs[0].String() | ||
| require.Contains(t, line, "b=x") | ||
| require.False(t, strings.Contains(line, "a="), "empty field a= should be dropped: %s", line) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.