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