Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
3f93c4a
feat(flags): read the /flags?v=3 response with typed values
andehen Oct 7, 2026
811c6c2
feat(flags): drop experiment_id from v3 flag metadata
andehen Oct 15, 2026
ae6dcde
feat(flags): drop holdout_id from v3 flag metadata
andehen Oct 15, 2026
ab872fa
feat(flags): request /flags?v=4 and call the typed record v4
andehen Oct 26, 2026
20168e5
Merge remote-tracking branch 'origin/main' into feat/flags-v3-response
andehen Oct 28, 2026
a5d7c7e
feat(flags): match typed flag details to the SDK spec and contract 7.0.0
andehen Oct 28, 2026
460f0c3
test(flags): declare the flags_response_v4 compliance capability
andehen Oct 28, 2026
d3af9f8
Merge remote-tracking branch 'origin/main' into feat/flags-v3-response
andehen Oct 29, 2026
3d3ac59
ci(compliance): run harness 1.23.0 for the flags_response_v4 capability
andehen Oct 29, 2026
ac7b619
feat(flags): name the unresolved cause in typed details
andehen Oct 29, 2026
adc46f4
refactor(flags): move the typed flag value accessors to a follow-up
andehen Oct 29, 2026
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
8d1db30
fix(flags): fail only the unreadable record, whatever the server version
andehen Oct 29, 2026
1a00f0d
fix(flags): keep a flag ID and version absent when the record has none
andehen Oct 29, 2026
8584fb5
fix(flags): make a number or object value its own payload
andehen Oct 29, 2026
808385f
docs(changeset): list the public fields the v4 response adds
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
5000d44
fix(flags): fail only the record whose value is too deep to encode
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
ad1b1fb
refactor(flags): keep the v4 value and rule metadata fields private
andehen Oct 30, 2026
76065a5
Merge branch 'feat/flags-v3-response' into feat/flags-typed-accessors
andehen Oct 30, 2026
64399a1
Merge remote-tracking branch 'origin/main' into feat/flags-v3-response
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
c5c53f2
fix(flags): keep the private v4 details out of the public dataclass f…
andehen Nov 2, 2026
406ee14
Merge remote-tracking branch 'origin/main' into feat/flags-v3-response
andehen Nov 2, 2026
ed04b1b
Merge remote-tracking branch 'origin/main' into feat/flags-v3-response
andehen Nov 2, 2026
bd9c1ad
fix(flags): keep the last good cached value when a record is unreadable
andehen Nov 2, 2026
837b326
fix(flags): build the subclass FeatureFlag.from_json is called on
andehen Nov 2, 2026
63975e6
perf(flags): keep one copy of each remotely evaluated flag in a snapshot
andehen Nov 2, 2026
77b0354
test(flags): pin legacy flag results to those 7.82.1 recorded
andehen Nov 2, 2026
f8fe7d0
fix(flags): report an unreadable record's read as an unknown error
andehen Nov 2, 2026
c1b2856
Merge remote-tracking branch 'origin/feat/flags-v3-response' into fea…
andehen Nov 3, 2026
ef2d974
test(flags): compare 7.82.1 results without the fields this change ma…
andehen Nov 3, 2026
ca9acd8
Merge remote-tracking branch 'origin/main' into feat/flags-typed-acce…
andehen Nov 3, 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.
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 @@ -237,6 +237,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 @@ -899,9 +902,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
13 changes: 13 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,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 @@ -5020,6 +5023,7 @@ 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"),
version=flag_def.get("version"),
Expand Down Expand Up @@ -5091,9 +5095,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