Aller au contenu

Expériences (tests A/B)

phil/feature-flags-bundle

Une expérience répartit entre des variantes pondérées les contextes pour lesquels un indicateur est activé, selon un attribut du contexte.

flags:
    checkout-layout:
        active: true
        experiment:
            attribute: tenant
            variants: { one-column: 50, two-columns: 50 }
        kind: experiment

Déclarer

PartieTypeContrainte
attributeTargetingAttributetenant, project ou subject.
variantstable de nom de variante vers poidsAu moins deux variantes, chacune déclarée une seule fois.
Nom de varianteVariantNameL'alphabet des noms d'indicateur (segments ASCII en minuscules séparés par ., _ ou -), au plus 50 octets. Une clé numérique comme 1 se lit comme le nom "1".
PoidsVariantUn nombre entier de 0 à 100 ; la somme des poids vaut exactement 100.

Experiment::of() refuse tout le reste et, dans la configuration, la construction du conteneur échoue en indiquant le chemin de l'indicateur.

Qui voit l'expérience

L'expérience s'applique après l'état actif et les règles (flags-and-evaluation.md) :

SituationRésultat
Indicateur inactifdésactivé, DISABLED, aucune variante
Actif, règles présentes, aucune ne corresponddésactivé, DEFAULT, aucune variante
Actif, règles absentes ou l'une correspond, le contexte a l'attributactivé, SPLIT, avec sa variante
Actif, règles absentes ou l'une correspond, le contexte n'a pas l'attributdésactivé, DEFAULT, aucune variante

Les règles choisissent donc la population et l'expérience la répartit : rules: [{ attribute: tenant, percentage: 10 }] avec une expérience mène le test sur un dixième des locataires.

Attribution

Une valeur de l'attribut tombe dans un compartiment qui lui est propre (Bucket::forVariant()) :

bucket = murmur3a("<flag>:variant:<value>") mod 100

Les compartiments sont remis aux variantes dans leur ordre de déclaration, par poids cumulé, chaque borne étant exclusive : avec control: 50, treatment: 50, les compartiments 0 à 49 voient control et 50 à 99 voient treatment.

  • Stable. Le même locataire voit la même variante à chaque requête et sur chaque serveur.
  • Salé à part du déploiement progressif. Le sel :variant: évite que les locataires admis en premier par un déploiement progressif de l'indicateur soient aussi ceux de sa première variante. Un nom d'indicateur ne contient aucun : : le sel ne peut donc pas entrer en collision avec l'entrée d'un autre indicateur.
  • Un poids de 0 garde une variante déclarée sans y envoyer personne. La somme des poids vaut toujours 100 : sa part va donc à une autre branche. Donnez-la à une branche voisine, et seuls les contextes de la branche fermée se déplacent ; chaque autre branche garde les siens.
  • Ne changez les poids qu'entre deux expériences. Déplacer une borne fait passer des contextes d'une branche à l'autre au milieu d'une mesure.

Une porte à sens unique

La fonction de compartiment des variantes est fixée par les tests (ExperimentTest), comme celle du déploiement progressif :

EntréeCompartiment
new-checkout:variant:tenant-148
new-checkout:variant:tenant-267
new-checkout:variant:4298

La changer déplace les cohortes de chaque expérience en cours.

Lire la variante

$variant = $flags->variant(FlagName::of('checkout-layout'));   // ?VariantName
if ('two-columns' === $variant?->value) { /* … */ }
{% if feature_variant('checkout-layout') == 'two-columns' %}…{% endif %}
#[IsFeatureEnabled('checkout-layout', variant: 'two-columns')]   // 404 outside that arm
condition: "service('feature_flags').variant('checkout-layout') == 'two-columns'"

Voyez placement.md.

Expositions

Lorsqu'un exposure_recorder est configuré, FlagEvaluator appelle ExposureRecorder::record(FlagName $flag, VariantName $variant) à la première évaluation d'un indicateur qui donne une variante pour un contexte dans une requête : le moment où l'application pose la question est celui où elle bifurque selon la réponse. Les évaluations suivantes du même indicateur pour le même contexte dans la même requête sont mémoïsées et n'enregistrent rien ; un contexte qui n'est placé dans aucune branche (désactivé, aucune variante) n'enregistre rien.

Une exposition est un compte, jamais un enregistrement. L'enregistreur reçoit l'indicateur et la variante, et rien du contexte : aucun locataire, aucun projet, aucun sujet. Il ajoute un à un agrégat que tient l'application (« variante treatment de checkout-layout vue une fois de plus aujourd'hui »), et l'application compare les branches selon ses propres mesures agrégées (nombres de conversions, durées). Un journal d'expositions par personne serait un registre de qui a vu quoi, soit exactement ce que l'évaluation refuse de produire ; une application qui a besoin d'une analyse par sujet la conçoit et la justifie de son côté.

Toute évaluation compte comme une exposition, d'où qu'elle vienne : l'attribut de contrôleur, une condition de route, une fonction Twig ou un service.

use Phil\FeatureFlagsBundle\Application\Flag\ExposureRecorder;
use Phil\FeatureFlagsBundle\Domain\Flag\FlagName;
use Phil\FeatureFlagsBundle\Domain\Flag\VariantName;

final readonly class DailyExposureCounter implements ExposureRecorder
{
    public function __construct(private \Doctrine\DBAL\Connection $db, private \Psr\Clock\ClockInterface $clock) {}

    public function record(FlagName $flag, VariantName $variant): void
    {
        $this->db->executeStatement(
            'INSERT INTO flag_exposure (day, flag, variant, seen) VALUES (?, ?, ?, 1)
             ON CONFLICT (day, flag, variant) DO UPDATE SET seen = flag_exposure.seen + 1',
            [$this->clock->now()->format('Y-m-d'), $flag->value, $variant->value],
        );
    }
}

(La table et l'insertion ou mise à jour appartiennent à l'application ; le bundle ne fournit aucun magasin d'expositions.)

Dans la liste et l'administration

La liste et l'administration affichent le ciblage d'une expérience sous la forme A/B by <attribute>: <variant> <weight>%, … (console.md, admin.md). Dans le formulaire de modification de l'administration, l'expérience s'écrit en JSON à côté des règles : "experiment": {"attribute": "tenant", "variants": {"control": 50, "treatment": 50}}.