Skip to content

Commit 1f1d0eb

Browse files
authored
Merge pull request #59 from taskbadger/sk/sentry
Add pluggable context providers, with a Sentry integration
2 parents b7e5669 + 1462e74 commit 1f1d0eb

16 files changed

Lines changed: 606 additions & 10 deletions

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]

taskbadger/_error_context.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Builds the `data` payload attached to a task when it errors, combining the
2+
exception message with any registered `context_providers` (e.g. Sentry). Not
3+
part of the public API.
4+
"""
5+
6+
import logging
7+
from contextvars import ContextVar
8+
9+
from taskbadger.mug import Badger
10+
11+
log = logging.getLogger("taskbadger")
12+
13+
_snapshots: ContextVar[dict] = ContextVar("taskbadger_context_snapshots", default=None)
14+
15+
16+
def _providers():
17+
settings = Badger.current.settings
18+
return settings.context_providers if settings else []
19+
20+
21+
def start_error_context():
22+
"""Snapshot every configured context provider. Call this when a tracked task
23+
starts, before user code runs, so `capture_error_data` can later tell a fresh
24+
capture from a stale one left over from something unrelated.
25+
26+
Returns a token that can be passed to `reset_error_context` to restore the
27+
previous snapshot (for nested tracking within the same thread).
28+
"""
29+
snapshot = {}
30+
for provider in _providers():
31+
try:
32+
snapshot[provider.identifier] = provider.snapshot()
33+
except Exception:
34+
log.warning("Error snapshotting context provider '%s'", provider.identifier, exc_info=True)
35+
return _snapshots.set(snapshot)
36+
37+
38+
def reset_error_context(token) -> None:
39+
_snapshots.reset(token)
40+
41+
42+
def capture_error_data(exception: BaseException, message: str = None) -> dict:
43+
"""Arguments:
44+
exception: The exception to report to context providers.
45+
message: Text to store as `data["exception"]`. Defaults to `str(exception)`;
46+
override when the caller has a more descriptive representation (e.g. Celery's
47+
`ExceptionInfo`, which wraps the original exception).
48+
"""
49+
data = {"exception": message if message is not None else str(exception)}
50+
snapshot = _snapshots.get() or {}
51+
for provider in _providers():
52+
try:
53+
extra = provider.capture_error_context(exception, snapshot.get(provider.identifier))
54+
except Exception:
55+
log.warning("Error capturing context from provider '%s'", provider.identifier, exc_info=True)
56+
extra = None
57+
if extra:
58+
data[provider.identifier] = extra
59+
return data

taskbadger/celery.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from kombu import serialization
1515

1616
from . import sdk
17+
from ._error_context import capture_error_data, reset_error_context, start_error_context
1718
from ._heartbeat import heartbeat
1819
from ._integrations import TERMINAL_STATES, resolve_heartbeat_options, safe_get_task, task_cache
1920
from .internal.models import StatusEnum
@@ -30,6 +31,9 @@
3031
# Marks a request whose signal handlers opened the Task Badger session, so that
3132
# only they close it again.
3233
TB_OWNS_SESSION = f"{KWARG_PREFIX}owns_session"
34+
# Token returned by start_error_context(), stashed on the request so
35+
# task_postrun_handler can restore the previous context (see comment there).
36+
TB_ERROR_CTX_TOKEN = f"{KWARG_PREFIX}error_ctx_token"
3337

3438
log = logging.getLogger("taskbadger")
3539

@@ -301,6 +305,16 @@ def task_prerun_handler(sender=None, **kwargs):
301305
_maybe_create_task(sender)
302306
_update_task(sender, StatusEnum.PROCESSING)
303307
_start_heartbeat(sender)
308+
if _get_taskbadger_task_id(sender.request):
309+
# Snapshotted here (same thread as the task body and the failure/retry
310+
# signals below) so context providers can tell a fresh capture from a
311+
# stale one if the task errors. The token is restored in
312+
# task_postrun_handler rather than discarded: a task synchronously
313+
# invoking another tracked task in its body (eager mode, `.apply()`,
314+
# canvas primitives) would otherwise leave this task's context
315+
# clobbered by the inner task's snapshot for the rest of its run.
316+
token = start_error_context()
317+
sender.request.update({TB_ERROR_CTX_TOKEN: token})
304318

305319

306320
@task_postrun.connect
@@ -309,6 +323,9 @@ def task_postrun_handler(sender=None, **kwargs):
309323
task_id = _get_taskbadger_task_id(sender.request)
310324
if task_id:
311325
heartbeat.stop(task_id)
326+
token = sender.request.get(TB_ERROR_CTX_TOKEN)
327+
if token is not None:
328+
reset_error_context(token)
312329

313330

314331
@task_success.connect
@@ -351,7 +368,10 @@ def _update_task(signal_sender, status, einfo=None):
351368

352369
data = None
353370
if einfo:
354-
data = DefaultMergeStrategy().merge(task.data, {"exception": str(einfo)})
371+
# `einfo.exception` wraps the real exception (see billiard.einfo.ExceptionWithTraceback);
372+
# unwrap it so context providers (e.g. Sentry) see the original exception.
373+
exc = getattr(einfo.exception, "exc", einfo.exception)
374+
data = DefaultMergeStrategy().merge(task.data, capture_error_data(exc, message=str(einfo)))
355375
task = update_task_safe(task.id, status=status, data=data)
356376
if task:
357377
task_cache.set(task_id, task)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
class ContextProvider:
2+
"""Base class for pluggable providers that attach extra context to a task's
3+
``data`` when it errors, e.g. so the TaskBadger UI can link out to an
4+
external system (Sentry, Rollbar, etc.).
5+
6+
Registered via `init(context_providers=[...])` and consulted whenever a
7+
tracked task (via `@track`, the Celery/Procrastinate integrations) errors.
8+
9+
Implementations that read back state some other system captured on its own
10+
(rather than capturing it themselves, which risks duplicate reporting)
11+
should override `snapshot` to record a baseline when the task starts, so
12+
`capture_error_context` can tell a fresh capture from a stale one left
13+
over from something unrelated.
14+
"""
15+
16+
identifier: str = None
17+
18+
def snapshot(self):
19+
"""Called when a tracked task starts, before user code runs. Return an
20+
opaque value to be passed back as *snapshot* to `capture_error_context`.
21+
Default: `None` (no baseline tracking).
22+
"""
23+
return None
24+
25+
def capture_error_context(self, exception: BaseException, snapshot=None) -> dict | None:
26+
"""Return extra context for *exception*, or `None` if there is nothing
27+
to add. The result is stored under `data[self.identifier]`.
28+
29+
*snapshot* is whatever this provider's `snapshot()` returned when the
30+
task started.
31+
"""
32+
raise NotImplementedError
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

taskbadger/decorators.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from functools import wraps
33

4+
from ._error_context import capture_error_data, reset_error_context, start_error_context
45
from .mug import Session
56
from .safe_sdk import create_task_safe
67
from .sdk import StatusEnum
@@ -52,16 +53,19 @@ def _inner(*args, **kwargs):
5253
monitor_id=monitor_id,
5354
**task_kwargs,
5455
)
56+
token = start_error_context()
5557
try:
5658
result = func(*args, **kwargs)
5759
except Exception as e:
5860
_update_task(
5961
task,
6062
status=StatusEnum.ERROR,
61-
data={"exception": str(e)},
63+
data=capture_error_data(e),
6264
data_merge_strategy="default",
6365
)
6466
raise
67+
finally:
68+
reset_error_context(token)
6569

6670
_update_task(task, status=StatusEnum.SUCCESS)
6771
return result

taskbadger/mug.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from contextvars import ContextVar
55
from copy import deepcopy
66

7+
from taskbadger.context_providers import ContextProvider
78
from taskbadger.internal import AuthenticatedClient
89
from taskbadger.systems import System
910

@@ -21,6 +22,7 @@ class Settings:
2122
project_slug: str
2223
systems: dict[str, System] = dataclasses.field(default_factory=dict)
2324
before_create: Callback = None
25+
context_providers: list[ContextProvider] = dataclasses.field(default_factory=list)
2426

2527
def get_client(self):
2628
return AuthenticatedClient(self.base_url, self.token)

taskbadger/procrastinate.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import logging
1818
from contextvars import ContextVar
1919

20+
from ._error_context import capture_error_data, reset_error_context, start_error_context
2021
from ._heartbeat import heartbeat
2122
from ._integrations import (
2223
TERMINAL_STATES,
@@ -88,11 +89,14 @@ async def wrapped(*args, **kwargs):
8889
try:
8990
_update_status(tb_id, StatusEnum.PROCESSING)
9091
heartbeat.start(tb_id, _heartbeat_interval(task))
92+
ctx_token = start_error_context()
9193
try:
9294
result = await original_func(*args, **kwargs)
9395
except Exception as exc:
9496
_update_status(tb_id, StatusEnum.ERROR, exception=exc)
9597
raise
98+
finally:
99+
reset_error_context(ctx_token)
96100
_update_status(tb_id, StatusEnum.SUCCESS)
97101
return result
98102
finally:
@@ -109,11 +113,14 @@ def wrapped(*args, **kwargs):
109113
try:
110114
_update_status(tb_id, StatusEnum.PROCESSING)
111115
heartbeat.start(tb_id, _heartbeat_interval(task))
116+
ctx_token = start_error_context()
112117
try:
113118
result = original_func(*args, **kwargs)
114119
except Exception as exc:
115120
_update_status(tb_id, StatusEnum.ERROR, exception=exc)
116121
raise
122+
finally:
123+
reset_error_context(ctx_token)
117124
_update_status(tb_id, StatusEnum.SUCCESS)
118125
return result
119126
finally:
@@ -141,7 +148,7 @@ def _update_status(tb_id, status, exception=None):
141148
data = None
142149
if exception is not None and current is not None:
143150
base = dict(current.data) if current.data else None
144-
data = DefaultMergeStrategy().merge(base, {"exception": str(exception)})
151+
data = DefaultMergeStrategy().merge(base, capture_error_data(exception))
145152
if data is not None:
146153
updated = update_task_safe(tb_id, status=status, data=data)
147154
else:

taskbadger/sdk.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import warnings
66
from typing import Any
77

8+
from taskbadger._error_context import capture_error_data
9+
from taskbadger.context_providers import ContextProvider
810
from taskbadger.exceptions import (
911
ConfigurationError,
1012
MissingConfiguration,
@@ -63,6 +65,7 @@ def init(
6365
systems: list[System] = None,
6466
tags: dict[str, str] = None,
6567
before_create: Callback = None,
68+
context_providers: list[ContextProvider] = None,
6669
):
6770
"""Initialize Task Badger client.
6871
@@ -73,9 +76,13 @@ def init(
7376
For legacy API keys, *organization_slug* and *project_slug* are
7477
required and a deprecation warning is emitted.
7578
79+
Arguments:
80+
context_providers: Providers consulted when a tracked task errors, to attach extra
81+
context (e.g. a Sentry issue link) to the task's `data`. See `taskbadger.context_providers`.
82+
7683
Call this function once per thread.
7784
"""
78-
_init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create)
85+
_init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create, context_providers)
7986

8087

8188
def _init(
@@ -86,6 +93,7 @@ def _init(
8693
systems: list[System] = None,
8794
tags: dict[str, str] = None,
8895
before_create: Callback = None,
96+
context_providers: list[ContextProvider] = None,
8997
):
9098
host = host or os.environ.get("TASKBADGER_HOST", "https://taskbadger.net")
9199
organization_slug = organization_slug or os.environ.get("TASKBADGER_ORG")
@@ -118,6 +126,7 @@ def _init(
118126
project_slug,
119127
systems={system.identifier: system for system in systems},
120128
before_create=before_create,
129+
context_providers=context_providers or [],
121130
)
122131
Badger.current.bind(settings, tags)
123132
else:
@@ -387,8 +396,19 @@ def success(self, value: int = None):
387396
"""Update the task status to `success` and set the value."""
388397
self.update(status=StatusEnum.SUCCESS, value=value)
389398

390-
def error(self, value: int = None, data: dict = None):
391-
"""Update the task status to `error` and set the value and data."""
399+
def error(self, value: int = None, data: dict = None, exception: BaseException = None):
400+
"""Update the task status to `error` and set the value and data.
401+
402+
If `exception` is given, it's passed to any configured context providers
403+
(e.g. Sentry, see [taskbadger.context_providers][]) and the result merged into `data`.
404+
Called on its own (outside `@track` or the Celery/Procrastinate integrations), providers
405+
have no baseline to compare against, so e.g. `SentryContextProvider` will report whatever
406+
`sentry_sdk.last_event_id()` currently is.
407+
"""
408+
if exception is not None:
409+
error_data = capture_error_data(exception)
410+
error_data.update(data or {})
411+
data = error_data
392412
self.update(status=StatusEnum.ERROR, value=value, data=data)
393413

394414
def canceled(self):

0 commit comments

Comments
 (0)