|
5 | 5 | values the code branched on, with no additional /flags request. |
6 | 6 | """ |
7 | 7 |
|
| 8 | +import json |
8 | 9 | from dataclasses import dataclass |
9 | 10 | from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union |
10 | 11 |
|
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 | +) |
12 | 20 |
|
13 | 21 |
|
14 | 22 | @dataclass(frozen=True) |
@@ -37,6 +45,131 @@ class _FeatureFlagEvaluationsHost: |
37 | 45 | log_warning: Callable[[str], None] |
38 | 46 |
|
39 | 47 |
|
| 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 | + |
40 | 173 | class FeatureFlagEvaluations: |
41 | 174 | """A point-in-time snapshot of feature flag evaluations for a single distinct_id. |
42 | 175 |
|
@@ -216,40 +349,33 @@ def _record_access(self, key: str) -> None: |
216 | 349 | else: |
217 | 350 | response = flag.variant if flag.variant is not None else True |
218 | 351 |
|
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 | | - |
241 | 352 | # Build the comma-joined `$feature_flag_error` matching the single-flag path's |
242 | 353 | # granularity: response-level errors (errors-while-computing, quota-limited) are |
243 | 354 | # combined with per-flag errors (flag-missing) so consumers can filter by type. |
244 | 355 | errors: List[str] = [] |
245 | 356 | if self._errors_while_computing: |
246 | | - errors.append("errors_while_computing_flags") |
| 357 | + errors.append(_FeatureFlagError.ERRORS_WHILE_COMPUTING) |
247 | 358 | if self._quota_limited: |
248 | | - errors.append("quota_limited") |
| 359 | + errors.append(_FeatureFlagError.QUOTA_LIMITED) |
249 | 360 | 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 | + ) |
253 | 379 |
|
254 | 380 | self._host.capture_flag_called_event_if_needed( |
255 | 381 | distinct_id=self._distinct_id, |
|
0 commit comments