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:
@@ -26,8 +26,18 @@ type LogIssue struct {
|
||||
Message string `json:"message"`
|
||||
Count int `json:"count"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// Context is up to ±5 raw log lines around the FIRST occurrence of an error-severity
|
||||
// issue in this scrape window (≤11 lines, each ≤400 chars, redacted). Warn-severity
|
||||
// issues never carry context — bounds the payload. Additive since v0.111.0.
|
||||
Context []string `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// contextRadius lines before + the error line + contextRadius after = ≤11 lines.
|
||||
contextRadius = 5
|
||||
contextMaxLineChars = 400
|
||||
)
|
||||
|
||||
var (
|
||||
// Strip ANSI escape codes (color, bold, etc.)
|
||||
reANSI = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
@@ -97,17 +107,28 @@ func scanOneContainer(name string, since time.Duration, logger *log.Logger) Cont
|
||||
return summary
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
summary.ErrorCount, summary.WarnCount, summary.RecentIssues = analyzeLogLines(lines)
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// analyzeLogLines classifies and deduplicates a scan window's raw log lines.
|
||||
// Pure (no docker, no I/O) so it is unit-testable with synthetic windows.
|
||||
// Error-severity issues get a ±5-line context captured on their FIRST occurrence
|
||||
// only (never on repeats); warns carry no context.
|
||||
func analyzeLogLines(lines []string) (errorCount, warnCount int, issues []LogIssue) {
|
||||
// fingerprint → issue tracking
|
||||
type issueEntry struct {
|
||||
severity string
|
||||
message string
|
||||
count int
|
||||
lastSeen time.Time
|
||||
context []string
|
||||
}
|
||||
fingerprints := make(map[string]*issueEntry)
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for i, line := range lines {
|
||||
if !utf8.Valid([]byte(line)) {
|
||||
continue
|
||||
}
|
||||
@@ -124,9 +145,9 @@ func scanOneContainer(name string, since time.Duration, logger *log.Logger) Cont
|
||||
}
|
||||
|
||||
if severity == "error" {
|
||||
summary.ErrorCount++
|
||||
errorCount++
|
||||
} else {
|
||||
summary.WarnCount++
|
||||
warnCount++
|
||||
}
|
||||
|
||||
fp := fingerprint(line)
|
||||
@@ -138,23 +159,28 @@ func scanOneContainer(name string, since time.Duration, logger *log.Logger) Cont
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
fingerprints[fp] = &issueEntry{
|
||||
entry := &issueEntry{
|
||||
severity: severity,
|
||||
message: msg,
|
||||
count: 1,
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
if severity == "error" {
|
||||
entry.context = captureContext(lines, i)
|
||||
}
|
||||
fingerprints[fp] = entry
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice, sort by count DESC then lastSeen DESC, cap at 10
|
||||
issues := make([]LogIssue, 0, len(fingerprints))
|
||||
issues = make([]LogIssue, 0, len(fingerprints))
|
||||
for _, e := range fingerprints {
|
||||
issues = append(issues, LogIssue{
|
||||
Severity: e.severity,
|
||||
Message: e.message,
|
||||
Count: e.count,
|
||||
LastSeen: e.lastSeen,
|
||||
Context: e.context,
|
||||
})
|
||||
}
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
@@ -166,9 +192,58 @@ func scanOneContainer(name string, since time.Duration, logger *log.Logger) Cont
|
||||
if len(issues) > 10 {
|
||||
issues = issues[:10]
|
||||
}
|
||||
summary.RecentIssues = issues
|
||||
return errorCount, warnCount, issues
|
||||
}
|
||||
|
||||
return summary
|
||||
// captureContext returns the ±contextRadius window around lines[i], ordered as emitted.
|
||||
// Empty and invalid-UTF8 slots are dropped; each kept line is ANSI-stripped, truncated
|
||||
// to contextMaxLineChars with "…", and redacted (RedactLine) — nothing raw ships.
|
||||
func captureContext(lines []string, i int) []string {
|
||||
lo := i - contextRadius
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
hi := i + contextRadius
|
||||
if hi > len(lines)-1 {
|
||||
hi = len(lines) - 1
|
||||
}
|
||||
out := make([]string, 0, hi-lo+1)
|
||||
for j := lo; j <= hi; j++ {
|
||||
l := lines[j]
|
||||
if l == "" || !utf8.Valid([]byte(l)) {
|
||||
continue
|
||||
}
|
||||
l = reANSI.ReplaceAllString(l, "")
|
||||
l = truncateRunes(l, contextMaxLineChars)
|
||||
out = append(out, RedactLine(l))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateRunes rune-safely truncates s to max runes, appending "…" when cut.
|
||||
func truncateRunes(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
|
||||
// FetchContainerLogTail returns the last tailLines lines of one container's docker log,
|
||||
// ordered as emitted (stdout+stderr merged). Same CLI plumbing as the scanner; used by
|
||||
// the report's on-demand log-tail (operator request riding the ACK). Caller caps/redacts.
|
||||
func FetchContainerLogTail(name string, tailLines int) (string, error) {
|
||||
if tailLines <= 0 || tailLines > 1000 {
|
||||
tailLines = 200
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "docker", "logs", fmt.Sprintf("--tail=%d", tailLines), name)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker logs %s: %w", name, err)
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// cleanLine strips ANSI escape codes and timestamps from a log line.
|
||||
|
||||
Reference in New Issue
Block a user