Skip to content

Commit f022e2a

Browse files
committed
formatting
1 parent c4e09cd commit f022e2a

File tree

6 files changed

+256
-9
lines changed

6 files changed

+256
-9
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 6.2.0 - 2025-07-10
2+
3+
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
4+
15
# 6.1.0 - 2025-07-10
26

37
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller

posthog/args.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import numbers
66
from uuid import UUID
77

8+
from posthog.types import SendFeatureFlagsOptions
9+
810
ID_TYPES = Union[numbers.Number, str, UUID, int]
911

1012

@@ -22,7 +24,8 @@ class OptionalCaptureArgs(TypedDict):
2224
error ID if you capture an exception).
2325
groups: Group identifiers to associate with this event (format: {group_type: group_key})
2426
send_feature_flags: Whether to include currently active feature flags in the event properties.
25-
Defaults to False
27+
Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration.
28+
Defaults to False.
2629
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
2730
"""
2831

@@ -32,8 +35,8 @@ class OptionalCaptureArgs(TypedDict):
3235
uuid: NotRequired[Optional[str]]
3336
groups: NotRequired[Optional[Dict[str, str]]]
3437
send_feature_flags: NotRequired[
35-
Optional[bool]
36-
] # Optional so we can tell if the user is intentionally overriding a client setting or not
38+
Optional[Union[bool, SendFeatureFlagsOptions]]
39+
] # Updated to support both boolean and options object
3740
disable_geoip: NotRequired[
3841
Optional[bool]
3942
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not

posthog/client.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,11 +397,53 @@ def capture(
397397

398398
extra_properties: dict[str, Any] = {}
399399
feature_variants: Optional[dict[str, Union[bool, str]]] = {}
400-
if send_feature_flags:
400+
401+
# Parse send_feature_flags parameter
402+
should_send_flags = False
403+
only_evaluate_locally = None
404+
flag_person_properties = None
405+
flag_group_properties = None
406+
407+
if isinstance(send_feature_flags, dict):
408+
# SendFeatureFlagsOptions object
409+
should_send_flags = True
410+
only_evaluate_locally = send_feature_flags.get("only_evaluate_locally")
411+
flag_person_properties = send_feature_flags.get("person_properties")
412+
flag_group_properties = send_feature_flags.get("group_properties")
413+
elif send_feature_flags:
414+
# Boolean True
415+
should_send_flags = True
416+
417+
if should_send_flags:
401418
try:
402-
feature_variants = self.get_feature_variants(
403-
distinct_id, groups, disable_geoip=disable_geoip
404-
)
419+
if only_evaluate_locally is True:
420+
# Only use local evaluation
421+
feature_variants = self.get_all_flags(
422+
distinct_id,
423+
groups=(groups or {}),
424+
person_properties=flag_person_properties,
425+
group_properties=flag_group_properties,
426+
disable_geoip=disable_geoip,
427+
only_evaluate_locally=True,
428+
)
429+
elif only_evaluate_locally is False:
430+
# Force remote evaluation via /decide
431+
feature_variants = self.get_feature_variants(
432+
distinct_id,
433+
groups,
434+
person_properties=flag_person_properties,
435+
group_properties=flag_group_properties,
436+
disable_geoip=disable_geoip,
437+
)
438+
else:
439+
# Default behavior - use remote evaluation
440+
feature_variants = self.get_feature_variants(
441+
distinct_id,
442+
groups,
443+
person_properties=flag_person_properties,
444+
group_properties=flag_group_properties,
445+
disable_geoip=disable_geoip,
446+
)
405447
except Exception as e:
406448
self.log.exception(
407449
f"[FEATURE FLAGS] Unable to get feature variants: {e}"

posthog/test/test_client.py

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,186 @@ def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(
751751

752752
self.assertEqual(patch_flags.call_count, 0)
753753

754+
@mock.patch("posthog.client.flags")
755+
def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
756+
self, patch_flags
757+
):
758+
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=True uses local evaluation"""
759+
with mock.patch("posthog.client.batch_post") as mock_post:
760+
client = Client(
761+
FAKE_TEST_API_KEY,
762+
on_error=self.set_fail,
763+
personal_api_key=FAKE_TEST_API_KEY,
764+
sync_mode=True,
765+
)
766+
767+
# Set up local flags
768+
client.feature_flags = [
769+
{
770+
"id": 1,
771+
"key": "local-flag",
772+
"active": True,
773+
"filters": {
774+
"groups": [
775+
{
776+
"properties": [{"key": "region", "value": "US"}],
777+
"rollout_percentage": 100,
778+
}
779+
],
780+
},
781+
}
782+
]
783+
784+
send_options = {
785+
"only_evaluate_locally": True,
786+
"person_properties": {"region": "US"},
787+
}
788+
789+
msg_uuid = client.capture(
790+
"test event", distinct_id="distinct_id", send_feature_flags=send_options
791+
)
792+
793+
self.assertIsNotNone(msg_uuid)
794+
self.assertFalse(self.failed)
795+
796+
# Verify flags() was not called (no remote evaluation)
797+
patch_flags.assert_not_called()
798+
799+
# Check the message includes the local flag
800+
mock_post.assert_called_once()
801+
batch_data = mock_post.call_args[1]["batch"]
802+
msg = batch_data[0]
803+
804+
self.assertEqual(msg["properties"]["$feature/local-flag"], True)
805+
self.assertEqual(msg["properties"]["$active_feature_flags"], ["local-flag"])
806+
807+
@mock.patch("posthog.client.flags")
808+
def test_capture_with_send_feature_flags_options_only_evaluate_locally_false(
809+
self, patch_flags
810+
):
811+
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=False forces remote evaluation"""
812+
patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-value"}}
813+
814+
with mock.patch("posthog.client.batch_post") as mock_post:
815+
client = Client(
816+
FAKE_TEST_API_KEY,
817+
on_error=self.set_fail,
818+
personal_api_key=FAKE_TEST_API_KEY,
819+
sync_mode=True,
820+
)
821+
822+
send_options = {
823+
"only_evaluate_locally": False,
824+
"person_properties": {"plan": "premium"},
825+
"group_properties": {"company": {"type": "enterprise"}},
826+
}
827+
828+
msg_uuid = client.capture(
829+
"test event",
830+
distinct_id="distinct_id",
831+
groups={"company": "acme"},
832+
send_feature_flags=send_options,
833+
)
834+
835+
self.assertIsNotNone(msg_uuid)
836+
self.assertFalse(self.failed)
837+
838+
# Verify flags() was called with the correct properties
839+
patch_flags.assert_called_once()
840+
call_args = patch_flags.call_args[1]
841+
self.assertEqual(call_args["person_properties"], {"plan": "premium"})
842+
self.assertEqual(
843+
call_args["group_properties"], {"company": {"type": "enterprise"}}
844+
)
845+
846+
# Check the message includes the remote flag
847+
mock_post.assert_called_once()
848+
batch_data = mock_post.call_args[1]["batch"]
849+
msg = batch_data[0]
850+
851+
self.assertEqual(msg["properties"]["$feature/remote-flag"], "remote-value")
852+
853+
@mock.patch("posthog.client.flags")
854+
def test_capture_with_send_feature_flags_options_default_behavior(
855+
self, patch_flags
856+
):
857+
"""Test that SendFeatureFlagsOptions without only_evaluate_locally defaults to remote evaluation"""
858+
patch_flags.return_value = {"featureFlags": {"default-flag": "default-value"}}
859+
860+
with mock.patch("posthog.client.batch_post") as mock_post:
861+
client = Client(
862+
FAKE_TEST_API_KEY,
863+
on_error=self.set_fail,
864+
personal_api_key=FAKE_TEST_API_KEY,
865+
sync_mode=True,
866+
)
867+
868+
send_options = {
869+
"person_properties": {"subscription": "pro"},
870+
}
871+
872+
msg_uuid = client.capture(
873+
"test event", distinct_id="distinct_id", send_feature_flags=send_options
874+
)
875+
876+
self.assertIsNotNone(msg_uuid)
877+
self.assertFalse(self.failed)
878+
879+
# Verify flags() was called (default to remote evaluation)
880+
patch_flags.assert_called_once()
881+
call_args = patch_flags.call_args[1]
882+
self.assertEqual(call_args["person_properties"], {"subscription": "pro"})
883+
884+
# Check the message includes the flag
885+
mock_post.assert_called_once()
886+
batch_data = mock_post.call_args[1]["batch"]
887+
msg = batch_data[0]
888+
889+
self.assertEqual(
890+
msg["properties"]["$feature/default-flag"], "default-value"
891+
)
892+
893+
@mock.patch("posthog.client.flags")
894+
def test_capture_exception_with_send_feature_flags_options(self, patch_flags):
895+
"""Test that capture_exception also supports SendFeatureFlagsOptions"""
896+
patch_flags.return_value = {"featureFlags": {"exception-flag": True}}
897+
898+
with mock.patch("posthog.client.batch_post") as mock_post:
899+
client = Client(
900+
FAKE_TEST_API_KEY,
901+
on_error=self.set_fail,
902+
personal_api_key=FAKE_TEST_API_KEY,
903+
sync_mode=True,
904+
)
905+
906+
send_options = {
907+
"only_evaluate_locally": False,
908+
"person_properties": {"user_type": "admin"},
909+
}
910+
911+
try:
912+
raise ValueError("Test exception")
913+
except ValueError as e:
914+
msg_uuid = client.capture_exception(
915+
e, distinct_id="distinct_id", send_feature_flags=send_options
916+
)
917+
918+
self.assertIsNotNone(msg_uuid)
919+
self.assertFalse(self.failed)
920+
921+
# Verify flags() was called with the correct properties
922+
patch_flags.assert_called_once()
923+
call_args = patch_flags.call_args[1]
924+
self.assertEqual(call_args["person_properties"], {"user_type": "admin"})
925+
926+
# Check the message includes the flag
927+
mock_post.assert_called_once()
928+
batch_data = mock_post.call_args[1]["batch"]
929+
msg = batch_data[0]
930+
931+
self.assertEqual(msg["event"], "$exception")
932+
self.assertEqual(msg["properties"]["$feature/exception-flag"], True)
933+
754934
def test_stringifies_distinct_id(self):
755935
# A large number that loses precision in node:
756936
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
@@ -1591,7 +1771,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags):
15911771

15921772
@mock.patch("posthog.client.Poller")
15931773
@mock.patch("posthog.client.get")
1594-
def test_call_identify_fails(self, patch_get, patch_poll):
1774+
def test_call_identify_fails(self, patch_get, patch_poller):
15951775
def raise_effect():
15961776
raise Exception("http exception")
15971777

posthog/types.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@
99
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
1010

1111

12+
class SendFeatureFlagsOptions(TypedDict, total=False):
13+
"""Options for sending feature flags with capture events.
14+
15+
Args:
16+
only_evaluate_locally: Whether to only use local evaluation for feature flags.
17+
If True, only flags that can be evaluated locally will be included.
18+
If False, remote evaluation via /decide API will be used when needed.
19+
person_properties: Properties to use for feature flag evaluation specific to this event.
20+
These properties will be merged with any existing person properties.
21+
group_properties: Group properties to use for feature flag evaluation specific to this event.
22+
Format: { group_type_name: { group_properties } }
23+
"""
24+
25+
only_evaluate_locally: Optional[bool]
26+
person_properties: Optional[dict[str, Any]]
27+
group_properties: Optional[dict[str, dict[str, Any]]]
28+
29+
1230
@dataclass(frozen=True)
1331
class FlagReason:
1432
code: str

posthog/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = "6.1.0"
1+
VERSION = "6.2.0"
22

33
if __name__ == "__main__":
44
print(VERSION, end="") # noqa: T201

0 commit comments

Comments
 (0)