544c42a618
- 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
110 lines
3.5 KiB
Go
110 lines
3.5 KiB
Go
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]) + "…"
|
|
}
|