v0.111.0: remote app-log diagnostics — error context capture (±5 lines, capped+redacted) + on-demand log tails via ACK pull pattern

- metrics: LogIssue.Context on first-occurrence errors (≤11 lines, ≤400 chars/line, warns carry none); RedactLine sanitizer (password/token/api-key/bearer/64-hex) applied to everything shipped; FetchContainerLogTail
- report: 16KB per-report context budget (lowest-count issues dropped first); log_tail_requests ACK flag → next report ships log_tails (200 lines, ≤64KB/app head-truncated, ordered, redacted); consume-once drain
- tests: synthetic-window context capture, caps, redaction, budget order, consume-once, fetch-error skip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-10 15:43:29 +02:00
parent 332725a024
commit 544c42a618
12 changed files with 670 additions and 8 deletions
+109
View File
@@ -0,0 +1,109 @@
package report
import (
"log"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
)
// On-demand log tail (pull-based, same ACK-flag pattern as escrow/config-refresh):
// the hub stores a pending request per (customer, app); the report ACK advertises the
// requested app names in log_tail_requests; the NEXT report carries the collected tails
// in log_tails. The hub clears the pending request when a tail arrives (consume-once) —
// the controller listens to no one, it only pushes on its own cycle.
const (
logTailLines = 200
logTailMaxLineChars = 400
logTailMaxBytes = 64 * 1024 // per app; head-truncated (newest lines kept)
)
// LogTail is one app's on-demand ordered log tail riding the report.
type LogTail struct {
App string `json:"app"`
CollectedAt time.Time `json:"collected_at"`
Lines []string `json:"lines"`
}
var (
pendingTailsMu sync.Mutex
pendingTails []string
)
// SetPendingLogTails records the hub-requested apps from the latest ACK. The hub is
// the source of truth: an ACK without requests clears any stale local pending set.
func SetPendingLogTails(apps []string) {
pendingTailsMu.Lock()
defer pendingTailsMu.Unlock()
pendingTails = append([]string(nil), apps...)
}
// drainPendingLogTails takes and CLEARS the pending set (consume-once controller-side).
// If the subsequent push fails, the hub's request is still pending and the next ACK
// re-arms it — fail-safe retry, no duplicate shipping.
func drainPendingLogTails() []string {
pendingTailsMu.Lock()
defer pendingTailsMu.Unlock()
apps := pendingTails
pendingTails = nil
return apps
}
// buildLogTailsSection collects the requested tails via fetch (seam: stacks.GetLogs or
// metrics.FetchContainerLogTail). Lines stay ordered as emitted; each line is truncated
// to 400 chars and redacted; per-app total is capped at 64KB keeping the NEWEST lines.
// A failed fetch is skipped (logged) — the hub request stays pending and retries next cycle.
func buildLogTailsSection(apps []string, fetch func(app string, lines int) (string, error), logger *log.Logger) []LogTail {
var tails []LogTail
for _, app := range apps {
raw, err := fetch(app, logTailLines)
if err != nil {
if logger != nil {
logger.Printf("[WARN] [report] log-tail fetch for %s failed: %v", app, err)
}
continue
}
lines := capTailLines(strings.Split(raw, "\n"))
tails = append(tails, LogTail{App: app, CollectedAt: time.Now().UTC(), Lines: lines})
if logger != nil {
logger.Printf("[INFO] [report] log-tail collected for %s: %d lines", app, len(lines))
}
}
return tails
}
// capTailLines truncates each line to logTailMaxLineChars, redacts it, and enforces the
// per-app byte budget by dropping from the HEAD (oldest) so the newest lines survive.
func capTailLines(lines []string) []string {
// Drop a trailing empty split artifact.
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
out := make([]string, 0, len(lines))
for _, l := range lines {
l = metrics.RedactLine(truncateTailLine(l, logTailMaxLineChars))
out = append(out, l)
}
// Head-truncate to the byte budget: walk from the end accumulating.
total := 0
start := len(out)
for i := len(out) - 1; i >= 0; i-- {
total += len(out[i]) + 1 // +1 for the newline it represents
if total > logTailMaxBytes {
break
}
start = i
}
return out[start:]
}
func truncateTailLine(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "…"
}