Skip to content

Commit e094dc5

Browse files
authored
feat(flags): read the /flags?v=4 response (#1031)
1 parent 1dfca27 commit e094dc5

23 files changed

Lines changed: 10272 additions & 92 deletions

‎.github/workflows/sdk-compliance.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
with:
3434
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
3535
adapter-context: "."
36-
test-harness-version: "1.1.1"
36+
test-harness-version: "1.23.0"
3737
continue-on-error: false
3838
report-name: "sdk-compliance-report-v0"
3939

@@ -43,6 +43,6 @@ jobs:
4343
with:
4444
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1"
4545
adapter-context: "."
46-
test-harness-version: "1.1.1"
46+
test-harness-version: "1.23.0"
4747
continue-on-error: false
4848
report-name: "sdk-compliance-report-v1"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
Request `/flags?v=4` and read each flag's typed value, reason and metadata. They stay internal for now: this release adds no public methods, fields or types for them, and `repr`, equality and `dataclasses.asdict` of a flag show only its existing fields. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`.

‎posthog/_async_request.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ async def async_flags(
149149
_serialize_flags_body, api_key, request_data
150150
)
151151
response = await http_client.post(
152-
"/flags/?v=2", content=data, headers=headers, timeout=timeout
152+
"/flags/?v=4", content=data, headers=headers, timeout=timeout
153153
)
154154
return await asyncio.to_thread(_process_flags_response, response)
155155
except httpx_module.TransportError:

‎posthog/async_client.py‎

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@
4040
MAX_DICT_SIZE as _MAX_DICT_SIZE,
4141
_MINIMAL_FLAG_CALLED_EVENT_PROPERTIES,
4242
Client as _SyncClient,
43-
_metadata_has_experiment,
44-
_parse_flag_payload,
4543
add_context_tags as _add_context_tags,
4644
get_identity_state as _get_identity_state,
4745
stringify_id as _stringify_id,
@@ -74,11 +72,17 @@
7472
from .feature_flag_evaluations import (
7573
FeatureFlagEvaluations,
7674
_EvaluatedFlagRecord,
75+
_RemoteFlagRecord,
7776
_FeatureFlagEvaluationsHost,
7877
)
7978
from .release_id import _resolve_release_id
8079
from .request import QuotaLimitError, determine_server_host, normalize_host
81-
from .types import FlagMetadata, FlagValue, normalize_flags_response
80+
from .types import (
81+
FlagValue,
82+
_has_untyped_flag_records,
83+
_UNTYPED_FLAGS_RESPONSE_MESSAGE,
84+
normalize_flags_response,
85+
)
8286
from .utils import SizeLimitedDict, _normalize_timestamp, clean, system_context
8387
from .version import VERSION
8488

@@ -231,6 +235,8 @@ def __init__(
231235
self.log.setLevel(logging.DEBUG)
232236
else:
233237
self.log.setLevel(logging.WARNING)
238+
# Whether this client has logged that /flags sent records without typed values.
239+
self._logged_untyped_flags_response = False
234240

235241
if not self.api_key:
236242
self.log.error(
@@ -927,6 +933,11 @@ async def _get_flags_decision(
927933
client=self._get_http_client(),
928934
**request_data,
929935
)
936+
if not self._logged_untyped_flags_response and _has_untyped_flag_records(
937+
response
938+
):
939+
self._logged_untyped_flags_response = True
940+
self.log.info(_UNTYPED_FLAGS_RESPONSE_MESSAGE)
930941
return normalize_flags_response(response)
931942

932943
async def evaluate_flags(
@@ -1004,24 +1015,7 @@ async def evaluate_flags(
10041015
for key, detail in response.get("flags", {}).items():
10051016
if requested_keys is not None and key not in requested_keys:
10061017
continue
1007-
metadata = detail.metadata
1008-
records[key] = _EvaluatedFlagRecord(
1009-
key=key,
1010-
enabled=detail.enabled,
1011-
variant=detail.variant,
1012-
payload=_parse_flag_payload(metadata.payload),
1013-
id=metadata.id if isinstance(metadata, FlagMetadata) else None,
1014-
version=(
1015-
metadata.version if isinstance(metadata, FlagMetadata) else None
1016-
),
1017-
reason=(
1018-
detail.reason.description
1019-
if detail.reason and detail.reason.description
1020-
else None
1021-
),
1022-
locally_evaluated=False,
1023-
has_experiment=_metadata_has_experiment(metadata),
1024-
)
1018+
records[key] = _RemoteFlagRecord(detail)
10251019
except QuotaLimitError:
10261020
self.log.warning(
10271021
"[FEATURE FLAGS] Feature flag evaluation was quota limited"

‎posthog/client.py‎

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@
7777
)
7878
from posthog.feature_flag_evaluations import (
7979
FeatureFlagEvaluations,
80+
_LocalFlagRecord,
8081
_EvaluatedFlagRecord,
82+
_RemoteFlagRecord,
8183
_FeatureFlagEvaluationsHost,
8284
)
8385
from posthog.feature_flags import (
@@ -123,6 +125,8 @@
123125
FlagValue,
124126
SendFeatureFlagsOptions,
125127
UnresolvedFlagReason,
128+
_has_untyped_flag_records,
129+
_UNTYPED_FLAGS_RESPONSE_MESSAGE,
126130
normalize_flags_response,
127131
to_flags_and_payloads,
128132
to_payloads,
@@ -1016,6 +1020,8 @@ def __init__(
10161020
# the /flags v2 response and the local-evaluation payload. False until the
10171021
# server reports it, so full events are the fail-safe.
10181022
self._minimal_flag_called_events: bool = False
1023+
# Whether this client has logged that /flags sent records without typed values.
1024+
self._logged_untyped_flags_response = False
10191025

10201026
self.capture_trace_context = capture_trace_context
10211027
self.capture_exception_code_variables = capture_exception_code_variables
@@ -1716,6 +1722,11 @@ def _get_flags_decision(
17161722
**request_data,
17171723
)
17181724

1725+
if not self._logged_untyped_flags_response and _has_untyped_flag_records(
1726+
resp_data
1727+
):
1728+
self._logged_untyped_flags_response = True
1729+
self.log.info(_UNTYPED_FLAGS_RESPONSE_MESSAGE)
17191730
response = normalize_flags_response(resp_data)
17201731
# Server-controlled gate for minimal $feature_flag_called events. Only the
17211732
# v2 response shape carries it; absent (legacy shape, older server, team
@@ -4172,9 +4183,23 @@ def _get_feature_flag_result(
41724183
flag_details, override_match_value
41734184
)
41744185

4186+
if (
4187+
flag_details is not None
4188+
and flag_details._malformed_field is not None
4189+
):
4190+
# The record could not be read, so it reads as disabled. It must
4191+
# not replace the last good cached value, which it falls back to
4192+
# like a failed request, and it reports what a failed request
4193+
# reports: an unknown error and no request details.
4194+
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
4195+
request_id = None
4196+
evaluated_at = None
4197+
flag_result = (
4198+
self._get_stale_flag_fallback(distinct_id, key) or flag_result
4199+
)
41754200
# The request-start generation is an invalidation boundary, not
41764201
# a claim about the server's definitions. Refresh rejects late writes.
4177-
if self.flag_cache and flag_result:
4202+
elif self.flag_cache and flag_result:
41784203
self.flag_cache._set_cached_remote_flag(
41794204
distinct_id, key, flag_result, local_definition_version
41804205
)
@@ -4991,7 +5016,7 @@ def evaluate_flags(
49915016

49925017
for key, value in local_flags.items():
49935018
flag_def = feature_flags_by_key.get(key) or {}
4994-
records[key] = _EvaluatedFlagRecord(
5019+
records[key] = _LocalFlagRecord(
49955020
key=key,
49965021
enabled=value is not False,
49975022
variant=value if isinstance(value, str) else None,
@@ -5034,34 +5059,7 @@ def evaluate_flags(
50345059
continue
50355060
if key in locally_evaluated_keys:
50365061
continue
5037-
payload = _parse_flag_payload(
5038-
detail.metadata.payload
5039-
if isinstance(detail.metadata, FlagMetadata)
5040-
else getattr(detail.metadata, "payload", None)
5041-
)
5042-
records[key] = _EvaluatedFlagRecord(
5043-
key=key,
5044-
enabled=detail.enabled,
5045-
variant=detail.variant,
5046-
payload=payload,
5047-
id=(
5048-
detail.metadata.id
5049-
if isinstance(detail.metadata, FlagMetadata)
5050-
else None
5051-
),
5052-
version=(
5053-
detail.metadata.version
5054-
if isinstance(detail.metadata, FlagMetadata)
5055-
else None
5056-
),
5057-
reason=(
5058-
detail.reason.description
5059-
if detail.reason and detail.reason.description
5060-
else None
5061-
),
5062-
locally_evaluated=False,
5063-
has_experiment=_metadata_has_experiment(detail.metadata),
5064-
)
5062+
records[key] = _RemoteFlagRecord(detail)
50655063
except QuotaLimitError as e:
50665064
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
50675065
quota_limited = True

‎posthog/feature_flag_evaluations.py‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
from types import MappingProxyType
1010
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union
1111

12-
from posthog.types import FlagValue, UnresolvedFlagReason
12+
from .types import FeatureFlag as _FeatureFlag
13+
from .types import FlagMetadata as _FlagMetadata
14+
from .types import FlagValue, UnresolvedFlagReason, _parse_flag_payload
1315

1416

1517
@dataclass(frozen=True)
16-
class _EvaluatedFlagRecord:
17-
"""Internal per-flag record stored by a FeatureFlagEvaluations instance."""
18+
class _LocalFlagRecord:
19+
"""Internal per-flag record of a locally evaluated flag."""
1820

1921
key: str
2022
enabled: bool
@@ -29,6 +31,70 @@ class _EvaluatedFlagRecord:
2931
has_experiment: Optional[bool] = None
3032

3133

34+
_UNDECODED: Any = object()
35+
36+
37+
class _RemoteFlagRecord:
38+
"""Internal per-flag record of a remotely evaluated flag.
39+
40+
The parsed ``/flags`` record is the only copy of the flag: the record fields are
41+
read from it, and the payload is decoded on its first read and then kept.
42+
"""
43+
44+
__slots__ = ("details", "_payload")
45+
46+
locally_evaluated = False
47+
48+
def __init__(self, details: _FeatureFlag) -> None:
49+
# The parsed /flags record, with its typed value, reason and metadata.
50+
self.details = details
51+
self._payload: Any = _UNDECODED
52+
53+
@property
54+
def key(self) -> str:
55+
return self.details.key
56+
57+
@property
58+
def enabled(self) -> bool:
59+
return self.details.enabled
60+
61+
@property
62+
def variant(self) -> Optional[str]:
63+
return self.details.variant
64+
65+
@property
66+
def payload(self) -> Optional[Any]:
67+
if self._payload is _UNDECODED:
68+
self._payload = _parse_flag_payload(
69+
getattr(self.details.metadata, "payload", None)
70+
)
71+
return self._payload
72+
73+
@property
74+
def id(self) -> Optional[int]:
75+
metadata = self.details.metadata
76+
return metadata.id if isinstance(metadata, _FlagMetadata) else None
77+
78+
@property
79+
def version(self) -> Optional[int]:
80+
metadata = self.details.metadata
81+
return metadata.version if isinstance(metadata, _FlagMetadata) else None
82+
83+
@property
84+
def reason(self) -> Optional[str]:
85+
reason = self.details.reason
86+
return reason.description if reason and reason.description else None
87+
88+
@property
89+
def has_experiment(self) -> Optional[bool]:
90+
metadata = self.details.metadata
91+
return metadata.has_experiment if isinstance(metadata, _FlagMetadata) else None
92+
93+
94+
# The per-flag record stored by a FeatureFlagEvaluations instance.
95+
_EvaluatedFlagRecord = Union[_LocalFlagRecord, _RemoteFlagRecord]
96+
97+
3298
@dataclass
3399
class _FeatureFlagEvaluationsHost:
34100
"""Callbacks the evaluations object uses to talk back to the client.

‎posthog/request.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ def flags(
344344
res = post(
345345
api_key,
346346
host,
347-
"/flags/?v=2",
347+
"/flags/?v=4",
348348
gzip,
349349
timeout,
350350
session=_get_flags_session(),
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"description": "Copied unchanged from the released feature flag rules v2 contract in PostHog/posthog-sdk-test-harness. Do not edit these files; copy a newer release instead.",
3+
"repository": "https://github.com/PostHog/posthog-sdk-test-harness",
4+
"release": "1.22.0",
5+
"revision": "17d861f8e230fe943934d70e3eebf5cff6bf3aa4",
6+
"contract_directory": "contracts/feature_flag_rules_v2",
7+
"contract_version": "7.0.0",
8+
"files": {
9+
"readers.json": {
10+
"source_path": "fixtures/wire/readers.json",
11+
"sha256": "fd8edd89f827b732c4659ce7246eab534b0106b96a465a4a2d98cfa0a17d7827"
12+
},
13+
"responses.json": {
14+
"source_path": "fixtures/wire/responses.json",
15+
"sha256": "39aa62e340d66903e2fe86da226e622979c7dbc2efbf2b32afd040248a707c61"
16+
},
17+
"legacy_projection.json": {
18+
"source_path": "corpus/legacy_projection.json",
19+
"sha256": "536c8386eefa75721d113bd8718ddeb7fb1aad3b4f2cce1fbcf516a346a16e26"
20+
},
21+
"v2_value_evaluation.json": {
22+
"source_path": "corpus/v2_value_evaluation.json",
23+
"sha256": "aaddd05e47fc5a4ae07ea42ada5a659798f4b5fc8d0c0827f6a68e4daf68c128"
24+
}
25+
}
26+
}

0 commit comments

Comments
 (0)