Skip to content

Commit a365483

Browse files
committed
Refactor shared feature flag evaluation helpers
1 parent c927579 commit a365483

2 files changed

Lines changed: 182 additions & 122 deletions

File tree

posthog/client.py

Lines changed: 28 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import atexit
22
import inspect
3-
import json
43
import logging
54
import os
65
import sys
@@ -51,8 +50,11 @@
5150
)
5251
from posthog.feature_flag_evaluations import (
5352
FeatureFlagEvaluations,
54-
_EvaluatedFlagRecord,
5553
_FeatureFlagEvaluationsHost,
54+
_feature_flag_called_properties,
55+
_flag_details_metadata,
56+
_local_evaluation_records,
57+
_remote_evaluation_records,
5658
)
5759
from posthog.feature_flags import (
5860
InconclusiveMatchError,
@@ -85,7 +87,7 @@
8587
FeatureFlag,
8688
FeatureFlagError,
8789
FeatureFlagResult,
88-
FlagMetadata,
90+
FlagMetadata as FlagMetadata,
8991
FlagsAndPayloads,
9092
FlagsResponse,
9193
FlagValue,
@@ -2530,31 +2532,19 @@ def _capture_feature_flag_called(
25302532
flag_details: Optional[FeatureFlag],
25312533
feature_flag_error: Optional[str] = None,
25322534
):
2533-
properties: dict[str, Any] = {
2534-
"$feature_flag": key,
2535-
"$feature_flag_response": response,
2536-
"locally_evaluated": flag_was_locally_evaluated,
2537-
f"$feature/{key}": response,
2538-
}
2539-
2540-
if payload is not None:
2541-
# if payload is not a string, json serialize it to a string
2542-
properties["$feature_flag_payload"] = payload
2543-
2544-
if request_id:
2545-
properties["$feature_flag_request_id"] = request_id
2546-
if evaluated_at:
2547-
properties["$feature_flag_evaluated_at"] = evaluated_at
2548-
if isinstance(flag_details, FeatureFlag):
2549-
if flag_details.reason and flag_details.reason.description:
2550-
properties["$feature_flag_reason"] = flag_details.reason.description
2551-
if isinstance(flag_details.metadata, FlagMetadata):
2552-
if flag_details.metadata.version:
2553-
properties["$feature_flag_version"] = flag_details.metadata.version
2554-
if flag_details.metadata.id:
2555-
properties["$feature_flag_id"] = flag_details.metadata.id
2556-
if feature_flag_error:
2557-
properties["$feature_flag_error"] = feature_flag_error
2535+
flag_id, flag_version, flag_reason = _flag_details_metadata(flag_details)
2536+
properties = _feature_flag_called_properties(
2537+
key=key,
2538+
response=response,
2539+
locally_evaluated=flag_was_locally_evaluated,
2540+
payload=payload,
2541+
request_id=request_id,
2542+
evaluated_at=evaluated_at,
2543+
flag_id=flag_id,
2544+
flag_version=flag_version,
2545+
flag_reason=flag_reason,
2546+
feature_flag_error=feature_flag_error,
2547+
)
25582548

25592549
self._capture_feature_flag_called_if_needed(
25602550
distinct_id=distinct_id,
@@ -2875,12 +2865,10 @@ def evaluate_flags(
28752865
)
28762866
groups = groups or {}
28772867

2878-
records: Dict[str, _EvaluatedFlagRecord] = {}
28792868
request_id: Optional[str] = None
28802869
evaluated_at: Optional[int] = None
28812870
errors_while_computing = False
28822871
quota_limited = False
2883-
locally_evaluated_keys: set[str] = set()
28842872

28852873
# Try local evaluation first when the poller has loaded definitions.
28862874
local_result, fallback_to_server = self._get_all_flags_and_payloads_locally(
@@ -2891,24 +2879,9 @@ def evaluate_flags(
28912879
flag_keys_to_evaluate=flag_keys,
28922880
)
28932881

2894-
feature_flags_by_key: Dict[str, Any] = self.feature_flags_by_key or {}
2895-
local_flags = local_result.get("featureFlags") or {}
2896-
local_payloads = local_result.get("featureFlagPayloads") or {}
2897-
for key, value in local_flags.items():
2898-
flag_def = feature_flags_by_key.get(key) or {}
2899-
records[key] = _EvaluatedFlagRecord(
2900-
key=key,
2901-
enabled=value is not False,
2902-
variant=value if isinstance(value, str) else None,
2903-
payload=local_payloads.get(key),
2904-
id=flag_def.get("id"),
2905-
# The local-evaluation flag definition does not carry a version field;
2906-
# only the remote ``/flags`` response does via ``metadata.version``.
2907-
version=None,
2908-
reason="Evaluated locally",
2909-
locally_evaluated=True,
2910-
)
2911-
locally_evaluated_keys.add(key)
2882+
records, locally_evaluated_keys = _local_evaluation_records(
2883+
local_result, self.feature_flags_by_key or {}
2884+
)
29122885

29132886
# Fall back to remote evaluation for any flags the poller couldn't resolve locally.
29142887
# Use the flags decision path directly so the resulting records carry id/version/reason
@@ -2924,52 +2897,13 @@ def evaluate_flags(
29242897
flag_keys_to_evaluate=flag_keys,
29252898
device_id=device_id,
29262899
)
2927-
request_id = response.get("requestId")
2928-
raw_evaluated_at = response.get("evaluatedAt")
2929-
evaluated_at = (
2930-
raw_evaluated_at if isinstance(raw_evaluated_at, int) else None
2931-
)
2932-
errors_while_computing = bool(
2933-
response.get("errorsWhileComputingFlags", False)
2934-
)
2935-
for key, detail in response.get("flags", {}).items():
2936-
if key in locally_evaluated_keys:
2937-
continue
2938-
payload: Optional[Any] = None
2939-
raw_payload = (
2940-
detail.metadata.payload
2941-
if isinstance(detail.metadata, FlagMetadata)
2942-
else getattr(detail.metadata, "payload", None)
2943-
)
2944-
if isinstance(raw_payload, str) and raw_payload:
2945-
try:
2946-
payload = json.loads(raw_payload)
2947-
except (json.JSONDecodeError, TypeError):
2948-
payload = raw_payload
2949-
elif raw_payload is not None:
2950-
payload = raw_payload
2951-
records[key] = _EvaluatedFlagRecord(
2952-
key=key,
2953-
enabled=detail.enabled,
2954-
variant=detail.variant,
2955-
payload=payload,
2956-
id=(
2957-
detail.metadata.id
2958-
if isinstance(detail.metadata, FlagMetadata)
2959-
else None
2960-
),
2961-
version=(
2962-
detail.metadata.version
2963-
if isinstance(detail.metadata, FlagMetadata)
2964-
else None
2965-
),
2966-
reason=(
2967-
detail.reason.description
2968-
if detail.reason and detail.reason.description
2969-
else None
2970-
),
2971-
locally_evaluated=False,
2972-
)
2900+
(
2901+
remote_records,
2902+
request_id,
2903+
evaluated_at,
2904+
errors_while_computing,
2905+
) = _remote_evaluation_records(response, locally_evaluated_keys)
2906+
records.update(remote_records)
29732907
except QuotaLimitError as e:
29742908
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
29752909
quota_limited = True

posthog/feature_flag_evaluations.py

Lines changed: 154 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@
55
values the code branched on, with no additional /flags request.
66
"""
77

8+
import json
89
from dataclasses import dataclass
910
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union
1011

11-
from posthog.types import FlagValue
12+
from .types import (
13+
FeatureFlag as _FeatureFlag,
14+
FeatureFlagError as _FeatureFlagError,
15+
FlagMetadata as _FlagMetadata,
16+
FlagsAndPayloads as _FlagsAndPayloads,
17+
FlagsResponse as _FlagsResponse,
18+
FlagValue,
19+
)
1220

1321

1422
@dataclass(frozen=True)
@@ -37,6 +45,131 @@ class _FeatureFlagEvaluationsHost:
3745
log_warning: Callable[[str], None]
3846

3947

48+
def _parse_evaluation_payload(raw_payload: Any) -> Optional[Any]:
49+
if isinstance(raw_payload, str) and raw_payload:
50+
try:
51+
return json.loads(raw_payload)
52+
except (json.JSONDecodeError, TypeError):
53+
return raw_payload
54+
if raw_payload is not None:
55+
return raw_payload
56+
return None
57+
58+
59+
def _flag_details_metadata(
60+
flag_details: Optional[_FeatureFlag],
61+
) -> tuple[Optional[int], Optional[int], Optional[str]]:
62+
if not isinstance(flag_details, _FeatureFlag):
63+
return None, None, None
64+
65+
flag_id: Optional[int] = None
66+
flag_version: Optional[int] = None
67+
if isinstance(flag_details.metadata, _FlagMetadata):
68+
flag_id = flag_details.metadata.id
69+
flag_version = flag_details.metadata.version
70+
flag_reason = (
71+
flag_details.reason.description
72+
if flag_details.reason and flag_details.reason.description
73+
else None
74+
)
75+
return flag_id, flag_version, flag_reason
76+
77+
78+
def _feature_flag_called_properties(
79+
*,
80+
key: str,
81+
response: Optional[FlagValue],
82+
locally_evaluated: bool,
83+
payload: Optional[Any] = None,
84+
request_id: Optional[str] = None,
85+
evaluated_at: Optional[int] = None,
86+
flag_id: Optional[int] = None,
87+
flag_version: Optional[int] = None,
88+
flag_reason: Optional[str] = None,
89+
feature_flag_error: Optional[str] = None,
90+
) -> Dict[str, Any]:
91+
properties: Dict[str, Any] = {
92+
"$feature_flag": key,
93+
"$feature_flag_response": response,
94+
"locally_evaluated": locally_evaluated,
95+
f"$feature/{key}": response,
96+
}
97+
if payload is not None:
98+
properties["$feature_flag_payload"] = payload
99+
if request_id:
100+
properties["$feature_flag_request_id"] = request_id
101+
if evaluated_at:
102+
properties["$feature_flag_evaluated_at"] = evaluated_at
103+
if flag_id:
104+
properties["$feature_flag_id"] = flag_id
105+
if flag_version:
106+
properties["$feature_flag_version"] = flag_version
107+
if flag_reason:
108+
properties["$feature_flag_reason"] = flag_reason
109+
if feature_flag_error:
110+
properties["$feature_flag_error"] = feature_flag_error
111+
return properties
112+
113+
114+
def _local_evaluation_records(
115+
local_result: _FlagsAndPayloads, feature_flags_by_key: Mapping[str, Any]
116+
) -> tuple[Dict[str, _EvaluatedFlagRecord], set[str]]:
117+
records: Dict[str, _EvaluatedFlagRecord] = {}
118+
locally_evaluated_keys: set[str] = set()
119+
local_flags = local_result.get("featureFlags") or {}
120+
local_payloads = local_result.get("featureFlagPayloads") or {}
121+
for key, value in local_flags.items():
122+
flag_def = feature_flags_by_key.get(key) or {}
123+
records[key] = _EvaluatedFlagRecord(
124+
key=key,
125+
enabled=value is not False,
126+
variant=value if isinstance(value, str) else None,
127+
payload=local_payloads.get(key),
128+
id=flag_def.get("id"),
129+
# The local-evaluation flag definition does not carry a version field;
130+
# only the remote ``/flags`` response does via ``metadata.version``.
131+
version=None,
132+
reason="Evaluated locally",
133+
locally_evaluated=True,
134+
)
135+
locally_evaluated_keys.add(key)
136+
return records, locally_evaluated_keys
137+
138+
139+
def _remote_evaluation_records(
140+
response: _FlagsResponse, excluded_keys: Set[str]
141+
) -> tuple[Dict[str, _EvaluatedFlagRecord], Optional[str], Optional[int], bool]:
142+
records: Dict[str, _EvaluatedFlagRecord] = {}
143+
for key, detail in response.get("flags", {}).items():
144+
if key in excluded_keys:
145+
continue
146+
flag_id, flag_version, flag_reason = _flag_details_metadata(detail)
147+
raw_payload = (
148+
detail.metadata.payload
149+
if isinstance(detail.metadata, _FlagMetadata)
150+
else getattr(detail.metadata, "payload", None)
151+
)
152+
records[key] = _EvaluatedFlagRecord(
153+
key=key,
154+
enabled=detail.enabled,
155+
variant=detail.variant,
156+
payload=_parse_evaluation_payload(raw_payload),
157+
id=flag_id,
158+
version=flag_version,
159+
reason=flag_reason,
160+
locally_evaluated=False,
161+
)
162+
163+
raw_evaluated_at = response.get("evaluatedAt")
164+
evaluated_at = raw_evaluated_at if isinstance(raw_evaluated_at, int) else None
165+
return (
166+
records,
167+
response.get("requestId"),
168+
evaluated_at,
169+
bool(response.get("errorsWhileComputingFlags", False)),
170+
)
171+
172+
40173
class FeatureFlagEvaluations:
41174
"""A point-in-time snapshot of feature flag evaluations for a single distinct_id.
42175
@@ -216,40 +349,33 @@ def _record_access(self, key: str) -> None:
216349
else:
217350
response = flag.variant if flag.variant is not None else True
218351

219-
properties: Dict[str, Any] = {
220-
"$feature_flag": key,
221-
"$feature_flag_response": response,
222-
"locally_evaluated": flag.locally_evaluated if flag else False,
223-
f"$feature/{key}": response,
224-
}
225-
226-
if flag is not None:
227-
if flag.payload is not None:
228-
properties["$feature_flag_payload"] = flag.payload
229-
if flag.id:
230-
properties["$feature_flag_id"] = flag.id
231-
if flag.version:
232-
properties["$feature_flag_version"] = flag.version
233-
if flag.reason:
234-
properties["$feature_flag_reason"] = flag.reason
235-
236-
if self._request_id:
237-
properties["$feature_flag_request_id"] = self._request_id
238-
if self._evaluated_at and not (flag and flag.locally_evaluated):
239-
properties["$feature_flag_evaluated_at"] = self._evaluated_at
240-
241352
# Build the comma-joined `$feature_flag_error` matching the single-flag path's
242353
# granularity: response-level errors (errors-while-computing, quota-limited) are
243354
# combined with per-flag errors (flag-missing) so consumers can filter by type.
244355
errors: List[str] = []
245356
if self._errors_while_computing:
246-
errors.append("errors_while_computing_flags")
357+
errors.append(_FeatureFlagError.ERRORS_WHILE_COMPUTING)
247358
if self._quota_limited:
248-
errors.append("quota_limited")
359+
errors.append(_FeatureFlagError.QUOTA_LIMITED)
249360
if flag is None:
250-
errors.append("flag_missing")
251-
if errors:
252-
properties["$feature_flag_error"] = ",".join(errors)
361+
errors.append(_FeatureFlagError.FLAG_MISSING)
362+
363+
properties = _feature_flag_called_properties(
364+
key=key,
365+
response=response,
366+
locally_evaluated=flag.locally_evaluated if flag else False,
367+
payload=flag.payload if flag else None,
368+
request_id=self._request_id,
369+
evaluated_at=(
370+
self._evaluated_at
371+
if self._evaluated_at and not (flag and flag.locally_evaluated)
372+
else None
373+
),
374+
flag_id=flag.id if flag else None,
375+
flag_version=flag.version if flag else None,
376+
flag_reason=flag.reason if flag else None,
377+
feature_flag_error=",".join(errors) if errors else None,
378+
)
253379

254380
self._host.capture_flag_called_event_if_needed(
255381
distinct_id=self._distinct_id,

0 commit comments

Comments
 (0)