feat(logging)_: introduce namespaces tree

iterates: #6128
This commit is contained in:
Patryk Osmaczko 2024-11-26 12:50:30 +01:00 committed by osmaczko
parent 34d2dafbd2
commit ae121486ff
2 changed files with 253 additions and 0 deletions

141
logutils/namespaces_tree.go Normal file
View File

@ -0,0 +1,141 @@
package logutils
import (
"fmt"
"regexp"
"strings"
"sync"
"go.uber.org/zap/zapcore"
)
var namespacesRegex = regexp.MustCompile(`^([a-zA-Z0-9_\.]+:(debug|info|warn|error))(,[a-zA-Z0-9_\.]+:(debug|info|warn|error))*$`)
var errInvalidNamespacesFormat = fmt.Errorf("invalid namespaces format")
type namespacesTree struct {
namespaces map[string]*namespaceNode
minLevel zapcore.Level
mu sync.RWMutex
}
type namespaceNode struct {
level zapcore.Level
children map[string]*namespaceNode
explicit bool
}
func newNamespacesTree() *namespacesTree {
return &namespacesTree{
namespaces: make(map[string]*namespaceNode),
minLevel: zapcore.InvalidLevel,
}
}
// buildNamespacesTree creates a new tree instance from a string of namespaces.
// The namespaces string should be a comma-separated list of namespace:level pairs,
// where level is one of: "debug", "info", "warn", "error".
//
// Example: "namespace1:debug,namespace2.namespace3:error"
func buildNamespacesTree(namespaces string) (*namespacesTree, error) {
tree := newNamespacesTree()
err := tree.Rebuild(namespaces)
if err != nil {
return nil, err
}
return tree, nil
}
// LevelFor finds the log level for the given namespace by searching for the closest match in the tree.
// In case of no match, it returns zapcore.InvalidLevel.
func (t *namespacesTree) LevelFor(namespace string) zapcore.Level {
t.mu.RLock()
defer t.mu.RUnlock()
currentNode := t.namespaces
var lastFound *namespaceNode
for _, name := range strings.Split(namespace, ".") {
if node, exists := currentNode[name]; exists {
if node.explicit {
lastFound = node
}
currentNode = node.children
} else {
break
}
}
if lastFound != nil {
return lastFound.level
}
// Default to zapcore.InvalidLevel if no match found.
return zapcore.InvalidLevel
}
// Rebuild updates the tree with a new set of namespaces.
func (t *namespacesTree) Rebuild(namespaces string) error {
t.mu.Lock()
defer t.mu.Unlock()
if namespaces == "" {
t.namespaces = make(map[string]*namespaceNode)
t.minLevel = zapcore.InvalidLevel
return nil
}
if !namespacesRegex.MatchString(namespaces) {
return errInvalidNamespacesFormat
}
for _, ns := range strings.Split(namespaces, ",") {
parts := strings.Split(ns, ":")
if len(parts) != 2 {
return errInvalidNamespacesFormat
}
lvl, err := lvlFromString(parts[1])
if err != nil {
return err
}
t.addNamespace(parts[0], lvl)
if t.minLevel == zapcore.InvalidLevel || lvl < t.minLevel {
t.minLevel = lvl
}
}
return nil
}
func (t *namespacesTree) MinLevel() zapcore.Level {
t.mu.RLock()
defer t.mu.RUnlock()
return t.minLevel
}
// addNamespace adds a namespace with a specific logging level to the tree.
func (t *namespacesTree) addNamespace(namespace string, level zapcore.Level) {
currentParent := t.namespaces
var currentNode *namespaceNode
for _, name := range strings.Split(namespace, ".") {
if node, exists := currentParent[name]; exists {
currentNode = node
} else {
newNode := &namespaceNode{
children: make(map[string]*namespaceNode),
}
currentParent[name] = newNode
currentNode = newNode
}
currentParent = currentNode.children
}
currentNode.level = level
currentNode.explicit = true
}

View File

@ -0,0 +1,112 @@
package logutils
import (
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"
)
func TestBuildNamespacesTree(t *testing.T) {
tests := []struct {
name string
namespaces string
minLevel zapcore.Level
err error
}{
{
name: "valid namespaces",
namespaces: "namespace1:debug,namespace2.namespace3:error",
minLevel: zapcore.DebugLevel,
err: nil,
},
{
name: "invalid format",
namespaces: "namespace1:debug,namespace2.namespace3",
minLevel: zapcore.DebugLevel,
err: errInvalidNamespacesFormat,
},
{
name: "empty namespaces",
namespaces: "",
minLevel: zapcore.InvalidLevel,
err: nil,
},
{
name: "invalid level",
namespaces: "namespace1:invalid",
minLevel: zapcore.InvalidLevel,
err: errInvalidNamespacesFormat,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tree, err := buildNamespacesTree(tt.namespaces)
require.ErrorIs(t, err, tt.err)
if tt.err == nil {
require.NotNil(t, tree)
}
})
}
}
func TestLevelFor(t *testing.T) {
tree, err := buildNamespacesTree("namespace1:error,namespace1.namespace2:debug,namespace1.namespace2.namespace3:info,namespace3.namespace4:warn")
require.NoError(t, err)
require.NotNil(t, tree)
tests := []struct {
name string
input string
expected zapcore.Level
}{
{
name: "exact match 1",
input: "namespace1",
expected: zapcore.ErrorLevel,
},
{
name: "exact match 2",
input: "namespace1.namespace2",
expected: zapcore.DebugLevel,
},
{
name: "exact match 3",
input: "namespace3.namespace4",
expected: zapcore.WarnLevel,
},
{
name: "exact match 3",
input: "namespace1.namespace2.namespace3",
expected: zapcore.InfoLevel,
},
{
name: "partial match 1",
input: "namespace1.unregistered",
expected: zapcore.ErrorLevel,
},
{
name: "partial match 2",
input: "namespace1.namespace2.unregistered",
expected: zapcore.DebugLevel,
},
{
name: "no match 1",
input: "namespace2",
expected: zapcore.InvalidLevel,
},
{
name: "no match 2",
input: "namespace3",
expected: zapcore.InvalidLevel,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
level := tree.LevelFor(tt.input)
require.Equal(t, tt.expected, level)
})
}
}