Skip to content

Commit e0fcaa0

Browse files
committed
Add Sentry context provider
Links a failed task to its Sentry issue by reading back sentry_sdk.last_event_id() rather than capturing the exception itself, since the app is assumed to already report it via its own Sentry integration. A snapshot taken when the task starts filters out stale ids left over from an unrelated earlier capture.
1 parent 42b0294 commit e0fcaa0

4 files changed

Lines changed: 120 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ cli = [
4747
procrastinate = [
4848
"procrastinate>=3.0",
4949
]
50+
sentry = [
51+
"sentry-sdk>=1.0",
52+
]
5053

5154
[tool.uv]
5255
package = true
@@ -69,6 +72,7 @@ dev = [
6972
"redis",
7073
"openapi-python-client",
7174
"taskbadger[cli]",
75+
"taskbadger[sentry]",
7276
]
7377

7478
[project.scripts]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from taskbadger.context_providers import ContextProvider
2+
3+
4+
class SentryContextProvider(ContextProvider):
5+
"""Links a failed task to the corresponding Sentry issue.
6+
7+
Reads back `sentry_sdk.last_event_id()` rather than capturing the exception
8+
itself, on the assumption the surrounding system already reports its own
9+
exceptions to Sentry (e.g. via a framework integration). To avoid linking to
10+
a stale event left over from something unrelated, a snapshot is taken when
11+
the task starts and the event id is only reported if it changed by the time
12+
the task errors.
13+
14+
Requires the `sentry-sdk` package; a no-op if it isn't installed.
15+
"""
16+
17+
identifier = "sentry"
18+
19+
def __init__(self, organization_slug: str = None, base_url: str = "https://sentry.io"):
20+
self.organization_slug = organization_slug
21+
self.base_url = base_url.rstrip("/")
22+
23+
def snapshot(self):
24+
try:
25+
import sentry_sdk
26+
except ImportError:
27+
return None
28+
return sentry_sdk.last_event_id()
29+
30+
def capture_error_context(self, exception: BaseException, snapshot=None) -> dict | None:
31+
try:
32+
import sentry_sdk
33+
except ImportError:
34+
return None
35+
36+
event_id = sentry_sdk.last_event_id()
37+
if not event_id or event_id == snapshot:
38+
return None
39+
40+
context = {"event_id": event_id}
41+
if self.organization_slug:
42+
context["url"] = f"{self.base_url}/organizations/{self.organization_slug}/issues/?query={event_id}"
43+
return context
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import sys
2+
from unittest import mock
3+
4+
from taskbadger.context_providers.sentry import SentryContextProvider
5+
6+
7+
def test_sentry_provider_not_installed():
8+
provider = SentryContextProvider()
9+
with mock.patch.dict(sys.modules, {"sentry_sdk": None}):
10+
assert provider.snapshot() is None
11+
assert provider.capture_error_context(ValueError("boom")) is None
12+
13+
14+
def test_sentry_provider_no_event_id():
15+
provider = SentryContextProvider()
16+
with mock.patch("sentry_sdk.last_event_id", return_value=None):
17+
assert provider.capture_error_context(ValueError("boom")) is None
18+
19+
20+
def test_sentry_provider_stale_event_id_not_reported():
21+
"""If last_event_id() hasn't changed since the snapshot, the exception
22+
was never actually captured by Sentry -- don't report the stale id."""
23+
provider = SentryContextProvider()
24+
with mock.patch("sentry_sdk.last_event_id", return_value="stale123"):
25+
snapshot = provider.snapshot()
26+
context = provider.capture_error_context(ValueError("boom"), snapshot)
27+
assert context is None
28+
29+
30+
def test_sentry_provider_event_id_only():
31+
provider = SentryContextProvider()
32+
snapshot = None
33+
with mock.patch("sentry_sdk.last_event_id", return_value="abc123"):
34+
context = provider.capture_error_context(ValueError("boom"), snapshot)
35+
assert context == {"event_id": "abc123"}
36+
37+
38+
def test_sentry_provider_with_url():
39+
provider = SentryContextProvider(organization_slug="acme")
40+
with mock.patch("sentry_sdk.last_event_id", return_value="abc123"):
41+
context = provider.capture_error_context(ValueError("boom"))
42+
assert context == {
43+
"event_id": "abc123",
44+
"url": "https://sentry.io/organizations/acme/issues/?query=abc123",
45+
}
46+
47+
48+
def test_sentry_provider_custom_base_url():
49+
provider = SentryContextProvider(organization_slug="acme", base_url="https://sentry.example.com/")
50+
with mock.patch("sentry_sdk.last_event_id", return_value="abc123"):
51+
context = provider.capture_error_context(ValueError("boom"))
52+
assert context["url"] == "https://sentry.example.com/organizations/acme/issues/?query=abc123"

uv.lock

Lines changed: 21 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)