FLUID-WP-0005 T02-T04. Redaction is deterministic configuration rather than a heuristic: the evidence store is append-only, so anything it accepts cannot be taken back out. Consumer identities are pseudonymized with HMAC rather than a bare hash, since a plain digest of a short identifier is reversible by enumeration. Applied rules are recorded on each event, so later analysis knows what it cannot see instead of mistaking an absence of evidence for evidence of absence. Redacted query parameters keep their key: which parameters a consumer sent is itself interface evidence. The topology analyzer requires several independent consumers before calling a shape a pattern. One client looping is a client bug; several converging on the same workaround is interface pressure. It also distinguishes a recovered error from a hard failure, because those two deserve different hypotheses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
223 lines
6.8 KiB
Go
223 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")
|
|
}
|
|
}
|