Skip to content
quarry Artifact [932ab5c5a6]
Login

Artifact [932ab5c5a6]

Artifact 932ab5c5a63ab49bfaf82ee49371b41a0bfe6b57bbca09de229343077f389bf3:


package errlog

import (
	"errors"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/fault"
)

// The ring's whole value is what it does under repetition: one
// signature is one row with a count (a hot loop must not flood), the
// first sighting fires the alert hook exactly once, and eviction
// removes what STOPPED happening, never what is still happening.
func TestDedupFirstSightingAndEviction(t *testing.T) {
	r := New(2)
	var fired []string
	r.OnFirst = func(e Entry) { fired = append(fired, e.Message) }

	boom := errors.New("boom")
	for i := 0; i < 5; i++ {
		r.Report("web", "/x", boom)
	}
	if got := r.Recent(0); len(got) != 1 || got[0].Count != 5 {
		t.Fatalf("recent = %+v, want one row counted 5", got)
	}
	if len(fired) != 1 {
		t.Fatalf("OnFirst fired %d times, want once", len(fired))
	}

	r.Report("api", "/y", errors.New("crash"))
	r.Report("web", "/x", boom)                 // boom is STILL happening
	r.Report("panic", "/z", errors.New("bang")) // full: evict the stalest
	got := r.Recent(0)
	if len(got) != 2 {
		t.Fatalf("ring exceeded its cap: %+v", got)
	}
	for _, e := range got {
		if e.Message == "crash" {
			t.Fatalf("evicted the still-happening error instead of the stopped one: %+v", got)
		}
	}
	if got[0].Message != "bang" {
		t.Fatalf("recent not newest-first: %+v", got)
	}
	total, distinct, lastAt := r.Stats()
	if total != 7 || distinct != 2 || lastAt.IsZero() {
		t.Fatalf("stats = %d %d %v", total, distinct, lastAt)
	}
	r.Report("web", "/x", nil) // a nil error is not an error
	if tot, _, _ := r.Stats(); tot != 7 {
		t.Fatal("nil error was counted")
	}
}

// The append file is per-OCCURRENCE (the ring dedupes for the UI; the
// file is forensics and wants every event), and it must survive the
// server the ring cannot.
func TestOutputReceivesEveryOccurrence(t *testing.T) {
	var buf syncBuf
	r := New(10)
	r.SetOutput(&buf)
	e := errors.New("boom")
	r.Report("web", "/x", e)
	r.Report("web", "/x", e)
	if got := buf.String(); len(got) == 0 || countLines(got) != 2 {
		t.Fatalf("file lines = %q, want two occurrences", got)
	}
}

type syncBuf struct{ b []byte }

func (s *syncBuf) Write(p []byte) (int, error) { s.b = append(s.b, p...); return len(p), nil }
func (s *syncBuf) String() string              { return string(s.b) }

func countLines(s string) int {
	n := 0
	for _, c := range s {
		if c == '\n' {
			n++
		}
	}
	return n
}

// One outage that touches six names is ONE row. The journal used to key
// on the message text, so a workflow name inside it made six.
func TestOccurrencesGroupByFaultNotByText(t *testing.T) {
	r := New(50)
	for _, name := range []string{"mention", "landing", "forum-reply", "slow"} {
		r.Report("notify", "ensure workflows",
			fault.Wrap(errors.New(`Put "http://127.0.0.1:3000/v1/workflows/`+name+`": refused`),
				"notify workflow could not be provisioned").
				Cost("its alerts are not delivered").
				With("workflow", name))
	}
	got := r.Recent(10)
	if len(got) != 1 {
		t.Fatalf("%d rows for one outage: %v", len(got), got)
	}
	if got[0].Count != 4 {
		t.Errorf("count %d, want every occurrence", got[0].Count)
	}
	// The title is the sentence; the specifics are data beside it. A
	// message carrying the name is a new row each time it moves.
	if got[0].Message != "notify workflow could not be provisioned" {
		t.Errorf("message carries specifics: %q", got[0].Message)
	}
	if got[0].Why == "" {
		t.Error("the cost did not survive the trip through the journal")
	}
	// Fields describe the LATEST occurrence, and the cause is one of
	// them rather than trailing the title.
	var w, cause string
	for _, f := range got[0].Fields {
		switch f.Key {
		case "workflow":
			w = f.Value
		case "cause":
			cause = f.Value
		}
	}
	if w != "slow" {
		t.Errorf("workflow field %q, want the latest occurrence", w)
	}
	if !strings.Contains(cause, "refused") {
		t.Errorf("cause field %q", cause)
	}
	// Two genuinely different faults still get two rows.
	r.Report("health", "dagnats", fault.New("dagnats is unreachable"))
	if got := r.Recent(10); len(got) != 2 {
		t.Errorf("%d rows, want the second fault to stand alone", len(got))
	}
}

// Every occurrence is stamped with the build that saw it: without it a
// count cannot tell a live fault from a ghost of the binary before the
// fix.
func TestOccurrencesCarryTheBuild(t *testing.T) {
	r := New(10)
	r.SetBuild("1.2.3")
	r.Report("web", "/x", errors.New("boom"))
	got := r.Recent(1)
	if len(got) != 1 || got[0].Build != "1.2.3" {
		t.Fatalf("build %+v", got)
	}
}