carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package applog is the pure half of per-app log shipping (`carlos logs`,
2// spec 2026-08-10-carlos-logs-design.md): the wire record, the box-side
3// sink (ring + budgets + drop honesty), the gzipped-JSONL chunk codec and
4// bucket key builders, and the read-side filter/merge fold. No I/O and no
5// clock of its own — internal/host owns the shipping loop, internal/console
6// owns the read path, exactly the internal/metrics split.
7package applog
8
9import (
10 "encoding/json"
11 "strconv"
12 "strings"
13 "time"
14)
15
16// Streams a Record can belong to. The vocabulary is fixed by the spec (§1)
17// so later stages are additive: stage 1 ships app + platform; edge and
18// release exist in the vocabulary now so nothing re-parses when they arrive.
19const (
20 StreamApp = "app" // the app process's own stdout/stderr
21 StreamPlatform = "platform" // wake/restart/lifecycle events, member-safe text
22 StreamEdge = "edge" // per-request access lines (stage 2)
23 StreamRelease = "release" // ship/promote history join (stage 3)
24)
25
26// ValidStream reports whether s names a known stream — the CLI/API's
27// vocabulary check, kept here so there is exactly one list.
28func ValidStream(s string) bool {
29 switch s {
30 case StreamApp, StreamPlatform, StreamEdge, StreamRelease:
31 return true
32 }
33 return false
34}
35
36// Levels a Record may carry. Order matters to Filter's threshold semantics;
37// levelRank is the one place that order lives.
38const (
39 LevelDebug = "debug"
40 LevelInfo = "info"
41 LevelWarn = "warn"
42 LevelError = "error"
43)
44
45// levelRank maps a level to its severity order. Unknown/absent levels rank
46// -1: a record that never claimed a level can never satisfy a -level
47// threshold, because a guessed level is a confident wrong answer (spec §4).
48func levelRank(level string) int {
49 switch level {
50 case LevelDebug:
51 return 0
52 case LevelInfo:
53 return 1
54 case LevelWarn:
55 return 2
56 case LevelError:
57 return 3
58 }
59 return -1
60}
61
62// ValidLevel reports whether s names a known level.
63func ValidLevel(s string) bool { return levelRank(s) >= 0 }
64
65// Record is one log line on the wire — one JSONL line inside a chunk.
66// Additive shape (AGENTS.md rule): every field but TS and Msg is omitempty,
67// unknown keys are ignored on read, and anything written before a shape
68// change must still parse. TS is the box clock in UTC; cross-box order is
69// best-effort by TS with (Box, Seq) as tiebreak — per-box order is the only
70// promise. Seq is monotonic per box process, which is what makes the follow
71// cursor exact and gaps detectable rather than invisible.
72type Record struct {
73 TS time.Time `json:"ts"`
74 Box string `json:"box,omitempty"`
75 Host string `json:"host,omitempty"` // route host — how owners name instances
76 Stream string `json:"stream,omitempty"`
77 Level string `json:"level,omitempty"`
78 Msg string `json:"msg"`
79 Fields map[string]string `json:"fields,omitempty"`
80 Seq uint64 `json:"seq,omitempty"`
81}
82
83// ParseAppLine interprets one line of app output. A line that parses as a
84// top-level JSON object yields: fields from its scalar values (numbers and
85// bools stringified; nested values ignored rather than flattened), msg from
86// a string-valued "msg" or "message" key (the raw line when neither is
87// present), and level ONLY when a "level" key carries a recognized value
88// (case-insensitive; "warning" counts as warn) — an unrecognized level
89// stays in fields as data. Anything else is opaque text: msg is the line,
90// untouched. Level is never guessed from text (spec §4): a guessed level
91// that makes -level error drop the crash line is worse than no level.
92func ParseAppLine(line string) (msg, level string, fields map[string]string) {
93 trimmed := strings.TrimSpace(line)
94 if !strings.HasPrefix(trimmed, "{") {
95 return line, "", nil
96 }
97 var obj map[string]any
98 if err := json.Unmarshal([]byte(trimmed), &obj); err != nil {
99 return line, "", nil
100 }
101 msg = line
102 for _, key := range []string{"msg", "message"} {
103 if s, ok := obj[key].(string); ok {
104 msg = s
105 delete(obj, key)
106 break
107 }
108 }
109 if s, ok := obj["level"].(string); ok {
110 if l := normalizeLevel(s); l != "" {
111 level = l
112 delete(obj, "level")
113 }
114 }
115 for k, v := range obj {
116 var s string
117 switch v := v.(type) {
118 case string:
119 s = v
120 case bool:
121 s = strconv.FormatBool(v)
122 case float64:
123 s = strconv.FormatFloat(v, 'f', -1, 64)
124 default: // nested object/array/null — not flattened, not invented
125 continue
126 }
127 if fields == nil {
128 fields = make(map[string]string)
129 }
130 fields[k] = s
131 }
132 return msg, level, fields
133}
134
135// normalizeLevel folds the common spellings onto the fixed vocabulary,
136// answering "" for anything it does not recognize.
137func normalizeLevel(s string) string {
138 switch strings.ToLower(s) {
139 case "debug", "trace":
140 return LevelDebug
141 case "info":
142 return LevelInfo
143 case "warn", "warning":
144 return LevelWarn
145 case "error", "err", "fatal", "panic":
146 return LevelError
147 }
148 return ""
149}
150
151// truncMarker is appended to a line NewLineWriter cut at its cap. It reads
152// as what happened rather than pretending the line ended there.
153const truncMarker = "…(truncated)"
154
155// LineWriter adapts a byte stream (an exec child's stdout/stderr) into
156// whole log lines: split on \n, blank lines dropped, over-long lines cut at
157// max bytes with truncMarker appended and the remainder of that line
158// discarded (not emitted as a phantom second line). Close flushes a
159// trailing partial so a crash's last unterminated words still ship.
160//
161// Not safe for concurrent Write from multiple goroutines — each stream gets
162// its own LineWriter, which is also what keeps interleaving per-stream.
163type LineWriter struct {
164 max int
165 emit func(line string)
166 buf []byte
167 skipping bool // inside the discarded tail of an over-long line
168}
169
170// NewLineWriter returns a LineWriter emitting complete lines to emit, each
171// at most max bytes before the truncation marker.
172func NewLineWriter(max int, emit func(line string)) *LineWriter {
173 return &LineWriter{max: max, emit: emit}
174}
175
176// Write is infallible by design: a log tee must never be the reason an
177// app's stdout write fails.
178func (w *LineWriter) Write(p []byte) (int, error) {
179 for _, b := range p {
180 if b == '\n' {
181 w.flush()
182 continue
183 }
184 if w.skipping {
185 continue
186 }
187 w.buf = append(w.buf, b)
188 if len(w.buf) >= w.max {
189 w.emit(string(w.buf) + truncMarker)
190 w.buf = w.buf[:0]
191 w.skipping = true
192 }
193 }
194 return len(p), nil
195}
196
197// Close flushes any trailing partial line. It never errors; the signature
198// exists so a LineWriter can stand where an io.Closer is expected.
199func (w *LineWriter) Close() error {
200 w.flush()
201 return nil
202}
203
204func (w *LineWriter) flush() {
205 if w.skipping {
206 w.skipping = false
207 w.buf = w.buf[:0]
208 return
209 }
210 if len(w.buf) == 0 {
211 return
212 }
213 w.emit(string(w.buf))
214 w.buf = w.buf[:0]
215}
216