Skip to content

Commit 243b98d

Browse files
oliverb123greptile-apps[bot]daibhin
authored
feat(err): add context manager and tag functions (#239)
* add context maanager and tag functions * Update posthog/scopes.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * ran black * black locally disagrees with ci. python is awful * bleh * isort * fix mypyp thing * Revert "fix mypyp thing" This reverts commit 21ad873. * update baseline * lets try again * alright lets try again * revert to baseline * try ignoring it i guess * black * try supporting async too * black * mypy * formatting * fix changelog * fix comment * we only support python 3.9+ * change decorator name * fix example * isort * auto-capture in with blocks * fix tests * black * inherit tags by default * assert swap * add tags to all events * rm comment * Update example.py Co-authored-by: David Newell <[email protected]> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: David Newell <[email protected]>
1 parent 7ab2080 commit 243b98d

File tree

9 files changed

+334
-5
lines changed

9 files changed

+334
-5
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 4.3.0
2+
3+
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.scoped` - 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.2.1 - 2025-6-05
211

312
1. fix: feature flag request use geoip_disable (#235)

example.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,44 @@
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 any events (including exceptions) captured
108+
# within that context.
109+
110+
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
111+
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
112+
# the new context inherits tags from the parent context.
113+
with posthog.new_context():
114+
posthog.tag("transaction_id", "abc123")
115+
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
116+
117+
# This event will be captured with the tags set above
118+
posthog.capture("order_processed")
119+
# This exception will be captured with the tags set above
120+
raise Exception("Order processing failed")
121+
122+
123+
# Use fresh=True to start with a clean context (no inherited tags)
124+
with posthog.new_context(fresh=True):
125+
posthog.tag("session_id", "xyz789")
126+
# Only session_id tag will be present, no inherited tags
127+
raise Exception("Session handling failed")
128+
129+
130+
# You can also use the `@posthog.scoped()` decorator to enter a new context.
131+
# By default, it inherits tags from the parent context
132+
@posthog.scoped()
133+
def process_order(order_id):
134+
posthog.tag("order_id", order_id)
135+
# Exception will be captured and tagged automatically
136+
raise Exception("Order processing failed")
137+
138+
139+
# Use fresh=True to start with a clean context (no inherited tags)
140+
@posthog.scoped(fresh=True)
141+
def process_payment(payment_id):
142+
posthog.tag("payment_id", payment_id)
143+
# Only payment_id tag will be present, no inherited tags
144+
raise Exception("Payment processing failed")
145+
146+
107147
posthog.shutdown()

posthog/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@
44

55
from posthog.client import Client
66
from posthog.exception_capture import Integrations # noqa: F401
7+
from posthog.scopes import clear_tags, get_tags, new_context, scoped, tag
78
from posthog.types import FeatureFlag, FlagsAndPayloads
89
from posthog.version import VERSION
910

1011
__version__ = VERSION
1112

13+
"""Context management."""
14+
new_context = new_context
15+
tag = tag
16+
get_tags = get_tags
17+
clear_tags = clear_tags
18+
tracked = scoped
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
@@ -29,6 +29,7 @@
2929
get,
3030
remote_config,
3131
)
32+
from posthog.scopes import get_tags
3233
from posthog.types import (
3334
FeatureFlag,
3435
FeatureFlagResult,
@@ -466,6 +467,11 @@ def capture(
466467
require("properties", properties, dict)
467468
require("event", event, string_types)
468469

470+
# Grab current context tags, if any exist
471+
context_tags = get_tags()
472+
if context_tags:
473+
properties.update(context_tags)
474+
469475
msg = {
470476
"properties": properties,
471477
"timestamp": timestamp,
@@ -662,6 +668,11 @@ def capture_exception(
662668
try:
663669
properties = properties or {}
664670

671+
# Check if this exception has already been captured
672+
if exception is not None and hasattr(exception, "__posthog_exception_captured"):
673+
self.log.debug("Exception already captured, skipping")
674+
return
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/exception_integrations/django.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ class DjangoIntegration:
3232
identifier = "django"
3333

3434
def __init__(self, capture_exception_fn=None):
35-
3635
if DJANGO_VERSION < (4, 2):
3736
raise IntegrationEnablingError("Django 4.2 or newer is required.")
3837

@@ -60,7 +59,6 @@ def uninstall(self):
6059

6160

6261
class DjangoRequestExtractor:
63-
6462
def __init__(self, request):
6563
# type: (Any) -> None
6664
self.request = request

posthog/exception_utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424

2525

2626
if TYPE_CHECKING:
27-
2827
from types import FrameType, TracebackType
2928
from typing import ( # noqa: F401
3029
Any,

posthog/scopes.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import contextvars
2+
from contextlib import contextmanager
3+
from typing import Any, Callable, Dict, TypeVar, cast
4+
5+
_context_stack: contextvars.ContextVar[list] = contextvars.ContextVar("posthog_context_stack", default=[{}])
6+
7+
8+
def _get_current_context() -> Dict[str, Any]:
9+
return _context_stack.get()[-1]
10+
11+
12+
@contextmanager
13+
def new_context(fresh=False):
14+
"""
15+
Create a new context scope that will be active for the duration of the with block.
16+
Any tags set within this scope will be isolated to this context. Any exceptions raised
17+
or events captured within the context will be tagged with the context tags.
18+
19+
Args:
20+
fresh: Whether to start with a fresh context (default: False).
21+
If False, inherits tags from parent context.
22+
If True, starts with no tags.
23+
24+
Examples:
25+
# Inherit parent context tags
26+
with posthog.new_context():
27+
posthog.tag("request_id", "123")
28+
# Both this event and the exception will be tagged with the context tags
29+
posthog.capture("event_name", {"property": "value"})
30+
raise ValueError("Something went wrong")
31+
32+
# Start with fresh context (no inherited tags)
33+
with posthog.new_context(fresh=True):
34+
posthog.tag("request_id", "123")
35+
# Both this event and the exception will be tagged with the context tags
36+
posthog.capture("event_name", {"property": "value"})
37+
raise ValueError("Something went wrong")
38+
39+
"""
40+
import posthog
41+
42+
current_tags = _get_current_context().copy()
43+
current_stack = _context_stack.get()
44+
new_stack = current_stack + [{}] if fresh else current_stack + [current_tags]
45+
token = _context_stack.set(new_stack)
46+
47+
try:
48+
yield
49+
except Exception as e:
50+
posthog.capture_exception(e)
51+
raise
52+
finally:
53+
_context_stack.reset(token)
54+
55+
56+
def tag(key: str, value: Any) -> None:
57+
"""
58+
Add a tag to the current context.
59+
60+
Args:
61+
key: The tag key
62+
value: The tag value
63+
64+
Example:
65+
posthog.tag("user_id", "123")
66+
"""
67+
_get_current_context()[key] = value
68+
69+
70+
def get_tags() -> Dict[str, Any]:
71+
"""
72+
Get all tags from the current context. Note, modifying
73+
the returned dictionary will not affect the current context.
74+
75+
Returns:
76+
Dict of all tags in the current context
77+
"""
78+
return _get_current_context().copy()
79+
80+
81+
def clear_tags() -> None:
82+
"""Clear all tags in the current context."""
83+
_get_current_context().clear()
84+
85+
86+
F = TypeVar("F", bound=Callable[..., Any])
87+
88+
89+
def scoped(fresh=False):
90+
"""
91+
Decorator that creates a new context for the function. Simply wraps
92+
the function in a with posthog.new_context(): block.
93+
94+
Args:
95+
fresh: Whether to start with a fresh context (default: False)
96+
97+
Example:
98+
@posthog.scoped()
99+
def process_payment(payment_id):
100+
posthog.tag("payment_id", payment_id)
101+
posthog.tag("payment_method", "credit_card")
102+
103+
# This event will be captured with tags
104+
posthog.capture("payment_started")
105+
# If this raises an exception, it will be captured with tags
106+
# and then re-raised
107+
some_risky_function()
108+
"""
109+
110+
def decorator(func: F) -> F:
111+
from functools import wraps
112+
113+
@wraps(func)
114+
def wrapper(*args, **kwargs):
115+
with new_context(fresh=fresh):
116+
return func(*args, **kwargs)
117+
118+
return cast(F, wrapper)
119+
120+
return decorator

0 commit comments

Comments
 (0)