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
5 changes: 5 additions & 0 deletions .sampo/changesets/experiment-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Send `$experiment_exposure` when a remotely evaluated config version 2 flag assigns an experiment variant. The SDK sends it together with `$feature_flag_called`, from `get_feature_flag()`, `feature_enabled()`, `get_feature_flag_result()`, and the `is_enabled()` and `get_flag()` reads of an `evaluate_flags()` snapshot. It carries the flag key, the same `$feature_flag_response` as its `$feature_flag_called`, the variant key and rule ID, `locally_evaluated: false`, and the flag ID, version, request ID and evaluation time when the response has them. It is sent once per distinct ID, groups, flag, rule and variant, whatever value or request it came from. `$feature_flag_called` for a config version 2 flag gets `$feature_flag_config_version` and `$feature_flag_reason_code`, which replaces the human-readable `$feature_flag_reason`. A targeting match, rollout miss, paused experiment or holdout adds the `$feature_flag_rule_id` and `$feature_flag_rule_type` it names, and a complete experiment split adds these and `$feature_flag_variant`. Other outcomes, including failed flags, add no rule properties. Minimal `$feature_flag_called` events keep these properties, and their dedupe includes them, so equal-valued arms and successive experiments of one flag are each reported. A split that lacks its rule type, rule ID or variant key sends no exposure and reports no rule or variant. A `get_feature_flag_payload()` lookup with `match_value` reports the caller's value, so it sends no exposure. `send_feature_flag_events=False` suppresses both events. Events of config version 1 flags, flags from servers without typed values and locally evaluated flags are unchanged, including their `$feature_flag_reason`, and `$feature_flag_response` is unchanged on every event.
44 changes: 20 additions & 24 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
from .capture_mode import CaptureMode, _resolve_capture_mode
from .client import (
MAX_DICT_SIZE as _MAX_DICT_SIZE,
_MINIMAL_FLAG_CALLED_EVENT_PROPERTIES,
Client as _SyncClient,
_capture_flag_events,
_metadata_has_experiment,
_parse_flag_payload,
add_context_tags as _add_context_tags,
Expand Down Expand Up @@ -78,6 +78,7 @@
from .release_id import _resolve_release_id
from .request import QuotaLimitError, determine_server_host, normalize_host
from .types import (
FeatureFlag,
FlagMetadata,
FlagValue,
_has_untyped_flag_records,
Expand Down Expand Up @@ -186,6 +187,9 @@ def __init__(
0, feature_flags_request_max_retries
)
self.distinct_ids_feature_flags_reported = SizeLimitedDict(_MAX_DICT_SIZE, set)
# $experiment_exposure dedupe, kept apart from $feature_flag_called's so that a
# reported call never suppresses its exposure. Same bound and eviction.
self._experiment_exposures_reported = SizeLimitedDict(_MAX_DICT_SIZE, set)
self._feature_flag_evaluations_host_cache: Optional[
_FeatureFlagEvaluationsHost
] = None
Expand Down Expand Up @@ -1054,38 +1058,29 @@ def _capture_feature_flag_called_if_needed(
disable_geoip: Optional[bool] = None,
has_experiment: Optional[bool] = None,
minimal_flag_called_events: bool = False,
details: Optional[FeatureFlag] = None,
value_from_details: bool = False,
) -> None:
groups_key = (
tuple(sorted((str(k), str(v)) for k, v in groups.items())) if groups else ()
)
reported_key = (key, response, groups_key)
reported_flags = self.distinct_ids_feature_flags_reported.get(distinct_id)
if reported_flags is None:
reported_flags = set()
self.distinct_ids_feature_flags_reported[distinct_id] = reported_flags
if reported_key in reported_flags:
return

if has_experiment is not None:
properties["$feature_flag_has_experiment"] = has_experiment
capture_kwargs: dict[str, Any] = {}
if minimal_flag_called_events and has_experiment is False:
capture_kwargs["_property_allowlist"] = (
_MINIMAL_FLAG_CALLED_EVENT_PROPERTIES
)
capture_groups = {
str(group_type): _stringify_id(group_key)
for group_type, group_key in (groups or {}).items()
}
self.capture(
"$feature_flag_called",
_capture_flag_events(
self.capture,
self.distinct_ids_feature_flags_reported,
self._experiment_exposures_reported,
distinct_id=distinct_id,
key=key,
response=response,
properties=properties,
groups=capture_groups,
groups=groups,
capture_groups=capture_groups,
disable_geoip=disable_geoip,
**capture_kwargs,
has_experiment=has_experiment,
minimal_flag_called_events=minimal_flag_called_events,
details=details,
value_from_details=value_from_details,
)
reported_flags.add(reported_key)

def _pending_queue_items(self) -> int:
return int(getattr(self._queue, "_unfinished_tasks", self._queue.qsize()))
Expand Down Expand Up @@ -1240,6 +1235,7 @@ async def shutdown(self) -> None:
errors.append(error)

self.distinct_ids_feature_flags_reported.clear()
self._experiment_exposures_reported.clear()
self._closed = True
if errors and self.debug:
raise errors[0]
Expand Down
Loading
Loading