posthog 6.8.0__tar.gz → 6.9.0__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.
- {posthog-6.8.0/posthog.egg-info → posthog-6.9.0}/PKG-INFO +1 -1
- {posthog-6.8.0 → posthog-6.9.0}/posthog/__init__.py +29 -2
- {posthog-6.8.0 → posthog-6.9.0}/posthog/client.py +49 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/contexts.py +81 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/exception_utils.py +192 -0
- posthog-6.9.0/posthog/test/test_exception_capture.py +334 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/version.py +1 -1
- {posthog-6.8.0 → posthog-6.9.0/posthog.egg-info}/PKG-INFO +1 -1
- posthog-6.8.0/posthog/test/test_exception_capture.py +0 -34
- {posthog-6.8.0 → posthog-6.9.0}/LICENSE +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/MANIFEST.in +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/README.md +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/__init__.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/anthropic/__init__.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/anthropic/anthropic.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/anthropic/anthropic_async.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/anthropic/anthropic_converter.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/anthropic/anthropic_providers.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/gemini/__init__.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/gemini/gemini.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/gemini/gemini_converter.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/langchain/__init__.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/langchain/callbacks.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/openai/__init__.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/openai/openai.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/openai/openai_async.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/openai/openai_converter.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/openai/openai_providers.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/sanitization.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/types.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/ai/utils.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/args.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/consumer.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/exception_capture.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/feature_flags.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/integrations/__init__.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/integrations/django.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/poller.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/py.typed +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/request.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/__init__.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_before_send.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_client.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_consumer.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_contexts.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_feature_flag.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_feature_flag_result.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_feature_flags.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_module.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_request.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_size_limited_dict.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_types.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/test/test_utils.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/types.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog/utils.py +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog.egg-info/SOURCES.txt +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog.egg-info/dependency_links.txt +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog.egg-info/requires.txt +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/posthog.egg-info/top_level.txt +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/pyproject.toml +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/setup.cfg +0 -0
- {posthog-6.8.0 → posthog-6.9.0}/setup.py +0 -0
|
@@ -10,6 +10,9 @@ from posthog.contexts import (
|
|
|
10
10
|
tag as inner_tag,
|
|
11
11
|
set_context_session as inner_set_context_session,
|
|
12
12
|
identify_context as inner_identify_context,
|
|
13
|
+
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
|
|
14
|
+
set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
|
|
15
|
+
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
|
|
13
16
|
)
|
|
14
17
|
from posthog.feature_flags import InconclusiveMatchError, RequiresServerEvaluation
|
|
15
18
|
from posthog.types import FeatureFlag, FlagsAndPayloads, FeatureFlagResult
|
|
@@ -20,13 +23,14 @@ __version__ = VERSION
|
|
|
20
23
|
"""Context management."""
|
|
21
24
|
|
|
22
25
|
|
|
23
|
-
def new_context(fresh=False, capture_exceptions=True):
|
|
26
|
+
def new_context(fresh=False, capture_exceptions=True, client=None):
|
|
24
27
|
"""
|
|
25
28
|
Create a new context scope that will be active for the duration of the with block.
|
|
26
29
|
|
|
27
30
|
Args:
|
|
28
31
|
fresh: Whether to start with a fresh context (default: False)
|
|
29
32
|
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
|
|
33
|
+
client: Optional Posthog client instance to use for this context (default: None)
|
|
30
34
|
|
|
31
35
|
Examples:
|
|
32
36
|
```python
|
|
@@ -39,7 +43,9 @@ def new_context(fresh=False, capture_exceptions=True):
|
|
|
39
43
|
Category:
|
|
40
44
|
Contexts
|
|
41
45
|
"""
|
|
42
|
-
return inner_new_context(
|
|
46
|
+
return inner_new_context(
|
|
47
|
+
fresh=fresh, capture_exceptions=capture_exceptions, client=client
|
|
48
|
+
)
|
|
43
49
|
|
|
44
50
|
|
|
45
51
|
def scoped(fresh=False, capture_exceptions=True):
|
|
@@ -103,6 +109,27 @@ def identify_context(distinct_id: str):
|
|
|
103
109
|
return inner_identify_context(distinct_id)
|
|
104
110
|
|
|
105
111
|
|
|
112
|
+
def set_capture_exception_code_variables_context(enabled: bool):
|
|
113
|
+
"""
|
|
114
|
+
Set whether code variables are captured for the current context.
|
|
115
|
+
"""
|
|
116
|
+
return inner_set_capture_exception_code_variables_context(enabled)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_code_variables_mask_patterns_context(mask_patterns: list):
|
|
120
|
+
"""
|
|
121
|
+
Variable names matching these patterns will be masked with *** when capturing code variables.
|
|
122
|
+
"""
|
|
123
|
+
return inner_set_code_variables_mask_patterns_context(mask_patterns)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def set_code_variables_ignore_patterns_context(ignore_patterns: list):
|
|
127
|
+
"""
|
|
128
|
+
Variable names matching these patterns will be ignored completely when capturing code variables.
|
|
129
|
+
"""
|
|
130
|
+
return inner_set_code_variables_ignore_patterns_context(ignore_patterns)
|
|
131
|
+
|
|
132
|
+
|
|
106
133
|
def tag(name: str, value: Any):
|
|
107
134
|
"""
|
|
108
135
|
Add a tag to the current context.
|
|
@@ -19,6 +19,9 @@ from posthog.exception_utils import (
|
|
|
19
19
|
handle_in_app,
|
|
20
20
|
exception_is_already_captured,
|
|
21
21
|
mark_exception_as_captured,
|
|
22
|
+
try_attach_code_variables_to_frames,
|
|
23
|
+
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
|
|
24
|
+
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
|
|
22
25
|
)
|
|
23
26
|
from posthog.feature_flags import (
|
|
24
27
|
InconclusiveMatchError,
|
|
@@ -39,6 +42,9 @@ from posthog.contexts import (
|
|
|
39
42
|
_get_current_context,
|
|
40
43
|
get_context_distinct_id,
|
|
41
44
|
get_context_session_id,
|
|
45
|
+
get_capture_exception_code_variables_context,
|
|
46
|
+
get_code_variables_mask_patterns_context,
|
|
47
|
+
get_code_variables_ignore_patterns_context,
|
|
42
48
|
new_context,
|
|
43
49
|
)
|
|
44
50
|
from posthog.types import (
|
|
@@ -178,6 +184,9 @@ class Client(object):
|
|
|
178
184
|
before_send=None,
|
|
179
185
|
flag_fallback_cache_url=None,
|
|
180
186
|
enable_local_evaluation=True,
|
|
187
|
+
capture_exception_code_variables=False,
|
|
188
|
+
code_variables_mask_patterns=None,
|
|
189
|
+
code_variables_ignore_patterns=None,
|
|
181
190
|
):
|
|
182
191
|
"""
|
|
183
192
|
Initialize a new PostHog client instance.
|
|
@@ -233,6 +242,18 @@ class Client(object):
|
|
|
233
242
|
self.privacy_mode = privacy_mode
|
|
234
243
|
self.enable_local_evaluation = enable_local_evaluation
|
|
235
244
|
|
|
245
|
+
self.capture_exception_code_variables = capture_exception_code_variables
|
|
246
|
+
self.code_variables_mask_patterns = (
|
|
247
|
+
code_variables_mask_patterns
|
|
248
|
+
if code_variables_mask_patterns is not None
|
|
249
|
+
else DEFAULT_CODE_VARIABLES_MASK_PATTERNS
|
|
250
|
+
)
|
|
251
|
+
self.code_variables_ignore_patterns = (
|
|
252
|
+
code_variables_ignore_patterns
|
|
253
|
+
if code_variables_ignore_patterns is not None
|
|
254
|
+
else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
|
|
255
|
+
)
|
|
256
|
+
|
|
236
257
|
if project_root is None:
|
|
237
258
|
try:
|
|
238
259
|
project_root = os.getcwd()
|
|
@@ -979,6 +1000,34 @@ class Client(object):
|
|
|
979
1000
|
**properties,
|
|
980
1001
|
}
|
|
981
1002
|
|
|
1003
|
+
context_enabled = get_capture_exception_code_variables_context()
|
|
1004
|
+
context_mask = get_code_variables_mask_patterns_context()
|
|
1005
|
+
context_ignore = get_code_variables_ignore_patterns_context()
|
|
1006
|
+
|
|
1007
|
+
enabled = (
|
|
1008
|
+
context_enabled
|
|
1009
|
+
if context_enabled is not None
|
|
1010
|
+
else self.capture_exception_code_variables
|
|
1011
|
+
)
|
|
1012
|
+
mask_patterns = (
|
|
1013
|
+
context_mask
|
|
1014
|
+
if context_mask is not None
|
|
1015
|
+
else self.code_variables_mask_patterns
|
|
1016
|
+
)
|
|
1017
|
+
ignore_patterns = (
|
|
1018
|
+
context_ignore
|
|
1019
|
+
if context_ignore is not None
|
|
1020
|
+
else self.code_variables_ignore_patterns
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if enabled:
|
|
1024
|
+
try_attach_code_variables_to_frames(
|
|
1025
|
+
all_exceptions_with_trace_and_in_app,
|
|
1026
|
+
exc_info,
|
|
1027
|
+
mask_patterns=mask_patterns,
|
|
1028
|
+
ignore_patterns=ignore_patterns,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
982
1031
|
if self.log_captured_exceptions:
|
|
983
1032
|
self.log.exception(exception, extra=kwargs)
|
|
984
1033
|
|
|
@@ -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 posthog.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
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from textwrap import dedent
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_excepthook(tmpdir):
|
|
9
|
+
app = tmpdir.join("app.py")
|
|
10
|
+
app.write(
|
|
11
|
+
dedent(
|
|
12
|
+
"""
|
|
13
|
+
from posthog import Posthog
|
|
14
|
+
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
|
15
|
+
|
|
16
|
+
# frame_value = "LOL"
|
|
17
|
+
|
|
18
|
+
1/0
|
|
19
|
+
"""
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
|
24
|
+
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
|
25
|
+
|
|
26
|
+
output = excinfo.value.output
|
|
27
|
+
|
|
28
|
+
assert b"ZeroDivisionError" in output
|
|
29
|
+
assert b"LOL" in output
|
|
30
|
+
assert b"DEBUG:posthog:data uploaded successfully" in output
|
|
31
|
+
assert (
|
|
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
|
+
in output
|
|
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 posthog 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 posthog 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 posthog 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 posthog 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 posthog 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,34 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import sys
|
|
3
|
-
from textwrap import dedent
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_excepthook(tmpdir):
|
|
9
|
-
app = tmpdir.join("app.py")
|
|
10
|
-
app.write(
|
|
11
|
-
dedent(
|
|
12
|
-
"""
|
|
13
|
-
from posthog import Posthog
|
|
14
|
-
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
|
15
|
-
|
|
16
|
-
# frame_value = "LOL"
|
|
17
|
-
|
|
18
|
-
1/0
|
|
19
|
-
"""
|
|
20
|
-
)
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
|
24
|
-
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
|
25
|
-
|
|
26
|
-
output = excinfo.value.output
|
|
27
|
-
|
|
28
|
-
assert b"ZeroDivisionError" in output
|
|
29
|
-
assert b"LOL" in output
|
|
30
|
-
assert b"DEBUG:posthog:data uploaded successfully" in output
|
|
31
|
-
assert (
|
|
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
|
-
in output
|
|
34
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|