Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3f8c315
feat(flags): add typed flag value accessors
andehen Oct 29, 2026
33b9f61
fix(flags): limit typed integers to the exact double range
andehen Oct 29, 2026
bc749e7
feat: add a per-read send_feature_flag_events option to snapshot reads
andehen Oct 29, 2026
60a294f
Merge branch 'feat/flags-v3-response' into feat/flags-typed-accessors
andehen Oct 29, 2026
94b5afd
test(flags): check typed details of unreadable, failed and sparse rec…
andehen Oct 29, 2026
d6c7593
fix(flags): read an object too deeply nested to copy as unreadable
andehen Oct 29, 2026
2936c0f
Merge remote-tracking branch 'origin/feat/flags-v3-response' into fea…
andehen Oct 29, 2026
8333a51
docs(flags): name the error fields and unreadable reads in the changeset
andehen Oct 29, 2026
e0ce99f
fix(flags): say a too-deep value is nested too deeply, not mistyped
andehen Oct 29, 2026
76065a5
Merge branch 'feat/flags-v3-response' into feat/flags-typed-accessors
andehen Oct 30, 2026
3ee964f
Merge remote-tracking branch 'origin/feat/flags-v3-response' into fea…
andehen Oct 30, 2026
08706fb
Merge remote-tracking branch 'origin/feat/snapshot-flag-event-option'…
andehen Oct 30, 2026
4425340
feat(flags): add the per-read send_feature_flag_events option to type…
andehen Oct 30, 2026
0a333e6
fix(flags): include the definition version in local typed details
andehen Oct 30, 2026
164cd90
fix(flags): read integers beyond 2**53 - 1 as doubles in the number a…
andehen Oct 30, 2026
97ce8d6
fix(flags): send no flag event for a typed read of the wrong type
andehen Oct 30, 2026
d25a101
fix(flags): read an integer beyond the range of a double as an infinity
andehen Oct 30, 2026
b749299
refactor(flags): keep the type mismatch log beside the other flag log…
andehen Oct 30, 2026
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/flags-typed-accessors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Add `get_boolean_value`, `get_string_value`, `get_number_value`, `get_integer_value`, `get_float_value` and `get_object_value` to the `evaluate_flags()` result, each with a `_details` form that returns a `FlagEvaluationDetails` with an OpenFeature `reason`, `error_code` and `flag_metadata`. These accessors never coerce and never raise: they return your default when the flag is missing, failed, unreadable, has no value, or has a value of another type, and an object value too deeply nested to copy reads as unreadable. A read of a value of another type (`TYPE_MISMATCH`) is a bug in the calling code: it sends no `$feature_flag_called` and leaves the dedupe cache alone, but still counts as accessed for `only_accessed()`. The next read of the right type, or a `get_flag()` or `is_enabled()` read, reports the flag. The SDK logs the details' `error_message` as an error once per client for each flag and requested type. Numbers are IEEE-754 doubles: `get_number_value` returns an integer beyond `2**53 - 1` in magnitude as the nearest `float`, `get_float_value` returns every number as the nearest `float`, and both return an infinity for an integer beyond the range of a double, as JavaScript parses it. `get_integer_value` returns your default with `TYPE_MISMATCH` for an integer beyond `2**53 - 1`. Each accessor takes the keyword-only `send_feature_flag_events` option, like `is_enabled()` and `get_flag()`: pass `send_feature_flag_events=False` to read a flag without sending `$feature_flag_called`. The key still counts as accessed for `only_accessed()`, and a later read that sends events still reports the flag. Details of a locally evaluated flag have `config_version` 1 and carry the `id` and `version` of the local flag definition in `flag_metadata`. `FeatureFlag` gains `value` (the typed value), `failed`, and `error_code` and `error_message`, which say why a flag failed or could not be read. `FlagMetadata` gains `config_version`, `rule_type`, `rule_id`, `variant_key` and `forced_variant`. Number and object values need a server that sends typed values; the SDK logs once per client when it reads a response without them.
5 changes: 5 additions & 0 deletions .sampo/changesets/snapshot-flag-event-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Add a per-read `send_feature_flag_events` option to `FeatureFlagEvaluations.is_enabled()` and `get_flag()`. Pass `send_feature_flag_events=False` to read a flag from an `evaluate_flags()` snapshot without sending `$feature_flag_called`. The read leaves the dedupe cache alone, so a later read that sends events still reports the flag, and the key still counts as accessed for `only_accessed()`. The option defaults to `True`, as on `get_feature_flag()`.
3 changes: 3 additions & 0 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@
BeforeSendCallback as BeforeSendCallback,
FeatureFlag as FeatureFlag,
FeatureFlagEvaluationRuntime as FeatureFlagEvaluationRuntime,
FlagEvaluationDetails as FlagEvaluationDetails,
FlagEvaluationErrorCode as FlagEvaluationErrorCode,
FlagEvaluationReason as FlagEvaluationReason,
FlagValue as FlagValue,
FlagsAndPayloads as FlagsAndPayloads,
UnresolvedFlagReason as UnresolvedFlagReason,
Expand Down
12 changes: 12 additions & 0 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ def __init__(
self.log.setLevel(logging.WARNING)
# Whether this client has logged that /flags sent records without typed values.
self._logged_untyped_flags_response = False
# The (flag key, requested type) pairs of typed reads of the wrong type that
# this client has logged. Same bound and eviction as the flag-event dedupe.
self._logged_flag_type_mismatches = SizeLimitedDict(_MAX_DICT_SIZE)

if not self.api_key:
self.log.error(
Expand Down Expand Up @@ -850,9 +853,18 @@ def _get_feature_flag_evaluations_host(self) -> _FeatureFlagEvaluationsHost:
self._feature_flag_evaluations_host_cache = _FeatureFlagEvaluationsHost(
capture_flag_called_event_if_needed=self._capture_feature_flag_called_if_needed,
log_warning=lambda message: self.log.warning(message),
log_type_mismatch=self._log_flag_type_mismatch,
)
return self._feature_flag_evaluations_host_cache

def _log_flag_type_mismatch(self, key: str, value_type: str, message: str) -> None:
"""Log a typed flag read of the wrong type, once per flag key and type."""
logged_key = (key, value_type)
if logged_key in self._logged_flag_type_mismatches:
return
self._logged_flag_type_mismatches[logged_key] = True
self.log.error(message)

async def _get_flags_decision(
self,
distinct_id: ID_TYPES,
Expand Down
17 changes: 14 additions & 3 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,9 @@ def __init__(
self._minimal_flag_called_events: bool = False
# Whether this client has logged that /flags sent records without typed values.
self._logged_untyped_flags_response = False
# The (flag key, requested type) pairs of typed reads of the wrong type that
# this client has logged. Same bound and eviction as the flag-event dedupe.
self._logged_flag_type_mismatches = SizeLimitedDict(MAX_DICT_SIZE)

self.capture_trace_context = capture_trace_context
self.capture_exception_code_variables = capture_exception_code_variables
Expand Down Expand Up @@ -4928,11 +4931,10 @@ def evaluate_flags(
key=key,
enabled=value is not False,
variant=value if isinstance(value, str) else None,
inactive=value is False and not flag_def.get("active"),
payload=_parse_flag_payload(local_payloads.get(key)),
id=flag_def.get("id"),
# The local-evaluation flag definition does not carry a version field;
# only the remote ``/flags`` response does via ``metadata.version``.
version=None,
version=flag_def.get("version"),
reason="Evaluated locally",
locally_evaluated=True,
has_experiment=_parse_has_experiment(flag_def.get("has_experiment")),
Expand Down Expand Up @@ -5029,9 +5031,18 @@ def _get_feature_flag_evaluations_host(self) -> _FeatureFlagEvaluationsHost:
self._feature_flag_evaluations_host_cache = _FeatureFlagEvaluationsHost(
capture_flag_called_event_if_needed=self._capture_feature_flag_called_if_needed,
log_warning=lambda message: self.log.warning(message),
log_type_mismatch=self._log_flag_type_mismatch,
)
return self._feature_flag_evaluations_host_cache

def _log_flag_type_mismatch(self, key: str, value_type: str, message: str) -> None:
"""Log a typed flag read of the wrong type, once per flag key and type."""
logged_key = (key, value_type)
if logged_key in self._logged_flag_type_mismatches:
return
self._logged_flag_type_mismatches[logged_key] = True
self.log.error(message)

def _get_all_flags_and_payloads_locally(
self,
distinct_id: ID_TYPES,
Expand Down
Loading
Loading