posthoganalytics 6.7.0__py3-none-any.whl → 7.4.3__py3-none-any.whl

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 (40) hide show
  1. posthoganalytics/__init__.py +84 -7
  2. posthoganalytics/ai/anthropic/__init__.py +10 -0
  3. posthoganalytics/ai/anthropic/anthropic.py +95 -65
  4. posthoganalytics/ai/anthropic/anthropic_async.py +95 -65
  5. posthoganalytics/ai/anthropic/anthropic_converter.py +443 -0
  6. posthoganalytics/ai/gemini/__init__.py +15 -1
  7. posthoganalytics/ai/gemini/gemini.py +66 -71
  8. posthoganalytics/ai/gemini/gemini_async.py +423 -0
  9. posthoganalytics/ai/gemini/gemini_converter.py +652 -0
  10. posthoganalytics/ai/langchain/callbacks.py +58 -13
  11. posthoganalytics/ai/openai/__init__.py +16 -1
  12. posthoganalytics/ai/openai/openai.py +140 -149
  13. posthoganalytics/ai/openai/openai_async.py +127 -82
  14. posthoganalytics/ai/openai/openai_converter.py +741 -0
  15. posthoganalytics/ai/sanitization.py +248 -0
  16. posthoganalytics/ai/types.py +125 -0
  17. posthoganalytics/ai/utils.py +339 -356
  18. posthoganalytics/client.py +345 -97
  19. posthoganalytics/contexts.py +81 -0
  20. posthoganalytics/exception_utils.py +250 -2
  21. posthoganalytics/feature_flags.py +26 -10
  22. posthoganalytics/flag_definition_cache.py +127 -0
  23. posthoganalytics/integrations/django.py +157 -19
  24. posthoganalytics/request.py +203 -23
  25. posthoganalytics/test/test_client.py +250 -22
  26. posthoganalytics/test/test_exception_capture.py +418 -0
  27. posthoganalytics/test/test_feature_flag_result.py +441 -2
  28. posthoganalytics/test/test_feature_flags.py +308 -104
  29. posthoganalytics/test/test_flag_definition_cache.py +612 -0
  30. posthoganalytics/test/test_module.py +0 -8
  31. posthoganalytics/test/test_request.py +536 -0
  32. posthoganalytics/test/test_utils.py +4 -1
  33. posthoganalytics/types.py +40 -0
  34. posthoganalytics/version.py +1 -1
  35. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/METADATA +12 -12
  36. posthoganalytics-7.4.3.dist-info/RECORD +57 -0
  37. posthoganalytics-6.7.0.dist-info/RECORD +0 -49
  38. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/WHEEL +0 -0
  39. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/licenses/LICENSE +0 -0
  40. {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,13 @@ import mock
4
4
 
5
5
  from posthoganalytics.client import Client
6
6
  from posthoganalytics.test.test_utils import FAKE_TEST_API_KEY
7
- from posthoganalytics.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
7
+ from posthoganalytics.types import (
8
+ FeatureFlag,
9
+ FeatureFlagError,
10
+ FeatureFlagResult,
11
+ FlagMetadata,
12
+ FlagReason,
13
+ )
8
14
 
9
15
 
10
16
  class TestFeatureFlagResult(unittest.TestCase):
@@ -189,7 +195,6 @@ class TestGetFeatureFlagResult(unittest.TestCase):
189
195
 
190
196
  def set_fail(self, e, batch):
191
197
  """Mark the failure handler"""
192
- print("FAIL", e, batch) # noqa: T201
193
198
  self.failed = True
194
199
 
195
200
  def setUp(self):
@@ -241,6 +246,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
241
246
  groups={},
242
247
  disable_geoip=None,
243
248
  )
249
+ # Verify error property is NOT present on successful evaluation
250
+ captured_properties = patch_capture.call_args[1]["properties"]
251
+ self.assertNotIn("$feature_flag_error", captured_properties)
244
252
 
245
253
  @mock.patch.object(Client, "capture")
246
254
  def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
@@ -295,6 +303,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
295
303
  groups={},
296
304
  disable_geoip=None,
297
305
  )
306
+ # Verify error property is NOT present on successful evaluation
307
+ captured_properties = patch_capture.call_args[1]["properties"]
308
+ self.assertNotIn("$feature_flag_error", captured_properties)
298
309
 
299
310
  another_flag_result = self.client.get_feature_flag_result(
300
311
  "person-flag", "another-distinct-id", person_properties={"region": "USA"}
@@ -360,6 +371,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
360
371
  groups={},
361
372
  disable_geoip=None,
362
373
  )
374
+ # Verify error property is NOT present on successful evaluation
375
+ captured_properties = patch_capture.call_args[1]["properties"]
376
+ self.assertNotIn("$feature_flag_error", captured_properties)
363
377
 
364
378
  @mock.patch("posthog.client.flags")
365
379
  @mock.patch.object(Client, "capture")
@@ -403,6 +417,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
403
417
  groups={},
404
418
  disable_geoip=None,
405
419
  )
420
+ # Verify error property is NOT present on successful evaluation
421
+ captured_properties = patch_capture.call_args[1]["properties"]
422
+ self.assertNotIn("$feature_flag_error", captured_properties)
406
423
 
407
424
  @mock.patch("posthog.client.flags")
408
425
  @mock.patch.object(Client, "capture")
@@ -438,6 +455,428 @@ class TestGetFeatureFlagResult(unittest.TestCase):
438
455
  "$feature_flag_response": None,
439
456
  "locally_evaluated": False,
440
457
  "$feature/no-person-flag": None,
458
+ "$feature_flag_error": FeatureFlagError.FLAG_MISSING,
459
+ },
460
+ groups={},
461
+ disable_geoip=None,
462
+ )
463
+
464
+ @mock.patch("posthog.client.flags")
465
+ @mock.patch.object(Client, "capture")
466
+ def test_get_feature_flag_result_with_errors_while_computing_flags(
467
+ self, patch_capture, patch_flags
468
+ ):
469
+ """Test that errors_while_computing_flags is included in the $feature_flag_called event.
470
+
471
+ When the server returns errorsWhileComputingFlags=true, it indicates that there
472
+ was an error computing one or more flags. We include this in the event so users
473
+ can identify and debug flag evaluation issues.
474
+ """
475
+ patch_flags.return_value = {
476
+ "flags": {
477
+ "my-flag": {
478
+ "key": "my-flag",
479
+ "enabled": True,
480
+ "variant": None,
481
+ "reason": {"description": "Matched condition set 1"},
482
+ "metadata": {"id": 1, "version": 1, "payload": None},
483
+ },
484
+ },
485
+ "requestId": "test-request-id-789",
486
+ "errorsWhileComputingFlags": True,
487
+ }
488
+
489
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
490
+
491
+ self.assertEqual(flag_result.enabled, True)
492
+ patch_capture.assert_called_with(
493
+ "$feature_flag_called",
494
+ distinct_id="some-distinct-id",
495
+ properties={
496
+ "$feature_flag": "my-flag",
497
+ "$feature_flag_response": True,
498
+ "locally_evaluated": False,
499
+ "$feature/my-flag": True,
500
+ "$feature_flag_request_id": "test-request-id-789",
501
+ "$feature_flag_reason": "Matched condition set 1",
502
+ "$feature_flag_id": 1,
503
+ "$feature_flag_version": 1,
504
+ "$feature_flag_error": FeatureFlagError.ERRORS_WHILE_COMPUTING,
505
+ },
506
+ groups={},
507
+ disable_geoip=None,
508
+ )
509
+
510
+ @mock.patch("posthog.client.flags")
511
+ @mock.patch.object(Client, "capture")
512
+ def test_get_feature_flag_result_flag_not_in_response(
513
+ self, patch_capture, patch_flags
514
+ ):
515
+ """Test that when a flag is not in the API response, we capture flag_missing error.
516
+
517
+ This happens when a flag doesn't exist or the user doesn't match any conditions.
518
+ """
519
+ patch_flags.return_value = {
520
+ "flags": {
521
+ "other-flag": {
522
+ "key": "other-flag",
523
+ "enabled": True,
524
+ "variant": None,
525
+ "reason": {"description": "Matched condition set 1"},
526
+ "metadata": {"id": 1, "version": 1, "payload": None},
527
+ },
528
+ },
529
+ "requestId": "test-request-id-456",
530
+ }
531
+
532
+ flag_result = self.client.get_feature_flag_result(
533
+ "missing-flag", "some-distinct-id"
534
+ )
535
+
536
+ self.assertIsNone(flag_result)
537
+ patch_capture.assert_called_with(
538
+ "$feature_flag_called",
539
+ distinct_id="some-distinct-id",
540
+ properties={
541
+ "$feature_flag": "missing-flag",
542
+ "$feature_flag_response": None,
543
+ "locally_evaluated": False,
544
+ "$feature/missing-flag": None,
545
+ "$feature_flag_request_id": "test-request-id-456",
546
+ "$feature_flag_error": FeatureFlagError.FLAG_MISSING,
547
+ },
548
+ groups={},
549
+ disable_geoip=None,
550
+ )
551
+
552
+ @mock.patch("posthog.client.flags")
553
+ @mock.patch.object(Client, "capture")
554
+ def test_get_feature_flag_result_errors_computing_and_flag_missing(
555
+ self, patch_capture, patch_flags
556
+ ):
557
+ """Test that both errors are reported when errorsWhileComputingFlags=true AND flag is missing.
558
+
559
+ This can happen when the server encounters errors computing flags AND the requested
560
+ flag is not in the response. Both conditions should be reported for debugging.
561
+ """
562
+ patch_flags.return_value = {
563
+ "flags": {}, # Flag is missing
564
+ "requestId": "test-request-id-999",
565
+ "errorsWhileComputingFlags": True, # But errors also occurred
566
+ }
567
+
568
+ flag_result = self.client.get_feature_flag_result(
569
+ "missing-flag", "some-distinct-id"
570
+ )
571
+
572
+ self.assertIsNone(flag_result)
573
+ patch_capture.assert_called_with(
574
+ "$feature_flag_called",
575
+ distinct_id="some-distinct-id",
576
+ properties={
577
+ "$feature_flag": "missing-flag",
578
+ "$feature_flag_response": None,
579
+ "locally_evaluated": False,
580
+ "$feature/missing-flag": None,
581
+ "$feature_flag_request_id": "test-request-id-999",
582
+ "$feature_flag_error": f"{FeatureFlagError.ERRORS_WHILE_COMPUTING},{FeatureFlagError.FLAG_MISSING}",
583
+ },
584
+ groups={},
585
+ disable_geoip=None,
586
+ )
587
+
588
+ @mock.patch("posthog.client.flags")
589
+ @mock.patch.object(Client, "capture")
590
+ def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
591
+ """Test that unexpected exceptions are captured as unknown_error."""
592
+ patch_flags.side_effect = Exception("Unexpected error")
593
+
594
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
595
+
596
+ self.assertIsNone(flag_result)
597
+ patch_capture.assert_called_with(
598
+ "$feature_flag_called",
599
+ distinct_id="some-distinct-id",
600
+ properties={
601
+ "$feature_flag": "my-flag",
602
+ "$feature_flag_response": None,
603
+ "locally_evaluated": False,
604
+ "$feature/my-flag": None,
605
+ "$feature_flag_error": FeatureFlagError.UNKNOWN_ERROR,
606
+ },
607
+ groups={},
608
+ disable_geoip=None,
609
+ )
610
+
611
+ @mock.patch("posthog.client.flags")
612
+ @mock.patch.object(Client, "capture")
613
+ def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
614
+ """Test that timeout errors are captured specifically."""
615
+ from posthoganalytics.request import RequestsTimeout
616
+
617
+ patch_flags.side_effect = RequestsTimeout("Request timed out")
618
+
619
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
620
+
621
+ self.assertIsNone(flag_result)
622
+ patch_capture.assert_called_with(
623
+ "$feature_flag_called",
624
+ distinct_id="some-distinct-id",
625
+ properties={
626
+ "$feature_flag": "my-flag",
627
+ "$feature_flag_response": None,
628
+ "locally_evaluated": False,
629
+ "$feature/my-flag": None,
630
+ "$feature_flag_error": FeatureFlagError.TIMEOUT,
631
+ },
632
+ groups={},
633
+ disable_geoip=None,
634
+ )
635
+
636
+ @mock.patch("posthog.client.flags")
637
+ @mock.patch.object(Client, "capture")
638
+ def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
639
+ """Test that connection errors are captured specifically."""
640
+ from posthoganalytics.request import RequestsConnectionError
641
+
642
+ patch_flags.side_effect = RequestsConnectionError("Connection refused")
643
+
644
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
645
+
646
+ self.assertIsNone(flag_result)
647
+ patch_capture.assert_called_with(
648
+ "$feature_flag_called",
649
+ distinct_id="some-distinct-id",
650
+ properties={
651
+ "$feature_flag": "my-flag",
652
+ "$feature_flag_response": None,
653
+ "locally_evaluated": False,
654
+ "$feature/my-flag": None,
655
+ "$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
656
+ },
657
+ groups={},
658
+ disable_geoip=None,
659
+ )
660
+
661
+ @mock.patch("posthog.client.flags")
662
+ @mock.patch.object(Client, "capture")
663
+ def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
664
+ """Test that API errors include the status code."""
665
+ from posthoganalytics.request import APIError
666
+
667
+ patch_flags.side_effect = APIError(500, "Internal server error")
668
+
669
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
670
+
671
+ self.assertIsNone(flag_result)
672
+ patch_capture.assert_called_with(
673
+ "$feature_flag_called",
674
+ distinct_id="some-distinct-id",
675
+ properties={
676
+ "$feature_flag": "my-flag",
677
+ "$feature_flag_response": None,
678
+ "locally_evaluated": False,
679
+ "$feature/my-flag": None,
680
+ "$feature_flag_error": FeatureFlagError.api_error(500),
681
+ },
682
+ groups={},
683
+ disable_geoip=None,
684
+ )
685
+
686
+ @mock.patch("posthog.client.flags")
687
+ @mock.patch.object(Client, "capture")
688
+ def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
689
+ """Test that quota limit errors are captured specifically."""
690
+ from posthoganalytics.request import QuotaLimitError
691
+
692
+ patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
693
+
694
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
695
+
696
+ self.assertIsNone(flag_result)
697
+ patch_capture.assert_called_with(
698
+ "$feature_flag_called",
699
+ distinct_id="some-distinct-id",
700
+ properties={
701
+ "$feature_flag": "my-flag",
702
+ "$feature_flag_response": None,
703
+ "locally_evaluated": False,
704
+ "$feature/my-flag": None,
705
+ "$feature_flag_error": FeatureFlagError.QUOTA_LIMITED,
706
+ },
707
+ groups={},
708
+ disable_geoip=None,
709
+ )
710
+
711
+
712
+ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
713
+ """Tests for stale cache fallback behavior when flag evaluation fails.
714
+
715
+ When the PostHog API is unavailable (timeout, connection error, etc.), the SDK
716
+ falls back to stale cached flag values if available. These tests verify that:
717
+ 1. The stale cached value is returned when an error occurs
718
+ 2. The $feature_flag_error property is still set (for debugging)
719
+ 3. The response reflects the cached value, not None
720
+ """
721
+
722
+ def set_fail(self, e, batch):
723
+ """Mark the failure handler"""
724
+ self.failed = True
725
+
726
+ def setUp(self):
727
+ self.failed = False
728
+ # Create client with memory-based flag cache enabled
729
+ self.client = Client(
730
+ FAKE_TEST_API_KEY,
731
+ on_error=self.set_fail,
732
+ flag_fallback_cache_url="memory://local/?ttl=300&size=10000",
733
+ )
734
+
735
+ def _populate_stale_cache(self, distinct_id, flag_key, flag_result):
736
+ """Pre-populate the flag cache with a value that will be used for stale fallback."""
737
+ self.client.flag_cache.set_cached_flag(
738
+ distinct_id,
739
+ flag_key,
740
+ flag_result,
741
+ flag_definition_version=self.client.flag_definition_version,
742
+ )
743
+
744
+ @mock.patch("posthog.client.flags")
745
+ @mock.patch.object(Client, "capture")
746
+ def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_flags):
747
+ """Test that timeout errors return stale cached value when available."""
748
+ from posthoganalytics.request import RequestsTimeout
749
+
750
+ # Pre-populate cache with a flag result
751
+ cached_result = FeatureFlagResult.from_value_and_payload(
752
+ "my-flag", "cached-variant", '{"from": "cache"}'
753
+ )
754
+ self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
755
+
756
+ # Simulate timeout error
757
+ patch_flags.side_effect = RequestsTimeout("Request timed out")
758
+
759
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
760
+
761
+ # Should return the stale cached value
762
+ self.assertIsNotNone(flag_result)
763
+ self.assertEqual(flag_result.variant, "cached-variant")
764
+ self.assertEqual(flag_result.payload, {"from": "cache"})
765
+
766
+ # Error should still be tracked for debugging
767
+ patch_capture.assert_called_with(
768
+ "$feature_flag_called",
769
+ distinct_id="some-distinct-id",
770
+ properties={
771
+ "$feature_flag": "my-flag",
772
+ "$feature_flag_response": "cached-variant",
773
+ "locally_evaluated": False,
774
+ "$feature/my-flag": "cached-variant",
775
+ "$feature_flag_payload": {"from": "cache"},
776
+ "$feature_flag_error": FeatureFlagError.TIMEOUT,
777
+ },
778
+ groups={},
779
+ disable_geoip=None,
780
+ )
781
+
782
+ @mock.patch("posthog.client.flags")
783
+ @mock.patch.object(Client, "capture")
784
+ def test_connection_error_returns_stale_cached_value(
785
+ self, patch_capture, patch_flags
786
+ ):
787
+ """Test that connection errors return stale cached value when available."""
788
+ from posthoganalytics.request import RequestsConnectionError
789
+
790
+ # Pre-populate cache with a boolean flag result
791
+ cached_result = FeatureFlagResult.from_value_and_payload("my-flag", True, None)
792
+ self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
793
+
794
+ # Simulate connection error
795
+ patch_flags.side_effect = RequestsConnectionError("Connection refused")
796
+
797
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
798
+
799
+ # Should return the stale cached value
800
+ self.assertIsNotNone(flag_result)
801
+ self.assertEqual(flag_result.enabled, True)
802
+ self.assertIsNone(flag_result.variant)
803
+
804
+ # Error should still be tracked
805
+ patch_capture.assert_called_with(
806
+ "$feature_flag_called",
807
+ distinct_id="some-distinct-id",
808
+ properties={
809
+ "$feature_flag": "my-flag",
810
+ "$feature_flag_response": True,
811
+ "locally_evaluated": False,
812
+ "$feature/my-flag": True,
813
+ "$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
814
+ },
815
+ groups={},
816
+ disable_geoip=None,
817
+ )
818
+
819
+ @mock.patch("posthog.client.flags")
820
+ @mock.patch.object(Client, "capture")
821
+ def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags):
822
+ """Test that API errors return stale cached value when available."""
823
+ from posthoganalytics.request import APIError
824
+
825
+ # Pre-populate cache
826
+ cached_result = FeatureFlagResult.from_value_and_payload(
827
+ "my-flag", "control", None
828
+ )
829
+ self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
830
+
831
+ # Simulate API error
832
+ patch_flags.side_effect = APIError(503, "Service unavailable")
833
+
834
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
835
+
836
+ # Should return the stale cached value
837
+ self.assertIsNotNone(flag_result)
838
+ self.assertEqual(flag_result.variant, "control")
839
+
840
+ # Error should still be tracked with status code
841
+ patch_capture.assert_called_with(
842
+ "$feature_flag_called",
843
+ distinct_id="some-distinct-id",
844
+ properties={
845
+ "$feature_flag": "my-flag",
846
+ "$feature_flag_response": "control",
847
+ "locally_evaluated": False,
848
+ "$feature/my-flag": "control",
849
+ "$feature_flag_error": FeatureFlagError.api_error(503),
850
+ },
851
+ groups={},
852
+ disable_geoip=None,
853
+ )
854
+
855
+ @mock.patch("posthog.client.flags")
856
+ @mock.patch.object(Client, "capture")
857
+ def test_error_without_cache_returns_none(self, patch_capture, patch_flags):
858
+ """Test that errors return None when no stale cache is available."""
859
+ from posthoganalytics.request import RequestsTimeout
860
+
861
+ # Do NOT populate cache - no fallback available
862
+
863
+ patch_flags.side_effect = RequestsTimeout("Request timed out")
864
+
865
+ flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
866
+
867
+ # Should return None since no cache available
868
+ self.assertIsNone(flag_result)
869
+
870
+ # Error should still be tracked
871
+ patch_capture.assert_called_with(
872
+ "$feature_flag_called",
873
+ distinct_id="some-distinct-id",
874
+ properties={
875
+ "$feature_flag": "my-flag",
876
+ "$feature_flag_response": None,
877
+ "locally_evaluated": False,
878
+ "$feature/my-flag": None,
879
+ "$feature_flag_error": FeatureFlagError.TIMEOUT,
441
880
  },
442
881
  groups={},
443
882
  disable_geoip=None,