- config rules name their key; env strings overlay defaults; D-08 defaults - component floors, verbosity 1-3, quiet on the console only - daily rotation, retention, 0640 files, unwritable dir, lazy open, path confinement - redaction on every driver, console control-character stripping and prefix - loggers before Setup, Reset, HideOnTTY, 100 concurrent writers - testing/slogtest for the console handler and the JSON pipeline - sunscreen InstallDefault still installs a redacting logfmt handler
160 lines
4.2 KiB
Go
160 lines
4.2 KiB
Go
package diary_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"testing/slogtest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/diary"
|
|
"github.com/charmbracelet/colorprofile"
|
|
)
|
|
|
|
// TestSlogtestConsoleHandler: the styled console handler (with the NoTTY
|
|
// profile, so its lines carry no escape codes) passes testing/slogtest.
|
|
func TestSlogtestConsoleHandler(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
h := diary.NewConsoleHandler(&buf, diary.ConsoleOptions{Profile: colorprofile.NoTTY})
|
|
results := func() []map[string]any {
|
|
var out []map[string]any
|
|
for _, line := range strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
m, err := parseConsoleLine(line)
|
|
if err != nil {
|
|
t.Fatalf("unparsable console line %q: %v", line, err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
if err := slogtest.TestHandler(h, results); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestSlogtestPipeline: the full pipeline (component handler, sunscreen and
|
|
// a stack of a JSON stderr channel and a null channel) passes
|
|
// testing/slogtest through its JSON output, for a component logger and for
|
|
// the process default.
|
|
func TestSlogtestPipeline(t *testing.T) {
|
|
for _, name := range []string{"pipeline", ""} {
|
|
t.Run(fmt.Sprintf("component %q", name), func(t *testing.T) {
|
|
var buf lockedBuffer
|
|
setup(t, diary.Options{Stderr: &buf},
|
|
"SUMMER_LOG__DEFAULT=stack",
|
|
"SUMMER_LOG__CHANNELS__STACK__CHANNELS=stderr,null",
|
|
"SUMMER_LOG__CHANNELS__STDERR__FORMAT=json",
|
|
)
|
|
h := diary.For(name).Handler()
|
|
if name == "" {
|
|
h = slog.Default().Handler()
|
|
}
|
|
results := func() []map[string]any {
|
|
var out []map[string]any
|
|
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
|
t.Fatalf("pipeline line is not JSON: %q: %v", line, err)
|
|
}
|
|
if name != "" && m[diary.ComponentKey] != name {
|
|
t.Fatalf("pipeline record lacks component %q: %v", name, m)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
if err := slogtest.TestHandler(h, results); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
var consoleTime = regexp.MustCompile(`^\d{2}:\d{2}:\d{2} `)
|
|
|
|
// parseConsoleLine reads one console line without a component: an optional
|
|
// HH:MM:SS time, the level, the message (the words before the first
|
|
// key=value field) and key=value fields whose dotted keys become nested
|
|
// maps. Quoted values are unquoted.
|
|
func parseConsoleLine(line string) (map[string]any, error) {
|
|
m := map[string]any{}
|
|
if loc := consoleTime.FindStringIndex(line); loc != nil {
|
|
m[slog.TimeKey] = strings.TrimSpace(line[:loc[1]])
|
|
line = line[loc[1]:]
|
|
}
|
|
level, rest, ok := strings.Cut(line, " ")
|
|
if !ok {
|
|
return nil, fmt.Errorf("no level")
|
|
}
|
|
m[slog.LevelKey] = level
|
|
tokens, err := splitFields(strings.TrimLeft(rest, " "))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var msg []string
|
|
i := 0
|
|
for ; i < len(tokens) && !strings.Contains(tokens[i], "="); i++ {
|
|
msg = append(msg, tokens[i])
|
|
}
|
|
m[slog.MessageKey] = strings.Join(msg, " ")
|
|
for ; i < len(tokens); i++ {
|
|
key, raw, _ := strings.Cut(tokens[i], "=")
|
|
value := raw
|
|
if strings.HasPrefix(raw, `"`) {
|
|
if value, err = strconv.Unquote(raw); err != nil {
|
|
return nil, fmt.Errorf("value of %s: %w", key, err)
|
|
}
|
|
}
|
|
parts := strings.Split(key, ".")
|
|
cur := m
|
|
for _, p := range parts[:len(parts)-1] {
|
|
next, ok := cur[p].(map[string]any)
|
|
if !ok {
|
|
next = map[string]any{}
|
|
cur[p] = next
|
|
}
|
|
cur = next
|
|
}
|
|
cur[parts[len(parts)-1]] = value
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// splitFields splits s on spaces, keeping a double-quoted value (with
|
|
// backslash escapes) inside its field.
|
|
func splitFields(s string) ([]string, error) {
|
|
var out []string
|
|
for s != "" {
|
|
var b strings.Builder
|
|
for s != "" && s[0] != ' ' {
|
|
if s[0] == '"' {
|
|
end := 1
|
|
for end < len(s) && s[end] != '"' {
|
|
if s[end] == '\\' {
|
|
end++
|
|
}
|
|
end++
|
|
}
|
|
if end >= len(s) {
|
|
return nil, fmt.Errorf("unterminated quote")
|
|
}
|
|
b.WriteString(s[:end+1])
|
|
s = s[end+1:]
|
|
continue
|
|
}
|
|
b.WriteByte(s[0])
|
|
s = s[1:]
|
|
}
|
|
out = append(out, b.String())
|
|
s = strings.TrimLeft(s, " ")
|
|
}
|
|
return out, nil
|
|
}
|