1// Package logging configures component-scoped zerowrap logging.
2package logging
3
4import (
5 "context"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11 "time"
12
13 "github.com/bnema/zerowrap"
14 "github.com/rs/zerolog"
15 "golang.org/x/term"
16)
17
18var components = map[string]bool{
19 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
20 "render": true, "sync": true, "config": true, "app": true,
21 "screensaver": true, "sched": true,
22}
23
24// categories are debug switches narrower than a component, only on when
25// named: "all" leaves them off. input-motion logs every pointer motion,
26// input-keys every key (typed text), drm-flip every frame and commit
27// completion with its timing. Naming one turns on debug for its component.
28var categories = map[string]string{"input-motion": "input", "input-keys": "input", "drm-flip": "drm"}
29
30type debugKey struct{}
31type levelKey struct{}
32type debugSet map[string]bool
33
34func ParseDebug(value string) (map[string]bool, error) {
35 selected := make(debugSet)
36 if value == "" {
37 return selected, nil
38 }
39 for _, name := range strings.Split(value, ",") {
40 if name == "all" {
41 selected[name] = true
42 continue
43 }
44 if _, ok := categories[name]; !components[name] && !ok {
45 return nil, fmt.Errorf("invalid debug component %q", name)
46 }
47 selected[name] = true
48 }
49 return selected, nil
50}
51
52// keepRuns is how many per-run log files Open retains.
53const keepRuns = 20
54
55// Open creates runs/<backend>/<timestamp>.log, points latest.log in that
56// directory at it, prunes old runs of that backend and attaches the logger
57// to ctx. Each backend keeps its own runs: headless test runs never rotate
58// a DRM session's log away.
59// The caller must invoke close after all logging is complete.
60func Open(ctx context.Context, backend, level, debug string) (context.Context, func() error, error) {
61 selected, err := ParseDebug(debug)
62 if err != nil {
63 return nil, nil, err
64 }
65 state := os.Getenv("XDG_STATE_HOME")
66 if state == "" {
67 home, err := os.UserHomeDir()
68 if err != nil {
69 return nil, nil, err
70 }
71 state = filepath.Join(home, ".local", "state")
72 }
73 dir := filepath.Join(state, "neferwl", "runs", backend)
74 if err := os.MkdirAll(dir, 0o700); err != nil {
75 return nil, nil, err
76 }
77 name := time.Now().Format("20060102-150405.000") + ".log"
78 file, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
79 if err != nil {
80 return nil, nil, err
81 }
82 latest := filepath.Join(dir, "latest.log")
83 _ = os.Remove(latest)
84 if err := os.Symlink(name, latest); err != nil {
85 _ = file.Close()
86 return nil, nil, err
87 }
88 pruneRuns(dir)
89 var output = zerowrap.Config{Level: level, Format: "json", Output: file}
90 if term.IsTerminal(int(os.Stderr.Fd())) {
91 output.Output = &consoleAndFile{file: file}
92 }
93 ctx = zerowrap.WithCtx(ctx, zerowrap.New(output))
94 ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))
95 ctx = context.WithValue(ctx, levelKey{}, level)
96 return ctx, file.Close, nil
97}
98
99func pruneRuns(dir string) {
100 runs, _ := filepath.Glob(filepath.Join(dir, "2*.log"))
101 sort.Strings(runs)
102 for len(runs) > keepRuns {
103 _ = os.Remove(runs[0])
104 runs = runs[1:]
105 }
106}
107
108type consoleAndFile struct{ file *os.File }
109
110func (w *consoleAndFile) Write(p []byte) (int, error) {
111 // JSON remains the canonical output; terminal users also see each entry.
112 _, _ = os.Stderr.Write(p)
113 return w.file.Write(p)
114}
115
116// Enabled reports whether a debug category was named in --debug.
117func Enabled(ctx context.Context, category string) bool {
118 selected, _ := ctx.Value(debugKey{}).(debugSet)
119 return selected[category]
120}
121
122// For returns a component logger with debug enabled only for selected components.
123func For(ctx context.Context, component string) zerowrap.Logger {
124 log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)
125 selected, _ := ctx.Value(debugKey{}).(debugSet)
126 level := zerolog.InfoLevel
127 switch ctx.Value(levelKey{}) {
128 case "debug":
129 level = zerolog.DebugLevel
130 case "warn":
131 level = zerolog.WarnLevel
132 case "error":
133 level = zerolog.ErrorLevel
134 }
135 if selected["all"] || selected[component] {
136 level = zerolog.DebugLevel
137 }
138 for name, owner := range categories {
139 if selected[name] && owner == component {
140 level = zerolog.DebugLevel
141 }
142 }
143 log = zerowrap.Logger{Logger: log.Level(level)}
144 return log
145}