blob: 34bf0c1fc076d743a1b36f7aede9ad2532e59f39 [file]
// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/variations/variations_seed_processor.h"
#include <stddef.h>
#include <map>
#include <optional>
#include <utility>
#include <vector>
#include "base/command_line.h"
#include "base/feature_list.h"
#include "base/metrics/field_trial.h"
#include "base/metrics/field_trial_params.h"
#include "base/metrics/histogram_functions.h"
#include "base/rand_util.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "base/types/optional_ref.h"
#include "components/variations/client_filterable_state.h"
#include "components/variations/entropy_provider.h"
#include "components/variations/experiment_group_ids.h"
#include "components/variations/processed_study.h"
#include "components/variations/study_filtering.h"
#include "components/variations/variations_associated_data.h"
#include "components/variations/variations_layers.h"
namespace variations {
namespace internal {
const char kFeatureConflictGroupName[] = "ClientSideFeatureConflict";
const char kGoogleGroupFeatureParamName[] = "__GGIDS";
const char kGoogleGroupFeatureParamSeparator[] = ",";
} // namespace internal
namespace {
// Serializes the `google_groups` attribute of `filter`.
std::string SerializeGoogleGroupsFilter(const Study::Filter& filter) {
std::string result;
for (int64_t group_id : filter.google_group()) {
if (!result.empty()) {
result.append(internal::kGoogleGroupFeatureParamSeparator);
}
result.append(base::NumberToString(group_id));
}
return result;
}
// Converts `seconds` to a base::Time relative to the Unix epoch, or returns
// `default_value` if `seconds` is non-positive.
base::Time FromSecondsSinceUnixEpoch(int64_t seconds,
base::Time default_value) {
return (seconds <= 0) ? default_value
: base::Time::FromSecondsSinceUnixEpoch(seconds);
}
// Returns the time window during which the study may, upon becoming active, be
// visible to Google web properties. Note that the start and end proto fields
// default to 0, which are converted to base::Time::Min() and base::Time::Max()
// respectively, which is the desired default behavior for studies having no
// explicit start or end visibility dates.
TimeWindow GetWebVisibilityTimeWindow(const Study& study) {
const base::Time start_time =
FromSecondsSinceUnixEpoch(study.google_web_visibility_start_date(),
/*default_value=*/base::Time::Min());
const base::Time end_time =
FromSecondsSinceUnixEpoch(study.google_web_visibility_end_date(),
/*default_value=*/base::Time::Max());
// TimeWindow gracefully maps invalid time windows (e.g. start >= end)
// as empty (zero-duration) time spans.
return {start_time, std::max(start_time, end_time)};
}
// Associates the variations params of `experiment`, if present.
void RegisterExperimentParams(const Study& study,
const Study::Experiment& experiment) {
std::map<std::string, std::string> params;
for (const auto& param : experiment.param()) {
if (param.has_name() && param.has_value()) {
params[param.name()] = param.value();
}
}
// If the study has a filter with a `google_groups` attribute, we write those
// Google Group ids into a feature parameter. This allows looking up which
// Google Groups may influence a feature's state.
if (study.filter().google_group_size() > 0) {
params[internal::kGoogleGroupFeatureParamName] =
SerializeGoogleGroupsFilter(study.filter());
}
if (!params.empty()) {
base::AssociateFieldTrialParams(study.name(), experiment.name(),
std::move(params));
}
}
// Returns the IDCollectionKey with which |experiment| should be associated.
// Returns nullopt when |experiment| doesn't have a Google web or Google web
// trigger experiment ID.
std::optional<IDCollectionKey> GetKeyForWebExperiment(
const Study::Experiment& experiment) {
if (!HasGoogleWebExperimentId(experiment)) {
return std::nullopt;
}
bool has_web_experiment_id = experiment.has_google_web_experiment_id();
bool has_web_trigger_experiment_id =
experiment.has_google_web_trigger_experiment_id();
// An experiment cannot have both |google_web_experiment_id| and
// |google_trigger_web_experiment_id|. This is enforced by
// ValidateAndComputeTotalProbability() in processed_study.cc.
CHECK(!(has_web_experiment_id && has_web_trigger_experiment_id));
Study::GoogleWebVisibility visibility = experiment.google_web_visibility();
if (visibility == Study::FIRST_PARTY) {
return has_web_trigger_experiment_id
? GOOGLE_WEB_PROPERTIES_TRIGGER_FIRST_PARTY
: GOOGLE_WEB_PROPERTIES_FIRST_PARTY;
}
return has_web_trigger_experiment_id
? GOOGLE_WEB_PROPERTIES_TRIGGER_ANY_CONTEXT
: GOOGLE_WEB_PROPERTIES_ANY_CONTEXT;
}
// If there are VariationIDs associated with the |experiment| arm of |study|,
// register said VariationIDs unless `is_trial_overridden` is true.
void RegisterVariationIds(base::PassKey<VariationsSeedProcessor> pass_key,
const Study::Experiment& experiment,
const Study& study,
bool is_trial_overridden) {
if (is_trial_overridden && experiment.has_google_web_experiment_id()) {
Study::Experiment updated_experiment = experiment;
updated_experiment.clear_google_web_experiment_id();
RegisterVariationIds(pass_key, updated_experiment, study, false);
return;
}
const std::string& trial_name = study.name();
const auto web_visibility_time_window = GetWebVisibilityTimeWindow(study);
if (experiment.has_google_app_experiment_id()) {
const VariationID variation_id =
static_cast<VariationID>(experiment.google_app_experiment_id());
AssociateGoogleVariationID(pass_key, GOOGLE_APP,
MakeActiveGroupId(trial_name, experiment.name()),
variation_id, web_visibility_time_window);
}
std::optional<IDCollectionKey> key = GetKeyForWebExperiment(experiment);
if (!key.has_value()) {
return;
}
CHECK(HasGoogleWebExperimentId(experiment));
// An experiment cannot have both |google_web_experiment_id| and
// |google_trigger_web_experiment_id|. See GetKeyForWebExperiment() for more
// details.
const VariationID variation_id =
experiment.has_google_web_trigger_experiment_id()
? static_cast<VariationID>(
experiment.google_web_trigger_experiment_id())
: static_cast<VariationID>(experiment.google_web_experiment_id());
AssociateGoogleVariationID(pass_key, key.value(),
MakeActiveGroupId(trial_name, experiment.name()),
variation_id, web_visibility_time_window);
}
// Whether the given study should be activated on startup.
bool ShouldActivate(const Study& study,
const std::string group_name,
StickyActivationManager& sticky_activation_manager) {
switch (study.activation_type()) {
case Study::ACTIVATE_ON_STARTUP:
return true;
case Study::ACTIVATE_ON_QUERY:
return false;
case Study::STICKY_AFTER_QUERY:
return sticky_activation_manager.ShouldActivate(study.name(), group_name);
case Study_ActivationType_Study_ActivationType_INT_MIN_SENTINEL_DO_NOT_USE_:
case Study_ActivationType_Study_ActivationType_INT_MAX_SENTINEL_DO_NOT_USE_:
// Part of the enum but won't be seen in practice. See processed_study.cc.
return false;
}
}
// Forces the specified |experiment| to be enabled in |study|.
void ForceExperimentState(
base::PassKey<VariationsSeedProcessor> pass_key,
const Study& study,
const Study::Experiment& experiment,
StickyActivationManager& sticky_activation_manager,
base::FieldTrial& trial) {
RegisterExperimentParams(study, experiment);
RegisterVariationIds(pass_key, experiment, study, trial.IsOverridden());
}
// Associates features for groups that do not specify them manually.
void AssociateDefaultFeatures(const Study& study,
base::FieldTrial* trial,
base::FeatureList* feature_list) {
// Note: We only compute feature associations for ACTIVATE_ON_QUERY studies,
// since these associations are only used to determine that the trial has
// been queried when the feature is queried.
// Note: We only compute feature associations for ACTIVATE_ON_QUERY and
// STICKY_AFTER_QUERY studies, since these associations are only used to
// ensure that the trial is activated when the feature is queried
switch (study.activation_type()) {
case Study::ACTIVATE_ON_STARTUP:
return;
case Study::ACTIVATE_ON_QUERY:
// fall-through:
case Study::STICKY_AFTER_QUERY:
break;
case Study_ActivationType_Study_ActivationType_INT_MIN_SENTINEL_DO_NOT_USE_:
case Study_ActivationType_Study_ActivationType_INT_MAX_SENTINEL_DO_NOT_USE_:
// Part of the enum but won't be seen in practice. See processed_study.cc.
return;
}
std::set<std::string> features_to_associate;
for (const auto& experiment : study.experiment()) {
const auto& features = experiment.feature_association();
for (const auto& feature : features.enable_feature()) {
features_to_associate.insert(feature);
}
for (const auto& feature : features.disable_feature()) {
features_to_associate.insert(feature);
}
}
for (const auto& feature_name : features_to_associate) {
feature_list->RegisterFieldTrialOverride(
feature_name, base::FeatureList::OVERRIDE_USE_DEFAULT, trial);
}
}
// Registers feature overrides `experiment` in the `study`.
void RegisterFeatureOverrides(const Study& study,
const Study::Experiment& experiment,
base::FieldTrial* trial,
base::FeatureList* feature_list) {
// Process all the features to enable.
int feature_count = experiment.feature_association().enable_feature_size();
for (int i = 0; i < feature_count; ++i) {
feature_list->RegisterFieldTrialOverride(
experiment.feature_association().enable_feature(i),
base::FeatureList::OVERRIDE_ENABLE_FEATURE, trial);
}
// Process all the features to disable.
feature_count = experiment.feature_association().disable_feature_size();
for (int i = 0; i < feature_count; ++i) {
feature_list->RegisterFieldTrialOverride(
experiment.feature_association().disable_feature(i),
base::FeatureList::OVERRIDE_DISABLE_FEATURE, trial);
}
// Associate features for groups that do not specify them manually (e.g.
// "Default" group), so that such groups are reported.
if (!experiment.has_feature_association()) {
AssociateDefaultFeatures(study, trial, feature_list);
}
}
// Checks if |experiment| is associated with a forcing flag or feature and if
// it is, returns whether it should be forced enabled based on the
// |command_line| or |feature_list| state.
bool ShouldForceExperiment(const Study::Experiment& experiment,
const base::CommandLine& command_line,
const base::FeatureList& feature_list) {
if (experiment.feature_association().has_forcing_feature_on()) {
return feature_list.IsFeatureOverriddenFromCommandLine(
experiment.feature_association().forcing_feature_on(),
base::FeatureList::OVERRIDE_ENABLE_FEATURE);
}
if (experiment.feature_association().has_forcing_feature_off()) {
return feature_list.IsFeatureOverriddenFromCommandLine(
experiment.feature_association().forcing_feature_off(),
base::FeatureList::OVERRIDE_DISABLE_FEATURE);
}
if (experiment.has_forcing_flag()) {
return command_line.HasSwitch(experiment.forcing_flag());
}
return false;
}
bool StudyIsLowAnonymity(const Study& study) {
// Studies which are set based on Google group membership are potentially
// low anonymity (as the groups could in theory have a small number of
// members).
return study.filter().google_group_size() > 0 ||
study.filter().enterprise_group_size() > 0;
}
// Creates a placeholder trial that indicates the feature conflict.
//
// This forcibly associates |trial_name| with the |kFeatureConflictGroupName|
// group, which indicates the trial was not applied due to a feature conflict.
// This group has no features, params, or variation IDs associated with it.
//
// Trials may be associated with this group due to toggling flags in
// chrome://flags that are associated with the trial's features, or if there
// are different trials associated with the same feature.
base::FieldTrial* CreateTrialWithFeatureConflictGroup(const Study& study) {
base::FieldTrial* trial = base::FieldTrialList::CreateFieldTrial(
study.name(), internal::kFeatureConflictGroupName,
StudyIsLowAnonymity(study));
DCHECK(trial);
// Activate immediately to make the conflict obvious in metrics logs.
trial->Activate();
return trial;
}
} // namespace
VariationsSeedProcessor::VariationsSeedProcessor(
StickyActivationManager& sticky_activation_manager)
: sticky_activation_manager_(sticky_activation_manager) {}
VariationsSeedProcessor::~VariationsSeedProcessor() = default;
void VariationsSeedProcessor::CreateTrialsFromSeed(
const VariationsSeed& seed,
const ClientFilterableState& client_state,
const EntropyProviders& entropy_providers,
const VariationsLayers& layers,
base::FeatureList* feature_list) {
base::UmaHistogramCounts1000("Variations.AppliedSeed.StudyCount",
seed.study().size());
std::vector<ProcessedStudy> filtered_studies =
FilterAndValidateStudies(seed, client_state, layers);
for (const ProcessedStudy& study : filtered_studies) {
bool run_simulation = base::ShouldRecordSubsampledMetric(0.01) &&
!base::FieldTrialList::Find(study.study()->name());
scoped_refptr<base::FieldTrial> actual_trial = CreateTrialFromStudyImpl(
study, entropy_providers, layers, feature_list, /*simulated=*/false);
if (run_simulation) {
scoped_refptr<base::FieldTrial> simulated_trial =
CreateTrialFromStudyImpl(study, entropy_providers, layers,
feature_list, /*simulated=*/true);
// Validate that the simulation returns the same result as the actual
// trial. Note that `CreateTrialFromStudyImpl()` (`simulated = false`) can
// return a trial with the group `kFeatureConflictGroupName` when a
// feature conflict occurs. `CreateTrialFromStudyImpl()` (`simulated =
// true`) skips this check (`if (!simulated)`) and simulates the group
// assignment from scratch. We only validate when no feature conflict
// occurs.
if (!actual_trial || actual_trial->GetGroupNameWithoutActivation() !=
internal::kFeatureConflictGroupName) {
bool matches = (simulated_trial && actual_trial &&
simulated_trial->GetGroupNameWithoutActivation() ==
actual_trial->GetGroupNameWithoutActivation()) ||
(!simulated_trial && !actual_trial);
base::UmaHistogramBoolean("Variations.CreateTrial.SimulationMatches",
matches);
}
}
}
}
scoped_refptr<base::FieldTrial> VariationsSeedProcessor::CreateTrialFromStudy(
base::PassKey<VariationsService>,
const ProcessedStudy& processed_study,
const EntropyProviders& entropy_providers,
const VariationsLayers& layers,
base::FeatureList* feature_list,
bool simulated) {
return CreateTrialFromStudyImpl(processed_study, entropy_providers, layers,
feature_list, simulated);
}
scoped_refptr<base::FieldTrial>
VariationsSeedProcessor::CreateTrialFromStudyImpl(
const ProcessedStudy& processed_study,
const EntropyProviders& entropy_providers,
const VariationsLayers& layers,
base::FeatureList* feature_list,
bool simulated) {
// Since trials and features can come from many different sources (variations
// seed, about://flags, and command line), there are special cases for when
// they conflict with each other. See the following doc:
// https://docs.google.com/document/d/1PAlx0KyjRwLJsmkIWlZMgZ-R422Oetgxa3ZPq0Q98aQ
const Study& study = *processed_study.study();
// If the trial already exists (e.g. was forced via a command line flag),
// check if the selected group exists in the `processed_study`, and if so,
// pick up its params, features, etc. If not, there is nothing to do here.
// If the trial does not exist yet (most common case), then verify that it
// does not specify features that have already been overridden (otherwise it's
// a conflict).
// Note: If this is a simulation, ignore these checks -- we don't want the
// currently selected group to affect the simulation, as we may be trying to
// simulate a new seed with groups that may differ.
base::FieldTrial* existing_trial = base::FieldTrialList::Find(study.name());
if (!simulated) {
if (existing_trial) {
int experiment_index = processed_study.GetExperimentIndexByName(
existing_trial->GetGroupNameWithoutActivation());
if (experiment_index == -1) {
return nullptr;
}
// If the selected group exists in |processed_study|, then there may be
// some variation ids, params, and features to pick up, so do not return
// early. For example, if a user specifies the command line flag
// "--force-fieldtrials=Study/Enabled" and the variations seed includes
// a "Study" trial with an "Enabled" group that specifies features or
// other details, then use those details, even though they were not
// directly specified on the command line.
} else {
// If an experiment group in the study specifies a feature that is already
// associated with another trial, forcibly select the
// |kFeatureConflictGroupName| group to indicate a conflict. Usually, the
// server-side enforces that no two studies enable/disable the same
// feature, but this might happen from the client-side, such as through
// flags or through the command line.
//
// Only check for this if the trial does not already exist. If it already
// exists, then we cannot create the |kFeatureConflictGroupName| group for
// it.
for (const Study::Experiment& experiment : study.experiment()) {
const auto& features = experiment.feature_association();
for (const std::string& feature_name : features.enable_feature()) {
if (feature_list->HasAssociatedFieldTrialByFeatureName(
feature_name)) {
return CreateTrialWithFeatureConflictGroup(study);
}
}
for (const std::string& feature_name : features.disable_feature()) {
if (feature_list->HasAssociatedFieldTrialByFeatureName(
feature_name)) {
return CreateTrialWithFeatureConflictGroup(study);
}
}
}
}
}
// Check if any experiments need to be forced due to a command line flag.
// Force the first experiment with an existing flag.
// For example, if the user disabled FeatureA via the command line, and
// this study controls FeatureA, we should forcibly choose the group that
// sets `forcing_feature_off`.
base::CommandLine* command_line = base::CommandLine::ForCurrentProcess();
for (const auto& experiment : study.experiment()) {
if (ShouldForceExperiment(experiment, *command_line, *feature_list)) {
base::FieldTrial* trial;
if (!simulated) {
// CreateFieldTrial() registers the trial it creates.
trial = base::FieldTrialList::CreateFieldTrial(
study.name(), experiment.name(), StudyIsLowAnonymity(study));
// If `trial` is null, then a trial already exists and was already
// forced to a different group (e.g. via --force-fieldtrials). In this
// scenario, we can't set the forcing group. Break out of the loop, but
// don't return, so that variation ids and params for the selected group
// will still be picked up.
if (!trial) {
break;
}
if (experiment.feature_association().has_forcing_feature_on()) {
feature_list->AssociateReportingFieldTrial(
experiment.feature_association().forcing_feature_on(),
base::FeatureList::OVERRIDE_ENABLE_FEATURE, trial);
} else if (experiment.feature_association().has_forcing_feature_off()) {
feature_list->AssociateReportingFieldTrial(
experiment.feature_association().forcing_feature_off(),
base::FeatureList::OVERRIDE_DISABLE_FEATURE, trial);
}
// Apply params and variation IDs from this forcing group (if any). Note
// that we do not need to apply any feature state overrides, since
// forcing groups are only forced when the feature state was already
// overridden by e.g. the command line.
ForceExperimentState(base::PassKey<VariationsSeedProcessor>(), study,
experiment, *sticky_activation_manager_, *trial);
} else {
// CreateSimulatedFieldTrial() does not register the trial it creates.
// Note that we pass a total probability of 100 and an entropy value of
// 0 (similarly to base::FieldTrialList::CreateFieldTrial()) because
// we are not randomizing the group selection but rather forcing a
// specific one.
trial = base::FieldTrial::CreateSimulatedFieldTrial(
study.name(), /*total_probability=*/100, experiment.name(),
/*entropy_value=*/0);
}
// Note: Do not activate simulated trials. Activation is a no-op for them
// (they are not registered and their group choice is already finalized),
// but ShouldActivate() has side effects for STICKY_AFTER_QUERY studies:
// it mutates StickyActivationManager's state and CHECK-fails once
// monitoring has started (i.e. after startup). A simulation must not
// affect the real activation state.
if (!simulated && ShouldActivate(study, experiment.name(),
*sticky_activation_manager_)) {
// This call must happen after all params have been registered for the
// trial. Otherwise, since we look up params by trial and group name,
// the params won't be registered under the correct key.
trial->Activate();
}
return trial;
}
}
// This study has no randomized experiments and none of its experiments were
// forced by flags so don't create a field trial.
if (processed_study.total_probability() <= 0) {
return nullptr;
}
base::optional_ref<const base::FieldTrial::EntropyProvider> entropy_provider =
layers.SelectEntropyProviderForStudy(processed_study, entropy_providers);
if (!entropy_provider.has_value()) {
// Do not randomize because no suitable entropy provider can be applied to
// the study.
return nullptr;
}
scoped_refptr<base::FieldTrial> trial;
if (!simulated) {
// FactoryGetFieldTrial() registers the trial it creates.
trial = base::FieldTrialList::FactoryGetFieldTrial(
study.name(), processed_study.total_probability(),
processed_study.GetDefaultExperimentName(), entropy_provider.value(),
study.randomization_seed(), StudyIsLowAnonymity(study));
} else {
const double entropy_value = entropy_provider.value().GetEntropyForTrial(
study.name(), study.randomization_seed());
// CreateSimulatedFieldTrial() does not register the trial it creates.
trial = base::FieldTrial::CreateSimulatedFieldTrial(
study.name(), processed_study.total_probability(),
processed_study.GetDefaultExperimentName(), entropy_value);
}
bool enables_or_disables_features = false;
// Append the various experiment groups to the trial.
for (const auto& experiment : study.experiment()) {
// Groups with forcing flags have probability 0 and will never be selected.
// Therefore, there's no need to add them to the field trial.
if (experiment.has_forcing_flag() ||
experiment.feature_association().has_forcing_feature_on() ||
experiment.feature_association().has_forcing_feature_off()) {
continue;
}
if (experiment.name() != study.default_experiment_name()) {
trial->AppendGroup(experiment.name(), experiment.probability_weight());
}
// Register the variation IDs if this is not a simulation.
if (!simulated) {
bool is_trial_overridden =
existing_trial && existing_trial->IsOverridden();
RegisterVariationIds(base::PassKey<VariationsSeedProcessor>(), experiment,
study, is_trial_overridden);
}
if (experiment.feature_association().enable_feature_size() != 0 ||
experiment.feature_association().disable_feature_size() != 0) {
enables_or_disables_features = true;
}
}
// Finalize the group choice.
trial->SetForced();
const std::string& group_name = trial->GetGroupNameWithoutActivation();
// If this is not a simulation, then register params and feature overrides.
if (!simulated) {
int experiment_index = processed_study.GetExperimentIndexByName(group_name);
// If the trial was forced on the command line, we may not be able to find
// the experiment.
if (experiment_index != -1) {
const Study::Experiment& experiment = study.experiment(experiment_index);
RegisterExperimentParams(study, experiment);
if (enables_or_disables_features) {
RegisterFeatureOverrides(study, experiment, trial.get(), feature_list);
}
}
}
// Note: Do not activate simulated trials. See the note above.
if (!simulated &&
ShouldActivate(study, group_name, *sticky_activation_manager_)) {
// This call must happen after all params have been registered for the
// trial. Otherwise, since we look up params by trial and group name, the
// params won't be registered under the correct key.
trial->Activate();
}
return trial;
}
} // namespace variations