posthoganalytics 6.2.1__tar.gz → 6.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {posthoganalytics-6.2.1/posthoganalytics.egg-info → posthoganalytics-6.3.1}/PKG-INFO +1 -1
  2. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/langchain/callbacks.py +41 -14
  3. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/args.py +6 -3
  4. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/client.py +60 -4
  5. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_client.py +254 -1
  6. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/types.py +18 -0
  7. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/version.py +1 -1
  8. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1/posthoganalytics.egg-info}/PKG-INFO +1 -1
  9. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/LICENSE +0 -0
  10. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/MANIFEST.in +0 -0
  11. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/README.md +0 -0
  12. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/__init__.py +0 -0
  13. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/__init__.py +0 -0
  14. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/anthropic/__init__.py +0 -0
  15. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/anthropic/anthropic.py +0 -0
  16. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/anthropic/anthropic_async.py +0 -0
  17. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/anthropic/anthropic_providers.py +0 -0
  18. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/gemini/__init__.py +0 -0
  19. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/gemini/gemini.py +0 -0
  20. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/langchain/__init__.py +0 -0
  21. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/openai/__init__.py +0 -0
  22. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/openai/openai.py +0 -0
  23. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/openai/openai_async.py +0 -0
  24. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/openai/openai_providers.py +0 -0
  25. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/ai/utils.py +0 -0
  26. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/consumer.py +0 -0
  27. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/contexts.py +0 -0
  28. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/exception_capture.py +0 -0
  29. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/exception_utils.py +0 -0
  30. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/feature_flags.py +0 -0
  31. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/integrations/__init__.py +0 -0
  32. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/integrations/django.py +0 -0
  33. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/poller.py +0 -0
  34. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/py.typed +0 -0
  35. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/request.py +0 -0
  36. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/__init__.py +0 -0
  37. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_before_send.py +0 -0
  38. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_consumer.py +0 -0
  39. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_contexts.py +0 -0
  40. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_exception_capture.py +0 -0
  41. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_feature_flag.py +0 -0
  42. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_feature_flag_result.py +0 -0
  43. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_feature_flags.py +0 -0
  44. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_module.py +0 -0
  45. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_request.py +0 -0
  46. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_size_limited_dict.py +0 -0
  47. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_types.py +0 -0
  48. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/test/test_utils.py +0 -0
  49. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics/utils.py +0 -0
  50. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics.egg-info/SOURCES.txt +0 -0
  51. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics.egg-info/dependency_links.txt +0 -0
  52. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics.egg-info/requires.txt +0 -0
  53. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/posthoganalytics.egg-info/top_level.txt +0 -0
  54. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/pyproject.toml +0 -0
  55. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/setup.cfg +0 -0
  56. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/setup.py +0 -0
  57. {posthoganalytics-6.2.1 → posthoganalytics-6.3.1}/setup_analytics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 6.2.1
3
+ Version: 6.3.1
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -5,6 +5,7 @@ except ImportError:
5
5
  "Please install LangChain to use this feature: 'pip install langchain'"
6
6
  )
7
7
 
8
+ import json
8
9
  import logging
9
10
  import time
10
11
  from dataclasses import dataclass
@@ -29,11 +30,12 @@ from langchain_core.messages import (
29
30
  HumanMessage,
30
31
  SystemMessage,
31
32
  ToolMessage,
33
+ ToolCall,
32
34
  )
33
35
  from langchain_core.outputs import ChatGeneration, LLMResult
34
36
  from pydantic import BaseModel
35
37
 
36
- from posthoganalytics import default_client
38
+ from posthoganalytics import setup
37
39
  from posthoganalytics.ai.utils import get_model_params, with_privacy_mode
38
40
  from posthoganalytics.client import Client
39
41
 
@@ -81,7 +83,7 @@ class CallbackHandler(BaseCallbackHandler):
81
83
  The PostHog LLM observability callback handler for LangChain.
82
84
  """
83
85
 
84
- _client: Client
86
+ _ph_client: Client
85
87
  """PostHog client instance."""
86
88
 
87
89
  _distinct_id: Optional[Union[str, int, UUID]]
@@ -127,10 +129,7 @@ class CallbackHandler(BaseCallbackHandler):
127
129
  privacy_mode: Whether to redact the input and output of the trace.
128
130
  groups: Optional additional PostHog groups to use for the trace.
129
131
  """
130
- posthog_client = client or default_client
131
- if posthog_client is None:
132
- raise ValueError("PostHog client is required")
133
- self._client = posthog_client
132
+ self._ph_client = client or setup()
134
133
  self._distinct_id = distinct_id
135
134
  self._trace_id = trace_id
136
135
  self._properties = properties or {}
@@ -481,7 +480,7 @@ class CallbackHandler(BaseCallbackHandler):
481
480
  event_properties = {
482
481
  "$ai_trace_id": trace_id,
483
482
  "$ai_input_state": with_privacy_mode(
484
- self._client, self._privacy_mode, run.input
483
+ self._ph_client, self._privacy_mode, run.input
485
484
  ),
486
485
  "$ai_latency": run.latency,
487
486
  "$ai_span_name": run.name,
@@ -497,13 +496,13 @@ class CallbackHandler(BaseCallbackHandler):
497
496
  event_properties["$ai_is_error"] = True
498
497
  elif outputs is not None:
499
498
  event_properties["$ai_output_state"] = with_privacy_mode(
500
- self._client, self._privacy_mode, outputs
499
+ self._ph_client, self._privacy_mode, outputs
501
500
  )
502
501
 
503
502
  if self._distinct_id is None:
504
503
  event_properties["$process_person_profile"] = False
505
504
 
506
- self._client.capture(
505
+ self._ph_client.capture(
507
506
  distinct_id=self._distinct_id or run_id,
508
507
  event=event_name,
509
508
  properties=event_properties,
@@ -550,14 +549,16 @@ class CallbackHandler(BaseCallbackHandler):
550
549
  "$ai_provider": run.provider,
551
550
  "$ai_model": run.model,
552
551
  "$ai_model_parameters": run.model_params,
553
- "$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.input),
552
+ "$ai_input": with_privacy_mode(
553
+ self._ph_client, self._privacy_mode, run.input
554
+ ),
554
555
  "$ai_http_status": 200,
555
556
  "$ai_latency": run.latency,
556
557
  "$ai_base_url": run.base_url,
557
558
  }
558
559
  if run.tools:
559
560
  event_properties["$ai_tools"] = with_privacy_mode(
560
- self._client,
561
+ self._ph_client,
561
562
  self._privacy_mode,
562
563
  run.tools,
563
564
  )
@@ -589,7 +590,7 @@ class CallbackHandler(BaseCallbackHandler):
589
590
  _extract_raw_esponse(generation) for generation in generation_result
590
591
  ]
591
592
  event_properties["$ai_output_choices"] = with_privacy_mode(
592
- self._client, self._privacy_mode, completions
593
+ self._ph_client, self._privacy_mode, completions
593
594
  )
594
595
 
595
596
  if self._properties:
@@ -598,7 +599,7 @@ class CallbackHandler(BaseCallbackHandler):
598
599
  if self._distinct_id is None:
599
600
  event_properties["$process_person_profile"] = False
600
601
 
601
- self._client.capture(
602
+ self._ph_client.capture(
602
603
  distinct_id=self._distinct_id or trace_id,
603
604
  event="$ai_generation",
604
605
  properties=event_properties,
@@ -630,12 +631,35 @@ def _extract_raw_esponse(last_response):
630
631
  return ""
631
632
 
632
633
 
633
- def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
634
+ def _convert_lc_tool_calls_to_oai(
635
+ tool_calls: list[ToolCall],
636
+ ) -> list[dict[str, Any]]:
637
+ try:
638
+ return [
639
+ {
640
+ "type": "function",
641
+ "id": tool_call["id"],
642
+ "function": {
643
+ "name": tool_call["name"],
644
+ "arguments": json.dumps(tool_call["args"]),
645
+ },
646
+ }
647
+ for tool_call in tool_calls
648
+ ]
649
+ except KeyError:
650
+ return tool_calls
651
+
652
+
653
+ def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
634
654
  # assistant message
635
655
  if isinstance(message, HumanMessage):
636
656
  message_dict = {"role": "user", "content": message.content}
637
657
  elif isinstance(message, AIMessage):
638
658
  message_dict = {"role": "assistant", "content": message.content}
659
+ if message.tool_calls:
660
+ message_dict["tool_calls"] = _convert_lc_tool_calls_to_oai(
661
+ message.tool_calls
662
+ )
639
663
  elif isinstance(message, SystemMessage):
640
664
  message_dict = {"role": "system", "content": message.content}
641
665
  elif isinstance(message, ToolMessage):
@@ -648,6 +672,9 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
648
672
  if message.additional_kwargs:
649
673
  message_dict.update(message.additional_kwargs)
650
674
 
675
+ if "content" in message_dict and not message_dict["content"]:
676
+ message_dict["content"] = ""
677
+
651
678
  return message_dict
652
679
 
653
680
 
@@ -5,6 +5,8 @@ from datetime import datetime
5
5
  import numbers
6
6
  from uuid import UUID
7
7
 
8
+ from posthoganalytics.types import SendFeatureFlagsOptions
9
+
8
10
  ID_TYPES = Union[numbers.Number, str, UUID, int]
9
11
 
10
12
 
@@ -22,7 +24,8 @@ class OptionalCaptureArgs(TypedDict):
22
24
  error ID if you capture an exception).
23
25
  groups: Group identifiers to associate with this event (format: {group_type: group_key})
24
26
  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.
26
29
  disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
27
30
  """
28
31
 
@@ -32,8 +35,8 @@ class OptionalCaptureArgs(TypedDict):
32
35
  uuid: NotRequired[Optional[str]]
33
36
  groups: NotRequired[Optional[Dict[str, str]]]
34
37
  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
37
40
  disable_geoip: NotRequired[
38
41
  Optional[bool]
39
42
  ] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
@@ -524,11 +524,31 @@ class Client(object):
524
524
 
525
525
  extra_properties: dict[str, Any] = {}
526
526
  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"]:
528
532
  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
+ )
532
552
  except Exception as e:
533
553
  self.log.exception(
534
554
  f"[FEATURE FLAGS] Unable to get feature variants: {e}"
@@ -559,6 +579,42 @@ class Client(object):
559
579
 
560
580
  return self._enqueue(msg, disable_geoip)
561
581
 
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
+
562
618
  def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
563
619
  """
564
620
  Set properties on a person profile.
@@ -751,6 +751,186 @@ class TestClient(unittest.TestCase):
751
751
 
752
752
  self.assertEqual(patch_flags.call_count, 0)
753
753
 
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
+
754
934
  def test_stringifies_distinct_id(self):
755
935
  # A large number that loses precision in node:
756
936
  # node -e "console.log(157963456373623802 + 1)" > 157963456373623800
@@ -1591,7 +1771,7 @@ class TestClient(unittest.TestCase):
1591
1771
 
1592
1772
  @mock.patch("posthog.client.Poller")
1593
1773
  @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):
1595
1775
  def raise_effect():
1596
1776
  raise Exception("http exception")
1597
1777
 
@@ -1993,3 +2173,76 @@ class TestClient(unittest.TestCase):
1993
2173
  result = client.get_remote_config_payload("test-flag")
1994
2174
 
1995
2175
  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))
@@ -9,6 +9,24 @@ FlagValue = Union[bool, str]
9
9
  BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
10
10
 
11
11
 
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 /flags 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
+
12
30
  @dataclass(frozen=True)
13
31
  class FlagReason:
14
32
  code: str
@@ -1,4 +1,4 @@
1
- VERSION = "6.2.1"
1
+ VERSION = "6.3.1"
2
2
 
3
3
  if __name__ == "__main__":
4
4
  print(VERSION, end="") # noqa: T201
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 6.2.1
3
+ Version: 6.3.1
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog