Skip to content

Commit 496b9f4

Browse files
committed
feat: add code variables capture
1 parent 700c922 commit 496b9f4

File tree

5 files changed

+378
-0
lines changed

5 files changed

+378
-0
lines changed

posthog/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
tag as inner_tag,
1111
set_context_session as inner_set_context_session,
1212
identify_context as inner_identify_context,
13+
set_code_variables_enabled_context as inner_set_code_variables_enabled_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,
1316
)
1417
from posthog.feature_flags import InconclusiveMatchError, RequiresServerEvaluation
1518
from posthog.types import FeatureFlag, FlagsAndPayloads, FeatureFlagResult
@@ -103,6 +106,27 @@ def identify_context(distinct_id: str):
103106
return inner_identify_context(distinct_id)
104107

105108

109+
def set_code_variables_enabled_context(enabled: bool):
110+
"""
111+
Set whether code variables are captured for the current context.
112+
"""
113+
return inner_set_code_variables_enabled_context(enabled)
114+
115+
116+
def set_code_variables_mask_patterns_context(mask_patterns: list):
117+
"""
118+
Variable names matching these patterns will be masked with *** when capturing code variables.
119+
"""
120+
return inner_set_code_variables_mask_patterns_context(mask_patterns)
121+
122+
123+
def set_code_variables_ignore_patterns_context(ignore_patterns: list):
124+
"""
125+
Variable names matching these patterns will be ignored completely when capturing code variables.
126+
"""
127+
return inner_set_code_variables_ignore_patterns_context(ignore_patterns)
128+
129+
106130
def tag(name: str, value: Any):
107131
"""
108132
Add a tag to the current context.

posthog/client.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
handle_in_app,
2020
exception_is_already_captured,
2121
mark_exception_as_captured,
22+
extract_code_variables_from_exception,
23+
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
24+
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
2225
)
2326
from posthog.feature_flags import (
2427
InconclusiveMatchError,
@@ -39,6 +42,9 @@
3942
_get_current_context,
4043
get_context_distinct_id,
4144
get_context_session_id,
45+
get_code_variables_enabled_context,
46+
get_code_variables_mask_patterns_context,
47+
get_code_variables_ignore_patterns_context,
4248
new_context,
4349
)
4450
from posthog.types import (
@@ -179,6 +185,9 @@ def __init__(
179185
before_send=None,
180186
flag_fallback_cache_url=None,
181187
enable_local_evaluation=True,
188+
code_variables_enabled=False,
189+
code_variables_mask_patterns=None,
190+
code_variables_ignore_patterns=None,
182191
):
183192
"""
184193
Initialize a new PostHog client instance.
@@ -233,6 +242,17 @@ def __init__(
233242
self.exception_capture = None
234243
self.privacy_mode = privacy_mode
235244
self.enable_local_evaluation = enable_local_evaluation
245+
246+
# Code variables configuration
247+
self.code_variables_enabled = code_variables_enabled
248+
self.code_variables_mask_patterns = (
249+
code_variables_mask_patterns if code_variables_mask_patterns is not None
250+
else DEFAULT_CODE_VARIABLES_MASK_PATTERNS
251+
)
252+
self.code_variables_ignore_patterns = (
253+
code_variables_ignore_patterns if code_variables_ignore_patterns is not None
254+
else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
255+
)
236256

237257
if project_root is None:
238258
try:
@@ -980,6 +1000,23 @@ def capture_exception(
9801000
**properties,
9811001
}
9821002

1003+
context_enabled = get_code_variables_enabled_context()
1004+
context_mask = get_code_variables_mask_patterns_context()
1005+
context_ignore = get_code_variables_ignore_patterns_context()
1006+
1007+
enabled = context_enabled if context_enabled is not None else self.code_variables_enabled
1008+
mask_patterns = context_mask if context_mask is not None else self.code_variables_mask_patterns
1009+
ignore_patterns = context_ignore if context_ignore is not None else self.code_variables_ignore_patterns
1010+
1011+
if enabled:
1012+
code_variables = extract_code_variables_from_exception(
1013+
exc_info,
1014+
mask_patterns=mask_patterns,
1015+
ignore_patterns=ignore_patterns
1016+
)
1017+
if code_variables:
1018+
properties["$exception_code_variables"] = code_variables
1019+
9831020
if self.log_captured_exceptions:
9841021
self.log.exception(exception, extra=kwargs)
9851022

posthog/contexts.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ def __init__(
2222
self.session_id: Optional[str] = None
2323
self.distinct_id: Optional[str] = None
2424
self.tags: Dict[str, Any] = {}
25+
self.code_variables_enabled: Optional[bool] = None
26+
self.code_variables_mask_patterns: Optional[list] = None
27+
self.code_variables_ignore_patterns: Optional[list] = None
2528

2629
def set_session_id(self, session_id: str):
2730
self.session_id = session_id
@@ -32,6 +35,15 @@ def set_distinct_id(self, distinct_id: str):
3235
def add_tag(self, key: str, value: Any):
3336
self.tags[key] = value
3437

38+
def set_code_variables_enabled(self, enabled: bool):
39+
self.code_variables_enabled = 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+
3547
def get_parent(self):
3648
return self.parent
3749

@@ -59,6 +71,27 @@ def collect_tags(self) -> Dict[str, Any]:
5971
tags.update(new_tags)
6072
return tags
6173

74+
def get_code_variables_enabled(self) -> Optional[bool]:
75+
if self.code_variables_enabled is not None:
76+
return self.code_variables_enabled
77+
if self.parent is not None and not self.fresh:
78+
return self.parent.get_code_variables_enabled()
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+
6295

6396
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
6497
"posthog_context_stack", default=None
@@ -243,6 +276,54 @@ def get_context_distinct_id() -> Optional[str]:
243276
return None
244277

245278

279+
def set_code_variables_enabled_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_code_variables_enabled(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_code_variables_enabled_context() -> Optional[bool]:
307+
current_context = _get_current_context()
308+
if current_context:
309+
return current_context.get_code_variables_enabled()
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+
246327
F = TypeVar("F", bound=Callable[..., Any])
247328

248329

posthog/exception_utils.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
Union,
2727
cast,
2828
TYPE_CHECKING,
29+
Pattern,
2930
)
3031

3132
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
@@ -40,6 +41,22 @@
4041

4142
DEFAULT_MAX_VALUE_LENGTH = 1024
4243

44+
DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
45+
r"(?i).*password.*",
46+
r"(?i).*secret.*",
47+
r"(?i).*passwd.*",
48+
r"(?i).*pwd.*",
49+
r"(?i).*api_key.*",
50+
r"(?i).*apikey.*",
51+
r"(?i).*auth.*",
52+
r"(?i).*credentials.*",
53+
r"(?i).*privatekey.*",
54+
r"(?i).*private_key.*",
55+
r"(?i).*token.*",
56+
]
57+
58+
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
59+
4360
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
4461

4562
Event = TypedDict(
@@ -884,3 +901,129 @@ def strip_string(value, max_length=None):
884901
"rem": [["!limit", "x", max_length - 3, max_length]],
885902
},
886903
)
904+
905+
906+
def _compile_patterns(patterns):
907+
compiled = []
908+
for pattern in patterns:
909+
try:
910+
compiled.append(re.compile(pattern))
911+
except re.error:
912+
pass
913+
return compiled
914+
915+
916+
def _should_ignore_variable(name, ignore_patterns):
917+
for pattern in ignore_patterns:
918+
if pattern.search(name):
919+
return True
920+
return False
921+
922+
923+
def _should_mask_variable(name, mask_patterns):
924+
for pattern in mask_patterns:
925+
if pattern.search(name):
926+
return True
927+
return False
928+
929+
930+
def _serialize_variable_value(value, max_length=1024):
931+
try:
932+
if value is None:
933+
return "None"
934+
elif isinstance(value, bool):
935+
return str(value)
936+
elif isinstance(value, (int, float)):
937+
result = str(value)
938+
elif isinstance(value, str):
939+
result = repr(value)
940+
else:
941+
result = repr(value)
942+
943+
# Truncate if too long
944+
if len(result) > max_length:
945+
result = result[:max_length - 3] + "..."
946+
947+
return result
948+
except Exception:
949+
try:
950+
return f"<{type(value).__name__} at {hex(id(value))}>"
951+
except Exception:
952+
return "<unserializable object>"
953+
954+
955+
def _is_simple_type(value):
956+
return isinstance(value, (type(None), bool, int, float, str))
957+
958+
959+
def serialize_code_variables(frame, mask_patterns=None, ignore_patterns=None, max_vars=20, max_length=1024):
960+
if mask_patterns is None:
961+
mask_patterns = []
962+
if ignore_patterns is None:
963+
ignore_patterns = []
964+
965+
compiled_mask = _compile_patterns(mask_patterns)
966+
compiled_ignore = _compile_patterns(ignore_patterns)
967+
968+
try:
969+
local_vars = frame.f_locals.copy()
970+
except Exception:
971+
return {}
972+
973+
simple_vars = {}
974+
complex_vars = {}
975+
976+
for name, value in local_vars.items():
977+
if _should_ignore_variable(name, compiled_ignore):
978+
continue
979+
980+
if _is_simple_type(value):
981+
simple_vars[name] = value
982+
else:
983+
complex_vars[name] = value
984+
985+
result = {}
986+
count = 0
987+
988+
all_vars = {**simple_vars, **complex_vars}
989+
ordered_names = list(sorted(simple_vars.keys())) + list(sorted(complex_vars.keys()))
990+
991+
for name in ordered_names:
992+
if count >= max_vars:
993+
break
994+
995+
value = all_vars[name]
996+
997+
if _should_mask_variable(name, compiled_mask):
998+
result[name] = "***"
999+
else:
1000+
result[name] = _serialize_variable_value(value, max_length)
1001+
1002+
count += 1
1003+
1004+
return result
1005+
1006+
1007+
def extract_code_variables_from_exception(exception_info, mask_patterns, ignore_patterns):
1008+
exc_type, exc_value, tb = exception_info
1009+
1010+
if tb is None:
1011+
return {}
1012+
1013+
innermost_tb = None
1014+
for tb_item in iter_stacks(tb):
1015+
innermost_tb = tb_item
1016+
1017+
if innermost_tb is None:
1018+
return {}
1019+
1020+
# Extract variables from the kaboom frame only
1021+
variables = serialize_code_variables(
1022+
innermost_tb.tb_frame,
1023+
mask_patterns=mask_patterns,
1024+
ignore_patterns=ignore_patterns,
1025+
max_vars=20,
1026+
max_length=1024
1027+
)
1028+
1029+
return variables

0 commit comments

Comments
 (0)