Slog Handler für bessere Lesbarkeit in lokalen Logs
Seit Go 1.21 ist das strukturierte Logging mit slog Teil der Standardbibliothek. Die standardmäßigen Handler für JSON und Text sind in Serverlog Tools wie Prometheus oder Grafana Loki sehr nützlich, da sie strukturierte Daten liefern, die maschinell ausgewertet werden können. Bei der lokalen Entwicklung sind diese Ausgaben jedoch oft schwer zu lesen - besonders wenn viele Logs durchlaufen und man schnell die wichtigen Informationen erfassen möchte.
Mit einem eigenen Handler können wir das Beste aus beiden Welten kombinieren: Farbige, gut lesbare Logs für die lokale Entwicklung und strukturierte JSON-Logs für Produktion. Der Wechsel zwischen beiden Modi erfolgt dabei transparent über die Handler-Auswahl. Zusätzlich ist es möglich, weitere Logik zu implementieren, die bestimmte Felder oder ganze Nachrichten lokal filtert.
Der folgende Code ist entstanden, um schnell lokal bessere Logs zu erhalten. Daher wurde nicht besonders viel Wert auf Performance oder Clean Code gelegt. Er musste schnell entstehen und funktioniert für meine Zwecke.
Handler Interface
Ein Slog Handler Interface ist nicht besonders kompliziert.
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
Die Enabled
Methode entscheidet, ob der Log Eintrag für das Level verarbeitet werden soll. Handle
schreibt die eigentliche Nachricht und formatiert diese. Die Methoden WithAttrs
und WithGroup
verarbeitet die Slog Attribute die z.B. mit slog.With(slog.String("key", "value"))
hinzugefügt werden können.
Glossy Handler
Um die Logs schöner zu rendern, erstelle ich mir einen eigenen Handler, der die Nachrichten mit libgloss formatiert. Lipgloss ist eine Bibliothek, die es ermöglicht, Terminal-Ausgaben mit Farben und Stilen zu versehen.
type GlossyHandler struct {
out io.Writer
mu *sync.Mutex
minLevel slog.Level
// Pre-computed attributes from WithAttrs calls
attrs []slog.Attr
// Current active groups from WithGroup calls
groups []string
}
// Enabled checks if the handler is enabled for the given log level.
func (h *GlossyHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.minLevel
}
// NewGlossyHandler creates a new GlossyHandler with the specified output writer and minimum log level.
func NewGlossyHandler(out io.Writer, minLevel slog.Level) *GlossyHandler {
return &GlossyHandler{
out: out,
mu: &sync.Mutex{},
minLevel: minLevel,
}
}
Ein Mutex wird verwendet, um die Attribute und Gruppen threadsafe zu ändern. Die Farben können einfach über lipgloss Styles definiert werden. Jedes Log-Level bekommt eine eigene Farbe zugewiesen - Grau für Debug, Blau für Info, Orange für Warnings und Rot für Errors.
var levelStyles = map[slog.Level]lipgloss.Style{
slog.LevelDebug: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Bold(true),
slog.LevelInfo: lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true),
slog.LevelWarn: lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true),
slog.LevelError: lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true),
}
Zusätzlich definiere ich Labels für die Log-Level, die später in der Ausgabe verwendet werden.
// levelAbbreviations provides shorter forms of level names
var levelAbbreviations = map[slog.Level]string{
slog.LevelDebug: "[DEBUG]",
slog.LevelInfo: "[INFO] ",
slog.LevelWarn: "[WARN] ",
slog.LevelError: "[ERROR]",
}
Strukturierte Log-Ausgabe
Die Handle
Methode formatiert jeden Log-Eintrag in eine gut lesbare Zeile mit farbigen Komponenten. Um schneller die Zugehörigkeit einer Log Nachricht zu erkennen, verwende ich in meinen Projekten gerne die Attribute component
und context
. Jedes Modul setzt in component
einen Identifier. Ein Beispiel könnte api
für einen HTTP Server oder handler.eventName
für einen Event Handler sein. Das Feld context
nutze ich etwas flexibler. Es kann der aktuelle Nutzer oder Dokument ID sein. Im Fall von HTTP Servern kann es auch der API Handler Name sein.
[INFO] [api] [createUser] User created {"userID": "1", "userName": "Gopher"}
Als erstes wird im Handler geprüft, ob der Log-Eintrag für das aktuelle Level aktiviert ist. Wenn nicht, wird die Methode einfach beendet. Anschließend wird der Mutex gesperrt, um mit den Attributen und Gruppen zu arbeiten. Eine mögliche Verbesserung wäre, den Mutex nur zu sperren, wenn einmal kurz die Attribute und Gruppen gelesen werden.
Als ersten Output suchen wir den Style und den Text für das Level und schreiben dies in den String Builder.
func (h *GlossyHandler) Handle(ctx context.Context, record slog.Record) error {
if !h.Enabled(ctx, record.Level) {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
var builder strings.Builder
levelStyle := levelStyles[record.Level]
levelName := levelAbbreviations[record.Level]
// Format the log message
builder.WriteString(levelStyle.Render(levelName))
builder.WriteString(" ")
// ToDo: Handle message formatting
// Add a newline
builder.WriteString("\n")
// Write the formatted message to the output
_, err := io.WriteString(h.out, builder.String())
return err
}
Im Anschluss müssen wir das Component und Context Value finden und wenn es existiert, mit dem jeweiligen Style in den String Builder schreiben.
func (h *GlossyHandler) Handle(ctx context.Context, record slog.Record) error {
// ... (vorheriger Code)
// Component
componentValue := ""
contextValue := ""
record.Attrs(func(a slog.Attr) bool {
if a.Key == "component" {
componentValue = a.Value.String()
} else if a.Key == "context" {
contextValue = a.Value.String()
}
if componentValue != "" && contextValue != "" {
return false
}
return true
})
if componentValue == "" {
for _, attr := range h.attrs {
if attr.Key == "component" {
componentValue = attr.Value.String()
} else if attr.Key == "context" {
contextValue = attr.Value.String()
}
if componentValue != "" && contextValue != "" {
break
}
}
}
if componentValue != "" {
componentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("5"))
componentFormatted := componentStyle.Render(fmt.Sprintf("[%s]", componentValue))
builder.WriteString(componentFormatted)
builder.WriteString(" ")
}
if contextValue != "" {
contextStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("20"))
contextFormatted := contextStyle.Render(fmt.Sprintf("[%s]", contextValue))
builder.WriteString(contextFormatted)
builder.WriteString(" ")
}
// ... (vorheriger Code)
}
Zum Abschluss müssen wir die eigentliche Nachricht formatieren und alle restlichen Attribute als JSON hinzufügen.
func (h *GlossyHandler) Handle(ctx context.Context, record slog.Record) error {
// ... (vorheriger Code)
// Format the message
messageStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
message := messageStyle.Render(record.Message)
builder.WriteString(message)
// Process all attributes (except "component" which was already handled)
attrMap := make(map[string]any)
// First add our pre-computed attributes
for _, attr := range h.attrs {
if attr.Key != "component" && attr.Key != "context" {
attrMap[attr.Key] = attr.Value.Any()
}
}
// Then add record attributes (possibly overriding pre-computed ones)
record.Attrs(func(attr slog.Attr) bool {
if attr.Key != "component" && attr.Key != "context" && attr.Key != slog.TimeKey && attr.Key != slog.LevelKey && attr.Key != slog.MessageKey {
attrMap[attr.Key] = attr.Value.Any()
}
return true
})
// Add attributes if there are any
if len(attrMap) > 0 {
builder.WriteString(" ")
// Format attributes in a subtle style
attrStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
attrJson, err := json.Marshal(attrMap)
if err != nil {
attrJson = []byte("{}")
}
builder.WriteString(attrStyle.Render(string(attrJson)))
}
// ... (vorheriger Code)
}
Attribute und Gruppen
Bei Aufrufen von slog.With
wird eine neue Instanz des Handlers erstellt. Mit den alten und neuen Attributen muss eine neue Instanz des Handlers erstellt werden. Um nicht die alte Instanz zu verändern, müssen die Slices für attrs
und groups
kopiert werden. So kann der Log Handler immer weiter übergeben werden, ohne z.B. die Logausgabe in anderen HTTP Handlern zu beeinflussen.
func (h *GlossyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
// Create a new handler with the same properties
newHandler := &GlossyHandler{
out: h.out,
mu: h.mu, // Share the mutex to ensure serialized output
minLevel: h.minLevel,
groups: slices.Clone(h.groups),
}
// Prepare a map to store existing attributes with their keys for quick lookup
existingAttrs := make(map[string]int, len(h.attrs))
for i, attr := range h.attrs {
existingAttrs[attr.Key] = i
}
// Copy existing attrs
newHandler.attrs = make([]slog.Attr, len(h.attrs))
copy(newHandler.attrs, h.attrs)
// Apply any active groups to the new attributes
prefix := ""
if len(h.groups) > 0 {
prefix = strings.Join(h.groups, ".")
}
// Add or override the new attributes
for _, attr := range attrs {
fullKey := attr.Key
if prefix != "" {
fullKey = prefix + "." + attr.Key
}
// Check if this attribute already exists
if idx, exists := existingAttrs[fullKey]; exists {
// Override existing attribute
newHandler.attrs[idx] = slog.Attr{Key: fullKey, Value: attr.Value}
} else {
// Add new attribute
newHandler.attrs = append(newHandler.attrs, slog.Attr{Key: fullKey, Value: attr.Value})
}
}
return newHandler
}
Ich selbst nutze das Gruppen Feature von Slog nicht, daher ist die Implementierung hier sehr einfach gehalten und deckt wahrscheinlich nicht alles ab.
func (h *GlossyHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
// Create a new handler with the same properties
newHandler := &GlossyHandler{
out: h.out,
mu: h.mu, // Share the mutex to ensure serialized output
minLevel: h.minLevel,
attrs: slices.Clone(h.attrs),
}
// Add the new group to the groups slice
newGroups := make([]string, 0, len(h.groups)+1)
newGroups = append(newGroups, h.groups...)
newGroups = append(newGroups, name)
newHandler.groups = newGroups
return newHandler
}
Verwendung
Der Handler ist sehr einfach zu verwenden. Um zwischen dem JSON und dem Glossy Handler zu wechseln, kann z.B. einfach auf eine Umgebungsvariable geprüft werden.
func main() {
var handler slog.Handler
if os.Getenv("ENV") == "production" {
// JSON handler for production
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
} else {
// Glossy handler for development
handler = NewGlossyHandler(os.Stdout, slog.LevelDebug)
}
log := slog.New(handler)
log.Info("Starting", slog.String("component", "main"))
}
Der Glossy Handler ist für die lokale Entwicklung gedacht, wo kein JSON Parser die Logs aufbereitet und Farben sichtbar sind. Die Performance wird im Vergleich zum JSON Handler wahrscheinlich schlechter sein.
In der Handle
Methode kann relative einfach weitere Logik implementiert werden, um z.B. bestimmte Nachrichten zu filtern oder zu formatieren.