carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package applog
2
3import (
4 "fmt"
5 "regexp"
6 "sort"
7 "strings"
8 "time"
9)
10
11// Filter is the read-side match: zero values mean "no constraint", so the
12// zero Filter matches everything. Applied console-side (spec §6) so the
13// whole window never crosses the wire just to be discarded.
14type Filter struct {
15 Since time.Time // inclusive; zero = unbounded
16 Until time.Time // inclusive; zero = unbounded
17 Stream string
18 Host string
19 // Level is a threshold, not an exact match: "warn" admits warn and
20 // error. A record with no level NEVER satisfies a set threshold —
21 // levels are never guessed (spec §4), so an unleveled record cannot
22 // claim to clear a bar it never set.
23 Level string
24 Grep *regexp.Regexp // matched against Msg
25 Fields map[string]string // every listed key must be present and equal
26}
27
28// Match reports whether r survives f.
29func (f Filter) Match(r Record) bool {
30 if !f.Since.IsZero() && r.TS.Before(f.Since) {
31 return false
32 }
33 if !f.Until.IsZero() && r.TS.After(f.Until) {
34 return false
35 }
36 if f.Stream != "" && r.Stream != f.Stream {
37 return false
38 }
39 if f.Host != "" && r.Host != f.Host {
40 return false
41 }
42 if f.Level != "" {
43 want := levelRank(f.Level)
44 got := levelRank(r.Level)
45 if got < 0 || got < want {
46 return false
47 }
48 }
49 if f.Grep != nil && !f.Grep.MatchString(r.Msg) {
50 return false
51 }
52 for k, v := range f.Fields {
53 if r.Fields[k] != v {
54 return false
55 }
56 }
57 return true
58}
59
60// MergeRecords folds per-source slices into one timeline ordered by
61// (TS, Box, Seq). Per-box order is exact (Seq is monotonic per box);
62// cross-box order is best-effort by box clock with a deterministic
63// tiebreak — the only promise the spec makes (§4).
64func MergeRecords(slices ...[]Record) []Record {
65 var out []Record
66 for _, s := range slices {
67 out = append(out, s...)
68 }
69 sort.SliceStable(out, func(i, j int) bool {
70 a, b := out[i], out[j]
71 if !a.TS.Equal(b.TS) {
72 return a.TS.Before(b.TS)
73 }
74 if a.Box != b.Box {
75 return a.Box < b.Box
76 }
77 return a.Seq < b.Seq
78 })
79 return out
80}
81
82// ParseTimeArg reads a -since/-until value: RFC3339, or a Go duration
83// shorthand ("15m", "1h") meaning that long before now. Empty is the zero
84// time (no constraint), not an error.
85func ParseTimeArg(s string, now time.Time) (time.Time, error) {
86 if s == "" {
87 return time.Time{}, nil
88 }
89 if d, err := time.ParseDuration(s); err == nil {
90 if d < 0 {
91 return time.Time{}, fmt.Errorf("negative duration %q", s)
92 }
93 return now.Add(-d), nil
94 }
95 if t, err := time.Parse(time.RFC3339, s); err == nil {
96 return t, nil
97 }
98 return time.Time{}, fmt.Errorf("cannot parse %q: want RFC3339 (2026-08-10T11:00:00Z) or a duration (15m, 1h)", s)
99}
100
101// ParseFieldArg reads one -field k=v argument.
102func ParseFieldArg(s string) (k, v string, err error) {
103 k, v, ok := strings.Cut(s, "=")
104 if !ok || k == "" {
105 return "", "", fmt.Errorf("cannot parse field %q: want key=value", s)
106 }
107 return k, v, nil
108}
109