Skip to content

Commit f7b0684

Browse files
authored
Add support for Sentry Crons to Celery Beat (#1935)
This adds a decorator @sentry.monitor that can be attached to Celery tasks. When the celery tasks are run, a check-in for Sentry Crons is created and also the status of the check-in is set when the tasks fails for finishes.
1 parent 251e27d commit f7b0684

File tree

5 files changed

+225
-2
lines changed

5 files changed

+225
-2
lines changed

sentry_sdk/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from sentry_sdk.consts import VERSION # noqa
99

10+
from sentry_sdk.crons import monitor # noqa
1011
from sentry_sdk.tracing import trace # noqa
1112

1213
__all__ = [ # noqa

sentry_sdk/client.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,11 @@ def capture_event(
440440
.pop("dynamic_sampling_context", {})
441441
)
442442

443-
# Transactions or events with attachments should go to the /envelope/
443+
is_checkin = event_opt.get("type") == "check_in"
444+
445+
# Transactions, events with attachments, and checkins should go to the /envelope/
444446
# endpoint.
445-
if is_transaction or attachments:
447+
if is_transaction or is_checkin or attachments:
446448

447449
headers = {
448450
"event_id": event_opt["event_id"],
@@ -458,11 +460,14 @@ def capture_event(
458460
if profile is not None:
459461
envelope.add_profile(profile.to_json(event_opt, self.options))
460462
envelope.add_transaction(event_opt)
463+
elif is_checkin:
464+
envelope.add_checkin(event_opt)
461465
else:
462466
envelope.add_event(event_opt)
463467

464468
for attachment in attachments or ():
465469
envelope.add_item(attachment.to_envelope_item())
470+
466471
self.transport.capture_envelope(envelope)
467472
else:
468473
# All other events go to the /store/ endpoint.

sentry_sdk/crons.py

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from functools import wraps
2+
import sys
3+
import uuid
4+
5+
from sentry_sdk import Hub
6+
from sentry_sdk._compat import reraise
7+
from sentry_sdk._types import TYPE_CHECKING
8+
from sentry_sdk.utils import nanosecond_time
9+
10+
11+
if TYPE_CHECKING:
12+
from typing import Any, Callable, Dict, Optional
13+
14+
15+
class MonitorStatus:
16+
IN_PROGRESS = "in_progress"
17+
OK = "ok"
18+
ERROR = "error"
19+
20+
21+
def _create_checkin_event(
22+
monitor_slug=None, check_in_id=None, status=None, duration=None
23+
):
24+
# type: (Optional[str], Optional[str], Optional[str], Optional[float]) -> Dict[str, Any]
25+
options = Hub.current.client.options if Hub.current.client else {}
26+
check_in_id = check_in_id or uuid.uuid4().hex # type: str
27+
# convert nanosecond to millisecond
28+
duration = int(duration * 0.000001) if duration is not None else duration
29+
30+
checkin = {
31+
"type": "check_in",
32+
"monitor_slug": monitor_slug,
33+
# TODO: Add schedule and schedule_type to monitor config
34+
# "monitor_config": {
35+
# "schedule": "*/10 0 0 0 0",
36+
# "schedule_type": "cron",
37+
# },
38+
"check_in_id": check_in_id,
39+
"status": status,
40+
"duration": duration,
41+
"environment": options["environment"],
42+
"release": options["release"],
43+
}
44+
45+
return checkin
46+
47+
48+
def capture_checkin(monitor_slug=None, check_in_id=None, status=None, duration=None):
49+
# type: (Optional[str], Optional[str], Optional[str], Optional[float]) -> str
50+
hub = Hub.current
51+
52+
check_in_id = check_in_id or uuid.uuid4().hex
53+
checkin_event = _create_checkin_event(
54+
monitor_slug=monitor_slug,
55+
check_in_id=check_in_id,
56+
status=status,
57+
duration=duration,
58+
)
59+
hub.capture_event(checkin_event)
60+
61+
return checkin_event["check_in_id"]
62+
63+
64+
def monitor(monitor_slug=None, app=None):
65+
# type: (Optional[str], Any) -> Callable[..., Any]
66+
"""
67+
Decorator to capture checkin events for a monitor.
68+
69+
Usage:
70+
```
71+
import sentry_sdk
72+
73+
app = Celery()
74+
75+
@app.task
76+
@sentry_sdk.monitor(monitor_slug='my-fancy-slug')
77+
def test(arg):
78+
print(arg)
79+
```
80+
81+
This does not have to be used with Celery, but if you do use it with celery,
82+
put the `@sentry_sdk.monitor` decorator below Celery's `@app.task` decorator.
83+
"""
84+
85+
def decorate(func):
86+
# type: (Callable[..., Any]) -> Callable[..., Any]
87+
if not monitor_slug:
88+
return func
89+
90+
@wraps(func)
91+
def wrapper(*args, **kwargs):
92+
# type: (*Any, **Any) -> Any
93+
start_timestamp = nanosecond_time()
94+
check_in_id = capture_checkin(
95+
monitor_slug=monitor_slug, status=MonitorStatus.IN_PROGRESS
96+
)
97+
98+
try:
99+
result = func(*args, **kwargs)
100+
except Exception:
101+
duration = nanosecond_time() - start_timestamp
102+
capture_checkin(
103+
monitor_slug=monitor_slug,
104+
check_in_id=check_in_id,
105+
status=MonitorStatus.ERROR,
106+
duration=duration,
107+
)
108+
exc_info = sys.exc_info()
109+
reraise(*exc_info)
110+
111+
duration = nanosecond_time() - start_timestamp
112+
capture_checkin(
113+
monitor_slug=monitor_slug,
114+
check_in_id=check_in_id,
115+
status=MonitorStatus.OK,
116+
duration=duration,
117+
)
118+
119+
return result
120+
121+
return wrapper
122+
123+
return decorate

sentry_sdk/envelope.py

+6
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ def add_profile(
6868
# type: (...) -> None
6969
self.add_item(Item(payload=PayloadRef(json=profile), type="profile"))
7070

71+
def add_checkin(
72+
self, checkin # type: Any
73+
):
74+
# type: (...) -> None
75+
self.add_item(Item(payload=PayloadRef(json=checkin), type="check_in"))
76+
7177
def add_session(
7278
self, session # type: Union[Session, Any]
7379
):

tests/test_crons.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import mock
2+
import pytest
3+
import uuid
4+
5+
import sentry_sdk
6+
from sentry_sdk.crons import capture_checkin
7+
8+
9+
@sentry_sdk.monitor(monitor_slug="abc123")
10+
def _hello_world(name):
11+
return "Hello, {}".format(name)
12+
13+
14+
@sentry_sdk.monitor(monitor_slug="def456")
15+
def _break_world(name):
16+
1 / 0
17+
return "Hello, {}".format(name)
18+
19+
20+
def test_decorator(sentry_init):
21+
sentry_init()
22+
23+
with mock.patch("sentry_sdk.crons.capture_checkin") as fake_capture_checking:
24+
result = _hello_world("Grace")
25+
assert result == "Hello, Grace"
26+
27+
# Check for initial checkin
28+
fake_capture_checking.assert_has_calls(
29+
[
30+
mock.call(monitor_slug="abc123", status="in_progress"),
31+
]
32+
)
33+
34+
# Check for final checkin
35+
assert fake_capture_checking.call_args[1]["monitor_slug"] == "abc123"
36+
assert fake_capture_checking.call_args[1]["status"] == "ok"
37+
assert fake_capture_checking.call_args[1]["duration"]
38+
assert fake_capture_checking.call_args[1]["check_in_id"]
39+
40+
41+
def test_decorator_error(sentry_init):
42+
sentry_init()
43+
44+
with mock.patch("sentry_sdk.crons.capture_checkin") as fake_capture_checking:
45+
with pytest.raises(Exception):
46+
result = _break_world("Grace")
47+
48+
assert "result" not in locals()
49+
50+
# Check for initial checkin
51+
fake_capture_checking.assert_has_calls(
52+
[
53+
mock.call(monitor_slug="def456", status="in_progress"),
54+
]
55+
)
56+
57+
# Check for final checkin
58+
assert fake_capture_checking.call_args[1]["monitor_slug"] == "def456"
59+
assert fake_capture_checking.call_args[1]["status"] == "error"
60+
assert fake_capture_checking.call_args[1]["duration"]
61+
assert fake_capture_checking.call_args[1]["check_in_id"]
62+
63+
64+
def test_capture_checkin_simple(sentry_init):
65+
sentry_init()
66+
67+
check_in_id = capture_checkin(
68+
monitor_slug="abc123",
69+
check_in_id="112233",
70+
status=None,
71+
duration=None,
72+
)
73+
assert check_in_id == "112233"
74+
75+
76+
def test_capture_checkin_new_id(sentry_init):
77+
sentry_init()
78+
79+
with mock.patch("uuid.uuid4") as mock_uuid:
80+
mock_uuid.return_value = uuid.UUID("a8098c1a-f86e-11da-bd1a-00112444be1e")
81+
check_in_id = capture_checkin(
82+
monitor_slug="abc123",
83+
check_in_id=None,
84+
status=None,
85+
duration=None,
86+
)
87+
88+
assert check_in_id == "a8098c1af86e11dabd1a00112444be1e"

0 commit comments

Comments
 (0)