Compare commits

...
3 Commits
Author SHA1 Message Date
Dylan MartinandGitHub 9e1bb8c58a fix(flags): bump the version (#148) 2024-11-27 17:15:38 -05:00
fb57de2e12 fix(flags): correctly emit feature flag events with the FF response on get_feature_flag_payload calls (#143)
* this is the fix, needs tests

* fix test

* tests

* yeah

* please work

* ran the formatter

* code review feedback

* how'd this get here

* bump version add changelog

* Update CHANGELOG.md

Co-authored-by: David Newell <d.newell1@outlook.com>

---------

Co-authored-by: David Newell <d.newell1@outlook.com>
2024-11-25 14:51:06 -05:00
db565bc0fd fix(err): fix distinct_id, set personless and use a uuid (#144)
Co-authored-by: David Newell <david@posthog.com>
2024-11-25 12:09:57 +00:00
7 changed files with 161 additions and 33 deletions
+8
View File
@@ -1,3 +1,11 @@
## 3.7.4 - 2024-11-25
1. Fix bug where this SDK incorrectly sent feature flag events with null values when calling `get_feature_flag_payload`.
## 3.7.3 - 2024-11-25
1. Use personless mode when sending an exception without a provided `distinct_id`.
## 3.7.2 - 2024-11-19
1. Add `type` property to exception stacks.
+2 -2
View File
@@ -2,7 +2,7 @@ import datetime # noqa: F401
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
from posthog.client import Client
from posthog.exception_capture import DEFAULT_DISTINCT_ID, Integrations # noqa: F401
from posthog.exception_capture import Integrations # noqa: F401
from posthog.version import VERSION
__version__ = VERSION
@@ -289,7 +289,7 @@ def capture_exception(
return _proxy(
"capture_exception",
exception=exception,
distinct_id=distinct_id or DEFAULT_DISTINCT_ID,
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
+57 -12
View File
@@ -4,13 +4,13 @@ import numbers
import os
import sys
from datetime import datetime, timedelta
from uuid import UUID
from uuid import UUID, uuid4
from dateutil.tz import tzutc
from six import string_types
from posthog.consumer import Consumer
from posthog.exception_capture import DEFAULT_DISTINCT_ID, ExceptionCapture
from posthog.exception_capture import ExceptionCapture
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
from posthog.poller import Poller
@@ -173,6 +173,15 @@ class Client(object):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlagPayloads"]
def get_feature_flags_and_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return {
"featureFlags": resp_data["featureFlags"],
"featureFlagPayloads": resp_data["featureFlagPayloads"],
}
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None):
require("distinct_id", distinct_id, ID_TYPES)
@@ -362,7 +371,7 @@ class Client(object):
def capture_exception(
self,
exception=None,
distinct_id=DEFAULT_DISTINCT_ID,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
@@ -373,6 +382,13 @@ class Client(object):
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
try:
properties = properties or {}
# if there's no distinct_id, we'll generate one and set personless mode
# via $process_person_profile = false
if distinct_id is None:
properties["$process_person_profile"] = False
distinct_id = uuid4()
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
@@ -385,7 +401,7 @@ class Client(object):
self.log.warning("No exception information available")
return
# Format stack trace like sentry
# Format stack trace for cymbal
all_exceptions_with_trace = exceptions_from_error_tuple(exc_info)
# Add in-app property to frames in the exceptions
@@ -739,23 +755,52 @@ class Client(object):
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
send_feature_flag_events=send_feature_flag_events,
only_evaluate_locally=True,
send_feature_flag_events=False,
# Disable automatic sending of feature flag events because we're manually handling event dispatch.
# This prevents sending events with empty data when `get_feature_flag` cannot be evaluated locally.
only_evaluate_locally=True, # Enable local evaluation of feature flags to avoid making multiple requests to `/decide`.
disable_geoip=disable_geoip,
)
response = None
payload = None
if match_value is not None:
response = self._compute_payload_locally(key, match_value)
payload = self._compute_payload_locally(key, match_value)
if response is None and not only_evaluate_locally:
decide_payloads = self.get_feature_payloads(
distinct_id, groups, person_properties, group_properties, disable_geoip
flag_was_locally_evaluated = payload is not None
if not flag_was_locally_evaluated and not only_evaluate_locally:
try:
responses_and_payloads = self.get_feature_flags_and_payloads(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
response = responses_and_payloads["featureFlags"].get(key, None)
payload = responses_and_payloads["featureFlagPayloads"].get(str(key).lower(), None)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
feature_flag_reported_key = f"{key}_{str(response)}"
if (
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
and send_feature_flag_events # noqa: W503
):
self.capture(
distinct_id,
"$feature_flag_called",
{
"$feature_flag": key,
"$feature_flag_response": response,
"$feature_flag_payload": payload,
"locally_evaluated": flag_was_locally_evaluated,
f"$feature/{key}": response,
},
groups=groups,
disable_geoip=disable_geoip,
)
response = decide_payloads.get(str(key).lower(), None)
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
return response
return payload
def _compute_payload_locally(self, key, match_value):
payload = None
+1 -11
View File
@@ -12,9 +12,6 @@ class Integrations(str, Enum):
Django = "django"
DEFAULT_DISTINCT_ID = "python-exceptions"
class ExceptionCapture:
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
@@ -61,14 +58,7 @@ class ExceptionCapture:
def capture_exception(self, exception, metadata=None):
try:
# if hasattr(sys, "ps1"):
# # Disable the excepthook for interactive Python shells
# return
distinct_id = metadata.get("distinct_id") if metadata else DEFAULT_DISTINCT_ID
# Make sure we have a distinct_id if its empty in metadata
distinct_id = distinct_id or DEFAULT_DISTINCT_ID
distinct_id = metadata.get("distinct_id") if metadata else None
self.client.capture_exception(exception, distinct_id)
except Exception as e:
self.log.exception(f"Failed to capture exception: {e}")
+5 -5
View File
@@ -104,11 +104,11 @@ class TestClient(unittest.TestCase):
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
client = self.client
exception = Exception("test exception")
client.capture_exception(exception)
client.capture_exception(exception, distinct_id="distinct_id")
self.assertTrue(patch_capture.called)
capture_call = patch_capture.call_args[0]
self.assertEqual(capture_call[0], "python-exceptions")
self.assertEqual(capture_call[0], "distinct_id")
self.assertEqual(capture_call[1], "$exception")
self.assertEqual(
capture_call[2],
@@ -123,7 +123,7 @@ class TestClient(unittest.TestCase):
"value": "test exception",
}
],
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/python-exceptions",
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id",
},
)
@@ -218,11 +218,11 @@ class TestClient(unittest.TestCase):
try:
raise Exception("test exception")
except Exception:
client.capture_exception()
client.capture_exception(distinct_id="distinct_id")
self.assertTrue(patch_capture.called)
capture_call = patch_capture.call_args[0]
self.assertEqual(capture_call[0], "python-exceptions")
self.assertEqual(capture_call[0], "distinct_id")
self.assertEqual(capture_call[1], "$exception")
self.assertEqual(capture_call[2]["$exception_type"], "Exception")
self.assertEqual(capture_call[2]["$exception_message"], "test exception")
+87 -2
View File
@@ -1632,9 +1632,10 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(patch_decide.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_boolean_feature_flag_payload_decide(self, patch_decide):
patch_decide.return_value = {"featureFlagPayloads": {"person-flag": 300}}
def test_boolean_feature_flag_payload_decide(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"person-flag": True}, "featureFlagPayloads": {"person-flag": 300}}
self.assertEqual(
self.client.get_feature_flag_payload(
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
@@ -1649,6 +1650,8 @@ class TestLocalEvaluation(unittest.TestCase):
300,
)
self.assertEqual(patch_decide.call_count, 2)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.reset_mock()
@mock.patch("posthog.client.decide")
def test_multivariate_feature_flag_payloads(self, patch_decide):
@@ -2334,6 +2337,88 @@ class TestCaptureCalls(unittest.TestCase):
disable_geoip=None,
)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_capture_is_called_in_get_feature_flag_payload(self, patch_decide, patch_capture):
patch_decide.return_value = {
"featureFlags": {"person-flag": True},
"featureFlagPayloads": {"person-flag": 300},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [{"key": "region", "value": "USA"}],
"rollout_percentage": 100,
}
],
},
}
]
# Call get_feature_flag_payload with match_value=None to trigger get_feature_flag
client.get_feature_flag_payload(
key="person-flag", distinct_id="some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
)
# Assert that capture was called once, with the correct parameters
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"$feature_flag_payload": 300,
"locally_evaluated": False,
"$feature/person-flag": True,
},
groups={},
disable_geoip=None,
)
# Reset mocks for further tests
patch_capture.reset_mock()
patch_decide.reset_mock()
# Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
client.get_feature_flag_payload(
key="person-flag", distinct_id="some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
)
self.assertEqual(patch_capture.call_count, 0)
patch_capture.reset_mock()
# Call get_feature_flag_payload for a different user; capture should be called
client.get_feature_flag_payload(
key="person-flag", distinct_id="some-distinct-id2", person_properties={"region": "USA", "name": "Aloha"}
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"$feature_flag_payload": 300,
"locally_evaluated": False,
"$feature/person-flag": True,
},
groups={},
disable_geoip=None,
)
patch_capture.reset_mock()
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_disable_geoip_get_flag_capture_call(self, patch_decide, patch_capture):
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "3.7.2"
VERSION = "3.7.4"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201