Files
syncthing/internal/slogutil/formatting.go
Jakob Borg 36254473a3 chore(slogutil): add configurable logging format (fixes #10352) (#10354)
This adds several options for configuring the log format of timestamps
and severity levels, making it more suitable for integration with log
systems like systemd.

      --log-format-timestamp="2006-01-02 15:04:05"
         Format for timestamp, set to empty to disable timestamps ($STLOGFORMATTIMESTAMP)

      --[no-]log-format-level-string
         Whether to include level string in log line ($STLOGFORMATLEVELSTRING)

      --[no-]log-format-level-syslog
         Whether to include level as syslog prefix in log line ($STLOGFORMATLEVELSYSLOG)

So, to get a timestamp suitable for systemd (syslog prefix, no level
string, no timestamp) we can pass `--log-format-timestamp=""
--no-log-format-level-string --log-format-level-syslog` or,
equivalently, set `STLOGFORMATTIMESTAMP="" STLOGFORMATLEVELSTRING=false
STLOGFORMATLEVELSYSLOG=true`.

Signed-off-by: Jakob Borg <jakob@kastelo.net>
2025-09-05 10:52:49 +02:00

201 lines
4.6 KiB
Go

// Copyright (C) 2025 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package slogutil
import (
"cmp"
"context"
"io"
"log/slog"
"path"
"runtime"
"strconv"
"strings"
"time"
)
type LineFormat struct {
TimestampFormat string
LevelString bool
LevelSyslog bool
}
type formattingOptions struct {
LineFormat
out io.Writer
recs []*lineRecorder
timeOverride time.Time
}
type formattingHandler struct {
attrs []slog.Attr
groups []string
opts *formattingOptions
}
func SetLineFormat(f LineFormat) {
globalFormatter.LineFormat = f
}
var _ slog.Handler = (*formattingHandler)(nil)
func (h *formattingHandler) Enabled(context.Context, slog.Level) bool {
return true
}
func (h *formattingHandler) Handle(_ context.Context, rec slog.Record) error {
fr := runtime.CallersFrames([]uintptr{rec.PC})
var logAttrs []any
if fram, _ := fr.Next(); fram.Function != "" {
pkgName, typeName := funcNameToPkg(fram.Function)
lvl := globalLevels.Get(pkgName)
if lvl > rec.Level {
// Logging not enabled at the record's level
return nil
}
logAttrs = append(logAttrs, slog.String("pkg", pkgName))
if lvl <= slog.LevelDebug {
// We are debugging, add additional source line data
if typeName != "" {
logAttrs = append(logAttrs, slog.String("type", typeName))
}
logAttrs = append(logAttrs, slog.Group("src", slog.String("file", path.Base(fram.File)), slog.Int("line", fram.Line)))
}
}
var prefix string
if len(h.groups) > 0 {
prefix = strings.Join(h.groups, ".") + "."
}
// Build the message string.
var sb strings.Builder
sb.WriteString(rec.Message)
// Collect all the attributes, adding the handler prefix.
attrs := make([]slog.Attr, 0, rec.NumAttrs()+len(h.attrs)+1)
rec.Attrs(func(attr slog.Attr) bool {
attr.Key = prefix + attr.Key
attrs = append(attrs, attr)
return true
})
attrs = append(attrs, h.attrs...)
attrs = append(attrs, slog.Group("log", logAttrs...))
// Expand and format attributes
var attrCount int
for _, attr := range attrs {
for _, attr := range expandAttrs("", attr) {
appendAttr(&sb, "", attr, &attrCount)
}
}
if attrCount > 0 {
sb.WriteRune(')')
}
line := Line{
When: cmp.Or(h.opts.timeOverride, rec.Time),
Message: sb.String(),
Level: rec.Level,
}
// If there is a recorder, record the line.
for _, rec := range h.opts.recs {
rec.record(line)
}
// If there's an output, print the line.
if h.opts.out != nil {
_, _ = line.WriteTo(h.opts.out, h.opts.LineFormat)
}
return nil
}
func expandAttrs(prefix string, a slog.Attr) []slog.Attr {
if prefix != "" {
a.Key = prefix + "." + a.Key
}
val := a.Value.Resolve()
if val.Kind() != slog.KindGroup {
return []slog.Attr{a}
}
var attrs []slog.Attr
for _, attr := range val.Group() {
attrs = append(attrs, expandAttrs(a.Key, attr)...)
}
return attrs
}
func appendAttr(sb *strings.Builder, prefix string, a slog.Attr, attrCount *int) {
const confusables = ` "()[]{},`
if a.Key == "" {
return
}
sb.WriteRune(' ')
if *attrCount == 0 {
sb.WriteRune('(')
}
sb.WriteString(prefix)
sb.WriteString(a.Key)
sb.WriteRune('=')
v := a.Value.Resolve().String()
if v == "" || strings.ContainsAny(v, confusables) {
v = strconv.Quote(v)
}
sb.WriteString(v)
*attrCount++
}
func (h *formattingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(h.groups) > 0 {
prefix := strings.Join(h.groups, ".") + "."
for i := range attrs {
attrs[i].Key = prefix + attrs[i].Key
}
}
return &formattingHandler{
attrs: append(h.attrs, attrs...),
groups: h.groups,
opts: h.opts,
}
}
func (h *formattingHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return &formattingHandler{
attrs: h.attrs,
groups: append([]string{name}, h.groups...),
opts: h.opts,
}
}
func funcNameToPkg(fn string) (string, string) {
fn = strings.ToLower(fn)
fn = strings.TrimPrefix(fn, "github.com/syncthing/syncthing/lib/")
fn = strings.TrimPrefix(fn, "github.com/syncthing/syncthing/internal/")
pkgTypFn := strings.Split(fn, ".") // [package, type, method] or [package, function]
if len(pkgTypFn) <= 2 {
return pkgTypFn[0], ""
}
pkg := pkgTypFn[0]
// Remove parenthesis and asterisk from the type name
typ := strings.TrimLeft(strings.TrimRight(pkgTypFn[1], ")"), "(*")
// Skip certain type names that add no value
typ = strings.TrimSuffix(typ, "service")
switch typ {
case pkg, "", "serveparams":
return pkg, ""
default:
return pkg, typ
}
}