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:
@@ -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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user