coderd/experiments/evaluator.go
Rama main · réplica de lectura del repositorio original.
package experiments
import (
"context"
"errors"
"slices"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/codersdk"
)
// Store supplies stored rules and subject attributes to the Evaluator.
type Store interface {
// Rules returns the stored rule for every configured experiment.
// Experiments without a rule are absent from the map.
Rules(ctx context.Context) (map[codersdk.Experiment]StoredRule, error)
// UserAttributes loads the attributes conditions can read for userID.
UserAttributes(ctx context.Context, userID uuid.UUID) (User, error)
}
// Evaluator decides whether experiments are enabled for a subject user.
// It is safe for concurrent use. It does not cache decisions: every call
// reads the rules once, so rule changes apply to the next call.
type Evaluator struct {
logger slog.Logger
store Store
static codersdk.Experiments
programs *programCache
}
// New returns an Evaluator. static is the startup --experiments list; it
// is the default for experiments whose rule is absent or inherits.
func New(logger slog.Logger, store Store, static codersdk.Experiments) (*Evaluator, error) {
if store == nil {
return nil, xerrors.New("experiments: store is required")
}
// Build the CEL environment now so a broken environment fails startup
// instead of every decision.
if _, err := celEnv(); err != nil {
return nil, xerrors.Errorf("experiments: create CEL environment: %w", err)
}
return &Evaluator{
logger: logger,
store: store,
static: slices.Clone(static),
programs: newProgramCache(),
}, nil
}
// Enabled reports whether ex is enabled for userID.
func (e *Evaluator) Enabled(ctx context.Context, userID uuid.UUID, ex codersdk.Experiment) bool {
if !IsUserScoped(ex) {
return e.static.Enabled(ex)
}
return e.newDecision(ctx, userID).enabled(ex)
}
// EnabledExperiments returns the experiments enabled for userID. Static
// entries keep their order, including unknown strings. User-scoped
// entries decided off are removed, and user-scoped entries decided on that
// are not in the static list are appended in ExperimentsKnown order. Rules
// and attributes are each loaded at most once.
func (e *Evaluator) EnabledExperiments(ctx context.Context, userID uuid.UUID) codersdk.Experiments {
d := e.newDecision(ctx, userID)
result := make(codersdk.Experiments, 0, len(e.static))
for _, ex := range e.static {
if IsUserScoped(ex) && !d.enabled(ex) {
continue
}
result = append(result, ex)
}
for _, ex := range codersdk.ExperimentsKnown {
if !IsUserScoped(ex) || e.static.Enabled(ex) {
continue
}
if d.enabled(ex) {
result = append(result, ex)
}
}
return result
}
// decision holds the state of one Enabled or EnabledExperiments call. It
// is not safe for concurrent use.
type decision struct {
e *Evaluator
ctx context.Context
userID uuid.UUID
// failed is set when every user-scoped decision in this call must be
// off: a nil subject or a rules read error.
failed bool
rules map[codersdk.Experiment]StoredRule
userLoaded bool
user User
userErr error
// decided memoizes per-experiment results within this call.
decided map[codersdk.Experiment]bool
}
func (e *Evaluator) newDecision(ctx context.Context, userID uuid.UUID) *decision {
d := &decision{e: e, ctx: ctx, userID: userID, decided: make(map[codersdk.Experiment]bool)}
if userID == uuid.Nil {
e.logger.Error(ctx, "experiment decision without a subject user; user-scoped experiments are off")
d.failed = true
return d
}
rules, err := e.store.Rules(ctx)
if err != nil {
e.logReadError(ctx, "read experiment rules failed; user-scoped experiments are off", err)
d.failed = true
return d
}
d.rules = rules
return d
}
func (d *decision) enabled(ex codersdk.Experiment) bool {
if result, ok := d.decided[ex]; ok {
return result
}
result := d.decide(ex)
d.decided[ex] = result
return result
}
// decide applies the rule order: failed call, canceled context, no rule or
// inherit, malformed rule, on or off, then condition.
func (d *decision) decide(ex codersdk.Experiment) bool {
if d.failed {
return false
}
// A canceled caller must not get an enabled decision, even from an on
// rule or a static default that needs no further reads.
if d.ctx.Err() != nil {
d.e.logger.Debug(d.ctx, "experiment decision canceled; experiment is off",
slog.F("experiment", ex),
slog.F("category", categoryCancelled))
return false
}
stored, ok := d.rules[ex]
if !ok {
return d.e.static.Enabled(ex)
}
rule, err := decodeRule(stored)
if err != nil {
// Never log err: JSON errors can quote stored content.
d.e.logger.Error(d.ctx, "experiment rule is malformed; experiment is off",
slog.F("experiment", ex),
slog.F("revision", rule.Revision),
slog.F("category", categoryMalformed))
return false
}
switch rule.Mode {
case ModeInherit:
return d.e.static.Enabled(ex)
case ModeOn:
return true
case ModeOff:
return false
case ModeCondition:
return d.evaluate(ex, rule)
default:
// decodeRule rejects unknown modes.
panic("developer error: unhandled experiment rule mode " + string(rule.Mode))
}
}
func (d *decision) evaluate(ex codersdk.Experiment, rule Rule) bool {
user, err := d.loadUser()
if err != nil {
d.e.logReadError(d.ctx, "load experiment subject attributes failed; experiment is off", err,
slog.F("experiment", ex),
slog.F("revision", rule.Revision))
return false
}
prg, err := d.e.programs.get(rule.Condition)
if err == nil {
var result bool
result, err = evalCondition(d.ctx, prg, user)
if err == nil {
return result
}
}
d.logConditionError(ex, rule, err)
return false
}
// logReadError logs a failed store read. A read that failed because the
// context was canceled or expired is expected when a caller goes away, so
// it logs at Debug with category canceled instead of as an error.
func (e *Evaluator) logReadError(ctx context.Context, msg string, err error, fields ...slog.Field) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
e.logger.Debug(ctx, msg, append(fields, slog.F("category", categoryCancelled), slog.Error(err))...)
return
}
e.logger.Error(ctx, msg, append(fields, slog.F("category", categoryRead), slog.Error(err))...)
}
func (d *decision) loadUser() (User, error) {
if !d.userLoaded {
d.userLoaded = true
d.user, d.userErr = d.e.store.UserAttributes(d.ctx, d.userID)
}
return d.user, d.userErr
}
// logConditionError logs only the category and location. CEL messages can
// quote the condition, so err's text is never logged.
func (d *decision) logConditionError(ex codersdk.Experiment, rule Rule, err error) {
fields := []slog.Field{
slog.F("experiment", ex),
slog.F("revision", rule.Revision),
}
var cerr *conditionError
if !errors.As(err, &cerr) {
// Only environment setup errors reach here; they contain no
// condition text.
d.e.logger.Error(d.ctx, "experiment condition failed; experiment is off", append(fields, slog.Error(err))...)
return
}
fields = append(fields,
slog.F("category", cerr.category),
slog.F("line", cerr.line),
slog.F("column", cerr.column))
if cerr.category == categoryCancelled {
d.e.logger.Debug(d.ctx, "experiment condition canceled; experiment is off", fields...)
return
}
d.e.logger.Error(d.ctx, "experiment condition failed; experiment is off", fields...)
}