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
116 lines
3.8 KiB
Go
116 lines
3.8 KiB
Go
package report
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Part D — the consume-once seam: an ACK arms the pending set, the FIRST build drains
|
|
// it, the FOLLOWING build gets nothing. Red-proof: removing the clear in
|
|
// drainPendingLogTails makes the second drain return the apps again → this fails.
|
|
func TestPendingLogTails_ConsumeOnce(t *testing.T) {
|
|
SetPendingLogTails([]string{"gokapi", "cwa"})
|
|
|
|
first := drainPendingLogTails()
|
|
if len(first) != 2 || first[0] != "gokapi" || first[1] != "cwa" {
|
|
t.Fatalf("first drain = %v, want the two requested apps", first)
|
|
}
|
|
second := drainPendingLogTails()
|
|
if len(second) != 0 {
|
|
t.Fatalf("second drain = %v — tails would ship on EVERY report (consume-once broken)", second)
|
|
}
|
|
}
|
|
|
|
// An ACK without requests clears any stale local set (hub is the source of truth).
|
|
func TestPendingLogTails_EmptyAckClears(t *testing.T) {
|
|
SetPendingLogTails([]string{"gokapi"})
|
|
SetPendingLogTails(nil)
|
|
if got := drainPendingLogTails(); len(got) != 0 {
|
|
t.Fatalf("stale pending set survived an empty ACK: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildLogTailsSection_OrderedAndRedacted(t *testing.T) {
|
|
fetch := func(app string, lines int) (string, error) {
|
|
if lines != 200 {
|
|
t.Fatalf("fetch asked for %d lines, want 200", lines)
|
|
}
|
|
return "line one\nline two with password=hunter2\nline three\n", nil
|
|
}
|
|
tails := buildLogTailsSection([]string{"gokapi"}, fetch, nil)
|
|
if len(tails) != 1 || tails[0].App != "gokapi" {
|
|
t.Fatalf("tails = %+v", tails)
|
|
}
|
|
got := tails[0].Lines
|
|
if len(got) != 3 {
|
|
t.Fatalf("lines = %d (trailing empty must be dropped): %#v", len(got), got)
|
|
}
|
|
if got[0] != "line one" || got[2] != "line three" {
|
|
t.Fatalf("order broken: %#v", got)
|
|
}
|
|
if strings.Contains(got[1], "hunter2") || !strings.Contains(got[1], "[REDACTED]") {
|
|
t.Fatalf("tail line not redacted: %q", got[1])
|
|
}
|
|
if tails[0].CollectedAt.IsZero() {
|
|
t.Fatalf("collected_at not stamped")
|
|
}
|
|
}
|
|
|
|
func TestBuildLogTailsSection_LineLengthCap(t *testing.T) {
|
|
fetch := func(string, int) (string, error) {
|
|
return strings.Repeat("y", 900) + "\nshort", nil
|
|
}
|
|
tails := buildLogTailsSection([]string{"a"}, fetch, nil)
|
|
first := tails[0].Lines[0]
|
|
if utf8.RuneCountInString(first) != 401 || !strings.HasSuffix(first, "…") {
|
|
t.Fatalf("line not capped at 400+ellipsis: runes=%d", utf8.RuneCountInString(first))
|
|
}
|
|
}
|
|
|
|
// 64KB per-app budget: HEAD-truncate — the newest lines must survive.
|
|
func TestBuildLogTailsSection_ByteBudgetKeepsNewest(t *testing.T) {
|
|
var sb strings.Builder
|
|
for i := 0; i < 300; i++ {
|
|
sb.WriteString(fmt.Sprintf("%04d ", i))
|
|
sb.WriteString(strings.Repeat("p", 295))
|
|
sb.WriteString("\n")
|
|
}
|
|
fetch := func(string, int) (string, error) { return sb.String(), nil }
|
|
tails := buildLogTailsSection([]string{"a"}, fetch, nil)
|
|
lines := tails[0].Lines
|
|
if len(lines) >= 300 {
|
|
t.Fatalf("budget not enforced: %d lines kept", len(lines))
|
|
}
|
|
total := 0
|
|
for _, l := range lines {
|
|
total += len(l) + 1
|
|
}
|
|
if total > logTailMaxBytes {
|
|
t.Fatalf("kept %d bytes > %d budget", total, logTailMaxBytes)
|
|
}
|
|
if !strings.HasPrefix(lines[len(lines)-1], "0299") {
|
|
t.Fatalf("NEWEST line lost — head-truncation inverted; last kept = %q", lines[len(lines)-1][:5])
|
|
}
|
|
if strings.HasPrefix(lines[0], "0000") {
|
|
t.Fatalf("oldest line survived a budget overflow — nothing was dropped")
|
|
}
|
|
}
|
|
|
|
// A failed fetch skips that app but the others still ship (hub retries the failed one
|
|
// next cycle since its pending request was never fulfilled).
|
|
func TestBuildLogTailsSection_FetchErrorSkips(t *testing.T) {
|
|
fetch := func(app string, _ int) (string, error) {
|
|
if app == "broken" {
|
|
return "", errors.New("no such stack")
|
|
}
|
|
return "ok line\n", nil
|
|
}
|
|
tails := buildLogTailsSection([]string{"broken", "healthy"}, fetch, nil)
|
|
if len(tails) != 1 || tails[0].App != "healthy" {
|
|
t.Fatalf("tails = %+v, want only the healthy app", tails)
|
|
}
|
|
}
|