Skip to content

Commit 4bf150b

Browse files
committed
feat: deduplication of exceptions
1 parent 91b05ed commit 4bf150b

File tree

7 files changed

+102
-39
lines changed

7 files changed

+102
-39
lines changed

sentry_sdk/client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import uuid
33

4-
from .utils import Dsn
4+
from .utils import Dsn, SkipEvent
55
from .transport import Transport
66
from .consts import DEFAULT_OPTIONS, SDK_INFO
77
from .stripping import strip_event, flatten_metadata
@@ -65,7 +65,10 @@ def capture_event(self, event, scope=None):
6565
"""Captures an event."""
6666
if self._transport is None:
6767
return
68-
event = self._prepare_event(event, scope)
68+
try:
69+
event = self._prepare_event(event, scope)
70+
except SkipEvent:
71+
return
6972
self._transport.capture_event(event)
7073

7174
def drain_events(self, timeout=None):

sentry_sdk/hub.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,7 @@
44

55
from ._compat import with_metaclass
66
from .scope import Scope
7-
from .utils import (
8-
exceptions_from_error_tuple,
9-
create_event,
10-
skip_internal_frames,
11-
ContextVar,
12-
)
7+
from .utils import Event, skip_internal_frames, ContextVar, DefaultEventProcessor
138

149

1510
_local = ContextVar("sentry_current_hub")
@@ -99,7 +94,7 @@ def capture_message(self, message, level=None):
9994
return
10095
if level is None:
10196
level = "info"
102-
event = create_event()
97+
event = Event()
10398
event["message"] = message
10499
if level is not None:
105100
event["level"] = level
@@ -129,13 +124,9 @@ def capture_exception(self, error=None):
129124
if tb is not None:
130125
tb = skip_internal_frames(tb)
131126

132-
event = create_event()
127+
event = Event()
133128
try:
134-
event["exception"] = {
135-
"values": exceptions_from_error_tuple(
136-
exc_type, exc_value, tb, client.options["with_locals"]
137-
)
138-
}
129+
event.set_exception(exc_type, exc_value, tb, client.options["with_locals"])
139130
return self.capture_event(event)
140131
except Exception:
141132
self.capture_internal_exception()
@@ -159,8 +150,7 @@ def add_breadcrumb(self, crumb):
159150

160151
def add_event_processor(self, factory):
161152
"""Registers a new event processor with the top scope."""
162-
if self._stack[1][0] is not None:
163-
self._pending_processors.append(factory)
153+
self._pending_processors.append(factory)
164154

165155
def push_scope(self):
166156
"""Pushes a new layer on the scope stack. Returns a context manager
@@ -191,3 +181,4 @@ def _flush_event_processors(self):
191181

192182

193183
GLOBAL_HUB = Hub()
184+
GLOBAL_HUB.add_event_processor(DefaultEventProcessor)

sentry_sdk/integrations/flask.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313

1414

1515
class FlaskSentry(object):
16-
def __init__(self, app=None):
16+
def __init__(self, app=None, **options):
1717
self.app = app
1818
if app is not None:
19-
self.init_app(app)
19+
self.init_app(app, **options)
2020

21-
def init_app(self, app):
21+
def init_app(self, app, setup_logger=True):
2222
if not hasattr(app, "extensions"):
2323
app.extensions = {}
2424
elif app.extensions.get(__name__, None):
@@ -30,6 +30,11 @@ def init_app(self, app):
3030
got_request_exception.connect(self._capture_exception, sender=app)
3131
app.before_request(self._before_request)
3232

33+
if setup_logger:
34+
from .logging import HANDLER
35+
36+
app.logger.addHandler(HANDLER)
37+
3338
def _push_appctx(self, *args, **kwargs):
3439
get_current_hub().push_scope()
3540
_app_ctx_stack.top._sentry_app_scope_pushed = True

sentry_sdk/integrations/logging.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,7 @@
55
import logging
66

77
from sentry_sdk import get_current_hub, capture_event, add_breadcrumb
8-
from sentry_sdk.utils import (
9-
to_string,
10-
create_event,
11-
exceptions_from_error_tuple,
12-
skip_internal_frames,
13-
)
8+
from sentry_sdk.utils import to_string, Event, skip_internal_frames
149

1510

1611
class SentryHandler(logging.Handler, object):
@@ -35,19 +30,19 @@ def _breadcrumb_from_record(self, record):
3530
def _emit(self, record):
3631
add_breadcrumb(self._breadcrumb_from_record(record))
3732

38-
if not self._should_create_event(record):
33+
if not self._should_Event(record):
3934
return
4035

4136
if not self.can_record(record):
4237
print(to_string(record.message), file=sys.stderr)
4338
return
4439

45-
event = create_event()
40+
event = Event()
4641

4742
# exc_info might be None or (None, None, None)
4843
if record.exc_info and all(record.exc_info):
4944
exc_type, exc_value, tb = record.exc_info
50-
event["exception"] = exceptions_from_error_tuple(
45+
event.set_exception(
5146
exc_type,
5247
exc_value,
5348
skip_internal_frames(tb),
@@ -64,7 +59,7 @@ def _emit(self, record):
6459
def _logging_to_event_level(self, levelname):
6560
return {"critical": "fatal"}.get(levelname.lower(), levelname.lower())
6661

67-
def _should_create_event(self, record):
62+
def _should_Event(self, record):
6863
# TODO: make configurable
6964
if record.levelno in (logging.ERROR, logging.CRITICAL):
7065
return True

sentry_sdk/transport.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def _make_pool():
3030

3131

3232
def send_event(event, auth):
33-
body = zlib.compress(json.dumps(event).encode("utf-8"))
33+
body = zlib.compress(json.dumps(event.get_json()).encode("utf-8"))
3434
response = _pool.request(
3535
"POST",
3636
auth.store_api_url,

sentry_sdk/utils.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,10 @@ def safe_repr(value):
240240
def object_to_json(obj):
241241
def _walk(obj, depth):
242242
if depth < 4:
243-
if isinstance(obj, Sequence):
243+
if isinstance(obj, Sequence) and not isinstance(obj, (bytes, text_type)):
244244
return [_walk(x, depth + 1) for x in obj]
245245
if isinstance(obj, Mapping):
246-
return {safe_repr(k): _walk(v, depth + 1) for k, v in obj.items()}
246+
return {safe_str(k): _walk(v, depth + 1) for k, v in obj.items()}
247247
return safe_repr(obj)
248248

249249
return _walk(obj, 0)
@@ -315,12 +315,63 @@ def exceptions_from_error_tuple(exc_type, exc_value, tb, with_locals=True):
315315
return rv
316316

317317

318-
def create_event():
319-
return {
320-
"event_id": uuid.uuid4().hex,
321-
"timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
322-
"level": "error",
323-
}
318+
class Event(Mapping):
319+
__slots__ = ("_data", "_exc_value")
320+
321+
def __init__(self):
322+
self._data = {
323+
"event_id": uuid.uuid4().hex,
324+
"timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
325+
"level": "error",
326+
}
327+
self._exc_value = None
328+
329+
def get_json(self):
330+
return self._data
331+
332+
def set_exception(self, exc_type, exc_value, tb, with_locals):
333+
self["exception"] = {
334+
"values": exceptions_from_error_tuple(exc_type, exc_value, tb, with_locals)
335+
}
336+
self._exc_value = exc_value
337+
338+
def __getitem__(self, key):
339+
return self._data[key]
340+
341+
def __contains__(self, key):
342+
return key in self._data
343+
344+
def get(self, *a, **kw):
345+
return self._data.get(*a, **kw)
346+
347+
def setdefault(self, *a, **kw):
348+
return self._data.setdefault(*a, **kw)
349+
350+
def __setitem__(self, key, value):
351+
self._data[key] = value
352+
353+
def __iter__(self):
354+
return iter(self._data)
355+
356+
def __len__(self):
357+
return len(self._data)
358+
359+
360+
class DefaultEventProcessor(object):
361+
def __init__(self):
362+
self._most_recent_exception = None
363+
364+
def __call__(self, event):
365+
if event._exc_value is None:
366+
return
367+
if self._most_recent_exception is event._exc_value:
368+
raise SkipEvent()
369+
self._most_recent_exception = event._exc_value
370+
371+
372+
class SkipEvent(Exception):
373+
"""Risen from an event processor to indicate that the event should be
374+
ignored and not be reported."""
324375

325376

326377
def to_string(value):

tests/integrations/flask/test_flask.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import pytest
3+
import logging
34

45
from io import BytesIO
56

@@ -21,6 +22,7 @@ def app():
2122
app = Flask(__name__)
2223
app.config["TESTING"] = True
2324
app.secret_key = "haha"
25+
app.logger.setLevel(logging.DEBUG)
2426

2527
login_manager.init_app(app)
2628

@@ -249,3 +251,19 @@ def index():
249251
"": {"len": 0, "rem": [["!filecontent", "x", 0, 0]]}
250252
}
251253
assert not event["request"]["data"]["file"]
254+
255+
256+
def test_errors_not_reported_twice(capture_events, app):
257+
@app.route("/")
258+
def index():
259+
try:
260+
1 / 0
261+
except Exception as e:
262+
app.logger.exception(e)
263+
raise e
264+
265+
client = app.test_client()
266+
with pytest.raises(ZeroDivisionError):
267+
client.get("/")
268+
269+
assert len(capture_events) == 1

0 commit comments

Comments
 (0)