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 }