posthoganalytics 6.7.14__py3-none-any.whl → 6.9.0__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.
@@ -22,6 +22,9 @@ class ContextScope:
22
22
  self.session_id: Optional[str] = None
23
23
  self.distinct_id: Optional[str] = None
24
24
  self.tags: Dict[str, Any] = {}
25
+ self.capture_exception_code_variables: Optional[bool] = None
26
+ self.code_variables_mask_patterns: Optional[list] = None
27
+ self.code_variables_ignore_patterns: Optional[list] = None
25
28
 
26
29
  def set_session_id(self, session_id: str):
27
30
  self.session_id = session_id
@@ -32,6 +35,15 @@ class ContextScope:
32
35
  def add_tag(self, key: str, value: Any):
33
36
  self.tags[key] = value
34
37
 
38
+ def set_capture_exception_code_variables(self, enabled: bool):
39
+ self.capture_exception_code_variables = enabled
40
+
41
+ def set_code_variables_mask_patterns(self, mask_patterns: list):
42
+ self.code_variables_mask_patterns = mask_patterns
43
+
44
+ def set_code_variables_ignore_patterns(self, ignore_patterns: list):
45
+ self.code_variables_ignore_patterns = ignore_patterns
46
+
35
47
  def get_parent(self):
36
48
  return self.parent
37
49
 
@@ -59,6 +71,27 @@ class ContextScope:
59
71
  tags.update(new_tags)
60
72
  return tags
61
73
 
74
+ def get_capture_exception_code_variables(self) -> Optional[bool]:
75
+ if self.capture_exception_code_variables is not None:
76
+ return self.capture_exception_code_variables
77
+ if self.parent is not None and not self.fresh:
78
+ return self.parent.get_capture_exception_code_variables()
79
+ return None
80
+
81
+ def get_code_variables_mask_patterns(self) -> Optional[list]:
82
+ if self.code_variables_mask_patterns is not None:
83
+ return self.code_variables_mask_patterns
84
+ if self.parent is not None and not self.fresh:
85
+ return self.parent.get_code_variables_mask_patterns()
86
+ return None
87
+
88
+ def get_code_variables_ignore_patterns(self) -> Optional[list]:
89
+ if self.code_variables_ignore_patterns is not None:
90
+ return self.code_variables_ignore_patterns
91
+ if self.parent is not None and not self.fresh:
92
+ return self.parent.get_code_variables_ignore_patterns()
93
+ return None
94
+
62
95
 
63
96
  _context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
64
97
  "posthog_context_stack", default=None
@@ -243,6 +276,54 @@ def get_context_distinct_id() -> Optional[str]:
243
276
  return None
244
277
 
245
278
 
279
+ def set_capture_exception_code_variables_context(enabled: bool) -> None:
280
+ """
281
+ Set whether code variables are captured for the current context.
282
+ """
283
+ current_context = _get_current_context()
284
+ if current_context:
285
+ current_context.set_capture_exception_code_variables(enabled)
286
+
287
+
288
+ def set_code_variables_mask_patterns_context(mask_patterns: list) -> None:
289
+ """
290
+ Variable names matching these patterns will be masked with *** when capturing code variables.
291
+ """
292
+ current_context = _get_current_context()
293
+ if current_context:
294
+ current_context.set_code_variables_mask_patterns(mask_patterns)
295
+
296
+
297
+ def set_code_variables_ignore_patterns_context(ignore_patterns: list) -> None:
298
+ """
299
+ Variable names matching these patterns will be ignored completely when capturing code variables.
300
+ """
301
+ current_context = _get_current_context()
302
+ if current_context:
303
+ current_context.set_code_variables_ignore_patterns(ignore_patterns)
304
+
305
+
306
+ def get_capture_exception_code_variables_context() -> Optional[bool]:
307
+ current_context = _get_current_context()
308
+ if current_context:
309
+ return current_context.get_capture_exception_code_variables()
310
+ return None
311
+
312
+
313
+ def get_code_variables_mask_patterns_context() -> Optional[list]:
314
+ current_context = _get_current_context()
315
+ if current_context:
316
+ return current_context.get_code_variables_mask_patterns()
317
+ return None
318
+
319
+
320
+ def get_code_variables_ignore_patterns_context() -> Optional[list]:
321
+ current_context = _get_current_context()
322
+ if current_context:
323
+ return current_context.get_code_variables_ignore_patterns()
324
+ return None
325
+
326
+
246
327
  F = TypeVar("F", bound=Callable[..., Any])
247
328
 
248
329
 
@@ -5,6 +5,7 @@
5
5
  # 💖open source (under MIT License)
6
6
  # We want to keep payloads as similar to Sentry as possible for easy interoperability
7
7
 
8
+ import json
8
9
  import linecache
9
10
  import os
10
11
  import re
@@ -26,6 +27,7 @@ from typing import ( # noqa: F401
26
27
  Union,
27
28
  cast,
28
29
  TYPE_CHECKING,
30
+ Pattern,
29
31
  )
30
32
 
31
33
  from posthoganalytics.args import ExcInfo, ExceptionArg # noqa: F401
@@ -40,6 +42,42 @@ except ImportError:
40
42
 
41
43
  DEFAULT_MAX_VALUE_LENGTH = 1024
42
44
 
45
+ DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
46
+ r"(?i).*password.*",
47
+ r"(?i).*secret.*",
48
+ r"(?i).*passwd.*",
49
+ r"(?i).*pwd.*",
50
+ r"(?i).*api_key.*",
51
+ r"(?i).*apikey.*",
52
+ r"(?i).*auth.*",
53
+ r"(?i).*credentials.*",
54
+ r"(?i).*privatekey.*",
55
+ r"(?i).*private_key.*",
56
+ r"(?i).*token.*",
57
+ ]
58
+
59
+ DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
60
+
61
+ CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
62
+
63
+ DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
64
+
65
+
66
+ class VariableSizeLimiter:
67
+ def __init__(self, max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT):
68
+ self.max_size = max_size
69
+ self.current_size = 0
70
+
71
+ def can_add(self, size):
72
+ return self.current_size + size <= self.max_size
73
+
74
+ def add(self, size):
75
+ self.current_size += size
76
+
77
+ def get_remaining_space(self):
78
+ return self.max_size - self.current_size
79
+
80
+
43
81
  LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
44
82
 
45
83
  Event = TypedDict(
@@ -884,3 +922,157 @@ def strip_string(value, max_length=None):
884
922
  "rem": [["!limit", "x", max_length - 3, max_length]],
885
923
  },
886
924
  )
925
+
926
+
927
+ def _compile_patterns(patterns):
928
+ compiled = []
929
+ for pattern in patterns:
930
+ try:
931
+ compiled.append(re.compile(pattern))
932
+ except:
933
+ pass
934
+ return compiled
935
+
936
+
937
+ def _pattern_matches(name, patterns):
938
+ for pattern in patterns:
939
+ if pattern.search(name):
940
+ return True
941
+ return False
942
+
943
+
944
+ def _serialize_variable_value(value, limiter, max_length=1024):
945
+ try:
946
+ if value is None:
947
+ result = "None"
948
+ elif isinstance(value, bool):
949
+ result = str(value)
950
+ elif isinstance(value, (int, float)):
951
+ result_size = len(str(value))
952
+ if not limiter.can_add(result_size):
953
+ return None
954
+ limiter.add(result_size)
955
+ return value
956
+ elif isinstance(value, str):
957
+ result = value
958
+ else:
959
+ result = json.dumps(value)
960
+
961
+ if len(result) > max_length:
962
+ result = result[: max_length - 3] + "..."
963
+
964
+ result_size = len(result)
965
+ if not limiter.can_add(result_size):
966
+ return None
967
+ limiter.add(result_size)
968
+
969
+ return result
970
+ except Exception:
971
+ try:
972
+ fallback = f"<{type(value).__name__}>"
973
+ fallback_size = len(fallback)
974
+ if not limiter.can_add(fallback_size):
975
+ return None
976
+ limiter.add(fallback_size)
977
+ return fallback
978
+ except Exception:
979
+ fallback = "<unserializable object>"
980
+ fallback_size = len(fallback)
981
+ if not limiter.can_add(fallback_size):
982
+ return None
983
+ limiter.add(fallback_size)
984
+ return fallback
985
+
986
+
987
+ def _is_simple_type(value):
988
+ return isinstance(value, (type(None), bool, int, float, str))
989
+
990
+
991
+ def serialize_code_variables(
992
+ frame, limiter, mask_patterns=None, ignore_patterns=None, max_length=1024
993
+ ):
994
+ if mask_patterns is None:
995
+ mask_patterns = []
996
+ if ignore_patterns is None:
997
+ ignore_patterns = []
998
+
999
+ compiled_mask = _compile_patterns(mask_patterns)
1000
+ compiled_ignore = _compile_patterns(ignore_patterns)
1001
+
1002
+ try:
1003
+ local_vars = frame.f_locals.copy()
1004
+ except Exception:
1005
+ return {}
1006
+
1007
+ simple_vars = {}
1008
+ complex_vars = {}
1009
+
1010
+ for name, value in local_vars.items():
1011
+ if _pattern_matches(name, compiled_ignore):
1012
+ continue
1013
+
1014
+ if _is_simple_type(value):
1015
+ simple_vars[name] = value
1016
+ else:
1017
+ complex_vars[name] = value
1018
+
1019
+ result = {}
1020
+
1021
+ all_vars = {**simple_vars, **complex_vars}
1022
+ ordered_names = list(sorted(simple_vars.keys())) + list(sorted(complex_vars.keys()))
1023
+
1024
+ for name in ordered_names:
1025
+ value = all_vars[name]
1026
+
1027
+ if _pattern_matches(name, compiled_mask):
1028
+ redacted_value = CODE_VARIABLES_REDACTED_VALUE
1029
+ redacted_size = len(redacted_value)
1030
+ if not limiter.can_add(redacted_size):
1031
+ break
1032
+ limiter.add(redacted_size)
1033
+ result[name] = redacted_value
1034
+ else:
1035
+ serialized = _serialize_variable_value(value, limiter, max_length)
1036
+ if serialized is None:
1037
+ break
1038
+ result[name] = serialized
1039
+
1040
+ return result
1041
+
1042
+
1043
+ def try_attach_code_variables_to_frames(
1044
+ all_exceptions, exc_info, mask_patterns, ignore_patterns
1045
+ ):
1046
+ exc_type, exc_value, traceback = exc_info
1047
+
1048
+ if traceback is None:
1049
+ return
1050
+
1051
+ tb_frames = list(iter_stacks(traceback))
1052
+
1053
+ if not tb_frames:
1054
+ return
1055
+
1056
+ limiter = VariableSizeLimiter()
1057
+
1058
+ for exception in all_exceptions:
1059
+ stacktrace = exception.get("stacktrace")
1060
+ if not stacktrace or "frames" not in stacktrace:
1061
+ continue
1062
+
1063
+ serialized_frames = stacktrace["frames"]
1064
+
1065
+ for serialized_frame, tb_item in zip(serialized_frames, tb_frames):
1066
+ if not serialized_frame.get("in_app"):
1067
+ continue
1068
+
1069
+ variables = serialize_code_variables(
1070
+ tb_item.tb_frame,
1071
+ limiter,
1072
+ mask_patterns=mask_patterns,
1073
+ ignore_patterns=ignore_patterns,
1074
+ max_length=1024,
1075
+ )
1076
+
1077
+ if variables:
1078
+ serialized_frame["code_variables"] = variables
@@ -32,3 +32,303 @@ def test_excepthook(tmpdir):
32
32
  b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
33
33
  in output
34
34
  )
35
+
36
+
37
+ def test_code_variables_capture(tmpdir):
38
+ app = tmpdir.join("app.py")
39
+ app.write(
40
+ dedent(
41
+ """
42
+ import os
43
+ from posthoganalytics import Posthog
44
+
45
+ class UnserializableObject:
46
+ pass
47
+
48
+ posthog = Posthog(
49
+ 'phc_x',
50
+ host='https://eu.i.posthog.com',
51
+ debug=True,
52
+ enable_exception_autocapture=True,
53
+ capture_exception_code_variables=True,
54
+ project_root=os.path.dirname(os.path.abspath(__file__))
55
+ )
56
+
57
+ def trigger_error():
58
+ my_string = "hello world"
59
+ my_number = 42
60
+ my_bool = True
61
+ my_dict = {"name": "test", "value": 123}
62
+ my_obj = UnserializableObject()
63
+ my_password = "secret123" # Should be masked by default
64
+ __should_be_ignored = "hidden" # Should be ignored by default
65
+
66
+ 1/0 # Trigger exception
67
+
68
+ def intermediate_function():
69
+ request_id = "abc-123"
70
+ user_count = 100
71
+ is_active = True
72
+
73
+ trigger_error()
74
+
75
+ def process_data():
76
+ batch_size = 50
77
+ retry_count = 3
78
+
79
+ intermediate_function()
80
+
81
+ process_data()
82
+ """
83
+ )
84
+ )
85
+
86
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
87
+ subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
88
+
89
+ output = excinfo.value.output
90
+
91
+ assert b"ZeroDivisionError" in output
92
+ assert b"code_variables" in output
93
+
94
+ # Variables from trigger_error frame
95
+ assert b"'my_string': 'hello world'" in output
96
+ assert b"'my_number': 42" in output
97
+ assert b"'my_bool': 'True'" in output
98
+ assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
99
+ assert b'"my_obj": "<UnserializableObject>"' in output
100
+ assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
101
+ assert b"'__should_be_ignored':" not in output
102
+
103
+ # Variables from intermediate_function frame
104
+ assert b"'request_id': 'abc-123'" in output
105
+ assert b"'user_count': 100" in output
106
+ assert b"'is_active': 'True'" in output
107
+
108
+ # Variables from process_data frame
109
+ assert b"'batch_size': 50" in output
110
+ assert b"'retry_count': 3" in output
111
+
112
+
113
+ def test_code_variables_context_override(tmpdir):
114
+ app = tmpdir.join("app.py")
115
+ app.write(
116
+ dedent(
117
+ """
118
+ import os
119
+ import posthog
120
+ from posthoganalytics import Posthog
121
+
122
+ posthog_client = Posthog(
123
+ 'phc_x',
124
+ host='https://eu.i.posthog.com',
125
+ debug=True,
126
+ enable_exception_autocapture=True,
127
+ capture_exception_code_variables=False,
128
+ project_root=os.path.dirname(os.path.abspath(__file__))
129
+ )
130
+
131
+ def process_data():
132
+ bank = "should_be_masked"
133
+ __dunder_var = "should_be_visible"
134
+
135
+ 1/0
136
+
137
+ with posthog.new_context(client=posthog_client):
138
+ posthog.set_capture_exception_code_variables_context(True)
139
+ posthog.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
140
+ posthog.set_code_variables_ignore_patterns_context([])
141
+
142
+ process_data()
143
+ """
144
+ )
145
+ )
146
+
147
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
148
+ subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
149
+
150
+ output = excinfo.value.output
151
+
152
+ assert b"ZeroDivisionError" in output
153
+ assert b"code_variables" in output
154
+ assert b"'bank': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
155
+ assert b"'__dunder_var': 'should_be_visible'" in output
156
+
157
+
158
+ def test_code_variables_size_limiter(tmpdir):
159
+ app = tmpdir.join("app.py")
160
+ app.write(
161
+ dedent(
162
+ """
163
+ import os
164
+ from posthoganalytics import Posthog
165
+
166
+ posthog = Posthog(
167
+ 'phc_x',
168
+ host='https://eu.i.posthog.com',
169
+ debug=True,
170
+ enable_exception_autocapture=True,
171
+ capture_exception_code_variables=True,
172
+ project_root=os.path.dirname(os.path.abspath(__file__))
173
+ )
174
+
175
+ def trigger_error():
176
+ var_a = "a" * 2000
177
+ var_b = "b" * 2000
178
+ var_c = "c" * 2000
179
+ var_d = "d" * 2000
180
+ var_e = "e" * 2000
181
+ var_f = "f" * 2000
182
+ var_g = "g" * 2000
183
+
184
+ 1/0
185
+
186
+ def intermediate_function():
187
+ var_h = "h" * 2000
188
+ var_i = "i" * 2000
189
+ var_j = "j" * 2000
190
+ var_k = "k" * 2000
191
+ var_l = "l" * 2000
192
+ var_m = "m" * 2000
193
+ var_n = "n" * 2000
194
+
195
+ trigger_error()
196
+
197
+ def process_data():
198
+ var_o = "o" * 2000
199
+ var_p = "p" * 2000
200
+ var_q = "q" * 2000
201
+ var_r = "r" * 2000
202
+ var_s = "s" * 2000
203
+ var_t = "t" * 2000
204
+ var_u = "u" * 2000
205
+
206
+ intermediate_function()
207
+
208
+ process_data()
209
+ """
210
+ )
211
+ )
212
+
213
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
214
+ subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
215
+
216
+ output = excinfo.value.output.decode("utf-8")
217
+
218
+ assert "ZeroDivisionError" in output
219
+ assert "code_variables" in output
220
+
221
+ captured_vars = []
222
+ for var_name in [
223
+ "var_a",
224
+ "var_b",
225
+ "var_c",
226
+ "var_d",
227
+ "var_e",
228
+ "var_f",
229
+ "var_g",
230
+ "var_h",
231
+ "var_i",
232
+ "var_j",
233
+ "var_k",
234
+ "var_l",
235
+ "var_m",
236
+ "var_n",
237
+ "var_o",
238
+ "var_p",
239
+ "var_q",
240
+ "var_r",
241
+ "var_s",
242
+ "var_t",
243
+ "var_u",
244
+ ]:
245
+ if f"'{var_name}'" in output:
246
+ captured_vars.append(var_name)
247
+
248
+ assert len(captured_vars) > 0
249
+ assert len(captured_vars) < 21
250
+
251
+
252
+ def test_code_variables_disabled_capture(tmpdir):
253
+ app = tmpdir.join("app.py")
254
+ app.write(
255
+ dedent(
256
+ """
257
+ import os
258
+ from posthoganalytics import Posthog
259
+
260
+ posthog = Posthog(
261
+ 'phc_x',
262
+ host='https://eu.i.posthog.com',
263
+ debug=True,
264
+ enable_exception_autocapture=True,
265
+ capture_exception_code_variables=False,
266
+ project_root=os.path.dirname(os.path.abspath(__file__))
267
+ )
268
+
269
+ def trigger_error():
270
+ my_string = "hello world"
271
+ my_number = 42
272
+ my_bool = True
273
+
274
+ 1/0
275
+
276
+ trigger_error()
277
+ """
278
+ )
279
+ )
280
+
281
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
282
+ subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
283
+
284
+ output = excinfo.value.output.decode("utf-8")
285
+
286
+ assert "ZeroDivisionError" in output
287
+ assert "'code_variables':" not in output
288
+ assert '"code_variables":' not in output
289
+ assert "'my_string'" not in output
290
+ assert "'my_number'" not in output
291
+
292
+
293
+ def test_code_variables_enabled_then_disabled_in_context(tmpdir):
294
+ app = tmpdir.join("app.py")
295
+ app.write(
296
+ dedent(
297
+ """
298
+ import os
299
+ import posthog
300
+ from posthoganalytics import Posthog
301
+
302
+ posthog_client = Posthog(
303
+ 'phc_x',
304
+ host='https://eu.i.posthog.com',
305
+ debug=True,
306
+ enable_exception_autocapture=True,
307
+ capture_exception_code_variables=True,
308
+ project_root=os.path.dirname(os.path.abspath(__file__))
309
+ )
310
+
311
+ def process_data():
312
+ my_var = "should not be captured"
313
+ important_value = 123
314
+
315
+ 1/0
316
+
317
+ with posthog.new_context(client=posthog_client):
318
+ posthog.set_capture_exception_code_variables_context(False)
319
+
320
+ process_data()
321
+ """
322
+ )
323
+ )
324
+
325
+ with pytest.raises(subprocess.CalledProcessError) as excinfo:
326
+ subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
327
+
328
+ output = excinfo.value.output.decode("utf-8")
329
+
330
+ assert "ZeroDivisionError" in output
331
+ assert "'code_variables':" not in output
332
+ assert '"code_variables":' not in output
333
+ assert "'my_var'" not in output
334
+ assert "'important_value'" not in output
@@ -1,4 +1,4 @@
1
- VERSION = "6.7.14"
1
+ VERSION = "6.9.0"
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.7.14
3
+ Version: 6.9.0
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog