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