Compare commits

...
5 Commits
Author SHA1 Message Date
Dylan MartinandGitHub 09dad8117f fix (#300) 2025-08-01 17:32:58 -07:00
Radu RaiceaandGitHub 09b9b5dc88 bug(llmo): fix anthropic tool call response (#297)
* bug(llmo): fix anthropic's tool call response

* bug(llmo): fix tool calls response handling for anthropic

* bug(llmo): run formatter

* bug(llmo): bump version

* bug(llmo): add date to changelog
2025-07-31 17:13:44 -04:00
Georgiy TarasovandGitHub 5a52af66a9 fix(ai): capture tool calls in reasoning models (#292)
* fix: capture tool calls in reasoning models

* fix: check for empty tool calls
2025-07-23 11:51:58 +02:00
Dylan MartinandGitHub 722c88701b feat(flags): make the sendFeatureFlags parameter more declarative and ergonomic (#283) 2025-07-22 15:20:54 -07:00
Radu RaiceaandGitHub 6ab2856f8d feat(llmo): Use default PH client for LangChain (#293)
* feat(llmo): Use default PH client for langchain

* chore: Run formatter

* feat: Test the CallbackHandler without any PH client

* chore: Run formatter
2025-07-22 14:09:47 -07:00
10 changed files with 669 additions and 33 deletions
+13 -1
View File
@@ -1,4 +1,16 @@
# 6.2.1 - 2025-06-21
# 6.3.3 - 2025-08-01
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
# 6.3.2 - 2025-07-31
- fix: Anthropic's tool calls are now handled properly
# 6.3.0 - 2025-07-22
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
# 6.2.1 - 2025-07-21
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
+41 -14
View File
@@ -5,6 +5,7 @@ except ImportError:
"Please install LangChain to use this feature: 'pip install langchain'"
)
import json
import logging
import time
from dataclasses import dataclass
@@ -29,11 +30,12 @@ from langchain_core.messages import (
HumanMessage,
SystemMessage,
ToolMessage,
ToolCall,
)
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
from posthog import default_client
from posthog import setup
from posthog.ai.utils import get_model_params, with_privacy_mode
from posthog.client import Client
@@ -81,7 +83,7 @@ class CallbackHandler(BaseCallbackHandler):
The PostHog LLM observability callback handler for LangChain.
"""
_client: Client
_ph_client: Client
"""PostHog client instance."""
_distinct_id: Optional[Union[str, int, UUID]]
@@ -127,10 +129,7 @@ class CallbackHandler(BaseCallbackHandler):
privacy_mode: Whether to redact the input and output of the trace.
groups: Optional additional PostHog groups to use for the trace.
"""
posthog_client = client or default_client
if posthog_client is None:
raise ValueError("PostHog client is required")
self._client = posthog_client
self._ph_client = client or setup()
self._distinct_id = distinct_id
self._trace_id = trace_id
self._properties = properties or {}
@@ -481,7 +480,7 @@ class CallbackHandler(BaseCallbackHandler):
event_properties = {
"$ai_trace_id": trace_id,
"$ai_input_state": with_privacy_mode(
self._client, self._privacy_mode, run.input
self._ph_client, self._privacy_mode, run.input
),
"$ai_latency": run.latency,
"$ai_span_name": run.name,
@@ -497,13 +496,13 @@ class CallbackHandler(BaseCallbackHandler):
event_properties["$ai_is_error"] = True
elif outputs is not None:
event_properties["$ai_output_state"] = with_privacy_mode(
self._client, self._privacy_mode, outputs
self._ph_client, self._privacy_mode, outputs
)
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
self._client.capture(
self._ph_client.capture(
distinct_id=self._distinct_id or run_id,
event=event_name,
properties=event_properties,
@@ -550,14 +549,16 @@ class CallbackHandler(BaseCallbackHandler):
"$ai_provider": run.provider,
"$ai_model": run.model,
"$ai_model_parameters": run.model_params,
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.input),
"$ai_input": with_privacy_mode(
self._ph_client, self._privacy_mode, run.input
),
"$ai_http_status": 200,
"$ai_latency": run.latency,
"$ai_base_url": run.base_url,
}
if run.tools:
event_properties["$ai_tools"] = with_privacy_mode(
self._client,
self._ph_client,
self._privacy_mode,
run.tools,
)
@@ -589,7 +590,7 @@ class CallbackHandler(BaseCallbackHandler):
_extract_raw_esponse(generation) for generation in generation_result
]
event_properties["$ai_output_choices"] = with_privacy_mode(
self._client, self._privacy_mode, completions
self._ph_client, self._privacy_mode, completions
)
if self._properties:
@@ -598,7 +599,7 @@ class CallbackHandler(BaseCallbackHandler):
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
self._client.capture(
self._ph_client.capture(
distinct_id=self._distinct_id or trace_id,
event="$ai_generation",
properties=event_properties,
@@ -630,12 +631,35 @@ def _extract_raw_esponse(last_response):
return ""
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
def _convert_lc_tool_calls_to_oai(
tool_calls: list[ToolCall],
) -> list[dict[str, Any]]:
try:
return [
{
"type": "function",
"id": tool_call["id"],
"function": {
"name": tool_call["name"],
"arguments": json.dumps(tool_call["args"]),
},
}
for tool_call in tool_calls
]
except KeyError:
return tool_calls
def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
# assistant message
if isinstance(message, HumanMessage):
message_dict = {"role": "user", "content": message.content}
elif isinstance(message, AIMessage):
message_dict = {"role": "assistant", "content": message.content}
if message.tool_calls:
message_dict["tool_calls"] = _convert_lc_tool_calls_to_oai(
message.tool_calls
)
elif isinstance(message, SystemMessage):
message_dict = {"role": "system", "content": message.content}
elif isinstance(message, ToolMessage):
@@ -648,6 +672,9 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
if message.additional_kwargs:
message_dict.update(message.additional_kwargs)
if "content" in message_dict and not message_dict["content"]:
message_dict["content"] = ""
return message_dict
+22 -3
View File
@@ -118,7 +118,12 @@ def format_response(response, provider: str):
def format_response_anthropic(response):
output = []
for choice in response.content:
if choice.text:
if (
hasattr(choice, "type")
and choice.type == "text"
and hasattr(choice, "text")
and choice.text
):
output.append(
{
"role": "assistant",
@@ -225,8 +230,21 @@ def format_response_gemini(response):
def format_tool_calls(response, provider: str):
if provider == "anthropic":
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
return response.tools
if hasattr(response, "content") and response.content:
tool_calls = []
for content_item in response.content:
if hasattr(content_item, "type") and content_item.type == "tool_use":
tool_calls.append(
{
"type": content_item.type,
"id": content_item.id,
"name": content_item.name,
"input": content_item.input,
}
)
return tool_calls if tool_calls else None
elif provider == "openai":
# Handle both Chat Completions and Responses API
if hasattr(response, "choices") and response.choices:
@@ -378,6 +396,7 @@ def call_llm_and_track_usage(
}
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
ph_client, posthog_privacy_mode, tool_calls
+6 -3
View File
@@ -5,6 +5,8 @@ from datetime import datetime
import numbers
from uuid import UUID
from posthog.types import SendFeatureFlagsOptions
ID_TYPES = Union[numbers.Number, str, UUID, int]
@@ -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.
"""
@@ -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
+63 -7
View File
@@ -524,11 +524,31 @@ class Client(object):
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}"
@@ -559,6 +579,42 @@ class Client(object):
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.
@@ -1229,7 +1285,7 @@ class Client(object):
lookup_match_value = override_match_value or flag_value
payload = (
self._compute_payload_locally(key, lookup_match_value)
if lookup_match_value
if lookup_match_value is not None
else None
)
flag_result = FeatureFlagResult.from_value_and_payload(
@@ -1530,7 +1586,7 @@ class Client(object):
f"$feature/{key}": response,
}
if payload:
if payload is not None:
# if payload is not a string, json serialize it to a string
properties["$feature_flag_payload"] = payload
@@ -1734,7 +1790,7 @@ class Client(object):
matched_payload = self._compute_payload_locally(
flag["key"], flags[flag["key"]]
)
if matched_payload:
if matched_payload is not None:
payloads[flag["key"]] = matched_payload
except InconclusiveMatchError:
# No need to log this, since it's just telling us to fall back to `/decide`
@@ -88,6 +88,31 @@ def mock_anthropic_response_with_cached_tokens():
)
@pytest.fixture
def mock_anthropic_response_with_tool_use():
return Message(
id="msg_123",
type="message",
role="assistant",
content=[
{"type": "text", "text": "I'll help you with that."},
{
"type": "tool_use",
"id": "tool_1",
"name": "get_weather",
"input": {"location": "New York"},
},
],
model="claude-3-opus-20240229",
usage=Usage(
input_tokens=20,
output_tokens=10,
),
stop_reason="end_turn",
stop_sequence=None,
)
def test_basic_completion(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
@@ -434,3 +459,49 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_tool_use_response(mock_client, mock_anthropic_response_with_tool_use):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_tool_use,
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "What's the weather like?"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response_with_tool_use
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [
{"role": "user", "content": "What's the weather like?"}
]
# Should only include text content, not tool_use content
assert props["$ai_output_choices"] == [
{"role": "assistant", "content": "I'll help you with that."}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured separately
assert props["$ai_tools"] == [
{
"type": "tool_use",
"id": "tool_1",
"name": "get_weather",
"input": {"location": "New York"},
}
]
@@ -1727,3 +1727,66 @@ def test_openai_reasoning_tokens(mock_client):
assert call["properties"]["$ai_reasoning_tokens"] is not None
assert call["properties"]["$ai_input_tokens"] is not None
assert call["properties"]["$ai_output_tokens"] is not None
def test_callback_handler_without_client():
"""Test that CallbackHandler works properly when no PostHog client is passed."""
with patch("posthog.ai.langchain.callbacks.setup") as mock_setup:
mock_client = mock_setup.return_value
callbacks = CallbackHandler()
# Verify that setup() was called
mock_setup.assert_called_once()
# Verify that the client was set to the result of setup()
assert callbacks._ph_client == mock_client
# Test that the callback handler works with a simple chain
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
model = FakeMessagesListChatModel(responses=[AIMessage(content="Bar")])
chain = prompt | model
# This should work and call the mock client
result = chain.invoke({}, config={"callbacks": [callbacks]})
assert result.content == "Bar"
# Verify that the mock client was used for capturing events
assert mock_client.capture.call_count == 3
def test_convert_message_to_dict_tool_calls():
"""Test that _convert_message_to_dict properly converts tool calls in AIMessage."""
from posthog.ai.langchain.callbacks import _convert_message_to_dict
from langchain_core.messages import AIMessage
from langchain_core.messages.tool import ToolCall
# Create an AIMessage with tool calls
tool_calls = [
ToolCall(
id="call_123",
name="get_weather",
args={"city": "San Francisco", "units": "celsius"},
)
]
ai_message = AIMessage(
content="I'll check the weather for you.", tool_calls=tool_calls
)
# Convert to dict
result = _convert_message_to_dict(ai_message)
# Verify the conversion
assert result["role"] == "assistant"
assert result["content"] == "I'll check the weather for you."
assert result["tool_calls"] == [
{
"type": "function",
"id": "call_123",
"function": {
"name": "get_weather",
"arguments": '{"city": "San Francisco", "units": "celsius"}',
},
}
]
+363 -1
View File
@@ -751,6 +751,186 @@ class TestClient(unittest.TestCase):
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
@@ -1591,7 +1771,7 @@ class TestClient(unittest.TestCase):
@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")
@@ -1993,3 +2173,185 @@ class TestClient(unittest.TestCase):
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))
@mock.patch("posthog.client.batch_post")
def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_post):
"""Test that get_feature_flag_result returns a FeatureFlagResult when payload is empty string"""
client = Client(
FAKE_TEST_API_KEY,
personal_api_key="test_personal_api_key",
sync_mode=True,
)
# Set up local evaluation with a flag that has empty string payload
client.feature_flags = [
{
"id": 1,
"name": "Test flag",
"key": "test-flag",
"is_simple_flag": False,
"active": True,
"rollout_percentage": None,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": None,
"variant": "empty-variant",
}
],
"multivariate": {
"variants": [
{
"key": "empty-variant",
"name": "Empty Variant",
"rollout_percentage": 100,
}
]
},
"payloads": {
"empty-variant": "" # Empty string payload
},
},
}
]
# Test get_feature_flag_result
result = client.get_feature_flag_result(
"test-flag", "test-user", only_evaluate_locally=True
)
# Should return a FeatureFlagResult, not None
self.assertIsNotNone(result)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.get_value(), "empty-variant")
self.assertEqual(result.payload, "") # Should be empty string, not None
@mock.patch("posthog.client.batch_post")
def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post):
"""Test that get_all_flags_and_payloads includes flags with empty string payloads"""
client = Client(
FAKE_TEST_API_KEY,
personal_api_key="test_personal_api_key",
sync_mode=True,
)
# Set up multiple flags with different payload types
client.feature_flags = [
{
"id": 1,
"name": "Flag with empty payload",
"key": "empty-payload-flag",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [{"properties": [], "variant": "variant1"}],
"multivariate": {
"variants": [{"key": "variant1", "rollout_percentage": 100}]
},
"payloads": {"variant1": ""}, # Empty string
},
},
{
"id": 2,
"name": "Flag with normal payload",
"key": "normal-payload-flag",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [{"properties": [], "variant": "variant2"}],
"multivariate": {
"variants": [{"key": "variant2", "rollout_percentage": 100}]
},
"payloads": {"variant2": "normal payload"},
},
},
]
result = client.get_all_flags_and_payloads(
"test-user", only_evaluate_locally=True
)
# Check that both flags are included
self.assertEqual(result["featureFlags"]["empty-payload-flag"], "variant1")
self.assertEqual(result["featureFlags"]["normal-payload-flag"], "variant2")
# Check that empty string payload is included (not filtered out)
self.assertIn("empty-payload-flag", result["featureFlagPayloads"])
self.assertEqual(result["featureFlagPayloads"]["empty-payload-flag"], "")
self.assertEqual(
result["featureFlagPayloads"]["normal-payload-flag"], "normal payload"
)
+26 -3
View File
@@ -9,6 +9,24 @@ FlagValue = Union[bool, str]
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
class SendFeatureFlagsOptions(TypedDict, total=False):
"""Options for sending feature flags with capture events.
Args:
only_evaluate_locally: Whether to only use local evaluation for feature flags.
If True, only flags that can be evaluated locally will be included.
If False, remote evaluation via /flags API will be used when needed.
person_properties: Properties to use for feature flag evaluation specific to this event.
These properties will be merged with any existing person properties.
group_properties: Group properties to use for feature flag evaluation specific to this event.
Format: { group_type_name: { group_properties } }
"""
only_evaluate_locally: Optional[bool]
person_properties: Optional[dict[str, Any]]
group_properties: Optional[dict[str, dict[str, Any]]]
@dataclass(frozen=True)
class FlagReason:
code: str
@@ -92,7 +110,7 @@ class FeatureFlag:
variant=variant,
reason=None,
metadata=LegacyFlagMetadata(
payload=payload if payload else None,
payload=payload,
),
)
@@ -160,7 +178,9 @@ class FeatureFlagResult:
key=key,
enabled=enabled,
variant=variant,
payload=json.loads(payload) if isinstance(payload, str) else payload,
payload=json.loads(payload)
if isinstance(payload, str) and payload
else payload,
reason=None,
)
@@ -201,6 +221,7 @@ class FeatureFlagResult:
payload=(
json.loads(details.metadata.payload)
if isinstance(details.metadata.payload, str)
and details.metadata.payload
else details.metadata.payload
),
reason=details.reason.description if details.reason else None,
@@ -278,5 +299,7 @@ def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
return {
key: value.metadata.payload
for key, value in response.get("flags", {}).items()
if isinstance(value, FeatureFlag) and value.enabled and value.metadata.payload
if isinstance(value, FeatureFlag)
and value.enabled
and value.metadata.payload is not None
}
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "6.2.1"
VERSION = "6.3.3"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201