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
148 lines
4.2 KiB
Go
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
|
|
}
|