Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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-v3-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Request `/flags?v=3` and keep each flag's typed value, reason and metadata. Servers that still send the older response keep working. 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. Add `get_boolean_value`, `get_string_value`, `get_number_value` and `get_object_value` to the `evaluate_flags()` result, each with a `_details` form that also returns the reason and metadata. These accessors never coerce: they return your default when the flag is missing, failed, has no value, or has a value of another type.
2 changes: 2 additions & 0 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
BeforeSendCallback as BeforeSendCallback,
FeatureFlag as FeatureFlag,
FeatureFlagEvaluationRuntime as FeatureFlagEvaluationRuntime,
FlagEvaluationDetails as FlagEvaluationDetails,
FlagEvaluationErrorCode as FlagEvaluationErrorCode,
FlagValue as FlagValue,
FlagsAndPayloads as FlagsAndPayloads,
)
Expand Down
2 changes: 1 addition & 1 deletion posthog/_async_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ async def async_flags(
_serialize_flags_body, api_key, request_data
)
response = await http_client.post(
"/flags/?v=2", content=data, headers=headers, timeout=timeout
"/flags/?v=3", content=data, headers=headers, timeout=timeout
)
return await asyncio.to_thread(_process_flags_response, response)
except httpx_module.TransportError:
Expand Down
1 change: 1 addition & 0 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,7 @@ async def evaluate_flags(
),
locally_evaluated=False,
has_experiment=_metadata_has_experiment(metadata),
details=detail,
)
except QuotaLimitError:
self.log.warning(
Expand Down
1 change: 1 addition & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4635,6 +4635,7 @@ def evaluate_flags(
),
locally_evaluated=False,
has_experiment=_metadata_has_experiment(detail.metadata),
details=detail,
)
except QuotaLimitError as e:
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
Expand Down
108 changes: 107 additions & 1 deletion posthog/feature_flag_evaluations.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union

from posthog.types import FlagValue
from posthog.types import (
FeatureFlag,
FlagEvaluationDetails,
FlagValue,
_resolve_flag_details,
)


@dataclass(frozen=True)
Expand All @@ -26,6 +31,9 @@ class _EvaluatedFlagRecord:
# Server-reported signal for whether the flag is linked to an experiment.
# ``None`` when the server did not report it (older deployments).
has_experiment: Optional[bool] = None
# The parsed /flags record, with its typed value, reason and metadata.
# ``None`` for a locally evaluated flag.
details: Optional[FeatureFlag] = None


@dataclass
Expand Down Expand Up @@ -123,6 +131,92 @@ def get_flag(self, key: str) -> Optional[FlagValue]:
return False
return flag.variant if flag.variant is not None else True

def get_boolean_value(self, key: str, default_value: bool) -> bool:
"""Return the flag's boolean value, or ``default_value``.

Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See
:meth:`get_boolean_details` for when the default is returned.
"""
return self.get_boolean_details(key, default_value).value

def get_boolean_details(
self, key: str, default_value: bool
) -> FlagEvaluationDetails[bool]:
"""Return the flag's boolean value with its evaluation details.

The value is never coerced. ``default_value`` is returned when the flag is
not in the evaluation, failed, has no value, or has a value of another
type; ``error_code`` says which, except for a flag without a value. Fires
``$feature_flag_called`` on first access, like :meth:`get_flag`.
"""
return self._get_typed_details(key, default_value, "boolean")

def get_string_value(self, key: str, default_value: str) -> str:
"""Return the flag's string value, or ``default_value``.

Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See
:meth:`get_string_details` for when the default is returned.
"""
return self.get_string_details(key, default_value).value

def get_string_details(
self, key: str, default_value: str
) -> FlagEvaluationDetails[str]:
"""Return the flag's string value with its evaluation details.

The value is never coerced. ``default_value`` is returned when the flag is
not in the evaluation, failed, has no value, or has a value of another
type; ``error_code`` says which, except for a flag without a value or with
the value ``False``. Fires ``$feature_flag_called`` on first access, like
:meth:`get_flag`.
"""
return self._get_typed_details(key, default_value, "string")

def get_number_value(self, key: str, default_value: float) -> float:
"""Return the flag's number value, or ``default_value``.

An integer value is returned as an ``int``. Like :meth:`get_flag`, fires
``$feature_flag_called`` on first access. See :meth:`get_number_details` for
when the default is returned.
"""
return self.get_number_details(key, default_value).value

def get_number_details(
self, key: str, default_value: float
) -> FlagEvaluationDetails[float]:
"""Return the flag's number value with its evaluation details.

The value is never coerced: a boolean is not a number. ``default_value`` is
returned when the flag is not in the evaluation, failed, has no value, or
has a value of another type; ``error_code`` says which, except for a flag
without a value or with the value ``False``. Fires ``$feature_flag_called``
on first access, like :meth:`get_flag`.
"""
return self._get_typed_details(key, default_value, "number")

def get_object_value(
self, key: str, default_value: Dict[str, Any]
) -> Dict[str, Any]:
"""Return the flag's object value, or ``default_value``.

Like :meth:`get_flag`, fires ``$feature_flag_called`` on first access. See
:meth:`get_object_details` for when the default is returned.
"""
return self.get_object_details(key, default_value).value

def get_object_details(
self, key: str, default_value: Dict[str, Any]
) -> FlagEvaluationDetails[Dict[str, Any]]:
"""Return the flag's object value with its evaluation details.

The value is never coerced: a payload is not an object value.
``default_value`` is returned when the flag is not in the evaluation,
failed, has no value, or has a value of another type; ``error_code`` says
which, except for a flag without a value or with the value ``False``. Fires
``$feature_flag_called`` on first access, like :meth:`get_flag`.
"""
return self._get_typed_details(key, default_value, "object")

def get_flag_payload(self, key: str) -> Optional[Any]:
"""Return the payload associated with a flag.

Expand Down Expand Up @@ -215,6 +309,18 @@ def _clone_with(
accessed=set(self._accessed),
)

def _get_typed_details(
self, key: str, default_value: Any, value_type: str
) -> FlagEvaluationDetails[Any]:
flag = self._flags.get(key)
self._record_access(key)
details = None
if flag is not None:
details = flag.details or FeatureFlag.from_value_and_payload(
key, flag.variant if flag.variant is not None else flag.enabled, None
)
return _resolve_flag_details(key, details, default_value, value_type)

def _record_access(self, key: str) -> None:
self._accessed.add(key)

Expand Down
2 changes: 1 addition & 1 deletion posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ def flags(
res = post(
api_key,
host,
"/flags/?v=2",
"/flags/?v=3",
gzip,
timeout,
session=_get_flags_session(),
Expand Down
27 changes: 27 additions & 0 deletions posthog/test/fixtures/feature_flag_rules_v2/SOURCE.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"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.",
"repository": "https://github.com/PostHog/posthog-sdk-test-harness",
"release": "1.13.1",
"revision": "e32f08bd30fab57ce543da5808a668d0fda697b0",
"contract_directory": "contracts/feature_flag_rules_v2",
"contract_version": "2.3.1",
"checksum_index_sha256": "8fbf9ae84ae29ee0b09150a9c8cc378942c716a287a202d6a057131467407c6e",
"files": {
"readers.json": {
"source_path": "fixtures/wire/readers.json",
"sha256": "07499c5a7ca7467968156ee16fa95bdeb9018c0b841c4ec8f5bffc61d72de4fa"
},
"responses.json": {
"source_path": "fixtures/wire/responses.json",
"sha256": "e34659b62facbc594bdc1ce6c0f127ff25d94672da332670b383d729f45e5960"
},
"legacy_projection.json": {
"source_path": "corpus/legacy_projection.json",
"sha256": "536c8386eefa75721d113bd8718ddeb7fb1aad3b4f2cce1fbcf516a346a16e26"
},
"v2_value_evaluation.json": {
"source_path": "corpus/v2_value_evaluation.json",
"sha256": "aaddd05e47fc5a4ae07ea42ada5a659798f4b5fc8d0c0827f6a68e4daf68c128"
}
}
}
Loading
Loading