// Package applog is the pure half of per-app log shipping (`carlos logs`, // spec 2026-08-10-carlos-logs-design.md): the wire record, the box-side // sink (ring + budgets + drop honesty), the gzipped-JSONL chunk codec and // bucket key builders, and the read-side filter/merge fold. No I/O and no // clock of its own — internal/host owns the shipping loop, internal/console // owns the read path, exactly the internal/metrics split. package applog import ( "encoding/json" "strconv" "strings" "time" ) // Streams a Record can belong to. The vocabulary is fixed by the spec (§1) // so later stages are additive: stage 1 ships app + platform; edge and // release exist in the vocabulary now so nothing re-parses when they arrive. const ( StreamApp = "app" // the app process's own stdout/stderr StreamPlatform = "platform" // wake/restart/lifecycle events, member-safe text StreamEdge = "edge" // per-request access lines (stage 2) StreamRelease = "release" // ship/promote history join (stage 3) ) // ValidStream reports whether s names a known stream — the CLI/API's // vocabulary check, kept here so there is exactly one list. func ValidStream(s string) bool { switch s { case StreamApp, StreamPlatform, StreamEdge, StreamRelease: return true } return false } // Levels a Record may carry. Order matters to Filter's threshold semantics; // levelRank is the one place that order lives. const ( LevelDebug = "debug" LevelInfo = "info" LevelWarn = "warn" LevelError = "error" ) // levelRank maps a level to its severity order. Unknown/absent levels rank // -1: a record that never claimed a level can never satisfy a -level // threshold, because a guessed level is a confident wrong answer (spec §4). func levelRank(level string) int { switch level { case LevelDebug: return 0 case LevelInfo: return 1 case LevelWarn: return 2 case LevelError: return 3 } return -1 } // ValidLevel reports whether s names a known level. func ValidLevel(s string) bool { return levelRank(s) >= 0 } // Record is one log line on the wire — one JSONL line inside a chunk. // Additive shape (AGENTS.md rule): every field but TS and Msg is omitempty, // unknown keys are ignored on read, and anything written before a shape // change must still parse. TS is the box clock in UTC; cross-box order is // best-effort by TS with (Box, Seq) as tiebreak — per-box order is the only // promise. Seq is monotonic per box process, which is what makes the follow // cursor exact and gaps detectable rather than invisible. type Record struct { TS time.Time `json:"ts"` Box string `json:"box,omitempty"` Host string `json:"host,omitempty"` // route host — how owners name instances Stream string `json:"stream,omitempty"` Level string `json:"level,omitempty"` Msg string `json:"msg"` Fields map[string]string `json:"fields,omitempty"` Seq uint64 `json:"seq,omitempty"` } // ParseAppLine interprets one line of app output. A line that parses as a // top-level JSON object yields: fields from its scalar values (numbers and // bools stringified; nested values ignored rather than flattened), msg from // a string-valued "msg" or "message" key (the raw line when neither is // present), and level ONLY when a "level" key carries a recognized value // (case-insensitive; "warning" counts as warn) — an unrecognized level // stays in fields as data. Anything else is opaque text: msg is the line, // untouched. Level is never guessed from text (spec §4): a guessed level // that makes -level error drop the crash line is worse than no level. func ParseAppLine(line string) (msg, level string, fields map[string]string) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "{") { return line, "", nil } var obj map[string]any if err := json.Unmarshal([]byte(trimmed), &obj); err != nil { return line, "", nil } msg = line for _, key := range []string{"msg", "message"} { if s, ok := obj[key].(string); ok { msg = s delete(obj, key) break } } if s, ok := obj["level"].(string); ok { if l := normalizeLevel(s); l != "" { level = l delete(obj, "level") } } for k, v := range obj { var s string switch v := v.(type) { case string: s = v case bool: s = strconv.FormatBool(v) case float64: s = strconv.FormatFloat(v, 'f', -1, 64) default: // nested object/array/null — not flattened, not invented continue } if fields == nil { fields = make(map[string]string) } fields[k] = s } return msg, level, fields } // normalizeLevel folds the common spellings onto the fixed vocabulary, // answering "" for anything it does not recognize. func normalizeLevel(s string) string { switch strings.ToLower(s) { case "debug", "trace": return LevelDebug case "info": return LevelInfo case "warn", "warning": return LevelWarn case "error", "err", "fatal", "panic": return LevelError } return "" } // truncMarker is appended to a line NewLineWriter cut at its cap. It reads // as what happened rather than pretending the line ended there. const truncMarker = "…(truncated)" // LineWriter adapts a byte stream (an exec child's stdout/stderr) into // whole log lines: split on \n, blank lines dropped, over-long lines cut at // max bytes with truncMarker appended and the remainder of that line // discarded (not emitted as a phantom second line). Close flushes a // trailing partial so a crash's last unterminated words still ship. // // Not safe for concurrent Write from multiple goroutines — each stream gets // its own LineWriter, which is also what keeps interleaving per-stream. type LineWriter struct { max int emit func(line string) buf []byte skipping bool // inside the discarded tail of an over-long line } // NewLineWriter returns a LineWriter emitting complete lines to emit, each // at most max bytes before the truncation marker. func NewLineWriter(max int, emit func(line string)) *LineWriter { return &LineWriter{max: max, emit: emit} } // Write is infallible by design: a log tee must never be the reason an // app's stdout write fails. func (w *LineWriter) Write(p []byte) (int, error) { for _, b := range p { if b == '\n' { w.flush() continue } if w.skipping { continue } w.buf = append(w.buf, b) if len(w.buf) >= w.max { w.emit(string(w.buf) + truncMarker) w.buf = w.buf[:0] w.skipping = true } } return len(p), nil } // Close flushes any trailing partial line. It never errors; the signature // exists so a LineWriter can stand where an io.Closer is expected. func (w *LineWriter) Close() error { w.flush() return nil } func (w *LineWriter) flush() { if w.skipping { w.skipping = false w.buf = w.buf[:0] return } if len(w.buf) == 0 { return } w.emit(string(w.buf)) w.buf = w.buf[:0] }