Skip to content

Commit f05371b

Browse files
snopokeclaude
andcommitted
Correct review findings and document the v2.5.2 opt-out fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5f3574c commit f05371b

6 files changed

Lines changed: 129 additions & 27 deletions

File tree

docs/changelog.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,22 @@ hide:
77

88
Full release notes for the Python SDK are available on [GitHub](https://github.com/taskbadger/taskbadger-python/releases).
99

10+
## v2.5.2
11+
12+
**2026-08-07**
13+
14+
**Python SDK**
15+
16+
* **FIX** [`taskbadger_track=False`](python-celery.md#opting-out) now opts a single Celery execution out of tracking, as an `apply_async` argument or in the message headers. Previously it could only ever enable tracking: it was ignored when the `CelerySystemIntegration` was auto-tracking, and `apply_async` overwrote it on tasks using `base=Task`.
17+
1018
## v2.5.1
1119

12-
**2026-08-06**
20+
**2026-08-07**
1321

1422
**Python SDK**
1523

1624
* **CHANGED** [`list_tasks`](python.md#listing-tasks) returns a `TaskList` of `taskbadger.Task` objects, which can be iterated over directly. It previously returned the generated `PaginatedTaskList`, whose `results` were internal models without the SDK's update methods. Note that an empty `TaskList` is falsy, where `PaginatedTaskList` was always truthy.
17-
* **FIX** Eager and canvas Celery tasks honour an explicit [`taskbadger_parent`](python-celery.md#subtasks), including `taskbadger_parent=None` to opt out of nesting.
25+
* **FIX** Eager Celery tasks honour an explicit [`taskbadger_parent`](python-celery.md#subtasks), including `taskbadger_parent=None` to opt out of nesting.
1826
* **FIX** Copying or pickling a `Task` no longer recurses until the stack overflows.
1927

2028
## v2.5.0

docs/data_model.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,12 @@ The main attributes or a task are:
8585

8686
: The ID of the task this task is part of. Tasks can only be nested one level deep, so the parent
8787
must be a task that is not itself a child. A task's parent can not be changed once it has been
88-
set. The `track` decorator and the Celery and Procrastinate integrations set this automatically
89-
for tasks enqueued from within a tracked task, and it can also be set explicitly when creating or
90-
updating a task.
88+
set. Both rules are enforced by the API, so breaking either one causes the request to fail.
89+
90+
It can be set explicitly when creating or updating a task. The Celery and Procrastinate
91+
integrations also set it automatically for tasks enqueued from within a tracked task, and the
92+
`track` decorator sets it on a decorated function called from within one. Each integration
93+
excludes some cases — see [Python SDK](python.md#parent-and-child-tasks) for details.
9194

9295
### Example Task
9396

docs/python-celery.md

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,17 @@ You can pass additional parameters to the Task Badger `Task` class which will be
110110
This can be done by passing keyword arguments prefixed with `taskbadger_` to the `.apply_async()` function or
111111
to the task decorator.
112112

113-
!!! warning "`taskbadger_` arguments require the task base class"
113+
!!! warning "`taskbadger_` arguments to `apply_async` require the task base class"
114114

115115
It is `taskbadger.celery.Task` that intercepts `taskbadger_`-prefixed arguments to `apply_async`, so
116-
they only work on tasks declared with `base=Task`. On a plain Celery task they are silently ignored:
116+
those only work on tasks declared with `base=Task`. On a plain Celery task they are silently ignored:
117117
the task still publishes, and is still tracked if the
118118
[system integration](#celery-system-integration) tracks it, but the options have no effect.
119119

120-
To customize a task that is tracked by the system integration alone, pass the options in the
120+
`taskbadger_` arguments on the **task decorator** are not affected. They are read off the task class
121+
when the task is published, so they apply whether or not the task uses `base=Task`.
122+
123+
To set options per call on a task that is tracked by the system integration alone, pass them in the
121124
message headers instead — see [Customization without the task base class](#customization-without-the-task-base-class).
122125

123126
```python
@@ -181,6 +184,16 @@ Unlike the `taskbadger_`-prefixed arguments, the header is read when the task is
181184
by the task class, so no `base=Task` is needed. It takes the same options as `taskbadger_kwargs`, minus
182185
the prefix, and takes precedence over values set on the task decorator.
183186

187+
!!! warning "Eager tasks read fewer options from the header"
188+
189+
That applies to tasks which are actually published. When Celery runs a task [eagerly][always_eager]
190+
nothing is published, so the Task Badger task is created as the task starts instead, reading the
191+
header directly. Only `parent`, `heartbeat_interval` and `stale_timeout` are honoured there —
192+
`name`, `value_max` and `data` are ignored.
193+
194+
`taskbadger_track` is also *required* for an eager task that doesn't use `base=Task`, even if the
195+
system integration would otherwise track it.
196+
184197
If the task would not otherwise be tracked — it isn't using the base class and doesn't match the system
185198
integration's tracking rules — add `taskbadger_track` to the headers to track it anyway:
186199

@@ -355,18 +368,30 @@ task a root task even though it was published from inside a tracked task:
355368

356369
```python
357370
# nest under a different task
358-
my_task.apply_async(taskbadger_parent=other_task.id)
371+
my_task.apply_async(args=[arg1, arg2], taskbadger_parent=parent_task.id)
359372

360373
# opt out of nesting
361-
my_task.apply_async(taskbadger_parent=None)
374+
my_task.apply_async(args=[arg1, arg2], taskbadger_parent=None)
362375
```
363376

364-
As with the other `taskbadger_` arguments this requires the task to use `base=Task`; without it, pass
365-
`headers={"taskbadger_kwargs": {"parent": None}}` instead. See
377+
`taskbadger_parent` takes a **Task Badger** task ID, not a Celery task or result ID.
378+
379+
As with the other `taskbadger_` arguments to `apply_async` this requires the task to use `base=Task`;
380+
without it, pass `headers={"taskbadger_kwargs": {"parent": None}}` instead. See
366381
[Customization without the task base class](#customization-without-the-task-base-class).
367382

368-
==Since v2.5.1== eager tasks and [canvas primitives](#canvas-primitives-map-starmap-chunks) honour
369-
`taskbadger_parent` too; before that they always nested under the enclosing task.
383+
==Since v2.5.1== eager tasks honour `taskbadger_parent`; before that they always nested under the
384+
enclosing task.
385+
386+
!!! warning "Canvas primitives ignore `taskbadger_parent`"
387+
388+
[Canvas primitives](#canvas-primitives-map-starmap-chunks) (`map`, `starmap`, `chunks`) are
389+
published under Celery's own `celery.map` / `celery.starmap` task rather than your own, so
390+
`taskbadger_parent` is never intercepted and the `taskbadger_kwargs` header doesn't reach the
391+
worker. On a worker their parent can't be set per call, and they are not nested at all.
392+
393+
Run eagerly they do nest under the enclosing task, and there the parent can be overridden with
394+
`headers={"taskbadger_kwargs": {"parent": ...}}`.
370395

371396
## External ID
372397

@@ -376,8 +401,42 @@ originating Celery task.
376401

377402
## Opting out
378403

379-
If you want to prevent TaskBadger from tracking a particular execution, set the `taskbadger_track` header (False) when publishing:
404+
To stop Task Badger tracking a single execution, set `taskbadger_track` to `False`, either as an
405+
argument to `apply_async` or in the message headers:
380406

381407
```python
408+
# as an argument — needs base=Task
409+
my_task.apply_async(args=[arg1, arg2], taskbadger_track=False)
410+
411+
# in the headers — works for any task
412+
my_task.apply_async(args=[arg1, arg2], headers={"taskbadger_track": False})
413+
414+
# canvas primitives take the header form only
382415
add.map([(1, 2), (2, 3)]).apply_async(headers={"taskbadger_track": False})
383416
```
417+
418+
This takes precedence over the `CelerySystemIntegration`, so it opts out even when `auto_track_tasks`
419+
is on, and over the `base=Task` class.
420+
421+
The value must be exactly `False`. Omitting it leaves tracking to the normal rules.
422+
423+
!!! warning "The argument form needs the task base class"
424+
425+
As with the other [`taskbadger_` arguments to `apply_async`](#task-customization),
426+
`taskbadger_track=False` is intercepted by `taskbadger.celery.Task`, so it only works on tasks
427+
declared with `base=Task`. On a plain Celery task, or on a
428+
[canvas primitive](#canvas-primitives-map-starmap-chunks), it is silently ignored and the task
429+
stays tracked.
430+
431+
The header form works in all three cases, so prefer it unless you know the task uses `base=Task`.
432+
433+
To exclude a task from tracking on every call rather than per execution, use the `excludes` argument to
434+
[`CelerySystemIntegration`](#celery-system-integration), which matches on task name.
435+
436+
!!! warning "Only works from v2.5.2"
437+
438+
==Since v2.5.2==
439+
440+
Before that this header could only ever *enable* tracking, never suppress it. `False` was
441+
indistinguishable from omitting the header, and `apply_async` on a `base=Task` task overwrote it
442+
with `True`. On earlier versions use `excludes` instead.

docs/python-decorator.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,14 @@ from the body, as well as [Celery](python-celery.md#subtasks) and
3030
[Procrastinate](python-procrastinate.md#subtasks) tasks enqueued from it. A bare `Task.create` in the
3131
function body is not nested unless you pass `parent` yourself.
3232

33+
Each integration carves out some exceptions — Celery, for instance, doesn't nest chain successors,
34+
`link` callbacks, or canvas primitives running on a worker. The [Celery](python-celery.md#subtasks) and
35+
[Procrastinate](python-procrastinate.md#subtasks) pages list what does and doesn't get nested.
36+
3337
Tasks nest a single level deep, so anything created by a decorated function that is itself a child
3438
becomes a sibling of that child rather than a grandchild. Passing `parent` to the decorator explicitly
3539
overrides the automatic nesting.
3640

3741
## API Docs
3842

39-
::: taskbadger.track
43+
::: taskbadger.track

docs/python-procrastinate.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ A job deferred from inside a tracked task is automatically nested under it via t
216216
Tasks nest a single level deep, so a job deferred by a task that is itself a child becomes a sibling
217217
of that child rather than a grandchild.
218218

219-
Nesting relies on the same wrapping as the rest of the integration, so the deferrals listed under
219+
Nesting relies on the same wrapping as the rest of the integration, so the cases listed under
220220
[Known Limitations](#known-limitations) are neither tracked nor nested.
221221

222222
## External ID

docs/python.md

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ for task in taskbadger.list_tasks(page_size=50):
8686
task.canceled()
8787
```
8888

89-
The `next_` and `previous` attributes hold the URL of the adjacent page, or are unset if there isn't
90-
one. To fetch the next page, pass its `cursor` query parameter back to `list_tasks`:
89+
The `next_` and `previous` attributes hold the URL of the adjacent page, or `None` if there isn't one.
90+
To fetch the next page, pass its `cursor` query parameter back to `list_tasks`:
9191

9292
```python
9393
from urllib.parse import parse_qs, urlparse
@@ -127,7 +127,8 @@ child = Task.create("import.chunk", parent=parent.id)
127127
```
128128

129129
Tasks nest a single level deep, so `parent` must be the ID of a task that is not itself a child.
130-
A task's parent can not be changed once it has been set.
130+
A task's parent can not be changed once it has been set. Both rules are enforced by the API rather
131+
than by the SDK, so breaking either one surfaces as a failed request.
131132

132133
Use `list_tasks` to fetch the children of a task:
133134

@@ -141,7 +142,11 @@ for child in taskbadger.list_tasks(parent=parent.id):
141142
`Task.create` and `create_task` only nest a task when you pass `parent` explicitly. The
142143
[function decorator](python-decorator.md#nested-tasks), [Celery](python-celery.md#subtasks) and
143144
[Procrastinate](python-procrastinate.md#subtasks) integrations do it for you: a task enqueued while
144-
another tracked task is running is nested under it automatically.
145+
another tracked task is running is nested under it automatically. Because nesting is capped at one
146+
level, a task enqueued from within a *child* becomes a sibling of that child rather than a grandchild.
147+
148+
Each integration excludes a few cases from the automatic nesting — see the linked pages for what
149+
does and doesn't get nested.
145150

146151
### Connection management
147152

@@ -217,9 +222,24 @@ def before_create(task_data: dict) -> dict:
217222

218223
Context providers attach extra data to a task when it errors, for example a link back to the system
219224
that reported the exception. They are consulted whenever a tracked task fails — via the
220-
[function decorator](python-decorator.md), the [Celery](python-celery.md) or
221-
[Procrastinate](python-procrastinate.md) integrations, or `Task.error(exception=...)` — and whatever
222-
a provider returns is stored on the task [`data`](data_model.md#data) under the provider's identifier.
225+
[function decorator](python-decorator.md), or the [Celery](python-celery.md) or
226+
[Procrastinate](python-procrastinate.md) integrations — and whatever a provider returns is stored on
227+
the task [`data`](data_model.md#data) under the provider's identifier.
228+
229+
You can also call `Task.error(exception=...)` yourself, but read the caveat below first.
230+
231+
!!! warning "`Task.error()` on its own has no baseline"
232+
233+
Those three integrations record the state of each provider as the task *starts*, so a provider can
234+
tell context belonging to this task from context left over from something unrelated.
235+
236+
Calling `task.error(exception=...)` directly does consult the providers, but nothing took that
237+
baseline, so they have nothing to compare against. With
238+
[`SentryContextProvider`](#sentry) that means the task gets a link to whatever
239+
`sentry_sdk.last_event_id()` happens to be — quite possibly a stale, unrelated issue.
240+
241+
Prefer letting an integration own the error path. If you must call `Task.error` yourself and the
242+
link matters, pass the context in explicitly via `data` instead of relying on a provider.
223243

224244
Providers are registered with `taskbadger.init`:
225245

@@ -238,8 +258,9 @@ task update.
238258

239259
### Sentry
240260

241-
`SentryContextProvider` links a failed task to the Sentry issue for the same exception. It requires
242-
the `sentry-sdk` package, available via the `sentry` extra:
261+
`SentryContextProvider` links a failed task to the Sentry issue for the same exception. It needs the
262+
`sentry-sdk` package, available via the `sentry` extra. If the package isn't installed the provider is
263+
a silent no-op — it adds no context and reports no error, so install the extra:
243264

244265
```bash
245266
uv add 'taskbadger[sentry]'
@@ -271,11 +292,18 @@ Failed tasks then carry the Sentry event ID in their data, plus a link to the is
271292

272293
Pass `base_url` if you are running a self-hosted Sentry.
273294

295+
The `exception` value is `str(exception)`, except on the Celery path, where Celery's own exception info
296+
is used and the value is a full traceback.
297+
274298
!!! note
275299

276300
The provider does not report the exception to Sentry itself. It assumes your application already
277301
does that (e.g. via a framework integration) and reads back the event ID, which avoids reporting
278-
the same exception twice. If the exception never reaches Sentry, no context is added.
302+
the same exception twice.
303+
304+
To avoid linking to an unrelated event, the provider records Sentry's current event ID when the
305+
task starts and only attaches context if it has changed by the time the task errors. So no context
306+
is added when the exception never reaches Sentry, or when Sentry saw nothing new while the task ran.
279307

280308
### Custom providers
281309

0 commit comments

Comments
 (0)