reddit_decider

Prerequisite packages

baseplate>=2.0.0

reddit-edgecontext>=1.0.0

reddit-v2-events>=2.8.2

Prerequisite infrastructure

Zookeeper live-data sidecar:

Define a live-data sidecar in the manifest file to fetch the experiment configuration, see example setup here (make sure that your service is authorized to fetch the appropriate secret from Vault).

Event publisher sidecar:

Set up your service to be able to publish v2 exposure events via an event publisher sidecar, see example setup here.

Prerequisite configuration

Setup reddit-experiments in your application’s configuration file:

[app:main]

...

# optional: a path to the file where experiment configuration is written
# default: /var/local/experiments.json
# note: production systems load the experiments.json file under nested `live-data/` dir
experiments.path = /var/local/live-data/experiments.json

# optional: how long to wait for the experiments file to exist before failing
# default:
#    >= v1.7.0 wait 30 seconds
#    <  v1.7.0 do not wait, fail immediately if not available
experiments.timeout = 60 seconds

# optional: the base amount of time for exponential backoff while waiting
# for the file to be available.
# default: 0.01
# see https://github.com/reddit/baseplate.py/blob/114248987ce0e8a0ddd102c80b00ef43f4dbf14e/baseplate/lib/file_watcher.py#L56C1-L56C35
experiments.backoff = 1 second

...

Integrate reddit-experiments into Baseplate service

Upgrade or integrate reddit-experiments package:

# import latest reddit-experiments package in service requirements.txt
reddit-experiments>=1.7.0

Initialize decider instance on Baseplate context

In your service’s initialization process, add a decider instance to baseplate’s context:

# application code
from event_utils.v2_event_utils import ExperimentLogger
from reddit_decider import decider_client_from_config
from reddit_decider import DeciderClient

# optional
from some_file import my_field_extractor


def make_wsgi_app(app_config):
    baseplate = Baseplate(app_config)
    decider_factory = decider_client_from_config(app_config=app_config,
                                                 event_logger=ExperimentLogger(),
                                                 prefix="experiments.",
                                                 request_field_extractor=my_field_extractor)  # this is optional, can be `None` if edge_context contains all the fields you need
    baseplate.add_to_context("decider", decider_factory)

    # Or use `DeciderClient` with `configure_context()`,
    # which internally calls `decider_client_from_config()`
    baseplate.configure_context({
        "decider": DeciderClient(
            prefix="experiments.",
            event_logger=ExperimentLogger()),
            request_field_extractor=my_field_extractor  # optional
    })

Make sure EdgeContext is accessible on request object like so:

request.edge_context

If you don’t have access to edge_context in your service/request, you can access the SDK’s internal decider instance for a lower level API, allowing you to pass in targeting context fields as a dict param, e.g. “user_is_employee”, “country_code”, or other targeting fields (instead of them being auto-derived from edge_context).

See full API in readme (reddit internal).

The internal decider instance can be accessed from the SDK’s top-level decider instance via:

internal_decider = request.decider.internal_decider()  # requires `reddit-experiments >= 1.4.1`
internal_decider.choose("experiment_name", {
        "user_id": "t2_abc",
        "user_is_employee": True,
        "other_info": { "arbitrary_field": "some_val" }
    }
)

[Optional] Define request field extractor function (example)

# Baseplate calls `make_object_for_context()` and creates a `DeciderContext`
# which fetches the following fields from EdgeContext automatically:
#   - user_id
#   - device_id
#   - logged_in
#   - cookie_created_timestamp
#   - oauth_client_id
#   - country_code
#   - locale
#   - origin_service
#   - is_employee
#   - loid_created_ms (>=1.3.11)

# Customized fields can be defined below to be extracted from a baseplate request
# and will override above edge_context fields.
# These fields may be used for targeting.

def my_field_extractor(request):
    # an example of customized baseplate request field extractor:
    return {"foo": request.headers.get("Foo"), "bar": "something"}

Basic Usage

Use the attached Decider instance in request to call decider.get_variant() (automatically sends an expose event):

def my_method(request):
    if request.decider.get_variant("foo") == "bar":
        ...

or optionally, if manual exposure is necessary, use:

def my_method(request):
    variant = request.decider.get_variant_without_expose(experiment_name='experiment_name')
    ...
    request.decider.expose(experiment_name='experiment_name', variant_name=variant)

This is an example of using a dynamic configuration:

def my_method(request):
    if request.decider.get_bool("foo") == True:
        ...

Decider API

class reddit_decider.Decider(decider_context, internal, server_span, context_name, event_logger=None)[source]

Access to experiments with automatic refresh when changed.

This decider client allows access to the experiments cached on disk by the experiment configuration fetcher daemon. It will automatically reload the cache when changed.

get_variant(experiment_name, **exposure_kwargs)[source]

Return a bucketing variant, if any, with auto-exposure.

Since calling get_variant() will fire an exposure event, it is best to call it when you are sure the user will be exposed to the experiment.

If you absolutely must check the status of an experiment before the user will be exposed to the experiment, use get_variant_without_expose() to disable exposure events and call expose() manually later.

Parameters
  • experiment_name (str) – Name of the experiment you want a variant for.

  • exposure_kwargs (Optional[Dict[str, Any]]) – Additional arguments that will be passed to events_logger (keys must be part of v2 event schema, use dicts for nested fields) under inputs and as kwargs

Return type

Optional[str]

Returns

Variant name if a variant is assigned, None otherwise.

get_variant_without_expose(experiment_name)[source]

Return a bucketing variant, if any, without emitting exposure event.

The expose() function is available to be manually called afterward.

However, experiments in Holdout Groups will still send an exposure for the holdout parent experiment, since it is not possible to manually expose the holdout later (because it’s impossible to know if a returned None or "control_1" string came from the holdout group or its child experiment once this function exits).

Parameters

experiment_name (str) – Name of the experiment you want a variant for.

Return type

Optional[str]

Returns

Variant name if a variant is assigned, None otherwise.

expose(experiment_name, variant_name, **exposure_kwargs)[source]

Log an event to indicate that a user has been exposed to an experimental treatment.

Meant to be used after calling get_variant_without_expose() since get_variant() emits exposure event automatically.

Parameters
  • experiment_name (str) – Name of the experiment that was exposed.

  • variant_name (str) – Name of the variant that was exposed.

  • exposure_kwargs (Optional[Dict[str, Any]]) – Additional arguments that will be passed to events_logger (keys must be part of v2 event schema, use dicts for nested fields) under inputs and as kwargs

Return type

None

get_variant_for_identifier(experiment_name, identifier, identifier_type, **exposure_kwargs)[source]

Return a bucketing variant, if any, with auto-exposure for a given identifier.

Note: If the experiment’s bucket_val

(e.g. “user_id”, “device_id”, “canonical_url”, “subreddit_id”, “ad_account_id”, “business_id”) does not match the identifier_type param, the identifier will be ignored and not used to bucket ({identifier_type: identifier} is added to internal DeciderContext instance, but doesn’t act like a bucketing override).

If the bucket_val field exists on the DeciderContext instance, that field will be used to bucket, since it corresponds to the experiment’s config.

Since calling get_variant_for_identifier() will fire an exposure event, it is best to call it when you are sure the user will be exposed to the experiment.

Parameters
  • experiment_name (str) – Name of the experiment you want a variant for.

  • identifier (str) – an arbitary string used to bucket the experiment by being set on DeciderContext’s identifier_type field.

  • identifier_type (Literal[‘user_id’, ‘device_id’, ‘canonical_url’, ‘subreddit_id’, ‘ad_account_id’, ‘business_id’]) – Sets {identifier_type: identifier} on DeciderContext. The experiment’s bucket_val will be looked up in DeciderContext and be used to bucket. If the experiment’s bucket_val field does not match identifier_type param, identifier will be ignored, and the field corresponding bucket_val will be looked up from DeciderContext for bucketing.

  • exposure_kwargs (Optional[Dict[str, Any]]) – Additional arguments that will be passed to events_logger (keys must be part of v2 event schema, use dicts for nested fields) under inputs and as kwargs

Return type

Optional[str]

Returns

Variant name if a variant is assigned, None otherwise.

get_variant_for_identifier_without_expose(experiment_name, identifier, identifier_type)[source]

Return a bucketing variant, if any, without emitting exposure event for a given identifier.

Note: If the experiment’s bucket_val

(e.g. “user_id”, “device_id”, “canonical_url”, “subreddit_id”, “ad_account_id”, “business_id”) does not match the identifier_type param, the identifier will be ignored and not used to bucket ({identifier_type: identifier} is added to internal DeciderContext instance, but doesn’t act like a bucketing override).

If the bucket_val field exists on the DeciderContext instance, that field will be used to bucket, since it corresponds to the experiment’s config.

The expose() function is available to be manually called afterward to emit exposure event.

However, experiments in Holdout Groups will still send an exposure for the holdout parent experiment, since it is not possible to manually expose the holdout later (because it’s impossible to know if a returned None or "control_1" string came from the holdout group or its child experiment once this function exits).

Parameters
  • experiment_name (str) – Name of the experiment you want a variant for.

  • identifier (str) – an arbitary string used to bucket the experiment by being set on DeciderContext’s identifier_type field.

  • identifier_type (Literal[‘user_id’, ‘device_id’, ‘canonical_url’, ‘subreddit_id’, ‘ad_account_id’, ‘business_id’]) – Sets {identifier_type: identifier} on DeciderContext. The experiment’s bucket_val will be looked up in DeciderContext and be used to bucket. If the experiment’s bucket_val field does not match identifier_type param, identifier will be ignored and the field corresponding bucket_val will be looked up from DeciderContext for bucketing.

Return type

Optional[str]

Returns

Variant name if a variant is assigned, None otherwise.

get_all_variants_without_expose()[source]

Return a list of experiment dicts in this format:

[
    {
        "id": 1,
        "name": "variant_1",
        "version": "1",
        "experimentName": "exp_1"

    }
]

If an experiment has a variant of None, it is not included in the returned list. All available experiments get bucketed. Exposure events are not emitted.

The expose() function is available to be manually called afterward to emit exposure event.

However, experiments in Holdout Groups will still send an exposure for the holdout parent experiment, since it is not possible to manually expose the holdout later (because it’s impossible to know if a returned None or "control_1" string came from the holdout group or its child experiment once this function exits).

Return type

List[Dict[str, Union[str, int]]]

Returns

list of experiment dicts with non-None variants.

get_all_variants_for_identifier_without_expose(identifier, identifier_type)[source]

Return a list of experiment dicts for experiments having bucket_val match identifier_type, for a given identifier, in this format:

[
    {
        "id": 1,
        "name": "variant_1",
        "version": "1",
        "experimentName": "exp_1"

    }
]

If an experiment has a variant of None, it is not included in the returned list. All available experiments get bucketed. Exposure events are not emitted.

However, experiments in Holdout Groups will still send an exposure for the holdout parent experiment, since it is not possible to manually expose the holdout later (because it’s impossible to know if a returned None or "control_1" string came from the holdout group or its child experiment once this function exits).

Parameters
  • identifier (str) – an arbitary string used to bucket the experiment by being set on DeciderContext’s identifier_type field.

  • identifier_type (Literal[‘user_id’, ‘device_id’, ‘canonical_url’, ‘subreddit_id’, ‘ad_account_id’, ‘business_id’]) – Sets {identifier_type: identifier} on DeciderContext and buckets all experiment with matching bucket_val.

Return type

List[Dict[str, Union[str, int]]]

Returns

list of experiment dicts with non-None variants.

get_bool(feature_name, default=False)[source]

Fetch a Dynamic Configuration of boolean type.

Parameters
  • feature_name (str) – Name of the dynamic config you want a value for.

  • default (bool) – what is returned if dynamic config is not active (False unless overriden).

Return type

bool

Returns

the boolean value of the dyanimc config if it is active/exists, default parameter otherwise.

get_int(feature_name, default=0)[source]

Fetch a Dynamic Configuration of int type.

Parameters
  • feature_name (str) – Name of the dynamic config you want a value for.

  • default (int) – what is returned if dynamic config is not active (0 unless overriden).

Return type

int

Returns

the int value of the dyanimc config if it is active/exists, default parameter otherwise.

get_float(feature_name, default=0.0)[source]

Fetch a Dynamic Configuration of float type.

Parameters
  • feature_name (str) – Name of the dynamic config you want a value for.

  • default (float) – what is returned if dynamic config is not active (0.0 unless overriden).

Return type

float

Returns

the float value of the dyanimc config if it is active/exists, default parameter otherwise.

get_string(feature_name, default='')[source]

Fetch a Dynamic Configuration of string type.

Parameters
  • feature_name (str) – Name of the dynamic config you want a value for.

  • default (str) – what is returned if dynamic config is not active ("" unless overriden).

Return type

str

Returns

the string value of the dyanimc config if it is active/exists, default parameter otherwise.

get_map(feature_name, default=None)[source]

Fetch a Dynamic Configuration of map type.

Parameters
  • feature_name (str) – Name of the dynamic config you want a value for.

  • default (Optional[dict]) – what is returned if dynamic config is not active (None unless overriden).

Return type

Optional[dict]

Returns

the map value of the dyanimc config if it is active/exists, default parameter otherwise.

get_all_dynamic_configs()[source]

Return a list of dynamic configuration dicts in this format:

[
    {
        "name": "example_dc",
        "type": "float",
        "value": 1.0
    }
]

where “type” field can be one of:

"boolean", "integer", "float", "string", "map"

Dynamic Configurations that are malformed, fail parsing, or otherwise error for any reason are included in the response and have their respective default values set:

"boolean" -> False
"integer" -> 0
"float"   -> 0.0
"string"  -> ""
"map"     -> {}
Return type

List[Dict[str, Any]]

Returns

list of all active dynamic config dicts.

get_experiment(experiment_name)[source]

Get an ExperimentConfig dataclass representation of an experiment or None if not found.

Parameters

experiment_name (str) – Name of the experiment to be fetched.

Return type

Optional[ExperimentConfig]

Returns

an ExperimentConfig dataclass representation of an experiment if found, else None.

Configuration Class

class reddit_decider.DeciderClient(event_logger, prefix='experiments.', request_field_extractor=None)[source]

Configure a decider client.

This is meant to be used with baseplate.Baseplate.configure_context().

See decider_client_from_config() for available configuration settings.

Parameters
  • event_logger (EventLogger) – The EventLogger instance to be used to log bucketing events.

  • prefix (str) – the prefix used to filter config keys (defaults to “experiments.”).

  • request_field_extractor (Optional[Callable[[RequestContext], Dict[str, Union[str, int, float, bool]]]]) – (optional) function used to populate fields such as "app_name" & "build_number" in DeciderContext() that may be used for targeting

Configuration Function

reddit_decider.decider_client_from_config(app_config, event_logger, prefix='experiments.', request_field_extractor=None)[source]

Configure and return an DeciderContextFactory object.

The keys used in your app’s some_config.ini file should be prefixed, e.g. experiments.path, etc.

Supported config keys:

path (optional)

The path to the experiment configuration file generated by the experiment configuration fetcher daemon. Defaults to "/var/local/experiments.json".

timeout (optional)

The time that we should wait for the file specified by path to exist. Defaults to blocking for 30 seconds.

backoff (optional)

The base amount of time for exponential backoff when trying to find the experiments config file. Defaults to no backoff between tries.

Parameters
  • app_config (Dict[str, str]) – The application configuration which should have settings for the decider client.

  • event_logger (EventLogger) – The EventLogger to be used to log bucketing events.

  • prefix (str) – the prefix used to filter keys (defaults to “experiments.”).

  • request_field_extractor (Optional[Callable[[RequestContext], Dict[str, Union[str, int, float, bool]]]]) – (optional) function used to populate fields such as “app_name” & “build_number” in DeciderContext() that may be used for targeting

Return type

DeciderContextFactory

Configuration Context Factory

class reddit_decider.DeciderContextFactory(path, event_logger=None, timeout=None, backoff=None, request_field_extractor=None)[source]

Decider client context factory.

This factory will attach a new reddit_decider.Decider to an attribute on the RequestContext.

Parameters
  • path (str) – Path to the experiment configuration file.

  • event_logger (Optional[EventLogger]) – The logger to use to log experiment eligibility events. If not provided, a DebugLogger will be created and used.

  • timeout (Optional[float]) – How long, in seconds, to block instantiation waiting for the watched experiments file to become available (defaults to not blocking).

  • backoff (Optional[float]) – retry backoff time for experiments file watcher. Defaults to None, which is mapped to DEFAULT_FILEWATCHER_BACKOFF.

  • request_field_extractor (Optional[Callable[[RequestContext], Dict[str, Union[str, int, float, bool]]]]) – an optional function used to populate fields such as “app_name” & “build_number” in DeciderContext() that may be used for targeting