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>
This commit is contained in:
Jakob Borg
2025-09-05 08:52:49 +00:00
committed by GitHub
parent 800596139e
commit 36254473a3
8 changed files with 122 additions and 58 deletions

View File

@@ -164,6 +164,9 @@ type serveCmd struct {
LogLevel slog.Level `help:"Log level for all packages (DEBUG,INFO,WARN,ERROR)" env:"STLOGLEVEL" default:"INFO"` LogLevel slog.Level `help:"Log level for all packages (DEBUG,INFO,WARN,ERROR)" env:"STLOGLEVEL" default:"INFO"`
LogMaxFiles int `name:"log-max-old-files" help:"Number of old files to keep (zero to keep only current)" default:"${logMaxFiles}" placeholder:"N" env:"STLOGMAXOLDFILES"` LogMaxFiles int `name:"log-max-old-files" help:"Number of old files to keep (zero to keep only current)" default:"${logMaxFiles}" placeholder:"N" env:"STLOGMAXOLDFILES"`
LogMaxSize int `help:"Maximum size of any file (zero to disable log rotation)" default:"${logMaxSize}" placeholder:"BYTES" env:"STLOGMAXSIZE"` LogMaxSize int `help:"Maximum size of any file (zero to disable log rotation)" default:"${logMaxSize}" placeholder:"BYTES" env:"STLOGMAXSIZE"`
LogFormatTimestamp string `name:"log-format-timestamp" help:"Format for timestamp, set to empty to disable timestamps" env:"STLOGFORMATTIMESTAMP" default:"${timestampFormat}"`
LogFormatLevelString bool `name:"log-format-level-string" help:"Whether to include level string in log line" env:"STLOGFORMATLEVELSTRING" default:"${levelString}" negatable:""`
LogFormatLevelSyslog bool `name:"log-format-level-syslog" help:"Whether to include level as syslog prefix in log line" env:"STLOGFORMATLEVELSYSLOG" default:"${levelSyslog}" negatable:""`
NoBrowser bool `help:"Do not start browser" env:"STNOBROWSER"` NoBrowser bool `help:"Do not start browser" env:"STNOBROWSER"`
NoPortProbing bool `help:"Don't try to find free ports for GUI and listen addresses on first startup" env:"STNOPORTPROBING"` NoPortProbing bool `help:"Don't try to find free ports for GUI and listen addresses on first startup" env:"STNOPORTPROBING"`
NoRestart bool `help:"Do not restart Syncthing when exiting due to API/GUI command, upgrade, or crash" env:"STNORESTART"` NoRestart bool `help:"Do not restart Syncthing when exiting due to API/GUI command, upgrade, or crash" env:"STNORESTART"`
@@ -186,10 +189,13 @@ type serveCmd struct {
} }
func defaultVars() kong.Vars { func defaultVars() kong.Vars {
vars := kong.Vars{} vars := kong.Vars{
"logMaxSize": strconv.Itoa(10 << 20), // 10 MiB
vars["logMaxSize"] = strconv.Itoa(10 << 20) // 10 MiB "logMaxFiles": "3", // plus the current one
vars["logMaxFiles"] = "3" // plus the current one "levelString": strconv.FormatBool(slogutil.DefaultLineFormat.LevelString),
"levelSyslog": strconv.FormatBool(slogutil.DefaultLineFormat.LevelSyslog),
"timestampFormat": slogutil.DefaultLineFormat.TimestampFormat,
}
// On non-Windows, we explicitly default to "-" which means stdout. On // On non-Windows, we explicitly default to "-" which means stdout. On
// Windows, the "default" options.logFile will later be replaced with the // Windows, the "default" options.logFile will later be replaced with the
@@ -262,8 +268,14 @@ func (c *serveCmd) Run() error {
osutil.HideConsole() osutil.HideConsole()
} }
// The default log level for all packages // Customize the logging early
slogutil.SetLineFormat(slogutil.LineFormat{
TimestampFormat: c.LogFormatTimestamp,
LevelString: c.LogFormatLevelString,
LevelSyslog: c.LogFormatLevelSyslog,
})
slogutil.SetDefaultLevel(c.LogLevel) slogutil.SetDefaultLevel(c.LogLevel)
slogutil.SetLevelOverrides(os.Getenv("STTRACE"))
// Treat an explicitly empty log file name as no log file // Treat an explicitly empty log file name as no log file
if c.LogFile == "" { if c.LogFile == "" {
@@ -1039,7 +1051,7 @@ func (m migratingAPI) Serve(ctx context.Context) error {
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("*** Database migration in progress ***\n\n")) w.Write([]byte("*** Database migration in progress ***\n\n"))
for _, line := range slogutil.GlobalRecorder.Since(time.Time{}) { for _, line := range slogutil.GlobalRecorder.Since(time.Time{}) {
line.WriteTo(w) _, _ = line.WriteTo(w, slogutil.DefaultLineFormat)
} }
}), }),
} }

View File

@@ -7,6 +7,9 @@ StartLimitBurst=4
[Service] [Service]
User=%i User=%i
Environment="STLOGFORMATTIMESTAMP="
Environment="STLOGFORMATLEVELSTRING=false"
Environment="STLOGFORMATLEVELSYSLOG=true"
ExecStart=/usr/bin/syncthing serve --no-browser --no-restart ExecStart=/usr/bin/syncthing serve --no-browser --no-restart
Restart=on-failure Restart=on-failure
RestartSec=1 RestartSec=1

View File

@@ -5,7 +5,10 @@ StartLimitIntervalSec=60
StartLimitBurst=4 StartLimitBurst=4
[Service] [Service]
ExecStart=/usr/bin/syncthing serve --no-browser --no-restart --logflags=0 Environment="STLOGFORMATTIMESTAMP="
Environment="STLOGFORMATLEVELSTRING=false"
Environment="STLOGFORMATLEVELSYSLOG=true"
ExecStart=/usr/bin/syncthing serve --no-browser --no-restart
Restart=on-failure Restart=on-failure
RestartSec=1 RestartSec=1
SuccessExitStatus=3 4 SuccessExitStatus=3 4

View File

@@ -18,14 +18,30 @@ import (
"time" "time"
) )
type formattingHandler struct { type LineFormat struct {
attrs []slog.Attr TimestampFormat string
groups []string LevelString bool
LevelSyslog bool
}
type formattingOptions struct {
LineFormat
out io.Writer out io.Writer
recs []*lineRecorder recs []*lineRecorder
timeOverride time.Time 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) var _ slog.Handler = (*formattingHandler)(nil)
func (h *formattingHandler) Enabled(context.Context, slog.Level) bool { func (h *formattingHandler) Enabled(context.Context, slog.Level) bool {
@@ -83,19 +99,19 @@ func (h *formattingHandler) Handle(_ context.Context, rec slog.Record) error {
} }
line := Line{ line := Line{
When: cmp.Or(h.timeOverride, rec.Time), When: cmp.Or(h.opts.timeOverride, rec.Time),
Message: sb.String(), Message: sb.String(),
Level: rec.Level, Level: rec.Level,
} }
// If there is a recorder, record the line. // If there is a recorder, record the line.
for _, rec := range h.recs { for _, rec := range h.opts.recs {
rec.record(line) rec.record(line)
} }
// If there's an output, print the line. // If there's an output, print the line.
if h.out != nil { if h.opts.out != nil {
_, _ = line.WriteTo(h.out) _, _ = line.WriteTo(h.opts.out, h.opts.LineFormat)
} }
return nil return nil
} }
@@ -145,9 +161,7 @@ func (h *formattingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &formattingHandler{ return &formattingHandler{
attrs: append(h.attrs, attrs...), attrs: append(h.attrs, attrs...),
groups: h.groups, groups: h.groups,
recs: h.recs, opts: h.opts,
out: h.out,
timeOverride: h.timeOverride,
} }
} }
@@ -158,9 +172,7 @@ func (h *formattingHandler) WithGroup(name string) slog.Handler {
return &formattingHandler{ return &formattingHandler{
attrs: h.attrs, attrs: h.attrs,
groups: append([]string{name}, h.groups...), groups: append([]string{name}, h.groups...),
recs: h.recs, opts: h.opts,
out: h.out,
timeOverride: h.timeOverride,
} }
} }

View File

@@ -17,8 +17,11 @@ import (
func TestFormattingHandler(t *testing.T) { func TestFormattingHandler(t *testing.T) {
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
h := &formattingHandler{ h := &formattingHandler{
opts: &formattingOptions{
LineFormat: DefaultLineFormat,
out: buf, out: buf,
timeOverride: time.Unix(1234567890, 0).In(time.UTC), timeOverride: time.Unix(1234567890, 0).In(time.UTC),
},
} }
l := slog.New(h).With("a", "a") l := slog.New(h).With("a", "a")

View File

@@ -9,6 +9,7 @@ package slogutil
import ( import (
"log/slog" "log/slog"
"maps" "maps"
"strings"
"sync" "sync"
) )
@@ -39,6 +40,24 @@ func SetDefaultLevel(level slog.Level) {
globalLevels.SetDefault(level) globalLevels.SetDefault(level)
} }
func SetLevelOverrides(sttrace string) {
pkgs := strings.Split(sttrace, ",")
for _, pkg := range pkgs {
pkg = strings.TrimSpace(pkg)
if pkg == "" {
continue
}
level := slog.LevelDebug
if cutPkg, levelStr, ok := strings.Cut(pkg, ":"); ok {
pkg = cutPkg
if err := level.UnmarshalText([]byte(levelStr)); err != nil {
slog.Warn("Bad log level requested in STTRACE", slog.String("pkg", pkg), slog.String("level", levelStr), Error(err))
}
}
globalLevels.Set(pkg, level)
}
}
type levelTracker struct { type levelTracker struct {
mut sync.RWMutex mut sync.RWMutex
defLevel slog.Level defLevel slog.Level

View File

@@ -7,6 +7,7 @@
package slogutil package slogutil
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -22,13 +23,22 @@ type Line struct {
Level slog.Level `json:"level"` Level slog.Level `json:"level"`
} }
func (l *Line) WriteTo(w io.Writer) (int64, error) { func (l *Line) WriteTo(w io.Writer, f LineFormat) (int64, error) {
n, err := fmt.Fprintf(w, "%s %s %s\n", l.timeStr(), l.levelStr(), l.Message) buf := new(bytes.Buffer)
return int64(n), err if f.LevelSyslog {
_, _ = fmt.Fprintf(buf, "<%d>", l.syslogPriority())
} }
if f.TimestampFormat != "" {
func (l *Line) timeStr() string { buf.WriteString(l.When.Format(f.TimestampFormat))
return l.When.Format("2006-01-02 15:04:05") buf.WriteRune(' ')
}
if f.LevelString {
buf.WriteString(l.levelStr())
buf.WriteRune(' ')
}
buf.WriteString(l.Message)
buf.WriteRune('\n')
return buf.WriteTo(w)
} }
func (l *Line) levelStr() string { func (l *Line) levelStr() string {
@@ -51,6 +61,19 @@ func (l *Line) levelStr() string {
} }
} }
func (l *Line) syslogPriority() int {
switch {
case l.Level < slog.LevelInfo:
return 7
case l.Level < slog.LevelWarn:
return 6
case l.Level < slog.LevelError:
return 4
default:
return 3
}
}
func (l *Line) MarshalJSON() ([]byte, error) { func (l *Line) MarshalJSON() ([]byte, error) {
// Custom marshal to get short level strings instead of default JSON serialisation // Custom marshal to get short level strings instead of default JSON serialisation
return json.Marshal(map[string]any{ return json.Marshal(map[string]any{

View File

@@ -10,20 +10,26 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"strings" "time"
) )
var ( var (
GlobalRecorder = &lineRecorder{level: -1000} GlobalRecorder = &lineRecorder{level: -1000}
ErrorRecorder = &lineRecorder{level: slog.LevelError} ErrorRecorder = &lineRecorder{level: slog.LevelError}
DefaultLineFormat = LineFormat{
TimestampFormat: time.DateTime,
LevelString: true,
}
globalLevels = &levelTracker{ globalLevels = &levelTracker{
levels: make(map[string]slog.Level), levels: make(map[string]slog.Level),
descrs: make(map[string]string), descrs: make(map[string]string),
} }
slogDef = slog.New(&formattingHandler{ globalFormatter = &formattingOptions{
LineFormat: DefaultLineFormat,
recs: []*lineRecorder{GlobalRecorder, ErrorRecorder}, recs: []*lineRecorder{GlobalRecorder, ErrorRecorder},
out: logWriter(), out: logWriter(),
}) }
slogDef = slog.New(&formattingHandler{opts: globalFormatter})
) )
func logWriter() io.Writer { func logWriter() io.Writer {
@@ -38,21 +44,4 @@ func logWriter() io.Writer {
func init() { func init() {
slog.SetDefault(slogDef) slog.SetDefault(slogDef)
// Handle legacy STTRACE var
pkgs := strings.Split(os.Getenv("STTRACE"), ",")
for _, pkg := range pkgs {
pkg = strings.TrimSpace(pkg)
if pkg == "" {
continue
}
level := slog.LevelDebug
if cutPkg, levelStr, ok := strings.Cut(pkg, ":"); ok {
pkg = cutPkg
if err := level.UnmarshalText([]byte(levelStr)); err != nil {
slog.Warn("Bad log level requested in STTRACE", slog.String("pkg", pkg), slog.String("level", levelStr), Error(err))
}
}
globalLevels.Set(pkg, level)
}
} }