224 lines
6.8 KiB
Go
224 lines
6.8 KiB
Go
|
|
package observation
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
)
|
||
|
|
|
||
|
|
var base = time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC)
|
||
|
|
|
||
|
|
func req(consumer string, offset time.Duration, method, route string, status int64) contract.FluidTelemetry {
|
||
|
|
cohort := contract.CohortID("coding-agents")
|
||
|
|
s := status
|
||
|
|
return contract.FluidTelemetry{
|
||
|
|
ID: fmt.Sprintf("tl-%s-%d", consumer, offset),
|
||
|
|
OccurredAt: base.Add(offset),
|
||
|
|
Kind: contract.FluidTelemetryKindRequest,
|
||
|
|
ConsumerRef: consumer,
|
||
|
|
Cohort: &cohort,
|
||
|
|
Request: &contract.FluidTelemetryRequest{Route: route, Method: method, Status: &s},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func errEv(consumer string, offset time.Duration, route string, class contract.FluidTelemetryErrorClass) contract.FluidTelemetry {
|
||
|
|
ev := req(consumer, offset, "GET", route, 400)
|
||
|
|
ev.Kind = contract.FluidTelemetryKindError
|
||
|
|
ev.Error = &contract.FluidTelemetryError{Class: class}
|
||
|
|
return ev
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestDetectsInefficientUsagePattern reproduces the Blueprint section 33
|
||
|
|
// worked example: consumers listing everything to find one record.
|
||
|
|
func TestDetectsInefficientUsagePattern(t *testing.T) {
|
||
|
|
a := NewTopologyAnalyzer()
|
||
|
|
|
||
|
|
var events []contract.FluidTelemetry
|
||
|
|
for _, consumer := range []string{"c-1", "c-2", "c-3"} {
|
||
|
|
for chain := 0; chain < 2; chain++ {
|
||
|
|
start := time.Duration(chain) * time.Hour
|
||
|
|
events = append(events,
|
||
|
|
req(consumer, start, "GET", "/customers/{id}/invoices", 200),
|
||
|
|
req(consumer, start+time.Second, "GET", "/customers/{id}/invoices", 200),
|
||
|
|
req(consumer, start+2*time.Second, "GET", "/customers/{id}/invoices", 200),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
patterns := a.Patterns(events)
|
||
|
|
if len(patterns) == 0 {
|
||
|
|
t.Fatal("no pattern detected in a clearly repeated shape")
|
||
|
|
}
|
||
|
|
|
||
|
|
p := patterns[0]
|
||
|
|
if p.Consumers != 3 {
|
||
|
|
t.Errorf("independent consumers = %d, want 3", p.Consumers)
|
||
|
|
}
|
||
|
|
if p.Count != 6 {
|
||
|
|
t.Errorf("occurrences = %d, want 6", p.Count)
|
||
|
|
}
|
||
|
|
// The repeated route is the signal that the consumer is compensating.
|
||
|
|
if p.MaxRepeats != 3 {
|
||
|
|
t.Errorf("max repeats = %d, want 3", p.MaxRepeats)
|
||
|
|
}
|
||
|
|
if p.RepeatedStep != "GET /customers/{id}/invoices" {
|
||
|
|
t.Errorf("repeated step = %q", p.RepeatedStep)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestOneConsumerRepeatingIsNotAPattern: a single client looping is a client
|
||
|
|
// bug; several independent consumers converging is interface pressure.
|
||
|
|
func TestOneConsumerRepeatingIsNotAPattern(t *testing.T) {
|
||
|
|
a := NewTopologyAnalyzer()
|
||
|
|
|
||
|
|
var events []contract.FluidTelemetry
|
||
|
|
for chain := 0; chain < 10; chain++ {
|
||
|
|
start := time.Duration(chain) * time.Hour
|
||
|
|
events = append(events,
|
||
|
|
req("c-1", start, "GET", "/entries", 200),
|
||
|
|
req("c-1", start+time.Second, "GET", "/entries", 200),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if patterns := a.Patterns(events); len(patterns) != 0 {
|
||
|
|
t.Errorf("a single consumer's loop was reported as a pattern: %+v", patterns)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestChainGapSplitsInteractions(t *testing.T) {
|
||
|
|
a := NewTopologyAnalyzer()
|
||
|
|
a.ChainGap = 10 * time.Second
|
||
|
|
|
||
|
|
events := []contract.FluidTelemetry{
|
||
|
|
req("c-1", 0, "GET", "/a", 200),
|
||
|
|
req("c-1", 2*time.Second, "GET", "/b", 200),
|
||
|
|
// Well past the gap: a new interaction, not a continuation.
|
||
|
|
req("c-1", time.Minute, "GET", "/c", 200),
|
||
|
|
}
|
||
|
|
|
||
|
|
interactions := a.Interactions(events)
|
||
|
|
if len(interactions) != 2 {
|
||
|
|
t.Fatalf("got %d interactions, want 2", len(interactions))
|
||
|
|
}
|
||
|
|
if len(interactions[0].Steps) != 2 || len(interactions[1].Steps) != 1 {
|
||
|
|
t.Errorf("steps split wrongly: %d and %d",
|
||
|
|
len(interactions[0].Steps), len(interactions[1].Steps))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestExplicitChainIDOverridesTiming(t *testing.T) {
|
||
|
|
a := NewTopologyAnalyzer()
|
||
|
|
a.ChainGap = time.Second
|
||
|
|
|
||
|
|
withChain := func(ev contract.FluidTelemetry, id string) contract.FluidTelemetry {
|
||
|
|
ev.Sequence = &contract.FluidTelemetrySequence{ChainID: id}
|
||
|
|
return ev
|
||
|
|
}
|
||
|
|
|
||
|
|
// Two calls an hour apart, but the consumer says they are one task.
|
||
|
|
events := []contract.FluidTelemetry{
|
||
|
|
withChain(req("c-1", 0, "GET", "/a", 200), "chain-1"),
|
||
|
|
withChain(req("c-1", time.Hour, "GET", "/b", 200), "chain-1"),
|
||
|
|
}
|
||
|
|
|
||
|
|
interactions := a.Interactions(events)
|
||
|
|
if len(interactions) != 1 {
|
||
|
|
t.Fatalf("an explicit chain id was split by timing: got %d interactions", len(interactions))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRecoveredErrorIsDistinguished: invalid request, then a corrected retry,
|
||
|
|
// is a recoverable misunderstanding rather than a hard failure, and the two
|
||
|
|
// deserve different hypotheses.
|
||
|
|
func TestRecoveredErrorIsDistinguished(t *testing.T) {
|
||
|
|
a := NewTopologyAnalyzer()
|
||
|
|
|
||
|
|
var events []contract.FluidTelemetry
|
||
|
|
for _, consumer := range []string{"c-1", "c-2", "c-3"} {
|
||
|
|
for chain := 0; chain < 2; chain++ {
|
||
|
|
start := time.Duration(chain) * time.Hour
|
||
|
|
events = append(events,
|
||
|
|
errEv(consumer, start, "/entries", contract.FluidTelemetryErrorClassValidation),
|
||
|
|
req(consumer, start+time.Second, "GET", "/entries", 200),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
patterns := a.Patterns(events)
|
||
|
|
if len(patterns) == 0 {
|
||
|
|
t.Fatal("no pattern detected")
|
||
|
|
}
|
||
|
|
if patterns[0].RecoveredError != contract.FluidTelemetryErrorClassValidation {
|
||
|
|
t.Errorf("recovered error = %q, want validation", patterns[0].RecoveredError)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPatternsAreDeterministicallyOrdered(t *testing.T) {
|
||
|
|
a := NewTopologyAnalyzer()
|
||
|
|
|
||
|
|
var events []contract.FluidTelemetry
|
||
|
|
for _, consumer := range []string{"c-1", "c-2", "c-3"} {
|
||
|
|
for chain := 0; chain < 5; chain++ {
|
||
|
|
start := time.Duration(chain) * time.Hour
|
||
|
|
events = append(events, req(consumer, start, "GET", "/frequent", 200))
|
||
|
|
}
|
||
|
|
for chain := 0; chain < 2; chain++ {
|
||
|
|
start := time.Duration(chain+10) * time.Hour
|
||
|
|
events = append(events, req(consumer, start, "GET", "/rare", 200))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
first := a.Patterns(events)
|
||
|
|
if len(first) < 2 {
|
||
|
|
t.Fatalf("expected two patterns, got %d", len(first))
|
||
|
|
}
|
||
|
|
// Biggest signal first, so an analyst reads the important thing first.
|
||
|
|
if first[0].Count < first[1].Count {
|
||
|
|
t.Error("patterns are not ordered by frequency")
|
||
|
|
}
|
||
|
|
|
||
|
|
for i := 0; i < 20; i++ {
|
||
|
|
again := a.Patterns(events)
|
||
|
|
for j := range first {
|
||
|
|
if again[j].Signature != first[j].Signature {
|
||
|
|
t.Fatal("pattern ordering varied between runs")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCohortPopulationsRespectMinimumSize(t *testing.T) {
|
||
|
|
policy := testPolicy()
|
||
|
|
e := NewCohortEngine("unclassified", policy)
|
||
|
|
|
||
|
|
var events []contract.FluidTelemetry
|
||
|
|
// One cohort with a single consumer, one with plenty.
|
||
|
|
small := contract.CohortID("lone-partner")
|
||
|
|
for i := 0; i < 20; i++ {
|
||
|
|
ev := req("only-one", time.Duration(i)*time.Second, "GET", "/a", 200)
|
||
|
|
ev.Cohort = &small
|
||
|
|
events = append(events, ev)
|
||
|
|
}
|
||
|
|
big := contract.CohortID("agents")
|
||
|
|
for i := 0; i < 20; i++ {
|
||
|
|
ev := req(fmt.Sprintf("c-%d", i), time.Duration(i)*time.Second, "GET", "/a", 200)
|
||
|
|
ev.Cohort = &big
|
||
|
|
events = append(events, ev)
|
||
|
|
}
|
||
|
|
|
||
|
|
pops := e.Populations(events)
|
||
|
|
byCohort := map[contract.CohortID]Population{}
|
||
|
|
for _, p := range pops {
|
||
|
|
byCohort[p.Cohort] = p
|
||
|
|
}
|
||
|
|
|
||
|
|
if !byCohort[small].Suppressed {
|
||
|
|
t.Error("a cohort of one consumer was reportable")
|
||
|
|
}
|
||
|
|
if byCohort[big].Suppressed {
|
||
|
|
t.Error("a cohort of twenty consumers was suppressed")
|
||
|
|
}
|
||
|
|
}
|