Skip to content

Commit 45526f6

Browse files
committed
add context maanager and tag functions
1 parent 7aea6b7 commit 45526f6

File tree

6 files changed

+278
-1
lines changed

6 files changed

+278
-1
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 4.1.0 - 2025-05-01
2+
3+
1. Add exception context management:
4+
- New context manager with `posthog.new_context()`
5+
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
6+
- Function decorator:
7+
- `@posthog.tracked` - Creates context and captures exceptions thrown within the function
8+
- Automatic deduplication of exceptions to ensure each exception is only captured once
9+
110
## 4.0.1 – 2025-04-29
211

312
1. Remove deprecated `monotonic` library. Use Python's core `time.monotonic` function instead

example.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,26 @@
104104
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
105105

106106

107+
# You can add tags to a context, and these are automatically added to exceptions captured within that context.
108+
# Recommended: you can use the `@posthog.tracked` decorator.
109+
@posthog.tracked
110+
def process_order(order_id):
111+
posthog.tag("order_id", order_id)
112+
# Exception will be captured and tagged automatically
113+
raise Exception("Order processing failed")
114+
115+
# You can also use the directly context manager, but you should prefer to use the decorator where possible,
116+
# because it's tricky to remember to always capture the exception within the context scope.
117+
with posthog.new_context():
118+
posthog.tag("user_id", "123")
119+
posthog.tag("transaction_id", "abc123")
120+
try:
121+
# Any exception within this block will include these tags
122+
raise Exception("Order processing failed")
123+
except Exception as e:
124+
# Note the exception must be captured within the scope for the tags
125+
# to be applied
126+
posthog.capture_exception(e)
127+
128+
107129
posthog.shutdown()

posthog/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@
55
from posthog.client import Client
66
from posthog.exception_capture import Integrations # noqa: F401
77
from posthog.types import FeatureFlag, FlagsAndPayloads
8+
from posthog.scopes import new_context, tag, get_tags, clear_tags, tracked
89
from posthog.version import VERSION
910

1011
__version__ = VERSION
1112

13+
"""Expose context management functions at module level."""
14+
new_context = new_context
15+
tag = tag
16+
get_tags = get_tags
17+
clear_tags = clear_tags
18+
tracked = tracked
19+
1220
"""Settings."""
1321
api_key = None # type: Optional[str]
1422
host = None # type: Optional[str]

posthog/client.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from dateutil.tz import tzutc
1515
from six import string_types
1616

17+
from posthog.scopes import get_tags
1718
from posthog.consumer import Consumer
1819
from posthog.exception_capture import ExceptionCapture
1920
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
@@ -662,6 +663,16 @@ def capture_exception(
662663
try:
663664
properties = properties or {}
664665

666+
# Check if this exception has already been captured
667+
if exception is not None and hasattr(exception, "__posthog_exception_captured"):
668+
self.log.debug("Exception already captured, skipping")
669+
return
670+
671+
# Grab current context tags, if any exist
672+
context_tags = get_tags()
673+
if context_tags:
674+
properties.update(context_tags)
675+
665676
# if there's no distinct_id, we'll generate one and set personless mode
666677
# via $process_person_profile = false
667678
if distinct_id is None:
@@ -705,7 +716,13 @@ def capture_exception(
705716
if self.log_captured_exceptions:
706717
self.log.exception(exception, extra=kwargs)
707718

708-
return self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups)
719+
res = self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups)
720+
721+
# Mark the exception as captured to prevent duplicate captures
722+
if exception is not None:
723+
setattr(exception, "__posthog_exception_captured", True)
724+
725+
return res
709726
except Exception as e:
710727
self.log.exception(f"Failed to capture exception: {e}")
711728

posthog/scopes.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import threading
2+
from contextlib import contextmanager
3+
from typing import Any, Dict, Callable, TypeVar, cast
4+
5+
_scopes_local = threading.local()
6+
7+
def _init_guard() -> None:
8+
if not hasattr(_scopes_local, "context_stack"):
9+
_scopes_local.context_stack = [{}]
10+
11+
12+
def _get_current_context() -> Dict[str, Any]:
13+
_init_guard()
14+
return _scopes_local.context_stack[-1]
15+
16+
17+
@contextmanager
18+
def new_context():
19+
# TODO - we could extend this context idea to also apply to other event types eventually,
20+
# but right now it only applies to exceptions...
21+
"""
22+
Create a new context scope that will be active for the duration of the with block.
23+
Any tags set within this scope will be isolated to this context. Tags added to a
24+
context will be added to exceptions captured within that context.
25+
26+
NOTE: tags set within a context will only added to exceptions captured within that
27+
context - ensure you call `posthog.capture_exception()` before the end of the with
28+
block, or the extra tags will be lost.
29+
30+
It's strongly recommended to use the `posthog.tracked` decorator to instrument functions, rather
31+
than directly using this context manager.
32+
33+
Example:
34+
with posthog.new_context():
35+
posthog.tag("user_id", "123")
36+
try:
37+
# Do something that might raise an exception
38+
except Exception as e:
39+
posthog.capture_exception(e)
40+
raise e
41+
"""
42+
_init_guard()
43+
_scopes_local.context_stack.append({})
44+
try:
45+
yield
46+
finally:
47+
if len(_scopes_local.context_stack) > 1:
48+
_scopes_local.context_stack.pop()
49+
50+
51+
def tag(key: str, value: Any) -> None:
52+
"""
53+
Add a tag to the current context.
54+
55+
Args:
56+
key: The tag key
57+
value: The tag value
58+
59+
Example:
60+
posthog.tag("user_id", "123")
61+
"""
62+
_get_current_context()[key] = value
63+
64+
65+
def get_tags() -> Dict[str, Any]:
66+
"""
67+
Get all tags from the current context. Note, modifying
68+
the returned dictionary will not affect the current context.
69+
70+
Returns:
71+
Dict of all tags in the current context
72+
"""
73+
return _get_current_context().copy()
74+
75+
76+
def clear_tags() -> None:
77+
"""Clear all tags in the current context."""
78+
_get_current_context().clear()
79+
80+
81+
F = TypeVar('F', bound=Callable[..., Any])
82+
def tracked(func: F) -> F:
83+
"""
84+
Decorator that creates a new context for the function, wraps the function in a
85+
try/except block, and if an exception occurs, captures it with the current context
86+
tags before re-raising it. This is the recommended way to wrap/track functions for
87+
posthog error tracking.
88+
89+
Args:
90+
func: The function to wrap
91+
92+
Example:
93+
@posthog.tracked
94+
def process_payment(payment_id):
95+
posthog.tag("payment_id", payment_id)
96+
posthog.tag("payment_method", "credit_card")
97+
# If this raises an exception, it will be captured with tags
98+
# and then re-raised
99+
"""
100+
from functools import wraps
101+
import posthog
102+
103+
@wraps(func)
104+
def wrapper(*args, **kwargs):
105+
with new_context():
106+
try:
107+
return func(*args, **kwargs)
108+
except Exception as e:
109+
# Capture the exception with current context tags
110+
# The capture_exception function will handle deduplication
111+
posthog.capture_exception(e, properties=get_tags())
112+
raise # Re-raise the exception after capturing it
113+
114+
return cast(F, wrapper)

posthog/test/test_scopes.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from posthog.scopes import new_context, tag, get_tags, clear_tags, tracked
5+
6+
7+
class TestScopes(unittest.TestCase):
8+
def setUp(self):
9+
# Reset any context between tests
10+
clear_tags()
11+
12+
def test_tag_and_get_tags(self):
13+
tag("key1", "value1")
14+
tag("key2", 2)
15+
16+
tags = get_tags()
17+
self.assertEqual(tags["key1"], "value1")
18+
self.assertEqual(tags["key2"], 2)
19+
20+
def test_clear_tags(self):
21+
tag("key1", "value1")
22+
self.assertEqual(get_tags()["key1"], "value1")
23+
24+
clear_tags()
25+
self.assertEqual(get_tags(), {})
26+
27+
def test_new_context_isolation(self):
28+
# Set tag in outer context
29+
tag("outer", "value")
30+
31+
with new_context():
32+
# Inner context should start empty
33+
self.assertEqual(get_tags(), {})
34+
35+
# Set tag in inner context
36+
tag("inner", "value")
37+
self.assertEqual(get_tags()["inner"], "value")
38+
39+
# Outer tag should not be visible
40+
self.assertNotIn("outer", get_tags())
41+
42+
# After exiting context, inner tag should be gone
43+
self.assertNotIn("inner", get_tags())
44+
45+
# Outer tag should still be there
46+
self.assertEqual(get_tags()["outer"], "value")
47+
48+
def test_nested_contexts(self):
49+
tag("level1", "value1")
50+
51+
with new_context():
52+
tag("level2", "value2")
53+
54+
with new_context():
55+
tag("level3", "value3")
56+
self.assertEqual(get_tags(), {"level3": "value3"})
57+
58+
# Back to level 2
59+
self.assertEqual(get_tags(), {"level2": "value2"})
60+
61+
# Back to level 1
62+
self.assertEqual(get_tags(), {"level1": "value1"})
63+
64+
@patch('posthog.capture_exception')
65+
def test_tracked_decorator_success(self, mock_capture):
66+
@tracked
67+
def successful_function(x, y):
68+
tag("x", x)
69+
tag("y", y)
70+
return x + y
71+
72+
result = successful_function(1, 2)
73+
74+
# Function should execute normally
75+
self.assertEqual(result, 3)
76+
77+
# No exception should be captured
78+
mock_capture.assert_not_called()
79+
80+
# Context should be cleared after function execution
81+
self.assertEqual(get_tags(), {})
82+
83+
@patch('posthog.capture_exception')
84+
def test_tracked_decorator_exception(self, mock_capture):
85+
test_exception = ValueError("Test exception")
86+
87+
@tracked
88+
def failing_function():
89+
tag("important_context", "value")
90+
raise test_exception
91+
92+
# Function should raise the exception
93+
with self.assertRaises(ValueError):
94+
failing_function()
95+
96+
# Exception should be captured with context
97+
mock_capture.assert_called_once()
98+
args, kwargs = mock_capture.call_args
99+
100+
# Check that the exception was passed
101+
self.assertEqual(args[0], test_exception)
102+
103+
# Check that the context was included in properties
104+
self.assertEqual(kwargs.get('properties', {}).get('important_context'), "value")
105+
106+
# Context should be cleared after function execution
107+
self.assertEqual(get_tags(), {})

0 commit comments

Comments
 (0)