Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ def my_method(request):
pass
```

Custom context fields can be returned by `request_field_extractor` or supplied in
`DeciderContext(extracted_fields={...})`. Values may be strings, booleans, integers,
floats, or `None`. Fields without a native Rust type are passed through
`other_fields` for targeting, overrides, and bucketing.

The identifier APIs also accept custom field names without a library update:

```python
variant = request.decider.get_variant_for_identifier(
"merchant_experiment", identifier="merchant_123", identifier_type="merchant_id"
)
```

The experiment must use `merchant_id` as its bucket field to bucket by this value.
The override applies only to this call; the stored request context is unchanged.
This also works with `get_variant_for_identifier_without_expose` and
`get_all_variants_for_identifier_without_expose`. Empty names and the reserved
`other_fields` name are rejected.

`get_variant` and `get_variant_without_expose` also accept fields for one call:

```python
variant = request.decider.get_variant(
"merchant_experiment", context_fields={"merchant_tier": "gold"}
)
```

These fields take precedence over stored fields for that call only. Empty names and
`other_fields` are rejected here too. Exposure events use the stored fields plus the
experiment's bucketing value.

Other names are accepted, including `experiment`, `variant`, `span`, `event_type`,
and `inputs`. In exposure events, those top-level logger arguments keep their
metadata values, and a custom field with the same name is kept in `inputs`.
A custom `self` field is also accepted, but exposure events log it only in `inputs`
because the event logger's `log` method already has a `self` parameter.

See [the documentation] for more information (documentation builds can be found [here](https://readthedocs.org/projects/reddit-experiments/builds/))
.

Expand Down
160 changes: 81 additions & 79 deletions reddit_decider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
from typing import cast
from typing import Dict
from typing import IO
from typing import Iterable
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TypeVar
from typing import Union
Expand All @@ -30,7 +32,6 @@
from rust_decider import Decision
from rust_decider import FeatureNotFoundException
from rust_decider import ValueTypeMismatchException
from typing_extensions import Literal

from .prometheus_metrics import experiments_client_counter

Expand All @@ -50,6 +51,7 @@
logger = logging.getLogger(__name__)

EMPLOYEE_ROLES = ["employee", "contractor"]
# Retained for callers that import this list; custom identifier names are also supported.
IDENTIFIERS = [
"user_id",
"device_id",
Expand Down Expand Up @@ -132,7 +134,7 @@ def to_dict(self) -> Dict:
if self._conversion_pixel_id is not None:
ef["conversion_pixel_id"] = self._conversion_pixel_id

return {
fields = {
"user_id": self._user_id,
"device_id": self._device_id,
"country_code": self._country_code,
Expand All @@ -143,9 +145,10 @@ def to_dict(self) -> Dict:
"origin_service": self._origin_service,
"cookie_created_timestamp": self._cookie_created_timestamp,
"loid_created_timestamp": self._loid_created_timestamp,
"other_fields": ef,
**ef,
}
# Native typed fields use top-level values; all other fields use this fallback.
return {**fields, "other_fields": fields}

def to_event_dict(self) -> Dict:
user_fields = {
Expand Down Expand Up @@ -265,13 +268,17 @@ def _send_expose(self, event: str, exposure_fields: dict) -> None:

event_fields = {**event_fields, **{bucket_val: bucketing_value}}

# Custom fields may share these names; logger metadata wins at the top level.
# `self` would collide with the bound log method's parameter, so it stays in inputs.
self._event_logger.log(
experiment=experiment,
variant=event_variant,
span=self._span,
event_type=EventType.EXPOSE,
inputs=event_fields,
**event_fields,
**{
**{k: v for k, v in event_fields.items() if k != "self"},
"experiment": experiment,
"variant": event_variant,
"span": self._span,
"event_type": EventType.EXPOSE,
"inputs": event_fields,
}
)
return

Expand Down Expand Up @@ -311,13 +318,17 @@ def _send_expose_if_holdout(self, event: str, exposure_fields: dict) -> None:

event_fields = {**event_fields, **{bucket_val: bucketing_value}}

# Custom fields may share these names; logger metadata wins at the top level.
# `self` would collide with the bound log method's parameter, so it stays in inputs.
self._event_logger.log(
experiment=experiment,
variant=event_variant,
span=self._span,
event_type=EventType.EXPOSE,
inputs=event_fields,
**event_fields,
**{
**{k: v for k, v in event_fields.items() if k != "self"},
"experiment": experiment,
"variant": event_variant,
"span": self._span,
"event_type": EventType.EXPOSE,
"inputs": event_fields,
}
)
return

Expand All @@ -331,7 +342,11 @@ def _cast_to_int(input: str) -> int:
return out

def get_variant(
self, experiment_name: str, **exposure_kwargs: Optional[Dict[str, Any]]
self,
experiment_name: str,
*,
context_fields: Optional[Dict[str, Any]] = None,
**exposure_kwargs: Optional[Dict[str, Any]],
) -> Optional[str]:
"""Return a bucketing variant, if any, with auto-exposure.

Expand All @@ -345,13 +360,20 @@ def get_variant(

:param experiment_name: Name of the experiment you want a variant for.

:param context_fields: Optional fields used for targeting, overrides, and bucketing
in this call only. They take precedence over the stored :code:`DeciderContext`.
Exposure events use the stored fields plus the experiment's bucketing value.

:param exposure_kwargs: Additional arguments that will be passed
to :code:`events_logger` (keys must be part of v2 event schema,
use dicts for nested fields) under :code:`inputs` and as :code:`kwargs`

:return: Variant name if a variant is assigned, :code:`None` otherwise.
"""
ctx = self._decider_context.to_dict()
ctx = self._context_with_fields((context_fields or {}).items())
if ctx is None:
return None

decision = self._get_decision(experiment_name, ctx)

if decision is None:
Expand All @@ -365,7 +387,9 @@ def get_variant(

return decision.variant

def get_variant_without_expose(self, experiment_name: str) -> Optional[str]:
def get_variant_without_expose(
self, experiment_name: str, *, context_fields: Optional[Dict[str, Any]] = None
) -> Optional[str]:
"""Return a bucketing variant, if any, without emitting exposure event.

The :code:`expose()` function is available to be manually called afterward.
Expand All @@ -378,9 +402,16 @@ def get_variant_without_expose(self, experiment_name: str) -> Optional[str]:

:param experiment_name: Name of the experiment you want a variant for.

:param context_fields: Optional fields used for targeting, overrides, and bucketing
in this call only. They take precedence over the stored :code:`DeciderContext`.
Exposure events use the stored fields plus the experiment's bucketing value.

:return: Variant name if a variant is assigned, None otherwise.
"""
ctx = self._decider_context.to_dict()
ctx = self._context_with_fields((context_fields or {}).items())
if ctx is None:
return None

decision = self._get_decision(experiment_name, ctx)

if decision is None:
Expand Down Expand Up @@ -441,28 +472,36 @@ def expose(
stop_ts=feature.stop_ts,
)

# Custom fields may share these names; logger metadata wins at the top level.
# `self` would collide with the bound log method's parameter, so it stays in inputs.
self._event_logger.log(
experiment=experiment,
variant=variant_name,
span=self._span,
event_type=EventType.EXPOSE,
inputs=event_fields,
**event_fields,
**{
**{k: v for k, v in event_fields.items() if k != "self"},
"experiment": experiment,
"variant": variant_name,
"span": self._span,
"event_type": EventType.EXPOSE,
"inputs": event_fields,
}
)

def _context_with_fields(self, fields: Iterable[Tuple[str, Any]]) -> Optional[Dict]:
ctx = self._decider_context.to_dict()
for name, value in fields:
if not isinstance(name, str) or not name or name == "other_fields":
logger.warning(
'Context field names must be non-empty strings other than "other_fields".'
)
return None
ctx[name] = value
ctx["other_fields"][name] = value
return ctx

def get_variant_for_identifier(
self,
experiment_name: str,
identifier: str,
identifier_type: Literal[
"user_id",
"device_id",
"canonical_url",
"subreddit_id",
"ad_account_id",
"business_id",
"conversion_pixel_id",
],
identifier_type: str,
**exposure_kwargs: Optional[Dict[str, Any]],
) -> Optional[str]:
"""Return a bucketing variant, if any, with auto-exposure for a given :code:`identifier`.
Expand Down Expand Up @@ -497,17 +536,10 @@ def get_variant_for_identifier(

:return: Variant name if a variant is assigned, None otherwise.
"""
if identifier_type not in IDENTIFIERS:
logger.warning(
f'"{identifier_type}" is not one of supported "identifier_type": {IDENTIFIERS}.'
)
ctx = self._context_with_fields([(identifier_type, identifier)])
if ctx is None:
return None

ctx = self._decider_context.to_dict()
ctx[identifier_type] = identifier
if identifier_type == "conversion_pixel_id":
ctx["other_fields"][identifier_type] = identifier

decision = self._get_decision(experiment_name, ctx)

if decision is None:
Expand All @@ -525,15 +557,7 @@ def get_variant_for_identifier_without_expose(
self,
experiment_name: str,
identifier: str,
identifier_type: Literal[
"user_id",
"device_id",
"canonical_url",
"subreddit_id",
"ad_account_id",
"business_id",
"conversion_pixel_id",
],
identifier_type: str,
) -> Optional[str]:
"""Return a bucketing variant, if any, without emitting exposure event for a given :code:`identifier`.

Expand Down Expand Up @@ -568,17 +592,10 @@ def get_variant_for_identifier_without_expose(

:return: Variant name if a variant is assigned, None otherwise.
"""
if identifier_type not in IDENTIFIERS:
logger.warning(
f'"{identifier_type}" is not one of supported "identifier_type": {IDENTIFIERS}.'
)
ctx = self._context_with_fields([(identifier_type, identifier)])
if ctx is None:
return None

ctx = self._decider_context.to_dict()
ctx[identifier_type] = identifier
if identifier_type == "conversion_pixel_id":
ctx["other_fields"][identifier_type] = identifier

decision = self._get_decision(experiment_name, ctx)

if decision is None:
Expand Down Expand Up @@ -654,15 +671,7 @@ def _decision_to_dict(self, decision: Decision) -> Dict[str, Any]:
def get_all_variants_for_identifier_without_expose(
self,
identifier: str,
identifier_type: Literal[
"user_id",
"device_id",
"canonical_url",
"subreddit_id",
"ad_account_id",
"business_id",
"conversion_pixel_id",
],
identifier_type: str,
) -> List[Dict[str, Union[str, int]]]:
"""Return a list of experiment dicts for experiments having :code:`bucket_val` match
:code:`identifier_type`, for a given :code:`identifier`, in this format:
Expand Down Expand Up @@ -696,17 +705,10 @@ def get_all_variants_for_identifier_without_expose(

:return: list of experiment dicts with non-:code:`None` variants.
"""
if identifier_type not in IDENTIFIERS:
logger.warning(
f'"{identifier_type}" is not one of supported "identifier_type": {IDENTIFIERS}.'
)
ctx = self._context_with_fields([(identifier_type, identifier)])
if ctx is None:
return []

ctx = self._decider_context.to_dict()
ctx[identifier_type] = identifier
if identifier_type == "conversion_pixel_id":
ctx["other_fields"][identifier_type] = identifier

all_decisions = self._get_all_decisions(ctx=ctx, bucketing_field_filter=identifier_type)

if all_decisions is None:
Expand Down
Loading
Loading