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
160 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|