fluid-core/internal/observation/topology.go

307 lines
8.4 KiB
Go
Raw Normal View History

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
}