posthoganalytics 6.7.5__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.
- posthoganalytics/__init__.py +84 -7
- posthoganalytics/ai/anthropic/anthropic_async.py +30 -67
- posthoganalytics/ai/anthropic/anthropic_converter.py +40 -0
- posthoganalytics/ai/gemini/__init__.py +3 -0
- posthoganalytics/ai/gemini/gemini.py +1 -1
- posthoganalytics/ai/gemini/gemini_async.py +423 -0
- posthoganalytics/ai/gemini/gemini_converter.py +160 -24
- posthoganalytics/ai/langchain/callbacks.py +55 -11
- posthoganalytics/ai/openai/openai.py +27 -2
- posthoganalytics/ai/openai/openai_async.py +49 -5
- posthoganalytics/ai/openai/openai_converter.py +130 -0
- posthoganalytics/ai/sanitization.py +27 -5
- posthoganalytics/ai/types.py +1 -0
- posthoganalytics/ai/utils.py +32 -2
- posthoganalytics/client.py +338 -90
- posthoganalytics/contexts.py +81 -0
- posthoganalytics/exception_utils.py +250 -2
- posthoganalytics/feature_flags.py +26 -10
- posthoganalytics/flag_definition_cache.py +127 -0
- posthoganalytics/integrations/django.py +149 -50
- posthoganalytics/request.py +203 -23
- posthoganalytics/test/test_client.py +250 -22
- posthoganalytics/test/test_exception_capture.py +418 -0
- posthoganalytics/test/test_feature_flag_result.py +441 -2
- posthoganalytics/test/test_feature_flags.py +306 -102
- posthoganalytics/test/test_flag_definition_cache.py +612 -0
- posthoganalytics/test/test_module.py +0 -8
- posthoganalytics/test/test_request.py +536 -0
- posthoganalytics/test/test_utils.py +4 -1
- posthoganalytics/types.py +40 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/METADATA +12 -12
- posthoganalytics-7.4.3.dist-info/RECORD +57 -0
- posthoganalytics-6.7.5.dist-info/RECORD +0 -54
- {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/WHEEL +0 -0
- {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/top_level.txt +0 -0
posthoganalytics/client.py
CHANGED
|
@@ -2,43 +2,62 @@ import atexit
|
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
|
+
import warnings
|
|
5
6
|
from datetime import datetime, timedelta
|
|
6
7
|
from typing import Any, Dict, Optional, Union
|
|
7
|
-
from typing_extensions import Unpack
|
|
8
8
|
from uuid import uuid4
|
|
9
9
|
|
|
10
10
|
from dateutil.tz import tzutc
|
|
11
11
|
from six import string_types
|
|
12
|
+
from typing_extensions import Unpack
|
|
12
13
|
|
|
13
|
-
from posthoganalytics.args import
|
|
14
|
+
from posthoganalytics.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
|
|
14
15
|
from posthoganalytics.consumer import Consumer
|
|
16
|
+
from posthoganalytics.contexts import (
|
|
17
|
+
_get_current_context,
|
|
18
|
+
get_capture_exception_code_variables_context,
|
|
19
|
+
get_code_variables_ignore_patterns_context,
|
|
20
|
+
get_code_variables_mask_patterns_context,
|
|
21
|
+
get_context_distinct_id,
|
|
22
|
+
get_context_session_id,
|
|
23
|
+
new_context,
|
|
24
|
+
)
|
|
15
25
|
from posthoganalytics.exception_capture import ExceptionCapture
|
|
16
26
|
from posthoganalytics.exception_utils import (
|
|
27
|
+
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
|
|
28
|
+
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
|
|
17
29
|
exc_info_from_error,
|
|
30
|
+
exception_is_already_captured,
|
|
18
31
|
exceptions_from_error_tuple,
|
|
19
32
|
handle_in_app,
|
|
20
|
-
exception_is_already_captured,
|
|
21
33
|
mark_exception_as_captured,
|
|
34
|
+
try_attach_code_variables_to_frames,
|
|
35
|
+
)
|
|
36
|
+
from posthoganalytics.feature_flags import (
|
|
37
|
+
InconclusiveMatchError,
|
|
38
|
+
RequiresServerEvaluation,
|
|
39
|
+
match_feature_flag_properties,
|
|
40
|
+
)
|
|
41
|
+
from posthoganalytics.flag_definition_cache import (
|
|
42
|
+
FlagDefinitionCacheData,
|
|
43
|
+
FlagDefinitionCacheProvider,
|
|
22
44
|
)
|
|
23
|
-
from posthoganalytics.feature_flags import InconclusiveMatchError, match_feature_flag_properties
|
|
24
45
|
from posthoganalytics.poller import Poller
|
|
25
46
|
from posthoganalytics.request import (
|
|
26
47
|
DEFAULT_HOST,
|
|
27
48
|
APIError,
|
|
49
|
+
QuotaLimitError,
|
|
50
|
+
RequestsConnectionError,
|
|
51
|
+
RequestsTimeout,
|
|
28
52
|
batch_post,
|
|
29
53
|
determine_server_host,
|
|
30
54
|
flags,
|
|
31
55
|
get,
|
|
32
56
|
remote_config,
|
|
33
57
|
)
|
|
34
|
-
from posthoganalytics.contexts import (
|
|
35
|
-
_get_current_context,
|
|
36
|
-
get_context_distinct_id,
|
|
37
|
-
get_context_session_id,
|
|
38
|
-
new_context,
|
|
39
|
-
)
|
|
40
58
|
from posthoganalytics.types import (
|
|
41
59
|
FeatureFlag,
|
|
60
|
+
FeatureFlagError,
|
|
42
61
|
FeatureFlagResult,
|
|
43
62
|
FlagMetadata,
|
|
44
63
|
FlagsAndPayloads,
|
|
@@ -56,7 +75,6 @@ from posthoganalytics.utils import (
|
|
|
56
75
|
SizeLimitedDict,
|
|
57
76
|
clean,
|
|
58
77
|
guess_timezone,
|
|
59
|
-
remove_trailing_slash,
|
|
60
78
|
system_context,
|
|
61
79
|
)
|
|
62
80
|
from posthoganalytics.version import VERSION
|
|
@@ -99,6 +117,34 @@ def add_context_tags(properties):
|
|
|
99
117
|
return properties
|
|
100
118
|
|
|
101
119
|
|
|
120
|
+
def no_throw(default_return=None):
|
|
121
|
+
"""
|
|
122
|
+
Decorator to prevent raising exceptions from public API methods.
|
|
123
|
+
Note that this doesn't prevent errors from propagating via `on_error`.
|
|
124
|
+
Exceptions will still be raised if the debug flag is enabled.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
default_return: Value to return on exception (default: None)
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def decorator(func):
|
|
131
|
+
from functools import wraps
|
|
132
|
+
|
|
133
|
+
@wraps(func)
|
|
134
|
+
def wrapper(self, *args, **kwargs):
|
|
135
|
+
try:
|
|
136
|
+
return func(self, *args, **kwargs)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
if self.debug:
|
|
139
|
+
raise e
|
|
140
|
+
self.log.exception(f"Error in {func.__name__}: {e}")
|
|
141
|
+
return default_return
|
|
142
|
+
|
|
143
|
+
return wrapper
|
|
144
|
+
|
|
145
|
+
return decorator
|
|
146
|
+
|
|
147
|
+
|
|
102
148
|
class Client(object):
|
|
103
149
|
"""
|
|
104
150
|
This is the SDK reference for the PostHog Python SDK.
|
|
@@ -147,6 +193,11 @@ class Client(object):
|
|
|
147
193
|
before_send=None,
|
|
148
194
|
flag_fallback_cache_url=None,
|
|
149
195
|
enable_local_evaluation=True,
|
|
196
|
+
flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None,
|
|
197
|
+
capture_exception_code_variables=False,
|
|
198
|
+
code_variables_mask_patterns=None,
|
|
199
|
+
code_variables_ignore_patterns=None,
|
|
200
|
+
in_app_modules: list[str] | None = None,
|
|
150
201
|
):
|
|
151
202
|
"""
|
|
152
203
|
Initialize a new PostHog client instance.
|
|
@@ -182,8 +233,8 @@ class Client(object):
|
|
|
182
233
|
self.timeout = timeout
|
|
183
234
|
self._feature_flags = None # private variable to store flags
|
|
184
235
|
self.feature_flags_by_key = None
|
|
185
|
-
self.group_type_mapping = None
|
|
186
|
-
self.cohorts = None
|
|
236
|
+
self.group_type_mapping: Optional[dict[str, str]] = None
|
|
237
|
+
self.cohorts: Optional[dict[str, Any]] = None
|
|
187
238
|
self.poll_interval = poll_interval
|
|
188
239
|
self.feature_flags_request_timeout_seconds = (
|
|
189
240
|
feature_flags_request_timeout_seconds
|
|
@@ -192,6 +243,8 @@ class Client(object):
|
|
|
192
243
|
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
|
|
193
244
|
self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
|
|
194
245
|
self.flag_definition_version = 0
|
|
246
|
+
self._flags_etag: Optional[str] = None
|
|
247
|
+
self._flag_definition_cache_provider = flag_definition_cache_provider
|
|
195
248
|
self.disabled = disabled
|
|
196
249
|
self.disable_geoip = disable_geoip
|
|
197
250
|
self.historical_migration = historical_migration
|
|
@@ -202,6 +255,19 @@ class Client(object):
|
|
|
202
255
|
self.privacy_mode = privacy_mode
|
|
203
256
|
self.enable_local_evaluation = enable_local_evaluation
|
|
204
257
|
|
|
258
|
+
self.capture_exception_code_variables = capture_exception_code_variables
|
|
259
|
+
self.code_variables_mask_patterns = (
|
|
260
|
+
code_variables_mask_patterns
|
|
261
|
+
if code_variables_mask_patterns is not None
|
|
262
|
+
else DEFAULT_CODE_VARIABLES_MASK_PATTERNS
|
|
263
|
+
)
|
|
264
|
+
self.code_variables_ignore_patterns = (
|
|
265
|
+
code_variables_ignore_patterns
|
|
266
|
+
if code_variables_ignore_patterns is not None
|
|
267
|
+
else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
|
|
268
|
+
)
|
|
269
|
+
self.in_app_modules = in_app_modules
|
|
270
|
+
|
|
205
271
|
if project_root is None:
|
|
206
272
|
try:
|
|
207
273
|
project_root = os.getcwd()
|
|
@@ -243,8 +309,9 @@ class Client(object):
|
|
|
243
309
|
# to call flush().
|
|
244
310
|
if send:
|
|
245
311
|
atexit.register(self.join)
|
|
246
|
-
|
|
247
|
-
|
|
312
|
+
|
|
313
|
+
self.consumers = []
|
|
314
|
+
for _ in range(thread):
|
|
248
315
|
consumer = Consumer(
|
|
249
316
|
self.queue,
|
|
250
317
|
self.api_key,
|
|
@@ -481,6 +548,7 @@ class Client(object):
|
|
|
481
548
|
|
|
482
549
|
return normalize_flags_response(resp_data)
|
|
483
550
|
|
|
551
|
+
@no_throw()
|
|
484
552
|
def capture(
|
|
485
553
|
self, event: str, **kwargs: Unpack[OptionalCaptureArgs]
|
|
486
554
|
) -> Optional[str]:
|
|
@@ -568,7 +636,28 @@ class Client(object):
|
|
|
568
636
|
if flag_options["should_send"]:
|
|
569
637
|
try:
|
|
570
638
|
if flag_options["only_evaluate_locally"] is True:
|
|
571
|
-
#
|
|
639
|
+
# Local evaluation explicitly requested
|
|
640
|
+
feature_variants = self.get_all_flags(
|
|
641
|
+
distinct_id,
|
|
642
|
+
groups=(groups or {}),
|
|
643
|
+
person_properties=flag_options["person_properties"],
|
|
644
|
+
group_properties=flag_options["group_properties"],
|
|
645
|
+
disable_geoip=disable_geoip,
|
|
646
|
+
only_evaluate_locally=True,
|
|
647
|
+
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
|
648
|
+
)
|
|
649
|
+
elif flag_options["only_evaluate_locally"] is False:
|
|
650
|
+
# Remote evaluation explicitly requested
|
|
651
|
+
feature_variants = self.get_feature_variants(
|
|
652
|
+
distinct_id,
|
|
653
|
+
groups,
|
|
654
|
+
person_properties=flag_options["person_properties"],
|
|
655
|
+
group_properties=flag_options["group_properties"],
|
|
656
|
+
disable_geoip=disable_geoip,
|
|
657
|
+
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
|
658
|
+
)
|
|
659
|
+
elif self.feature_flags:
|
|
660
|
+
# Local flags available, prefer local evaluation
|
|
572
661
|
feature_variants = self.get_all_flags(
|
|
573
662
|
distinct_id,
|
|
574
663
|
groups=(groups or {}),
|
|
@@ -579,7 +668,7 @@ class Client(object):
|
|
|
579
668
|
flag_keys_to_evaluate=flag_options["flag_keys_filter"],
|
|
580
669
|
)
|
|
581
670
|
else:
|
|
582
|
-
#
|
|
671
|
+
# Fall back to remote evaluation
|
|
583
672
|
feature_variants = self.get_feature_variants(
|
|
584
673
|
distinct_id,
|
|
585
674
|
groups,
|
|
@@ -593,15 +682,6 @@ class Client(object):
|
|
|
593
682
|
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
|
|
594
683
|
)
|
|
595
684
|
|
|
596
|
-
elif self.feature_flags and event != "$feature_flag_called":
|
|
597
|
-
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
|
598
|
-
feature_variants = self.get_all_flags(
|
|
599
|
-
distinct_id,
|
|
600
|
-
groups=(groups or {}),
|
|
601
|
-
disable_geoip=disable_geoip,
|
|
602
|
-
only_evaluate_locally=True,
|
|
603
|
-
)
|
|
604
|
-
|
|
605
685
|
for feature, variant in (feature_variants or {}).items():
|
|
606
686
|
extra_properties[f"$feature/{feature}"] = variant
|
|
607
687
|
|
|
@@ -657,6 +737,7 @@ class Client(object):
|
|
|
657
737
|
f"Expected bool or dict."
|
|
658
738
|
)
|
|
659
739
|
|
|
740
|
+
@no_throw()
|
|
660
741
|
def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
|
661
742
|
"""
|
|
662
743
|
Set properties on a person profile.
|
|
@@ -671,25 +752,13 @@ class Client(object):
|
|
|
671
752
|
Examples:
|
|
672
753
|
```python
|
|
673
754
|
# Set with distinct id
|
|
674
|
-
posthog.
|
|
675
|
-
'event_name',
|
|
676
|
-
distinct_id='user-distinct-id',
|
|
677
|
-
properties={
|
|
678
|
-
'$set': {'name': 'Max Hedgehog'},
|
|
679
|
-
'$set_once': {'initial_url': '/blog'}
|
|
680
|
-
}
|
|
681
|
-
)
|
|
682
|
-
```
|
|
683
|
-
```python
|
|
684
|
-
# Set using context
|
|
685
|
-
from posthoganalytics import new_context, identify_context
|
|
686
|
-
with new_context():
|
|
687
|
-
identify_context('user-distinct-id')
|
|
688
|
-
posthog.capture('event_name')
|
|
755
|
+
posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
|
|
689
756
|
```
|
|
690
757
|
|
|
691
758
|
Category:
|
|
692
759
|
Identification
|
|
760
|
+
|
|
761
|
+
Note: This method will not raise exceptions. Errors are logged.
|
|
693
762
|
"""
|
|
694
763
|
distinct_id = kwargs.get("distinct_id", None)
|
|
695
764
|
properties = kwargs.get("properties", None)
|
|
@@ -716,6 +785,7 @@ class Client(object):
|
|
|
716
785
|
|
|
717
786
|
return self._enqueue(msg, disable_geoip)
|
|
718
787
|
|
|
788
|
+
@no_throw()
|
|
719
789
|
def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
|
720
790
|
"""
|
|
721
791
|
Set properties on a person profile only if they haven't been set before.
|
|
@@ -734,6 +804,8 @@ class Client(object):
|
|
|
734
804
|
|
|
735
805
|
Category:
|
|
736
806
|
Identification
|
|
807
|
+
|
|
808
|
+
Note: This method will not raise exceptions. Errors are logged.
|
|
737
809
|
"""
|
|
738
810
|
distinct_id = kwargs.get("distinct_id", None)
|
|
739
811
|
properties = kwargs.get("properties", None)
|
|
@@ -759,6 +831,7 @@ class Client(object):
|
|
|
759
831
|
|
|
760
832
|
return self._enqueue(msg, disable_geoip)
|
|
761
833
|
|
|
834
|
+
@no_throw()
|
|
762
835
|
def group_identify(
|
|
763
836
|
self,
|
|
764
837
|
group_type: str,
|
|
@@ -791,6 +864,8 @@ class Client(object):
|
|
|
791
864
|
|
|
792
865
|
Category:
|
|
793
866
|
Identification
|
|
867
|
+
|
|
868
|
+
Note: This method will not raise exceptions. Errors are logged.
|
|
794
869
|
"""
|
|
795
870
|
properties = properties or {}
|
|
796
871
|
|
|
@@ -815,6 +890,7 @@ class Client(object):
|
|
|
815
890
|
|
|
816
891
|
return self._enqueue(msg, disable_geoip)
|
|
817
892
|
|
|
893
|
+
@no_throw()
|
|
818
894
|
def alias(
|
|
819
895
|
self,
|
|
820
896
|
previous_id: str,
|
|
@@ -840,6 +916,8 @@ class Client(object):
|
|
|
840
916
|
|
|
841
917
|
Category:
|
|
842
918
|
Identification
|
|
919
|
+
|
|
920
|
+
Note: This method will not raise exceptions. Errors are logged.
|
|
843
921
|
"""
|
|
844
922
|
(distinct_id, personless) = get_identity_state(distinct_id)
|
|
845
923
|
|
|
@@ -922,20 +1000,44 @@ class Client(object):
|
|
|
922
1000
|
"values": all_exceptions_with_trace,
|
|
923
1001
|
},
|
|
924
1002
|
},
|
|
1003
|
+
in_app_include=self.in_app_modules,
|
|
925
1004
|
project_root=self.project_root,
|
|
926
1005
|
)
|
|
927
1006
|
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
|
|
928
1007
|
|
|
929
1008
|
properties = {
|
|
930
|
-
"$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
|
|
931
|
-
"$exception_message": all_exceptions_with_trace_and_in_app[0].get(
|
|
932
|
-
"value"
|
|
933
|
-
),
|
|
934
1009
|
"$exception_list": all_exceptions_with_trace_and_in_app,
|
|
935
|
-
"$exception_personURL": f"{remove_trailing_slash(self.raw_host)}/project/{self.api_key}/person/{distinct_id}",
|
|
936
1010
|
**properties,
|
|
937
1011
|
}
|
|
938
1012
|
|
|
1013
|
+
context_enabled = get_capture_exception_code_variables_context()
|
|
1014
|
+
context_mask = get_code_variables_mask_patterns_context()
|
|
1015
|
+
context_ignore = get_code_variables_ignore_patterns_context()
|
|
1016
|
+
|
|
1017
|
+
enabled = (
|
|
1018
|
+
context_enabled
|
|
1019
|
+
if context_enabled is not None
|
|
1020
|
+
else self.capture_exception_code_variables
|
|
1021
|
+
)
|
|
1022
|
+
mask_patterns = (
|
|
1023
|
+
context_mask
|
|
1024
|
+
if context_mask is not None
|
|
1025
|
+
else self.code_variables_mask_patterns
|
|
1026
|
+
)
|
|
1027
|
+
ignore_patterns = (
|
|
1028
|
+
context_ignore
|
|
1029
|
+
if context_ignore is not None
|
|
1030
|
+
else self.code_variables_ignore_patterns
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
if enabled:
|
|
1034
|
+
try_attach_code_variables_to_frames(
|
|
1035
|
+
all_exceptions_with_trace_and_in_app,
|
|
1036
|
+
exc_info,
|
|
1037
|
+
mask_patterns=mask_patterns,
|
|
1038
|
+
ignore_patterns=ignore_patterns,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
939
1041
|
if self.log_captured_exceptions:
|
|
940
1042
|
self.log.exception(exception, extra=kwargs)
|
|
941
1043
|
|
|
@@ -1068,17 +1170,25 @@ class Client(object):
|
|
|
1068
1170
|
posthog.join()
|
|
1069
1171
|
```
|
|
1070
1172
|
"""
|
|
1071
|
-
|
|
1072
|
-
consumer.
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1173
|
+
if self.consumers:
|
|
1174
|
+
for consumer in self.consumers:
|
|
1175
|
+
consumer.pause()
|
|
1176
|
+
try:
|
|
1177
|
+
consumer.join()
|
|
1178
|
+
except RuntimeError:
|
|
1179
|
+
# consumer thread has not started
|
|
1180
|
+
pass
|
|
1078
1181
|
|
|
1079
1182
|
if self.poller:
|
|
1080
1183
|
self.poller.stop()
|
|
1081
1184
|
|
|
1185
|
+
# Shutdown the cache provider (release locks, cleanup)
|
|
1186
|
+
if self._flag_definition_cache_provider:
|
|
1187
|
+
try:
|
|
1188
|
+
self._flag_definition_cache_provider.shutdown()
|
|
1189
|
+
except Exception as e:
|
|
1190
|
+
self.log.error(f"[FEATURE FLAGS] Cache provider shutdown error: {e}")
|
|
1191
|
+
|
|
1082
1192
|
def shutdown(self):
|
|
1083
1193
|
"""
|
|
1084
1194
|
Flush all messages and cleanly shutdown the client. Call this before the process ends in serverless environments to avoid data loss.
|
|
@@ -1094,7 +1204,71 @@ class Client(object):
|
|
|
1094
1204
|
if self.exception_capture:
|
|
1095
1205
|
self.exception_capture.close()
|
|
1096
1206
|
|
|
1207
|
+
def _update_flag_state(
|
|
1208
|
+
self, data: FlagDefinitionCacheData, old_flags_by_key: Optional[dict] = None
|
|
1209
|
+
) -> None:
|
|
1210
|
+
"""Update internal flag state from cache data and invalidate evaluation cache if changed."""
|
|
1211
|
+
self.feature_flags = data["flags"]
|
|
1212
|
+
self.group_type_mapping = data["group_type_mapping"]
|
|
1213
|
+
self.cohorts = data["cohorts"]
|
|
1214
|
+
|
|
1215
|
+
# Invalidate evaluation cache if flag definitions changed
|
|
1216
|
+
if (
|
|
1217
|
+
self.flag_cache
|
|
1218
|
+
and old_flags_by_key is not None
|
|
1219
|
+
and old_flags_by_key != (self.feature_flags_by_key or {})
|
|
1220
|
+
):
|
|
1221
|
+
old_version = self.flag_definition_version
|
|
1222
|
+
self.flag_definition_version += 1
|
|
1223
|
+
self.flag_cache.invalidate_version(old_version)
|
|
1224
|
+
|
|
1097
1225
|
def _load_feature_flags(self):
|
|
1226
|
+
should_fetch = True
|
|
1227
|
+
if self._flag_definition_cache_provider:
|
|
1228
|
+
try:
|
|
1229
|
+
should_fetch = (
|
|
1230
|
+
self._flag_definition_cache_provider.should_fetch_flag_definitions()
|
|
1231
|
+
)
|
|
1232
|
+
except Exception as e:
|
|
1233
|
+
self.log.error(
|
|
1234
|
+
f"[FEATURE FLAGS] Cache provider should_fetch error: {e}"
|
|
1235
|
+
)
|
|
1236
|
+
# Fail-safe: fetch from API if cache provider errors
|
|
1237
|
+
should_fetch = True
|
|
1238
|
+
|
|
1239
|
+
# If not fetching, try to get from cache
|
|
1240
|
+
if not should_fetch and self._flag_definition_cache_provider:
|
|
1241
|
+
try:
|
|
1242
|
+
cached_data = (
|
|
1243
|
+
self._flag_definition_cache_provider.get_flag_definitions()
|
|
1244
|
+
)
|
|
1245
|
+
if cached_data:
|
|
1246
|
+
self.log.debug(
|
|
1247
|
+
"[FEATURE FLAGS] Using cached flag definitions from external cache"
|
|
1248
|
+
)
|
|
1249
|
+
self._update_flag_state(
|
|
1250
|
+
cached_data, old_flags_by_key=self.feature_flags_by_key or {}
|
|
1251
|
+
)
|
|
1252
|
+
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
|
1253
|
+
return
|
|
1254
|
+
else:
|
|
1255
|
+
# Emergency fallback: if cache is empty and we have no flags, fetch anyway.
|
|
1256
|
+
# There's really no other way of recovering in this case.
|
|
1257
|
+
if not self.feature_flags:
|
|
1258
|
+
self.log.debug(
|
|
1259
|
+
"[FEATURE FLAGS] Cache empty and no flags loaded, falling back to API fetch"
|
|
1260
|
+
)
|
|
1261
|
+
should_fetch = True
|
|
1262
|
+
except Exception as e:
|
|
1263
|
+
self.log.error(f"[FEATURE FLAGS] Cache provider get error: {e}")
|
|
1264
|
+
# Fail-safe: fetch from API if cache provider errors
|
|
1265
|
+
should_fetch = True
|
|
1266
|
+
|
|
1267
|
+
if should_fetch:
|
|
1268
|
+
self._fetch_feature_flags_from_api()
|
|
1269
|
+
|
|
1270
|
+
def _fetch_feature_flags_from_api(self):
|
|
1271
|
+
"""Fetch feature flags from the PostHog API."""
|
|
1098
1272
|
try:
|
|
1099
1273
|
# Store old flags to detect changes
|
|
1100
1274
|
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
|
|
@@ -1104,19 +1278,41 @@ class Client(object):
|
|
|
1104
1278
|
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
|
|
1105
1279
|
self.host,
|
|
1106
1280
|
timeout=10,
|
|
1281
|
+
etag=self._flags_etag,
|
|
1107
1282
|
)
|
|
1108
1283
|
|
|
1109
|
-
|
|
1110
|
-
self.
|
|
1111
|
-
|
|
1284
|
+
# Update stored ETag (clear if server stops sending one)
|
|
1285
|
+
self._flags_etag = response.etag
|
|
1286
|
+
|
|
1287
|
+
# If 304 Not Modified, flags haven't changed - skip processing
|
|
1288
|
+
if response.not_modified:
|
|
1289
|
+
self.log.debug(
|
|
1290
|
+
"[FEATURE FLAGS] Flags not modified (304), using cached data"
|
|
1291
|
+
)
|
|
1292
|
+
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
|
1293
|
+
return
|
|
1294
|
+
|
|
1295
|
+
if response.data is None:
|
|
1296
|
+
self.log.error(
|
|
1297
|
+
"[FEATURE FLAGS] Unexpected empty response data in non-304 response"
|
|
1298
|
+
)
|
|
1299
|
+
return
|
|
1112
1300
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1301
|
+
self._update_flag_state(response.data, old_flags_by_key=old_flags_by_key)
|
|
1302
|
+
|
|
1303
|
+
# Store in external cache if provider is configured
|
|
1304
|
+
if self._flag_definition_cache_provider:
|
|
1305
|
+
try:
|
|
1306
|
+
self._flag_definition_cache_provider.on_flag_definitions_received(
|
|
1307
|
+
{
|
|
1308
|
+
"flags": self.feature_flags or [],
|
|
1309
|
+
"group_type_mapping": self.group_type_mapping or {},
|
|
1310
|
+
"cohorts": self.cohorts or {},
|
|
1311
|
+
}
|
|
1312
|
+
)
|
|
1313
|
+
except Exception as e:
|
|
1314
|
+
self.log.error(f"[FEATURE FLAGS] Cache provider store error: {e}")
|
|
1315
|
+
# Flags are already in memory, so continue normally
|
|
1120
1316
|
|
|
1121
1317
|
except APIError as e:
|
|
1122
1318
|
if e.status == 401:
|
|
@@ -1216,7 +1412,8 @@ class Client(object):
|
|
|
1216
1412
|
flag_filters = feature_flag.get("filters") or {}
|
|
1217
1413
|
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
|
|
1218
1414
|
if aggregation_group_type_index is not None:
|
|
1219
|
-
|
|
1415
|
+
group_type_mapping = self.group_type_mapping or {}
|
|
1416
|
+
group_name = group_type_mapping.get(str(aggregation_group_type_index))
|
|
1220
1417
|
|
|
1221
1418
|
if not group_name:
|
|
1222
1419
|
self.log.warning(
|
|
@@ -1308,6 +1505,19 @@ class Client(object):
|
|
|
1308
1505
|
return None
|
|
1309
1506
|
return bool(response)
|
|
1310
1507
|
|
|
1508
|
+
def _get_stale_flag_fallback(
|
|
1509
|
+
self, distinct_id: ID_TYPES, key: str
|
|
1510
|
+
) -> Optional[FeatureFlagResult]:
|
|
1511
|
+
"""Returns a stale cached flag value if available, otherwise None."""
|
|
1512
|
+
if self.flag_cache:
|
|
1513
|
+
stale_result = self.flag_cache.get_stale_cached_flag(distinct_id, key)
|
|
1514
|
+
if stale_result:
|
|
1515
|
+
self.log.info(
|
|
1516
|
+
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
|
|
1517
|
+
)
|
|
1518
|
+
return stale_result
|
|
1519
|
+
return None
|
|
1520
|
+
|
|
1311
1521
|
def _get_feature_flag_result(
|
|
1312
1522
|
self,
|
|
1313
1523
|
key: str,
|
|
@@ -1340,6 +1550,8 @@ class Client(object):
|
|
|
1340
1550
|
flag_result = None
|
|
1341
1551
|
flag_details = None
|
|
1342
1552
|
request_id = None
|
|
1553
|
+
evaluated_at = None
|
|
1554
|
+
feature_flag_error: Optional[str] = None
|
|
1343
1555
|
|
|
1344
1556
|
flag_value = self._locally_evaluate_flag(
|
|
1345
1557
|
key, distinct_id, groups, person_properties, group_properties
|
|
@@ -1364,14 +1576,24 @@ class Client(object):
|
|
|
1364
1576
|
)
|
|
1365
1577
|
elif not only_evaluate_locally:
|
|
1366
1578
|
try:
|
|
1367
|
-
flag_details, request_id =
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1579
|
+
flag_details, request_id, evaluated_at, errors_while_computing = (
|
|
1580
|
+
self._get_feature_flag_details_from_server(
|
|
1581
|
+
key,
|
|
1582
|
+
distinct_id,
|
|
1583
|
+
groups,
|
|
1584
|
+
person_properties,
|
|
1585
|
+
group_properties,
|
|
1586
|
+
disable_geoip,
|
|
1587
|
+
)
|
|
1374
1588
|
)
|
|
1589
|
+
errors = []
|
|
1590
|
+
if errors_while_computing:
|
|
1591
|
+
errors.append(FeatureFlagError.ERRORS_WHILE_COMPUTING)
|
|
1592
|
+
if flag_details is None:
|
|
1593
|
+
errors.append(FeatureFlagError.FLAG_MISSING)
|
|
1594
|
+
if errors:
|
|
1595
|
+
feature_flag_error = ",".join(errors)
|
|
1596
|
+
|
|
1375
1597
|
flag_result = FeatureFlagResult.from_flag_details(
|
|
1376
1598
|
flag_details, override_match_value
|
|
1377
1599
|
)
|
|
@@ -1385,19 +1607,26 @@ class Client(object):
|
|
|
1385
1607
|
self.log.debug(
|
|
1386
1608
|
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
|
|
1387
1609
|
)
|
|
1610
|
+
except QuotaLimitError as e:
|
|
1611
|
+
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
|
|
1612
|
+
feature_flag_error = FeatureFlagError.QUOTA_LIMITED
|
|
1613
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1614
|
+
except RequestsTimeout as e:
|
|
1615
|
+
self.log.warning(f"[FEATURE FLAGS] Request timed out: {e}")
|
|
1616
|
+
feature_flag_error = FeatureFlagError.TIMEOUT
|
|
1617
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1618
|
+
except RequestsConnectionError as e:
|
|
1619
|
+
self.log.warning(f"[FEATURE FLAGS] Connection error: {e}")
|
|
1620
|
+
feature_flag_error = FeatureFlagError.CONNECTION_ERROR
|
|
1621
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1622
|
+
except APIError as e:
|
|
1623
|
+
self.log.warning(f"[FEATURE FLAGS] API error: {e}")
|
|
1624
|
+
feature_flag_error = FeatureFlagError.api_error(e.status)
|
|
1625
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1388
1626
|
except Exception as e:
|
|
1389
1627
|
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
if self.flag_cache:
|
|
1393
|
-
stale_result = self.flag_cache.get_stale_cached_flag(
|
|
1394
|
-
distinct_id, key
|
|
1395
|
-
)
|
|
1396
|
-
if stale_result:
|
|
1397
|
-
self.log.info(
|
|
1398
|
-
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
|
|
1399
|
-
)
|
|
1400
|
-
flag_result = stale_result
|
|
1628
|
+
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
|
|
1629
|
+
flag_result = self._get_stale_flag_fallback(distinct_id, key)
|
|
1401
1630
|
|
|
1402
1631
|
if send_feature_flag_events:
|
|
1403
1632
|
self._capture_feature_flag_called(
|
|
@@ -1409,7 +1638,9 @@ class Client(object):
|
|
|
1409
1638
|
groups,
|
|
1410
1639
|
disable_geoip,
|
|
1411
1640
|
request_id,
|
|
1641
|
+
evaluated_at,
|
|
1412
1642
|
flag_details,
|
|
1643
|
+
feature_flag_error,
|
|
1413
1644
|
)
|
|
1414
1645
|
|
|
1415
1646
|
return flag_result
|
|
@@ -1543,7 +1774,7 @@ class Client(object):
|
|
|
1543
1774
|
self.log.debug(
|
|
1544
1775
|
f"Successfully computed flag locally: {key} -> {response}"
|
|
1545
1776
|
)
|
|
1546
|
-
except InconclusiveMatchError as e:
|
|
1777
|
+
except (RequiresServerEvaluation, InconclusiveMatchError) as e:
|
|
1547
1778
|
self.log.debug(f"Failed to compute flag {key} locally: {e}")
|
|
1548
1779
|
except Exception as e:
|
|
1549
1780
|
self.log.exception(
|
|
@@ -1561,7 +1792,7 @@ class Client(object):
|
|
|
1561
1792
|
person_properties=None,
|
|
1562
1793
|
group_properties=None,
|
|
1563
1794
|
only_evaluate_locally=False,
|
|
1564
|
-
send_feature_flag_events=
|
|
1795
|
+
send_feature_flag_events=False,
|
|
1565
1796
|
disable_geoip=None,
|
|
1566
1797
|
):
|
|
1567
1798
|
"""
|
|
@@ -1575,7 +1806,7 @@ class Client(object):
|
|
|
1575
1806
|
person_properties: A dictionary of person properties.
|
|
1576
1807
|
group_properties: A dictionary of group properties.
|
|
1577
1808
|
only_evaluate_locally: Whether to only evaluate locally.
|
|
1578
|
-
send_feature_flag_events:
|
|
1809
|
+
send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
|
|
1579
1810
|
disable_geoip: Whether to disable GeoIP for this request.
|
|
1580
1811
|
|
|
1581
1812
|
Examples:
|
|
@@ -1591,6 +1822,14 @@ class Client(object):
|
|
|
1591
1822
|
Category:
|
|
1592
1823
|
Feature flags
|
|
1593
1824
|
"""
|
|
1825
|
+
if send_feature_flag_events:
|
|
1826
|
+
warnings.warn(
|
|
1827
|
+
"send_feature_flag_events is deprecated in get_feature_flag_payload() and will be removed "
|
|
1828
|
+
"in a future version. Use get_feature_flag() if you want to send $feature_flag_called events.",
|
|
1829
|
+
DeprecationWarning,
|
|
1830
|
+
stacklevel=2,
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1594
1833
|
feature_flag_result = self._get_feature_flag_result(
|
|
1595
1834
|
key,
|
|
1596
1835
|
distinct_id,
|
|
@@ -1612,9 +1851,10 @@ class Client(object):
|
|
|
1612
1851
|
person_properties: dict[str, str],
|
|
1613
1852
|
group_properties: dict[str, str],
|
|
1614
1853
|
disable_geoip: Optional[bool],
|
|
1615
|
-
) -> tuple[Optional[FeatureFlag], Optional[str]]:
|
|
1854
|
+
) -> tuple[Optional[FeatureFlag], Optional[str], Optional[int], bool]:
|
|
1616
1855
|
"""
|
|
1617
|
-
Calls /flags and returns the flag details
|
|
1856
|
+
Calls /flags and returns the flag details, request id, evaluated at timestamp,
|
|
1857
|
+
and whether there were errors while computing flags.
|
|
1618
1858
|
"""
|
|
1619
1859
|
resp_data = self.get_flags_decision(
|
|
1620
1860
|
distinct_id,
|
|
@@ -1625,9 +1865,11 @@ class Client(object):
|
|
|
1625
1865
|
flag_keys_to_evaluate=[key],
|
|
1626
1866
|
)
|
|
1627
1867
|
request_id = resp_data.get("requestId")
|
|
1868
|
+
evaluated_at = resp_data.get("evaluatedAt")
|
|
1869
|
+
errors_while_computing = resp_data.get("errorsWhileComputingFlags", False)
|
|
1628
1870
|
flags = resp_data.get("flags")
|
|
1629
1871
|
flag_details = flags.get(key) if flags else None
|
|
1630
|
-
return flag_details, request_id
|
|
1872
|
+
return flag_details, request_id, evaluated_at, errors_while_computing
|
|
1631
1873
|
|
|
1632
1874
|
def _capture_feature_flag_called(
|
|
1633
1875
|
self,
|
|
@@ -1639,7 +1881,9 @@ class Client(object):
|
|
|
1639
1881
|
groups: Dict[str, str],
|
|
1640
1882
|
disable_geoip: Optional[bool],
|
|
1641
1883
|
request_id: Optional[str],
|
|
1884
|
+
evaluated_at: Optional[int],
|
|
1642
1885
|
flag_details: Optional[FeatureFlag],
|
|
1886
|
+
feature_flag_error: Optional[str] = None,
|
|
1643
1887
|
):
|
|
1644
1888
|
feature_flag_reported_key = (
|
|
1645
1889
|
f"{key}_{'::null::' if response is None else str(response)}"
|
|
@@ -1662,6 +1906,8 @@ class Client(object):
|
|
|
1662
1906
|
|
|
1663
1907
|
if request_id:
|
|
1664
1908
|
properties["$feature_flag_request_id"] = request_id
|
|
1909
|
+
if evaluated_at:
|
|
1910
|
+
properties["$feature_flag_evaluated_at"] = evaluated_at
|
|
1665
1911
|
if isinstance(flag_details, FeatureFlag):
|
|
1666
1912
|
if flag_details.reason and flag_details.reason.description:
|
|
1667
1913
|
properties["$feature_flag_reason"] = flag_details.reason.description
|
|
@@ -1672,6 +1918,8 @@ class Client(object):
|
|
|
1672
1918
|
)
|
|
1673
1919
|
if flag_details.metadata.id:
|
|
1674
1920
|
properties["$feature_flag_id"] = flag_details.metadata.id
|
|
1921
|
+
if feature_flag_error:
|
|
1922
|
+
properties["$feature_flag_error"] = feature_flag_error
|
|
1675
1923
|
|
|
1676
1924
|
self.capture(
|
|
1677
1925
|
"$feature_flag_called",
|
|
@@ -1937,9 +2185,9 @@ class Client(object):
|
|
|
1937
2185
|
return None
|
|
1938
2186
|
|
|
1939
2187
|
try:
|
|
1940
|
-
from urllib.parse import
|
|
2188
|
+
from urllib.parse import parse_qs, urlparse
|
|
1941
2189
|
except ImportError:
|
|
1942
|
-
from urlparse import
|
|
2190
|
+
from urlparse import parse_qs, urlparse
|
|
1943
2191
|
|
|
1944
2192
|
try:
|
|
1945
2193
|
parsed = urlparse(cache_url)
|