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) } }