Files
felhom-controller/controller/internal/metrics/logscanner_test.go
T
admin 544c42a618 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
2026-07-10 15:43:29 +02:00

160 lines
5.2 KiB
Go

package metrics
import (
"fmt"
"strings"
"testing"
"unicode/utf8"
)
func findIssue(t *testing.T, issues []LogIssue, substr string) LogIssue {
t.Helper()
for _, is := range issues {
if strings.Contains(is.Message, substr) {
return is
}
}
t.Fatalf("no issue containing %q in %+v", substr, issues)
return LogIssue{}
}
// Part B — a synthetic scrape with an error mid-stream: Context must be EXACTLY the
// ±5 window, ordered as emitted. Red-proof: dropping the captureContext call leaves
// Context empty → this fails.
func TestAnalyzeLogLines_ErrorContextWindow(t *testing.T) {
lines := []string{
"boot line zero",
"ctx minus five",
"ctx minus four",
"ctx minus three",
"ctx minus two",
"ctx minus one",
"ERROR: nfs mount lost", // index 6
"ctx plus one",
"ctx plus two",
"ctx plus three",
"ctx plus four",
"ctx plus five",
"outside the window",
}
errC, warnC, issues := analyzeLogLines(lines)
if errC != 1 || warnC != 0 {
t.Fatalf("counts: errors=%d warns=%d, want 1/0", errC, warnC)
}
is := findIssue(t, issues, "nfs mount lost")
want := []string{
"ctx minus five", "ctx minus four", "ctx minus three", "ctx minus two", "ctx minus one",
"ERROR: nfs mount lost",
"ctx plus one", "ctx plus two", "ctx plus three", "ctx plus four", "ctx plus five",
}
if len(is.Context) != len(want) {
t.Fatalf("context has %d lines, want %d: %#v", len(is.Context), len(want), is.Context)
}
for i := range want {
if is.Context[i] != want[i] {
t.Fatalf("context[%d] = %q, want %q (order must match emission)", i, is.Context[i], want[i])
}
}
}
// Repeat occurrence of the same fingerprint in one window: count aggregates but the
// context stays the FIRST occurrence's window — never re-captured on repeats.
func TestAnalyzeLogLines_RepeatKeepsFirstContext(t *testing.T) {
lines := []string{
"first neighborhood",
"ERROR: db connection refused",
"after the first",
"filler a", "filler b", "filler c", "filler d", "filler e", "filler f",
"second neighborhood",
"ERROR: db connection refused",
"after the second",
}
_, _, issues := analyzeLogLines(lines)
is := findIssue(t, issues, "db connection refused")
if is.Count != 2 {
t.Fatalf("count = %d, want 2 (same fingerprint must dedupe)", is.Count)
}
joined := strings.Join(is.Context, "\n")
if !strings.Contains(joined, "first neighborhood") {
t.Fatalf("context lost the FIRST occurrence window: %#v", is.Context)
}
if strings.Contains(joined, "second neighborhood") {
t.Fatalf("context re-captured on a repeat occurrence: %#v", is.Context)
}
}
// Warn-severity issues carry NO context (message-only) — bounds the payload.
func TestAnalyzeLogLines_WarnHasNoContext(t *testing.T) {
lines := []string{"before", "WARN: disk latency high", "after"}
_, warnC, issues := analyzeLogLines(lines)
if warnC != 1 {
t.Fatalf("warns = %d, want 1", warnC)
}
is := findIssue(t, issues, "disk latency high")
if len(is.Context) != 0 {
t.Fatalf("warn issue must not carry context, got %#v", is.Context)
}
}
// Caps: oversized context lines are truncated to 400 runes + "…" (rune-safe).
func TestAnalyzeLogLines_ContextLineTruncated(t *testing.T) {
long := strings.Repeat("x", 450)
lines := []string{long, "ERROR: it broke", "after"}
_, _, issues := analyzeLogLines(lines)
is := findIssue(t, issues, "it broke")
if len(is.Context) == 0 {
t.Fatalf("expected context")
}
got := is.Context[0]
if utf8.RuneCountInString(got) != 401 || !strings.HasSuffix(got, "…") {
t.Fatalf("oversized line not truncated to 400+ellipsis: len=%d suffix=%q",
utf8.RuneCountInString(got), got[len(got)-3:])
}
}
// Part E on the capture path: a secret in a NEIGHBOR line must ship redacted.
func TestAnalyzeLogLines_ContextRedacted(t *testing.T) {
lines := []string{
"connecting with password=hunter2 now",
"ERROR: auth failed",
"retrying",
}
_, _, issues := analyzeLogLines(lines)
is := findIssue(t, issues, "auth failed")
joined := strings.Join(is.Context, "\n")
if strings.Contains(joined, "hunter2") {
t.Fatalf("secret shipped in context: %#v", is.Context)
}
if !strings.Contains(joined, "password=[REDACTED]") {
t.Fatalf("expected redaction marker in context: %#v", is.Context)
}
}
// Error at the very start/end of the window: the ±5 clamps without panicking
// and still contains the error line itself.
func TestAnalyzeLogLines_WindowClamped(t *testing.T) {
lines := []string{"ERROR: first line broke", "after one"}
_, _, issues := analyzeLogLines(lines)
is := findIssue(t, issues, "first line broke")
if len(is.Context) != 2 {
t.Fatalf("clamped context = %#v, want the 2 available lines", is.Context)
}
}
// The 10-issue cap and count-DESC ordering still hold with contexts attached.
func TestAnalyzeLogLines_CapAndOrder(t *testing.T) {
var lines []string
for i := 0; i < 12; i++ {
lines = append(lines, fmt.Sprintf("ERROR: distinct failure mode %s", strings.Repeat("z", i+1)))
}
// make one of them dominant
lines = append(lines, "ERROR: distinct failure mode z", "ERROR: distinct failure mode z")
_, _, issues := analyzeLogLines(lines)
if len(issues) != 10 {
t.Fatalf("issues = %d, want capped at 10", len(issues))
}
if issues[0].Count != 3 {
t.Fatalf("top issue count = %d, want the dominant one (3) first", issues[0].Count)
}
}