Skip to content

feat(flags): make the sendFeatureFlags parameter more declarative and ergonomic #283

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 6.2.0 - 2025-07-15

- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties

# 6.1.0 - 2025-07-10

- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
Expand Down
9 changes: 6 additions & 3 deletions posthog/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import numbers
from uuid import UUID

from posthog.types import SendFeatureFlagsOptions

ID_TYPES = Union[numbers.Number, str, UUID, int]


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

Expand All @@ -32,8 +35,8 @@ class OptionalCaptureArgs(TypedDict):
uuid: NotRequired[Optional[str]]
groups: NotRequired[Optional[Dict[str, str]]]
send_feature_flags: NotRequired[
Optional[bool]
] # Optional so we can tell if the user is intentionally overriding a client setting or not
Optional[Union[bool, SendFeatureFlagsOptions]]
] # Updated to support both boolean and options object
disable_geoip: NotRequired[
Optional[bool]
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
Expand Down
64 changes: 60 additions & 4 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,11 +524,31 @@ def capture(

extra_properties: dict[str, Any] = {}
feature_variants: Optional[dict[str, Union[bool, str]]] = {}
if send_feature_flags:

# Parse and normalize send_feature_flags parameter
flag_options = self._parse_send_feature_flags(send_feature_flags)

if flag_options["should_send"]:
try:
feature_variants = self.get_feature_variants(
distinct_id, groups, disable_geoip=disable_geoip
)
if flag_options["only_evaluate_locally"] is True:
# Only use local evaluation
feature_variants = self.get_all_flags(
distinct_id,
groups=(groups or {}),
person_properties=flag_options["person_properties"],
group_properties=flag_options["group_properties"],
disable_geoip=disable_geoip,
only_evaluate_locally=True,
)
else:
# Default behavior - use remote evaluation
feature_variants = self.get_feature_variants(
distinct_id,
groups,
person_properties=flag_options["person_properties"],
group_properties=flag_options["group_properties"],
disable_geoip=disable_geoip,
)
except Exception as e:
self.log.exception(
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
Expand Down Expand Up @@ -559,6 +579,42 @@ def capture(

return self._enqueue(msg, disable_geoip)

def _parse_send_feature_flags(self, send_feature_flags) -> dict:
"""
Parse and normalize send_feature_flags parameter into a standard format.

Args:
send_feature_flags: Either bool or SendFeatureFlagsOptions dict

Returns:
dict: Normalized options with keys: should_send, only_evaluate_locally,
person_properties, group_properties

Raises:
TypeError: If send_feature_flags is not bool or dict
"""
if isinstance(send_feature_flags, dict):
return {
"should_send": True,
"only_evaluate_locally": send_feature_flags.get(
"only_evaluate_locally"
),
"person_properties": send_feature_flags.get("person_properties"),
"group_properties": send_feature_flags.get("group_properties"),
}
elif isinstance(send_feature_flags, bool):
return {
"should_send": send_feature_flags,
"only_evaluate_locally": None,
"person_properties": None,
"group_properties": None,
}
else:
raise TypeError(
f"Invalid type for send_feature_flags: {type(send_feature_flags)}. "
f"Expected bool or dict."
)

def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
"""
Set properties on a person profile.
Expand Down
255 changes: 254 additions & 1 deletion posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,186 @@ def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(

self.assertEqual(patch_flags.call_count, 0)

@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
self, patch_flags
):
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=True uses local evaluation"""
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)

# Set up local flags
client.feature_flags = [
{
"id": 1,
"key": "local-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [{"key": "region", "value": "US"}],
"rollout_percentage": 100,
}
],
},
}
]

send_options = {
"only_evaluate_locally": True,
"person_properties": {"region": "US"},
}

msg_uuid = client.capture(
"test event", distinct_id="distinct_id", send_feature_flags=send_options
)

self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)

# Verify flags() was not called (no remote evaluation)
patch_flags.assert_not_called()

# Check the message includes the local flag
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]

self.assertEqual(msg["properties"]["$feature/local-flag"], True)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["local-flag"])

@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_options_only_evaluate_locally_false(
self, patch_flags
):
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=False forces remote evaluation"""
patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-value"}}

with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)

send_options = {
"only_evaluate_locally": False,
"person_properties": {"plan": "premium"},
"group_properties": {"company": {"type": "enterprise"}},
}

msg_uuid = client.capture(
"test event",
distinct_id="distinct_id",
groups={"company": "acme"},
send_feature_flags=send_options,
)

self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)

# Verify flags() was called with the correct properties
patch_flags.assert_called_once()
call_args = patch_flags.call_args[1]
self.assertEqual(call_args["person_properties"], {"plan": "premium"})
self.assertEqual(
call_args["group_properties"], {"company": {"type": "enterprise"}}
)

# Check the message includes the remote flag
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]

self.assertEqual(msg["properties"]["$feature/remote-flag"], "remote-value")

@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_options_default_behavior(
self, patch_flags
):
"""Test that SendFeatureFlagsOptions without only_evaluate_locally defaults to remote evaluation"""
patch_flags.return_value = {"featureFlags": {"default-flag": "default-value"}}

with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)

send_options = {
"person_properties": {"subscription": "pro"},
}

msg_uuid = client.capture(
"test event", distinct_id="distinct_id", send_feature_flags=send_options
)

self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)

# Verify flags() was called (default to remote evaluation)
patch_flags.assert_called_once()
call_args = patch_flags.call_args[1]
self.assertEqual(call_args["person_properties"], {"subscription": "pro"})

# Check the message includes the flag
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]

self.assertEqual(
msg["properties"]["$feature/default-flag"], "default-value"
)

@mock.patch("posthog.client.flags")
def test_capture_exception_with_send_feature_flags_options(self, patch_flags):
"""Test that capture_exception also supports SendFeatureFlagsOptions"""
patch_flags.return_value = {"featureFlags": {"exception-flag": True}}

with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)

send_options = {
"only_evaluate_locally": False,
"person_properties": {"user_type": "admin"},
}

try:
raise ValueError("Test exception")
except ValueError as e:
msg_uuid = client.capture_exception(
e, distinct_id="distinct_id", send_feature_flags=send_options
)

self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)

# Verify flags() was called with the correct properties
patch_flags.assert_called_once()
call_args = patch_flags.call_args[1]
self.assertEqual(call_args["person_properties"], {"user_type": "admin"})

# Check the message includes the flag
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]

self.assertEqual(msg["event"], "$exception")
self.assertEqual(msg["properties"]["$feature/exception-flag"], True)

def test_stringifies_distinct_id(self):
# A large number that loses precision in node:
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
Expand Down Expand Up @@ -1591,7 +1771,7 @@ def test_disable_geoip_default_on_decide(self, patch_flags):

@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_call_identify_fails(self, patch_get, patch_poll):
def test_call_identify_fails(self, patch_get, patch_poller):
def raise_effect():
raise Exception("http exception")

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

self.assertIsNone(result)

def test_parse_send_feature_flags_method(self):
"""Test the _parse_send_feature_flags helper method"""
client = Client(FAKE_TEST_API_KEY, sync_mode=True)

# Test boolean True
result = client._parse_send_feature_flags(True)
expected = {
"should_send": True,
"only_evaluate_locally": None,
"person_properties": None,
"group_properties": None,
}
self.assertEqual(result, expected)

# Test boolean False
result = client._parse_send_feature_flags(False)
expected = {
"should_send": False,
"only_evaluate_locally": None,
"person_properties": None,
"group_properties": None,
}
self.assertEqual(result, expected)

# Test options dict with all fields
options = {
"only_evaluate_locally": True,
"person_properties": {"plan": "premium"},
"group_properties": {"company": {"type": "enterprise"}},
}
result = client._parse_send_feature_flags(options)
expected = {
"should_send": True,
"only_evaluate_locally": True,
"person_properties": {"plan": "premium"},
"group_properties": {"company": {"type": "enterprise"}},
}
self.assertEqual(result, expected)

# Test options dict with partial fields
options = {"person_properties": {"user_id": "123"}}
result = client._parse_send_feature_flags(options)
expected = {
"should_send": True,
"only_evaluate_locally": None,
"person_properties": {"user_id": "123"},
"group_properties": None,
}
self.assertEqual(result, expected)

# Test empty dict
result = client._parse_send_feature_flags({})
expected = {
"should_send": True,
"only_evaluate_locally": None,
"person_properties": None,
"group_properties": None,
}
self.assertEqual(result, expected)

# Test invalid types
with self.assertRaises(TypeError) as cm:
client._parse_send_feature_flags("invalid")
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))

with self.assertRaises(TypeError) as cm:
client._parse_send_feature_flags(123)
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))

with self.assertRaises(TypeError) as cm:
client._parse_send_feature_flags(None)
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
Loading
Loading