package observation import ( "sort" "strings" "time" "github.com/tegwick/fluid-core/internal/contract" ) // Interaction is one consumer's ordered call chain. // // ArchitectureBlueprint.md section 6.4: the analyzer looks beyond individual // requests, because interaction topologies are often more informative than // simple error counts. A single 200 tells you nothing; the same 200 fetched // forty times to find one record tells you the interface is missing a concept. type Interaction struct { ConsumerRef string Cohort contract.CohortID Started time.Time Ended time.Time Steps []Step } // Step is one call within an interaction. type Step struct { Route string Method string Status int64 Error contract.FluidTelemetryErrorClass } // Signature renders an interaction as a comparable shape. // // Routes are used rather than concrete URLs so that two consumers doing the // same thing to different resources produce the same signature. Without that // normalization every chain is unique and no pattern is ever detected twice. func (i Interaction) Signature() string { parts := make([]string, 0, len(i.Steps)) for _, s := range i.Steps { part := s.Method + " " + s.Route if s.Error != "" { part += " !" + string(s.Error) } parts = append(parts, part) } return strings.Join(parts, " -> ") } // Pattern is a recurring interaction shape observed across consumers. type Pattern struct { Signature string `json:"signature"` Steps int `json:"steps"` Count int `json:"occurrences"` Consumers int `json:"independent_consumers"` Cohorts []contract.CohortID `json:"cohorts"` // RepeatedStep names a route called more than once in the same chain, which // is the usual shape of a consumer compensating for a missing capability. RepeatedStep string `json:"repeated_step,omitempty"` // MaxRepeats is how many times that route appeared in the worst chain. MaxRepeats int `json:"max_repeats,omitempty"` // RecoveredError names an error the consumer hit and then worked past, // which distinguishes a recoverable misunderstanding from a hard failure. RecoveredError contract.FluidTelemetryErrorClass `json:"recovered_error,omitempty"` FirstSeen time.Time `json:"first_seen"` LastSeen time.Time `json:"last_seen"` } // TopologyAnalyzer groups telemetry into interactions and finds patterns. type TopologyAnalyzer struct { // ChainGap is the idle time after which a consumer's next call starts a new // interaction rather than continuing the previous one. ChainGap time.Duration // MinOccurrences is how often a shape must appear before it is a pattern. MinOccurrences int // MinConsumers is how many independent consumers must show the shape. // One consumer repeating itself is a client bug; several independent // consumers converging on the same workaround is interface pressure. MinConsumers int } // NewTopologyAnalyzer returns an analyzer with workable defaults. func NewTopologyAnalyzer() *TopologyAnalyzer { return &TopologyAnalyzer{ ChainGap: 30 * time.Second, MinOccurrences: 3, MinConsumers: 2, } } // Interactions groups events into per-consumer call chains. // // Grouping prefers an explicit chain id when the consumer supplied one, and // falls back to time-bounded sessions per consumer. The fallback is a heuristic // and is why chain ids are worth asking agentic consumers for. func (a *TopologyAnalyzer) Interactions(events []contract.FluidTelemetry) []Interaction { ordered := make([]contract.FluidTelemetry, len(events)) copy(ordered, events) sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].OccurredAt.Before(ordered[j].OccurredAt) }) type key struct{ consumer, chain string } open := map[key]*Interaction{} var done []Interaction for _, ev := range ordered { if ev.Request == nil && ev.Error == nil { continue } consumer := ev.ConsumerRef if consumer == "" { consumer = ev.CorrelationID } if consumer == "" { continue } chain := "" if ev.Sequence != nil { chain = ev.Sequence.ChainID } k := key{consumer: consumer, chain: chain} current, ok := open[k] // With no explicit chain id, an idle gap ends the interaction. if ok && chain == "" && ev.OccurredAt.Sub(current.Ended) > a.ChainGap { done = append(done, *current) ok = false } if !ok { cohort := contract.CohortID("") if ev.Cohort != nil { cohort = *ev.Cohort } current = &Interaction{ ConsumerRef: consumer, Cohort: cohort, Started: ev.OccurredAt, } open[k] = current } current.Ended = ev.OccurredAt current.Steps = append(current.Steps, stepOf(ev)) } for _, in := range open { done = append(done, *in) } sort.Slice(done, func(i, j int) bool { return done[i].Started.Before(done[j].Started) }) return done } func stepOf(ev contract.FluidTelemetry) Step { var s Step if ev.Request != nil { s.Route = ev.Request.Route s.Method = ev.Request.Method if ev.Request.Status != nil { s.Status = *ev.Request.Status } } if ev.Error != nil { s.Error = ev.Error.Class } if s.Route == "" { s.Route = "(unknown)" } return s } // Patterns reports recurring interaction shapes. func (a *TopologyAnalyzer) Patterns(events []contract.FluidTelemetry) []Pattern { interactions := a.Interactions(events) type acc struct { count int consumers map[string]struct{} cohorts map[contract.CohortID]struct{} steps int repeated string maxRepeats int recovered contract.FluidTelemetryErrorClass first time.Time last time.Time } groups := map[string]*acc{} for _, in := range interactions { if len(in.Steps) == 0 { continue } sig := in.Signature() g, ok := groups[sig] if !ok { g = &acc{ consumers: map[string]struct{}{}, cohorts: map[contract.CohortID]struct{}{}, steps: len(in.Steps), first: in.Started, last: in.Ended, } groups[sig] = g } g.count++ g.consumers[in.ConsumerRef] = struct{}{} if in.Cohort != "" { g.cohorts[in.Cohort] = struct{}{} } if in.Started.Before(g.first) { g.first = in.Started } if in.Ended.After(g.last) { g.last = in.Ended } if route, n := repeatedRoute(in); n > g.maxRepeats { g.repeated, g.maxRepeats = route, n } if class, ok := recoveredError(in); ok { g.recovered = class } } out := make([]Pattern, 0, len(groups)) for sig, g := range groups { if g.count < a.MinOccurrences || len(g.consumers) < a.MinConsumers { continue } cohorts := make([]contract.CohortID, 0, len(g.cohorts)) for c := range g.cohorts { cohorts = append(cohorts, c) } sort.Slice(cohorts, func(i, j int) bool { return cohorts[i] < cohorts[j] }) p := Pattern{ Signature: sig, Steps: g.steps, Count: g.count, Consumers: len(g.consumers), Cohorts: cohorts, FirstSeen: g.first, LastSeen: g.last, } if g.maxRepeats > 1 { p.RepeatedStep, p.MaxRepeats = g.repeated, g.maxRepeats } p.RecoveredError = g.recovered out = append(out, p) } // Most frequent first: an analyst reading this wants the biggest signal at // the top, and a stable tiebreak keeps the output diffable. sort.Slice(out, func(i, j int) bool { if out[i].Count != out[j].Count { return out[i].Count > out[j].Count } return out[i].Signature < out[j].Signature }) return out } // repeatedRoute finds the most-repeated route within one interaction. func repeatedRoute(in Interaction) (string, int) { counts := map[string]int{} for _, s := range in.Steps { counts[s.Method+" "+s.Route]++ } best, bestN := "", 0 routes := make([]string, 0, len(counts)) for r := range counts { routes = append(routes, r) } sort.Strings(routes) for _, r := range routes { if counts[r] > bestN { best, bestN = r, counts[r] } } return best, bestN } // recoveredError reports an error the consumer hit and then got past. // // This is the shape Blueprint 6.4 calls out as a recoverable misunderstanding: // invalid request, schema lookup, retry with a corrected request. It is a // different problem from a chain that simply fails, and conflating the two // would send the wrong hypothesis to the Daimon. func recoveredError(in Interaction) (contract.FluidTelemetryErrorClass, bool) { var seen contract.FluidTelemetryErrorClass for _, s := range in.Steps { if s.Error != "" { seen = s.Error continue } if seen != "" && s.Status >= 200 && s.Status < 300 { return seen, true } } return "", false }