fluid-core/internal/observation/cohort.go
tegwick e779d1f8b9 Add redaction filter, cohort engine and topology analyzer
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
2026-09-04 03:08:05 +02:00

148 lines
4.2 KiB
Go

package observation
import (
"net/http"
"sort"
"strings"
"github.com/tegwick/fluid-core/internal/contract"
)
// CohortRule assigns a request to a cohort when every stated condition holds.
//
// Rules are deterministic and declarative rather than learned. Blueprint 6.3
// wants cohorts stable enough to compare over time, and a classifier that
// drifts makes last month's measurement incomparable with this month's.
type CohortRule struct {
// Cohort is the assignment this rule produces.
Cohort contract.CohortID
// Header matches a header value exactly, when both are set.
Header string
HeaderValue string
// HeaderPrefix matches a header by prefix, for SDK version families.
HeaderPrefix string
// PathPrefix matches the request path.
PathPrefix string
// Description explains the population, for the operator reading a report.
Description string
}
func (r CohortRule) matches(req *http.Request) bool {
if r.Header != "" {
got := req.Header.Get(r.Header)
switch {
case r.HeaderValue != "":
if !strings.EqualFold(got, r.HeaderValue) {
return false
}
case r.HeaderPrefix != "":
if !strings.HasPrefix(strings.ToLower(got), strings.ToLower(r.HeaderPrefix)) {
return false
}
default:
if got == "" {
return false
}
}
}
if r.PathPrefix != "" && !strings.HasPrefix(req.URL.Path, r.PathPrefix) {
return false
}
return true
}
// CohortEngine groups consumers into analytically useful populations.
type CohortEngine struct {
rules []CohortRule
fallback contract.CohortID
policy RedactionPolicy
}
// NewCohortEngine returns an engine. Rules are evaluated in order, first match
// wins, so ordering is how an operator expresses precedence.
func NewCohortEngine(fallback contract.CohortID, policy RedactionPolicy, rules ...CohortRule) *CohortEngine {
return &CohortEngine{rules: rules, fallback: fallback, policy: policy}
}
// Cohort implements the runtime's CohortResolver.
//
// It returns the pseudonymous consumer reference alongside the cohort, so the
// identity never reaches the data plane in raw form: redaction happens at
// assignment rather than later in the pipeline, where an intervening component
// could have logged it.
func (e *CohortEngine) Cohort(r *http.Request) (contract.CohortID, string) {
consumer := e.policy.Pseudonymize(consumerIdentity(r))
for _, rule := range e.rules {
if rule.matches(r) {
return rule.Cohort, consumer
}
}
return e.fallback, consumer
}
// consumerIdentity extracts the raw identity a request claims.
func consumerIdentity(r *http.Request) string {
for _, header := range []string{"X-FLUID-Consumer", "X-Consumer-ID"} {
if v := r.Header.Get(header); v != "" {
return v
}
}
return ""
}
// Describe lists the configured cohorts, for operator display.
func (e *CohortEngine) Describe() []CohortRule {
out := make([]CohortRule, len(e.rules))
copy(out, e.rules)
sort.Slice(out, func(i, j int) bool { return out[i].Cohort < out[j].Cohort })
return out
}
// Population counts distinct consumers per cohort over a set of events.
//
// Counts below the policy's minimum are reported as suppressed rather than as
// a number, so a report cannot accidentally single out an individual.
type Population struct {
Cohort contract.CohortID `json:"cohort"`
Consumers int `json:"consumers"`
Events int `json:"events"`
Suppressed bool `json:"suppressed"`
}
// Populations summarizes cohort sizes across events.
func (e *CohortEngine) Populations(events []contract.FluidTelemetry) []Population {
consumers := map[contract.CohortID]map[string]struct{}{}
counts := map[contract.CohortID]int{}
for _, ev := range events {
if ev.Cohort == nil {
continue
}
c := *ev.Cohort
counts[c]++
if consumers[c] == nil {
consumers[c] = map[string]struct{}{}
}
if ev.ConsumerRef != "" {
consumers[c][ev.ConsumerRef] = struct{}{}
}
}
out := make([]Population, 0, len(counts))
for c, n := range counts {
distinct := len(consumers[c])
out = append(out, Population{
Cohort: c,
Consumers: distinct,
Events: n,
Suppressed: e.policy.SuppressSmallCohort(distinct),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Cohort < out[j].Cohort })
return out
}