Skip to content

Commit ee20e94

Browse files
authored
feat(insights): event_context (#214)
* refactor threadlocals This now uses the ContextVar and a helper ContextStore class. * Basic event_context * More tests * Fix other Notice usage
1 parent 8fe36a7 commit ee20e94

File tree

10 files changed

+330
-95
lines changed

10 files changed

+330
-95
lines changed

dev-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ psutil
1515
pylint
1616
pytest
1717
pytest-cov
18+
pytest-asyncio
1819
six
1920
sqlalchemy
2021
testfixtures

honeybadger/connection.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import threading
44

55
from urllib.error import HTTPError, URLError
6-
from typing import Protocol
76
from six.moves.urllib import request
87
from six import b
98

honeybadger/context_store.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from contextvars import ContextVar
2+
from contextlib import contextmanager
3+
from typing import Any, Dict, Optional
4+
5+
6+
class ContextStore:
7+
def __init__(self, name: str):
8+
self._ctx: ContextVar[Optional[Dict[str, Any]]] = ContextVar(name, default=None)
9+
10+
def get(self) -> Dict[str, Any]:
11+
data = self._ctx.get()
12+
return {} if data is None else data.copy()
13+
14+
def clear(self) -> None:
15+
self._ctx.set({})
16+
17+
def update(self, ctx: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
18+
"""
19+
Merge into the current context. Accepts either:
20+
- update({'foo': 'bar'})
21+
- update(foo='bar', baz=123)
22+
- or both: update({'foo': 'bar'}, baz=123)
23+
"""
24+
to_merge: Dict[str, Any] = {}
25+
if ctx:
26+
to_merge.update(ctx)
27+
to_merge.update(kwargs)
28+
29+
new = self.get()
30+
new.update(to_merge)
31+
self._ctx.set(new)
32+
33+
@contextmanager
34+
def override(self, ctx: Optional[Dict[str, Any]] = None, **kwargs: Any):
35+
"""
36+
Temporarily merge these into context for the duration of the with-block.
37+
"""
38+
to_merge: Dict[str, Any] = {}
39+
if ctx:
40+
to_merge.update(ctx)
41+
to_merge.update(kwargs)
42+
43+
token = self._ctx.set({**self.get(), **to_merge})
44+
try:
45+
yield
46+
finally:
47+
self._ctx.reset(token)

honeybadger/core.py

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,33 @@
22
from contextlib import contextmanager
33
import sys
44
import logging
5-
import copy
6-
import time
75
import datetime
86
import atexit
7+
from typing import Optional, Dict, Any, List
98

109
from honeybadger.plugins import default_plugin_manager
1110
import honeybadger.connection as connection
1211
import honeybadger.fake_connection as fake_connection
1312
from .events_worker import EventsWorker
1413
from .config import Configuration
1514
from .notice import Notice
15+
from .context_store import ContextStore
1616

1717
logger = logging.getLogger("honeybadger")
1818
logger.addHandler(logging.NullHandler())
1919

20+
error_context = ContextStore("honeybadger_error_context")
21+
event_context = ContextStore("honeybadger_event_context")
22+
2023

2124
class Honeybadger(object):
2225
TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
2326

2427
def __init__(self):
28+
error_context.clear()
29+
event_context.clear()
30+
2531
self.config = Configuration()
26-
self.thread_local = threading.local()
27-
self.thread_local.context = {}
2832
self.events_worker = EventsWorker(
2933
self._connection(), self.config, logger=logging.getLogger("honeybadger")
3034
)
@@ -47,21 +51,16 @@ def _send_notice(self, notice):
4751

4852
self._connection().send_notice(self.config, notice)
4953

50-
def _get_context(self):
51-
return getattr(self.thread_local, "context", {})
52-
53-
def begin_request(self, request):
54-
self.thread_local.context = self._get_context()
54+
def begin_request(self, _):
55+
error_context.clear()
56+
event_context.clear()
5557

5658
def wrap_excepthook(self, func):
5759
self.existing_except_hook = func
5860
sys.excepthook = self.exception_hook
5961

6062
def exception_hook(self, type, exception, exc_traceback):
61-
notice = Notice(
62-
exception=exception, thread_local=self.thread_local, config=self.config
63-
)
64-
self._send_notice(notice)
63+
self.notify(exception=exception)
6564
self.existing_except_hook(type, exception, exc_traceback)
6665

6766
def shutdown(self):
@@ -72,18 +71,22 @@ def notify(
7271
exception=None,
7372
error_class=None,
7473
error_message=None,
75-
context={},
74+
context: Optional[Dict[str, Any]] = None,
7675
fingerprint=None,
77-
tags=[],
76+
tags: Optional[List[str]] = None,
7877
):
78+
base = error_context.get()
79+
tag_ctx = base.pop("_tags", [])
80+
merged_ctx = {**base, **(context or {})}
81+
merged_tags = list({*tag_ctx, *(tags or [])})
82+
7983
notice = Notice(
8084
exception=exception,
8185
error_class=error_class,
8286
error_message=error_message,
83-
context=context,
87+
context=merged_ctx,
8488
fingerprint=fingerprint,
85-
tags=tags,
86-
thread_local=self.thread_local,
89+
tags=merged_tags,
8790
config=self.config,
8891
)
8992
return self._send_notice(notice)
@@ -124,7 +127,9 @@ def event(self, event_type=None, data=None, **kwargs):
124127
if isinstance(payload["ts"], datetime.datetime):
125128
payload["ts"] = payload["ts"].strftime(self.TS_FORMAT)
126129

127-
return self.events_worker.push(payload)
130+
final_payload = {**self._get_event_context(), **payload}
131+
132+
return self.events_worker.push(final_payload)
128133

129134
def configure(self, **kwargs):
130135
self.config.set_config_from_dict(kwargs)
@@ -141,28 +146,37 @@ def auto_discover_plugins(self):
141146
if self.config.is_aws_lambda_environment:
142147
default_plugin_manager.register(contrib.AWSLambdaPlugin())
143148

144-
def set_context(self, ctx=None, **kwargs):
145-
# This operation is an update, not a set!
146-
if not ctx:
147-
ctx = kwargs
148-
else:
149-
ctx.update(kwargs)
150-
self.thread_local.context = self._get_context()
151-
self.thread_local.context.update(ctx)
149+
# Error context
150+
#
151+
def _get_context(self):
152+
return error_context.get()
153+
154+
def set_context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs):
155+
error_context.update(ctx, **kwargs)
152156

153157
def reset_context(self):
154-
self.thread_local.context = {}
158+
error_context.clear()
155159

156160
@contextmanager
157-
def context(self, **kwargs):
158-
original_context = copy.copy(self._get_context())
159-
self.set_context(**kwargs)
160-
try:
161+
def context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs):
162+
with error_context.override(ctx, **kwargs):
163+
yield
164+
165+
# Event context
166+
#
167+
def _get_event_context(self):
168+
return event_context.get()
169+
170+
def set_event_context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs):
171+
event_context.update(ctx, **kwargs)
172+
173+
def reset_event_context(self):
174+
event_context.clear()
175+
176+
@contextmanager
177+
def event_context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs):
178+
with event_context.override(ctx, **kwargs):
161179
yield
162-
except:
163-
raise
164-
else:
165-
self.thread_local.context = original_context
166180

167181
def _connection(self):
168182
if self.config.is_dev() and not self.config.force_report_data:

honeybadger/notice.py

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,11 @@ def __init__(self, *args, **kwargs):
99
self.error_message = kwargs.get("error_message", None)
1010
self.exc_traceback = kwargs.get("exc_traceback", None)
1111
self.fingerprint = kwargs.get("fingerprint", None)
12-
self.thread_local = kwargs.get("thread_local", None)
1312
self.config = kwargs.get("config", None)
1413
self.context = kwargs.get("context", {})
1514
self.tags = self._construct_tags(kwargs.get("tags", []))
1615

1716
self._process_exception()
18-
self._process_context()
19-
self._process_tags()
2017

2118
def _process_exception(self):
2219
if self.exception is None and self.error_class:
@@ -28,15 +25,6 @@ def _process_exception(self):
2825
elif self.exception and self.error_message:
2926
self.context["error_message"] = self.error_message
3027

31-
def _process_context(self):
32-
self.context = dict(**self._get_thread_context(), **self.context)
33-
34-
def _process_tags(self):
35-
tags_from_context = self._construct_tags(
36-
self._get_thread_context().get("_tags", [])
37-
)
38-
self.tags = list(set(tags_from_context + self.tags))
39-
4028
@cached_property
4129
def payload(self):
4230
return create_payload(
@@ -61,15 +49,24 @@ def excluded_exception(self):
6149
return True
6250
return False
6351

64-
def _get_thread_context(self):
65-
if self.thread_local is None:
66-
return {}
67-
return getattr(self.thread_local, "context", {})
68-
6952
def _construct_tags(self, tags):
70-
constructed_tags = []
53+
"""
54+
Accepts either:
55+
- a single string (possibly comma-separated)
56+
- a list of strings (each possibly comma-separated)
57+
and returns a flat list of stripped tags.
58+
"""
59+
raw = []
7160
if isinstance(tags, str):
72-
constructed_tags = [tag.strip() for tag in tags.split(",")]
73-
elif isinstance(tags, list):
74-
constructed_tags = tags
75-
return constructed_tags
61+
raw = [tags]
62+
elif isinstance(tags, (list, tuple)):
63+
raw = tags
64+
out = []
65+
for item in raw:
66+
if not isinstance(item, str):
67+
continue
68+
for part in item.split(","):
69+
t = part.strip()
70+
if t:
71+
out.append(t)
72+
return out

honeybadger/protocols.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from typing import Protocol, Dict, Any, Optional, List
1+
from typing import Protocol, Any, Optional, List
22
from .types import EventsSendResult, Notice, Event
33

44

55
class Connection(Protocol):
6-
def send_notice(self, config: Any, payload: Notice) -> Optional[str]:
6+
def send_notice(self, config: Any, notice: Notice) -> Optional[str]:
77
"""
88
Send an error notice to Honeybadger.
99

0 commit comments

Comments
 (0)