307 lines
8.4 KiB
Go
307 lines
8.4 KiB
Go
|
|
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
|
||
|
|
}
|