fluid-core/internal/observation/cohort.go

149 lines
4.2 KiB
Go
Raw Normal View History

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
}