Skip to content

Commit 722c887

Browse files
authored
feat(flags): make the sendFeatureFlags parameter more declarative and ergonomic (#283)
1 parent 6ab2856 commit 722c887

File tree

6 files changed

+344
-10
lines changed

6 files changed

+344
-10
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
# 6.2.1 - 2025-06-21
1+
# 6.3.0 - 2025-07-22
2+
3+
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
4+
5+
# 6.2.1 - 2025-07-21
26

37
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
48

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: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -524,11 +524,31 @@ def capture(
524524

525525
extra_properties: dict[str, Any] = {}
526526
feature_variants: Optional[dict[str, Union[bool, str]]] = {}
527-
if send_feature_flags:
527+
528+
# Parse and normalize send_feature_flags parameter
529+
flag_options = self._parse_send_feature_flags(send_feature_flags)
530+
531+
if flag_options["should_send"]:
528532
try:
529-
feature_variants = self.get_feature_variants(
530-
distinct_id, groups, disable_geoip=disable_geoip
531-
)
533+
if flag_options["only_evaluate_locally"] is True:
534+
# Only use local evaluation
535+
feature_variants = self.get_all_flags(
536+
distinct_id,
537+
groups=(groups or {}),
538+
person_properties=flag_options["person_properties"],
539+
group_properties=flag_options["group_properties"],
540+
disable_geoip=disable_geoip,
541+
only_evaluate_locally=True,
542+
)
543+
else:
544+
# Default behavior - use remote evaluation
545+
feature_variants = self.get_feature_variants(
546+
distinct_id,
547+
groups,
548+
person_properties=flag_options["person_properties"],
549+
group_properties=flag_options["group_properties"],
550+
disable_geoip=disable_geoip,
551+
)
532552
except Exception as e:
533553
self.log.exception(
534554
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
@@ -559,6 +579,42 @@ def capture(
559579

560580
return self._enqueue(msg, disable_geoip)
561581

582+
def _parse_send_feature_flags(self, send_feature_flags) -> dict:
583+
"""
584+
Parse and normalize send_feature_flags parameter into a standard format.
585+
586+
Args:
587+
send_feature_flags: Either bool or SendFeatureFlagsOptions dict
588+
589+
Returns:
590+
dict: Normalized options with keys: should_send, only_evaluate_locally,
591+
person_properties, group_properties
592+
593+
Raises:
594+
TypeError: If send_feature_flags is not bool or dict
595+
"""
596+
if isinstance(send_feature_flags, dict):
597+
return {
598+
"should_send": True,
599+
"only_evaluate_locally": send_feature_flags.get(
600+
"only_evaluate_locally"
601+
),
602+
"person_properties": send_feature_flags.get("person_properties"),
603+
"group_properties": send_feature_flags.get("group_properties"),
604+
}
605+
elif isinstance(send_feature_flags, bool):
606+
return {
607+
"should_send": send_feature_flags,
608+
"only_evaluate_locally": None,
609+
"person_properties": None,
610+
"group_properties": None,
611+
}
612+
else:
613+
raise TypeError(
614+
f"Invalid type for send_feature_flags: {type(send_feature_flags)}. "
615+
f"Expected bool or dict."
616+
)
617+
562618
def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
563619
"""
564620
Set properties on a person profile.

posthog/test/test_client.py

Lines changed: 254 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

@@ -1993,3 +2173,76 @@ def test_get_remote_config_payload_requires_personal_api_key(self):
19932173
result = client.get_remote_config_payload("test-flag")
19942174

19952175
self.assertIsNone(result)
2176+
2177+
def test_parse_send_feature_flags_method(self):
2178+
"""Test the _parse_send_feature_flags helper method"""
2179+
client = Client(FAKE_TEST_API_KEY, sync_mode=True)
2180+
2181+
# Test boolean True
2182+
result = client._parse_send_feature_flags(True)
2183+
expected = {
2184+
"should_send": True,
2185+
"only_evaluate_locally": None,
2186+
"person_properties": None,
2187+
"group_properties": None,
2188+
}
2189+
self.assertEqual(result, expected)
2190+
2191+
# Test boolean False
2192+
result = client._parse_send_feature_flags(False)
2193+
expected = {
2194+
"should_send": False,
2195+
"only_evaluate_locally": None,
2196+
"person_properties": None,
2197+
"group_properties": None,
2198+
}
2199+
self.assertEqual(result, expected)
2200+
2201+
# Test options dict with all fields
2202+
options = {
2203+
"only_evaluate_locally": True,
2204+
"person_properties": {"plan": "premium"},
2205+
"group_properties": {"company": {"type": "enterprise"}},
2206+
}
2207+
result = client._parse_send_feature_flags(options)
2208+
expected = {
2209+
"should_send": True,
2210+
"only_evaluate_locally": True,
2211+
"person_properties": {"plan": "premium"},
2212+
"group_properties": {"company": {"type": "enterprise"}},
2213+
}
2214+
self.assertEqual(result, expected)
2215+
2216+
# Test options dict with partial fields
2217+
options = {"person_properties": {"user_id": "123"}}
2218+
result = client._parse_send_feature_flags(options)
2219+
expected = {
2220+
"should_send": True,
2221+
"only_evaluate_locally": None,
2222+
"person_properties": {"user_id": "123"},
2223+
"group_properties": None,
2224+
}
2225+
self.assertEqual(result, expected)
2226+
2227+
# Test empty dict
2228+
result = client._parse_send_feature_flags({})
2229+
expected = {
2230+
"should_send": True,
2231+
"only_evaluate_locally": None,
2232+
"person_properties": None,
2233+
"group_properties": None,
2234+
}
2235+
self.assertEqual(result, expected)
2236+
2237+
# Test invalid types
2238+
with self.assertRaises(TypeError) as cm:
2239+
client._parse_send_feature_flags("invalid")
2240+
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
2241+
2242+
with self.assertRaises(TypeError) as cm:
2243+
client._parse_send_feature_flags(123)
2244+
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
2245+
2246+
with self.assertRaises(TypeError) as cm:
2247+
client._parse_send_feature_flags(None)
2248+
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))

0 commit comments

Comments
 (0)