django-lambda-tasks 0.11.1__tar.gz → 0.11.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/steering/product.md +14 -13
  2. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/steering/structure.md +3 -3
  3. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/steering/tech.md +1 -1
  4. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/PKG-INFO +25 -16
  5. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/README.md +24 -15
  6. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/README.md +13 -0
  7. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_project/sentry.py +13 -6
  8. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/handler.py +39 -15
  9. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/models.py +3 -3
  10. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/sentry.py +31 -16
  11. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/pyproject.toml +1 -1
  12. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_handler.py +140 -0
  13. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_sentry.py +79 -22
  14. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.github/dependabot.yml +0 -0
  15. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.github/workflows/ci.yml +0 -0
  16. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.github/workflows/release.yml +0 -0
  17. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.gitignore +0 -0
  18. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  19. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/async-local-execution/design.md +0 -0
  20. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/async-local-execution/requirements.md +0 -0
  21. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/async-local-execution/tasks.md +0 -0
  22. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  23. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  24. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  25. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  26. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  27. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  28. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  29. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  30. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  31. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  32. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  33. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  34. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  35. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  36. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  37. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  38. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/retry-delay/.config.kiro +0 -0
  39. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/retry-delay/design.md +0 -0
  40. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/retry-delay/requirements.md +0 -0
  41. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/retry-delay/tasks.md +0 -0
  42. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  43. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks/design.md +0 -0
  44. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  45. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  46. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  47. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  48. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  49. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  50. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/singleton-task/.config.kiro +0 -0
  51. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/singleton-task/design.md +0 -0
  52. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/singleton-task/requirements.md +0 -0
  53. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/singleton-task/tasks.md +0 -0
  54. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  55. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  56. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  57. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  58. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/task-retry/.config.kiro +0 -0
  59. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/task-retry/design.md +0 -0
  60. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/task-retry/requirements.md +0 -0
  61. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.kiro/specs/task-retry/tasks.md +0 -0
  62. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.pre-commit-config.yaml +0 -0
  63. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/.vscode/settings.json +0 -0
  64. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/docker-compose.yml +0 -0
  65. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/docker-compose.yml +0 -0
  66. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_app/__init__.py +0 -0
  67. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_app/apps.py +0 -0
  68. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_app/tasks.py +0 -0
  69. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_app/urls.py +0 -0
  70. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_app/views.py +0 -0
  71. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_project/__init__.py +0 -0
  72. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_project/settings.py +0 -0
  73. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_project/urls.py +0 -0
  74. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/example_project/wsgi.py +0 -0
  75. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/example/manage.py +0 -0
  76. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/__init__.py +0 -0
  77. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/admin.py +0 -0
  78. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/apps.py +0 -0
  79. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/checks.py +0 -0
  80. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/decorators.py +0 -0
  81. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/environment_loader.py +0 -0
  82. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/local_executor.py +0 -0
  83. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/logging.py +0 -0
  84. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/migrations/0001_initial.py +0 -0
  85. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  86. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/migrations/0003_taskrecord_taskrecord_kwargs_trgm.py +0 -0
  87. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/migrations/0004_remove_taskrecord_taskrecord_kwargs_trgm_and_more.py +0 -0
  88. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/migrations/__init__.py +0 -0
  89. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/secret_loader.py +0 -0
  90. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/settings.py +0 -0
  91. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/tasks.py +0 -0
  92. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/lambda_tasks/timeouts.py +0 -0
  93. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/conftest.py +0 -0
  94. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/settings.py +0 -0
  95. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_admin.py +0 -0
  96. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_batch.py +0 -0
  97. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_batch_worker.py +0 -0
  98. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_checks.py +0 -0
  99. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_decorator.py +0 -0
  100. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_decorators.py +0 -0
  101. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_deferred_enqueue.py +0 -0
  102. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_environment_loader.py +0 -0
  103. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_kwargs_only.py +0 -0
  104. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_local_executor.py +0 -0
  105. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_logging.py +0 -0
  106. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_memory_limit.py +0 -0
  107. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_models.py +0 -0
  108. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_noop_execution.py +0 -0
  109. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_secret_loader.py +0 -0
  110. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_serializer.py +0 -0
  111. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_settings.py +0 -0
  112. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_tasks.py +0 -0
  113. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_timeout_validation.py +0 -0
  114. {django_lambda_tasks-0.11.1 → django_lambda_tasks-0.11.2}/tests/test_timeouts.py +0 -0
@@ -315,29 +315,30 @@ def my_task(*, user_id: int) -> None:
315
315
 
316
316
  ## Sentry
317
317
 
318
- The library does not call `sentry_sdk.init(...)` — the host application owns Sentry setup (typically `DjangoIntegration` + `AwsLambdaIntegration`). The problem those integrations leave is attribution: on Lambda, `AwsLambdaIntegration` names every event's transaction after the Lambda function, so all tasks look alike; on AWS Batch there is no request and no Lambda integration, so the transaction/URL are `"--"`. Either way you cannot tell which task ran or raised.
318
+ The library does not call `sentry_sdk.init(...)` — the host application owns Sentry setup. The **recommended** host configuration is `DjangoIntegration` + `LoggingIntegration(event_level=logging.ERROR)`, and **without** `AwsLambdaIntegration`. `AwsLambdaIntegration` names every event's transaction after the Lambda function, which hides which task ran; omitting it lets each task be traced under its own task-named transaction.
319
319
 
320
- `lambda_tasks.sentry.task_scope(*, task_name, message_id, n_retries)` fixes this. `execute_immediately` wraps the task body in it, which — when a Sentry client is active — opens an isolated `sentry_sdk.new_scope()` and traces the task. Trace shape depends on whether a trace is already active:
320
+ `lambda_tasks.sentry.task_scope(*, task_name, message_id, n_retries)` does the per-task tracing. `execute_immediately` wraps the task body in it, which — when a Sentry client is active — opens an isolated `sentry_sdk.new_scope()` and traces the task. With `AwsLambdaIntegration` removed there is no invocation transaction competing for the name, so on **both** Lambda and Batch the task starts its **own transaction named after the task**. It records, both on the span and as scope tags:
321
321
 
322
- - **Lambda** — `AwsLambdaIntegration` already started an invocation transaction, so the task is traced as a **child span** of it (not a second transaction). This is deliberate: a nested *transaction* per task inherited (rather than independently re-rolled) the invocation's sampling decision, doubling transaction volume and defeating `traces_sample_rate`. A child span is sent only if its parent is sampled, so sampling is respected. `_start_task_root` picks child-vs-root by checking `sentry_sdk.get_current_scope().transaction` (2.x only; on 3.0 `start_span` is automatically root-or-child).
323
- - **Batch** (nothing active) — the task starts its **own root transaction** so it is still traced.
324
-
325
- It records, both on the span and as scope tags:
326
-
327
- - `lambda_task` = fully-qualified task name (e.g. `myapp.tasks.my_task`) — also the task span's name
322
+ - `lambda_task` = fully-qualified task name (e.g. `myapp.tasks.my_task`) — also the transaction/span name
328
323
  - `lambda_task.message_id` = the `TaskRecord` primary key
329
324
  - `lambda_task.n_retries` = the retry counter
330
325
  - `lambda_task.runtime` = the execution environment, from the AWS environment: `AWS_LAMBDA_FUNCTION_NAME` on Lambda or `AWS_BATCH_JQ_NAME` (Batch job queue name); omitted when neither is present
331
326
 
332
- `task_scope` deliberately does **not** call `scope.set_transaction_name(task_name)`: on Lambda that would rename the shared invocation transaction (fighting across sibling records in a batch) and, on SDK 3.0 stream mode, corrupt the root-segment naming. Error attribution relies on the `lambda_task` **tag** instead, which is reliable on both SDKs and independent of sampling.
327
+ Error attribution also relies on the `lambda_task` **tag**, so it is reliable even when the trace is not sampled.
328
+
329
+ **If a host keeps `AwsLambdaIntegration` anyway:** the library still behaves correctly. `_start_task_root` detects the active invocation transaction (via `sentry_sdk.get_current_scope().transaction` on 2.x; on 3.0 `start_span` is automatically root-or-child) and traces the task as a **child span** of it rather than a second, competing transaction. A nested *transaction* per task would inherit (not independently re-roll) the invocation's sampling decision, doubling transaction volume and defeating `traces_sample_rate`; a child span is sent only if its parent is sampled. In that case the transaction name is the Lambda function name and the task name lives on the `lambda_task` tag.
330
+
331
+ `task_scope` deliberately does **not** call `scope.set_transaction_name(task_name)`: when an invocation transaction is active it would rename the shared transaction (fighting across sibling records in a batch) and, on SDK 3.0 stream mode, corrupt the root-segment naming. The `lambda_task` tag is the reliable, version-independent attribution.
332
+
333
+ **Reporting task failures:** the executor catches task exceptions (recording `FAILED` and not re-raising). On the two **terminal** failure branches in `models.py` (plain failure and max-retries-exceeded) it logs the exception at ERROR via `task_logger.error(error, exc_info=True)`. With the assumed host config `LoggingIntegration(event_level=logging.ERROR)`, that log becomes a Sentry event, attributed to the task by the active `task_scope` tags. No explicit `capture_exception` call is made — the error log is the single reporting path, so there are no duplicate events. The **retry** branch logs at WARNING and is deliberately not reported — transient, to-be-re-enqueued failures would flood Sentry; only terminal failures are reported.
333
334
 
334
- **Reporting task failures:** the executor catches task exceptions (recording `FAILED` and not re-raising), so `lambda_tasks.sentry.capture_task_exception()` is called on the two **terminal** failure branches in `models.py` (plain failure and max-retries-exceeded) to report the error via `sentry_sdk.capture_exception()`. This is essential on 3.0, where a merely-caught exception produces no error event. The **retry** branch deliberately does not capture — transient, to-be-re-enqueued failures would flood Sentry; only terminal failures are reported.
335
+ **Reporting failures outside a task:** pre-execution failures in the handler (malformed SQS message, un-importable task, misconfiguration, cold-start errors) happen before `task_scope` opens. `handler.py` logs them at ERROR inside `lambda_tasks.sentry.handler_error_scope(message_id=...)`, a context manager that tags an isolated scope (`lambda_task.handler_error` + the message id) so the resulting log-driven Sentry event is identifiable. This relies on the same `LoggingIntegration(event_level=ERROR)` as task failures. Cold-start failures are additionally re-raised so AWS marks the invocation failed and redrives.
335
336
 
336
- **Error-level log forwarding:** on sentry-sdk 2.x, `LoggingIntegration` defaulted to `event_level=ERROR`, so `task_logger.error(...)` became Sentry events automatically. On 3.0 the default is `None`, so hosts that want error logs reported must configure `LoggingIntegration(event_level=logging.ERROR)` explicitly (the example app does this). This is a host-init concern, not something the library sets.
337
+ **Error-level log forwarding (required):** both task-failure and handler-failure reporting flow through `logger.error(..., exc_info=True)` + the host's `LoggingIntegration(event_level=logging.ERROR)`. On sentry-sdk 2.x `event_level=ERROR` was the default; on 3.0 the default is `None`, so the host **must** configure it explicitly for error reporting to work (it is part of the recommended config and the example app). The library does not call `capture_exception` itself, so this integration is not optional for error visibility.
337
338
 
338
- **SDK version compatibility:** `task_scope` is future-proof across the sentry-sdk 2.x→3.0 boundary and verified against both 2.74 and 3.0.0b4. On 2.x it uses `start_transaction` + `set_tag` for a root and `start_span` for a child; on 3.0 (stream mode) `start_span` is both root and child and metadata is attached via `set_attribute`. The correct API is detected at runtime via `hasattr`, so no code change is needed when the host upgrades the SDK.
339
+ **SDK version compatibility:** the integration is future-proof across the sentry-sdk 2.x→3.0 boundary and verified against both 2.74 and 3.0.0b4. On 2.x it uses `start_transaction` + `set_tag` for a root and `start_span` for a child; on 3.0 (stream mode) `start_span` is both root and child and metadata is attached via `set_attribute`. The correct API is detected at runtime via `hasattr`, so no code change is needed when the host upgrades the SDK.
339
340
 
340
- `task_scope` and `capture_task_exception` are no-ops when `sentry_sdk` is not installed or no client is initialised, so Sentry is not a dependency of the library. `sentry-sdk` is a dev dependency only (pinned `>=2,<3`), for the test suite. No custom Sentry integration is needed — per-task tracing at the one execution entry point covers both Lambda and Batch.
341
+ `task_scope` and `handler_error_scope` are no-ops when `sentry_sdk` is not installed or no client is initialised, so Sentry is not a dependency of the library. `sentry-sdk` is a dev dependency only (pinned `>=2,<3`), for the test suite; a dedicated CI job additionally runs the suite against the latest sentry-sdk 3.x pre-release on Python 3.14.
341
342
 
342
343
  ## Built-in Tasks
343
344
 
@@ -16,7 +16,7 @@ django-lambda-tasks/
16
16
  │ ├── decorators.py # LambdaTaskWrapper, @lambda_task
17
17
  │ ├── handler.py # AWS Lambda entry point + AWS Batch container entry point (main)
18
18
  │ ├── logging.py # task_logger — invocation-scoped LoggerAdapter
19
- │ ├── sentry.py # task_scope + capture_task_exception — per-task trace, tags, failure reporting
19
+ │ ├── sentry.py # task_scope + handler_error_scope — per-task trace, tags, failure attribution
20
20
  │ ├── models.py # TaskRecord, SQSLambdaTaskMessage, SQSLambdaTask
21
21
  │ ├── settings.py # LambdaTasksSettings, constants, queue type helpers
22
22
  │ ├── secret_loader.py # Resolves LAMBDA_TASKS_SECRET_* env vars at cold start
@@ -44,11 +44,11 @@ django-lambda-tasks/
44
44
  - `decorators.py` — defines `LambdaTaskWrapper` and `@lambda_task` decorator factory; enforces kwargs-only at decoration time; validates timeouts (> 0, soft < hard) at decoration time; upper-bound timeout validation deferred to execution time based on queue type
45
45
  - `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (Pydantic, SQS schema + execution logic; `execute_immediately` owns the logging + Sentry context and delegates the run to a private `_run` method), `SQSLambdaTask` (Pydantic, holds message + routing; `_execute()` inspects queue config to publish to SQS, submit to Batch via `submit_batch_job`, execute eagerly, or submit to the local process pool; `execute_on_commit()` registers `_execute` with `transaction.on_commit`)
46
46
  - `local_executor.py` — `ProcessPoolExecutor`-based async local execution; `get_pool()` lazily creates a module-level pool; `submit_task()` fire-and-forget submission; `_execute_in_worker()` deserializes and runs the task in a child process; `_pool_initializer()` calls `django.setup()` per worker (and sets `SIGINT` to `SIG_IGN` so workers ignore Ctrl+C); `_install_shutdown_handlers()` installs main-thread `SIGINT`/`SIGTERM` handlers that release the pool before chaining to the previous handler
47
- - `handler.py` — Lambda entry point (`handler(event, context)`); cold-start init (memory limit → temporary log handler → `resolve_environment()` → `resolve_secrets_into_env()` → handler removed → conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; calls `db.close_old_connections()` before each SQS record to handle stale connections on warm starts; processes SQS records independently; returns `batchItemFailures`. Also provides `main()` as the AWS Batch container entry point (`python -m lambda_tasks.handler`) — reads `LAMBDA_TASKS_MESSAGE` env var, constructs a synthetic SQS event, and delegates to `handler()`
47
+ - `handler.py` — Lambda entry point (`handler(event, context)`); cold-start init (memory limit → temporary log handler → `resolve_environment()` → `resolve_secrets_into_env()` → handler removed → conditional `django.setup()`) runs inside the handler on first invocation, guarded by `_cold_start_done` sentinel; calls `db.close_old_connections()` before each SQS record to handle stale connections on warm starts; processes SQS records independently; returns `batchItemFailures`; logs pre-execution failures (malformed message, import error, misconfiguration) at ERROR inside `handler_error_scope` so the host's `LoggingIntegration` reports them tagged `lambda_task.handler_error`; cold-start failures are reported the same way and re-raised. Also provides `main()` as the AWS Batch container entry point (`python -m lambda_tasks.handler`) — reads `LAMBDA_TASKS_MESSAGE` env var, constructs a synthetic SQS event, and delegates to `handler()`
48
48
  - `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
49
49
  - `secret_loader.py` — resolves `LAMBDA_TASKS_SECRET_*` env vars from Secrets Manager before Django starts; validates format, detects conflicts, batches API calls; idempotent via `_loaded` sentinel
50
50
  - `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
51
- - `sentry.py` — `task_scope` context manager + `capture_task_exception` helper. `task_scope` opens an isolated `new_scope` and traces the task: a **child span** of the invocation transaction on Lambda (where `AwsLambdaIntegration` already started one), or its **own root transaction** on Batch (nothing active). The child-span-on-Lambda behaviour avoids a second per-task transaction that would double trace volume and defeat `traces_sample_rate`. Records `lambda_task` / `lambda_task.message_id` / `lambda_task.n_retries` / `lambda_task.runtime` as span metadata and scope tags (runtime read from `AWS_LAMBDA_FUNCTION_NAME` or `AWS_BATCH_JQ_NAME`). Deliberately does not call `set_transaction_name` (would rename the shared invocation transaction and break 3.0 segment naming); error attribution rides on the `lambda_task` tag. `capture_task_exception` calls `sentry_sdk.capture_exception()` and is invoked on the terminal failure branches in `models.py` (plain failure + max-retries), since the executor catches exceptions without re-raising and 3.0 does not auto-report caught exceptions; the retry branch does not capture. Future-proof across the SDK 2.x→3.0 boundary (verified against 2.74 and 3.0.0b4): `start_transaction`/`set_tag`/child `start_span` on 2.x, `start_span`/`set_attribute` on 3.0, detected at runtime via `hasattr`. No-op when `sentry_sdk` is absent or uninitialised (`sentry-sdk` is a dev dependency only, pinned `>=2,<3`). Wrapped around the task body in `SQSLambdaTaskMessage.execute_immediately`. Note: `logger.error → Sentry event` needs `LoggingIntegration(event_level=logging.ERROR)` in the host init on 3.0 (default on 2.x); a host concern, demonstrated in the example app
51
+ - `sentry.py` — `task_scope` context manager + `handler_error_scope` context manager. `task_scope` opens an isolated `new_scope` and traces the task. With the **recommended** host config (no `AwsLambdaIntegration`) nothing is active, so the task starts its **own transaction named after the task** on both Lambda and Batch. If a host keeps `AwsLambdaIntegration`, the task is instead traced as a **child span** of the active invocation transaction (detected via `sentry_sdk.get_current_scope().transaction` on 2.x; `start_span` is auto root-or-child on 3.0) — never a second, competing transaction, which would double trace volume and defeat `traces_sample_rate`. Records `lambda_task` / `lambda_task.message_id` / `lambda_task.n_retries` / `lambda_task.runtime` as span metadata and scope tags (runtime read from `AWS_LAMBDA_FUNCTION_NAME` or `AWS_BATCH_JQ_NAME`). Deliberately does not call `set_transaction_name` (would rename an active invocation transaction and break 3.0 segment naming); error attribution rides on the `lambda_task` tag. **Failure reporting** is via error logs, not explicit captures: the terminal failure branches in `models.py` (plain failure + max-retries) call `task_logger.error(error, exc_info=True)`, and the host's `LoggingIntegration(event_level=ERROR)` turns that into the Sentry event (the retry branch logs at WARNING and is not reported). `handler_error_scope(message_id=...)` is a context manager used in `handler.py` to tag an isolated scope (`lambda_task.handler_error` + message id) around the handler's `logger.error`, so pre-task failures are reported and identifiable via the same LoggingIntegration. Future-proof across the SDK 2.x→3.0 boundary (verified against 2.74 and 3.0.0b4): `start_transaction`/`set_tag`/child `start_span` on 2.x, `start_span`/`set_attribute` on 3.0, detected at runtime via `hasattr`. No-op when `sentry_sdk` is absent or uninitialised (`sentry-sdk` is a dev dependency only, pinned `>=2,<3`). Wrapped around the task body in `SQSLambdaTaskMessage.execute_immediately`. Error reporting requires `LoggingIntegration(event_level=logging.ERROR)` in the host init (default on 2.x, must be set on 3.0); demonstrated in the example app
52
52
  - `settings.py` — constants (`MAX_DELAY`, `MAX_TIMEOUT`, `MAX_BATCH_TIMEOUT`); `_is_sqs_queue()` and `_is_batch_queue()` helpers; `LambdaTasksSettings` instantiated fresh per use (reads live Django settings); `queue_max_timeout()` method returns 900 or 3600 based on queue type
53
53
  - `admin.py` — Django admin registration for `TaskRecord`
54
54
  - `apps.py` — Django `AppConfig`; `ready()` imports `checks` to register deployment checks and installs the local-executor shutdown signal handlers when `LOCAL_WORKERS > 0`
@@ -23,7 +23,7 @@ inclusion: always
23
23
  |---|---|---|
24
24
  | `pytest` | `>= 9.0.2` | Test runner |
25
25
  | `hypothesis` | present | Property-based testing (`.hypothesis/` directory exists) |
26
- | `sentry-sdk` | `>=2,<3` | Testing the Sentry integration (`task_scope`, `capture_task_exception`); verified against 2.74 and 3.0.0b4. Not a runtime dependency |
26
+ | `sentry-sdk` | `>=2,<3` | Testing the Sentry integration (`task_scope`, `handler_error_scope`); verified against 2.74 and 3.0.0b4. Not a runtime dependency |
27
27
 
28
28
  ## Package Manager
29
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-lambda-tasks
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: Run async tasks in a lambda function
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: awslambdaric
@@ -446,48 +446,57 @@ fields @timestamp, @message
446
446
 
447
447
  ### Sentry
448
448
 
449
- If your application uses Sentry, task errors are automatically attributed to the task that raised them. Without this, Sentry's `AwsLambdaIntegration` names every event's transaction after the Lambda function (so all tasks look the same), and on AWS Batch there is no transaction at all (it shows as `"--"`) — so you cannot tell which task failed.
449
+ If your application uses Sentry, task errors are automatically attributed to the task that raised them, and each task is traced under its own task-named transaction.
450
450
 
451
- Keep your existing `sentry_sdk.init(...)`. On **sentry-sdk 2.x** no change is needed. On **sentry-sdk 3.0+** one option changed default and must be set explicitly if you want error-level logs reported (see below):
451
+ #### Recommended `sentry_sdk.init(...)`
452
+
453
+ Use `DjangoIntegration` + `LoggingIntegration(event_level=logging.ERROR)`, and **do not** add `AwsLambdaIntegration`:
452
454
 
453
455
  ```python
454
456
  import logging
457
+ from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
458
+ from sentry_sdk.integrations.django import DjangoIntegration
455
459
  from sentry_sdk.integrations.logging import LoggingIntegration
456
460
 
457
461
  sentry_sdk.init(
458
462
  dsn=SENTRY_DSN,
459
463
  integrations=[
460
464
  DjangoIntegration(),
461
- AwsLambdaIntegration(),
462
- # Only needed on sentry-sdk 3.0+: its default event_level is None, so
463
- # logger.error(...) no longer becomes a Sentry event. On 2.x this was
464
- # the default and setting it is harmless.
465
465
  LoggingIntegration(event_level=logging.ERROR),
466
466
  ],
467
+ disabled_integrations=[
468
+ AwsLambdaIntegration(),
469
+ ],
467
470
  traces_sample_rate=0.1,
468
471
  )
469
472
  ```
470
473
 
471
- #### What the library does per task
474
+ Two deliberate choices:
472
475
 
473
- When a task runs, `execute_immediately()` opens an isolated Sentry scope and traces the task:
476
+ - **No `AwsLambdaIntegration`.** It names every event's transaction after the Lambda function, which hides which task ran — all tasks would look the same. Without it, each task's trace is named after the task (see below). You lose `AwsLambdaIntegration`'s automatic reporting of handler-level failures, but the library reports those itself (see "Failures outside a task"), so nothing is lost in practice.
477
+ - **`LoggingIntegration(event_level=logging.ERROR)`.** This makes `logger.error(...)` (including `task_logger`) become Sentry events. On sentry-sdk 2.x `event_level=ERROR` was the default; on 3.0 the default is `None`, so you must set it explicitly. It is harmless on 2.x.
474
478
 
475
- - **On Lambda**, the task is traced as a **child span** of the invocation transaction that `AwsLambdaIntegration` already started — not a second transaction. This keeps task tracing from doubling your transaction volume and respects `traces_sample_rate`.
476
- - **On Batch** (no Lambda integration, nothing active), the task starts **its own root transaction** so it is still traced.
479
+ #### What the library does per task
477
480
 
478
- Either way it records, as both span metadata and scope tags:
481
+ When a task runs, `execute_immediately()` opens an isolated Sentry scope and traces the task. With `AwsLambdaIntegration` removed there is no invocation transaction competing for the name, so on **both** Lambda and Batch the task starts its **own transaction named after the task**. It records, as both span metadata and scope tags:
479
482
 
480
- - `lambda_task` — the fully-qualified task name (e.g. `myapp.tasks.send_welcome_email`). This is also the task span's name.
483
+ - `lambda_task` — the fully-qualified task name (e.g. `myapp.tasks.send_welcome_email`); also the transaction/span name.
481
484
  - `lambda_task.message_id`, `lambda_task.n_retries`
482
485
  - `lambda_task.runtime` — the execution environment, from the AWS environment: `AWS_LAMBDA_FUNCTION_NAME` on Lambda or `AWS_BATCH_JQ_NAME` (the Batch job queue name) on Batch. Omitted when neither is set (local/eager).
483
486
 
484
- Error attribution relies on the `lambda_task` **tag**, not on the transaction name, so it is reliable even when the trace is not sampled and regardless of whether the task ran as a child span (Lambda) or a root (Batch).
487
+ Error attribution also relies on the `lambda_task` **tag**, so it is reliable even when the trace is not sampled.
488
+
489
+ > If you keep `AwsLambdaIntegration` for other reasons, the library still does the right thing: it detects the active invocation transaction and traces the task as a **child span** of it (rather than a second, competing transaction, which would double trace volume and defeat `traces_sample_rate`). In that case the transaction name is the Lambda function name and the task name lives on the `lambda_task` tag.
485
490
 
486
491
  #### Task failures are reported
487
492
 
488
- The executor catches a failing task's exception (recording a `FAILED` `TaskRecord`) rather than re-raising it. So the library calls `sentry_sdk.capture_exception()` itself on the **terminal failure paths** — a plain failure and max-retries-exceeded — to report the error to Sentry. This is essential on 3.0, where a merely-caught exception produces no error event at all.
493
+ The executor catches a failing task's exception (recording a `FAILED` `TaskRecord`) rather than re-raising it. On the **terminal failure paths** — a plain failure and max-retries-exceeded — the library logs the exception at ERROR (`task_logger.error(..., exc_info=True)`), which your `LoggingIntegration(event_level=ERROR)` turns into a Sentry event, attributed to the task via the active scope tags. This is why that integration is part of the recommended config: without it, a merely-caught exception produces no error event on 3.0.
494
+
495
+ Transient, retryable failures (a task that will be re-enqueued) are logged at WARNING and **not** reported to Sentry, to avoid flooding on every retry; only the final, terminal failure is reported.
496
+
497
+ #### Failures outside a task
489
498
 
490
- Transient, retryable failures (a task that will be re-enqueued) are **not** reported, to avoid flooding Sentry on every retry; only the final, terminal failure is captured.
499
+ Pre-execution failures in the Lambda/Batch handler — a malformed SQS message, a task that cannot be imported, a misconfiguration, or a cold-start error — happen before the task scope opens and are caught by the handler. The handler logs them at ERROR inside a `handler_error_scope` that tags the event `lambda_task.handler_error` (plus the message id). With `LoggingIntegration(event_level=ERROR)` that log becomes a single, tagged Sentry event — so you are still alerted even though `AwsLambdaIntegration` is not installed. Cold-start failures are also re-raised so AWS marks the invocation failed and redrives it.
491
500
 
492
501
  #### SDK version compatibility
493
502
 
@@ -434,48 +434,57 @@ fields @timestamp, @message
434
434
 
435
435
  ### Sentry
436
436
 
437
- If your application uses Sentry, task errors are automatically attributed to the task that raised them. Without this, Sentry's `AwsLambdaIntegration` names every event's transaction after the Lambda function (so all tasks look the same), and on AWS Batch there is no transaction at all (it shows as `"--"`) — so you cannot tell which task failed.
437
+ If your application uses Sentry, task errors are automatically attributed to the task that raised them, and each task is traced under its own task-named transaction.
438
438
 
439
- Keep your existing `sentry_sdk.init(...)`. On **sentry-sdk 2.x** no change is needed. On **sentry-sdk 3.0+** one option changed default and must be set explicitly if you want error-level logs reported (see below):
439
+ #### Recommended `sentry_sdk.init(...)`
440
+
441
+ Use `DjangoIntegration` + `LoggingIntegration(event_level=logging.ERROR)`, and **do not** add `AwsLambdaIntegration`:
440
442
 
441
443
  ```python
442
444
  import logging
445
+ from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
446
+ from sentry_sdk.integrations.django import DjangoIntegration
443
447
  from sentry_sdk.integrations.logging import LoggingIntegration
444
448
 
445
449
  sentry_sdk.init(
446
450
  dsn=SENTRY_DSN,
447
451
  integrations=[
448
452
  DjangoIntegration(),
449
- AwsLambdaIntegration(),
450
- # Only needed on sentry-sdk 3.0+: its default event_level is None, so
451
- # logger.error(...) no longer becomes a Sentry event. On 2.x this was
452
- # the default and setting it is harmless.
453
453
  LoggingIntegration(event_level=logging.ERROR),
454
454
  ],
455
+ disabled_integrations=[
456
+ AwsLambdaIntegration(),
457
+ ],
455
458
  traces_sample_rate=0.1,
456
459
  )
457
460
  ```
458
461
 
459
- #### What the library does per task
462
+ Two deliberate choices:
460
463
 
461
- When a task runs, `execute_immediately()` opens an isolated Sentry scope and traces the task:
464
+ - **No `AwsLambdaIntegration`.** It names every event's transaction after the Lambda function, which hides which task ran — all tasks would look the same. Without it, each task's trace is named after the task (see below). You lose `AwsLambdaIntegration`'s automatic reporting of handler-level failures, but the library reports those itself (see "Failures outside a task"), so nothing is lost in practice.
465
+ - **`LoggingIntegration(event_level=logging.ERROR)`.** This makes `logger.error(...)` (including `task_logger`) become Sentry events. On sentry-sdk 2.x `event_level=ERROR` was the default; on 3.0 the default is `None`, so you must set it explicitly. It is harmless on 2.x.
462
466
 
463
- - **On Lambda**, the task is traced as a **child span** of the invocation transaction that `AwsLambdaIntegration` already started — not a second transaction. This keeps task tracing from doubling your transaction volume and respects `traces_sample_rate`.
464
- - **On Batch** (no Lambda integration, nothing active), the task starts **its own root transaction** so it is still traced.
467
+ #### What the library does per task
465
468
 
466
- Either way it records, as both span metadata and scope tags:
469
+ When a task runs, `execute_immediately()` opens an isolated Sentry scope and traces the task. With `AwsLambdaIntegration` removed there is no invocation transaction competing for the name, so on **both** Lambda and Batch the task starts its **own transaction named after the task**. It records, as both span metadata and scope tags:
467
470
 
468
- - `lambda_task` — the fully-qualified task name (e.g. `myapp.tasks.send_welcome_email`). This is also the task span's name.
471
+ - `lambda_task` — the fully-qualified task name (e.g. `myapp.tasks.send_welcome_email`); also the transaction/span name.
469
472
  - `lambda_task.message_id`, `lambda_task.n_retries`
470
473
  - `lambda_task.runtime` — the execution environment, from the AWS environment: `AWS_LAMBDA_FUNCTION_NAME` on Lambda or `AWS_BATCH_JQ_NAME` (the Batch job queue name) on Batch. Omitted when neither is set (local/eager).
471
474
 
472
- Error attribution relies on the `lambda_task` **tag**, not on the transaction name, so it is reliable even when the trace is not sampled and regardless of whether the task ran as a child span (Lambda) or a root (Batch).
475
+ Error attribution also relies on the `lambda_task` **tag**, so it is reliable even when the trace is not sampled.
476
+
477
+ > If you keep `AwsLambdaIntegration` for other reasons, the library still does the right thing: it detects the active invocation transaction and traces the task as a **child span** of it (rather than a second, competing transaction, which would double trace volume and defeat `traces_sample_rate`). In that case the transaction name is the Lambda function name and the task name lives on the `lambda_task` tag.
473
478
 
474
479
  #### Task failures are reported
475
480
 
476
- The executor catches a failing task's exception (recording a `FAILED` `TaskRecord`) rather than re-raising it. So the library calls `sentry_sdk.capture_exception()` itself on the **terminal failure paths** — a plain failure and max-retries-exceeded — to report the error to Sentry. This is essential on 3.0, where a merely-caught exception produces no error event at all.
481
+ The executor catches a failing task's exception (recording a `FAILED` `TaskRecord`) rather than re-raising it. On the **terminal failure paths** — a plain failure and max-retries-exceeded — the library logs the exception at ERROR (`task_logger.error(..., exc_info=True)`), which your `LoggingIntegration(event_level=ERROR)` turns into a Sentry event, attributed to the task via the active scope tags. This is why that integration is part of the recommended config: without it, a merely-caught exception produces no error event on 3.0.
482
+
483
+ Transient, retryable failures (a task that will be re-enqueued) are logged at WARNING and **not** reported to Sentry, to avoid flooding on every retry; only the final, terminal failure is reported.
484
+
485
+ #### Failures outside a task
477
486
 
478
- Transient, retryable failures (a task that will be re-enqueued) are **not** reported, to avoid flooding Sentry on every retry; only the final, terminal failure is captured.
487
+ Pre-execution failures in the Lambda/Batch handler — a malformed SQS message, a task that cannot be imported, a misconfiguration, or a cold-start error — happen before the task scope opens and are caught by the handler. The handler logs them at ERROR inside a `handler_error_scope` that tags the event `lambda_task.handler_error` (plus the message id). With `LoggingIntegration(event_level=ERROR)` that log becomes a single, tagged Sentry event — so you are still alerted even though `AwsLambdaIntegration` is not installed. Cold-start failures are also re-raised so AWS marks the invocation failed and redrives it.
479
488
 
480
489
  #### SDK version compatibility
481
490
 
@@ -47,3 +47,16 @@ The task runs in a background worker process and the response confirms it was tr
47
47
  Visit: `http://127.0.0.1:8000/admin/` → Lambda Tasks → Task records
48
48
 
49
49
  Log in with the superuser credentials you created above.
50
+
51
+ ## Testing the Sentry integration (optional)
52
+
53
+ Set `SENTRY_DSN` to see per-task traces and error attribution in Sentry. The init lives in `example_project/sentry.py` and uses `DjangoIntegration` + `LoggingIntegration(event_level=ERROR)` **without** `AwsLambdaIntegration`, so each task is traced under its own task-named transaction (see the library README's Sentry section for why).
54
+
55
+ ```bash
56
+ SENTRY_DSN="https://<key>@<org>.ingest.sentry.io/<project>" uv run python manage.py runserver
57
+ ```
58
+
59
+ - `http://127.0.0.1:8000/trigger/?name=Alice` — a successful task; produces a transaction named `example_app.tasks.greet`.
60
+ - `http://127.0.0.1:8000/trigger-error/?name=Bob` — a failing task; produces an error attributed to `example_app.tasks.boom` (via the library's `capture_exception` on the terminal failure path).
61
+
62
+ Without `SENTRY_DSN` set, Sentry initialisation is skipped and the app runs exactly as above.
@@ -36,14 +36,21 @@ def init_sentry() -> None:
36
36
  dsn=dsn,
37
37
  integrations=[
38
38
  DjangoIntegration(),
39
- AwsLambdaIntegration(),
40
- # On sentry-sdk 2.x the logging integration defaulted to
41
- # event_level=ERROR, so logger.error(...) calls (e.g. task_logger)
42
- # became Sentry events automatically. On 3.0 the default is None, so
43
- # error logs no longer surface as events. Set it explicitly so
44
- # task_logger.error output appears in Sentry on both SDK versions.
39
+ # LoggingIntegration(event_level=ERROR) is REQUIRED, not optional:
40
+ # the library reports task and handler failures by logging them at
41
+ # ERROR (not by calling capture_exception), so this integration is
42
+ # what turns those logs into Sentry events. On sentry-sdk 2.x
43
+ # event_level=ERROR was the default; on 3.0 the default is None, so
44
+ # it must be set explicitly.
45
45
  LoggingIntegration(event_level=logging.ERROR),
46
46
  ],
47
+ disabled_integrations=[
48
+ # AwsLambdaIntegration is intentionally omitted. It names every
49
+ # event's transaction after the Lambda function, which hides the
50
+ # task name. Without it, each task's trace is named after the task
51
+ # (see lambda_tasks.sentry).
52
+ AwsLambdaIntegration(),
53
+ ],
47
54
  # Sample every transaction so the per-task trace is always sent while
48
55
  # you are testing. Lower this in a real deployment.
49
56
  traces_sample_rate=float(os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "1.0")),
@@ -26,6 +26,7 @@ from django.apps import apps
26
26
 
27
27
  from lambda_tasks.environment_loader import resolve_environment
28
28
  from lambda_tasks.secret_loader import resolve_secrets_into_env
29
+ from lambda_tasks.sentry import handler_error_scope
29
30
 
30
31
  logger = logging.getLogger(__name__)
31
32
 
@@ -124,7 +125,17 @@ def handler(event: dict, context: object) -> dict:
124
125
  Returns a partial-batch failure report so AWS only re-drives failed records.
125
126
  Signature is fixed by AWS and uses two args only.
126
127
  """
127
- _perform_cold_start()
128
+ try:
129
+ _perform_cold_start()
130
+ except Exception:
131
+ # Cold-start failures (secret resolution, django.setup(), memory limit)
132
+ # escape handler() so the invocation is marked failed and redriven, as
133
+ # AWS expects. The error log is reported to Sentry by the host's
134
+ # LoggingIntegration; handler_error_scope tags it as a handler error.
135
+ # Re-raise to preserve the AWS failure contract.
136
+ with handler_error_scope():
137
+ logger.error("Cold-start initialisation failed", exc_info=True)
138
+ raise
128
139
 
129
140
  # Local import due to AppRegistryNotReady
130
141
  from lambda_tasks.models import SQSLambdaTaskMessage
@@ -138,10 +149,13 @@ def handler(event: dict, context: object) -> dict:
138
149
  record["body"]
139
150
  ).execute_immediately(message_id=record["messageId"])
140
151
  except Exception:
141
- logger.error(
142
- "Failed to process SQS record",
143
- exc_info=True,
144
- )
152
+ # Pre-execution failures (malformed message, import error,
153
+ # misconfiguration) happen before execute_immediately opens its
154
+ # task_scope. The error log is reported to Sentry by the host's
155
+ # LoggingIntegration; handler_error_scope tags it with the message
156
+ # id and marks it a handler error.
157
+ with handler_error_scope(message_id=record.get("messageId")):
158
+ logger.error("Failed to process SQS record", exc_info=True)
145
159
  batch_item_failures.append({"itemIdentifier": record["messageId"]})
146
160
 
147
161
  return {"batchItemFailures": batch_item_failures}
@@ -154,21 +168,31 @@ def main() -> int:
154
168
  constructs a synthetic SQS event, and delegates to handler().
155
169
 
156
170
  Returns 0 on success, 1 on failure.
171
+
172
+ Wrapped in a top-level handler so that failures on the Batch path — a
173
+ missing message, or an exception escaping cold-start/``handler()`` (e.g.
174
+ secret resolution or ``django.setup()``) — are reported to Sentry. The
175
+ Batch container has no AWS-side Sentry integration, so without this such
176
+ failures would only reach stderr.
157
177
  """
158
178
  logging.basicConfig(level=logging.INFO)
159
179
 
160
- message_json = os.environ.get("LAMBDA_TASKS_MESSAGE")
161
- if not message_json:
162
- logger.error("LAMBDA_TASKS_MESSAGE environment variable is not set.")
180
+ try:
181
+ message_json = os.environ.get("LAMBDA_TASKS_MESSAGE")
182
+ if not message_json:
183
+ raise RuntimeError("LAMBDA_TASKS_MESSAGE environment variable is not set.")
184
+
185
+ message_id = os.environ.get("AWS_BATCH_JOB_ID", str(uuid.uuid4()))
186
+
187
+ result = handler(
188
+ event={"Records": [{"messageId": message_id, "body": message_json}]},
189
+ context=None,
190
+ )
191
+ except Exception:
192
+ with handler_error_scope(message_id=os.environ.get("AWS_BATCH_JOB_ID")):
193
+ logger.error("AWS Batch entry point failed", exc_info=True)
163
194
  return 1
164
195
 
165
- message_id = os.environ.get("AWS_BATCH_JOB_ID", str(uuid.uuid4()))
166
-
167
- result = handler(
168
- event={"Records": [{"messageId": message_id, "body": message_json}]},
169
- context=None,
170
- )
171
-
172
196
  if result["batchItemFailures"]:
173
197
  return 1
174
198
  else:
@@ -21,7 +21,7 @@ from redis.exceptions import LockError
21
21
 
22
22
  from lambda_tasks.local_executor import submit_task
23
23
  from lambda_tasks.logging import task_logger
24
- from lambda_tasks.sentry import capture_task_exception, task_scope
24
+ from lambda_tasks.sentry import task_scope
25
25
  from lambda_tasks.settings import (
26
26
  MAX_DELAY,
27
27
  BatchQueueConfig,
@@ -210,7 +210,8 @@ class SQSLambdaTaskMessage(BaseModel):
210
210
  elif effective_retry_on and isinstance(error, effective_retry_on):
211
211
  conf = LambdaTasksSettings()
212
212
  if self.n_retries >= conf.MAX_RETRIES:
213
- capture_task_exception()
213
+ task_logger.error("Maximum retries exceeded", exc_info=True)
214
+
214
215
  record.status = TaskStatus.FAILED
215
216
  record.traceback = traceback.format_exc()
216
217
  record.end_time = now()
@@ -252,7 +253,6 @@ class SQSLambdaTaskMessage(BaseModel):
252
253
  else:
253
254
  task_logger.error(error, exc_info=True)
254
255
 
255
- capture_task_exception()
256
256
  record.status = TaskStatus.FAILED
257
257
  record.traceback = traceback.format_exc()
258
258
  record.end_time = now()
@@ -120,23 +120,38 @@ def _start_task_root(*, task_name: str) -> Any:
120
120
  return sentry_sdk.start_transaction(name=task_name, op="task")
121
121
 
122
122
 
123
- def capture_task_exception() -> None:
124
- """Report the exception currently being handled to Sentry.
125
-
126
- The executor catches task exceptions (recording a ``FAILED`` TaskRecord)
127
- rather than re-raising, so the exception never propagates to an integration
128
- that would report it. This matters most on SDK 3.0 (stream mode), where a
129
- caught exception produces no error event at all — on 2.x the exception
130
- happened to still be reported as it passed through the active span. Calling
131
- this on the terminal failure path gives consistent error reporting on both
132
- SDK versions.
133
-
134
- Must be called from within an ``except`` block while the task's
135
- :func:`task_scope` is still active, so the error carries the task name and
136
- tags. A no-op when ``sentry_sdk`` is unavailable or uninitialised.
123
+ def handler_error_scope(
124
+ *, message_id: str | None = None
125
+ ) -> "contextlib.AbstractContextManager[Any]":
126
+ """Scope a handler-level failure's tags so a subsequent error *log* carries them.
127
+
128
+ Pre-execution failures in the Lambda/Batch handler (malformed message,
129
+ un-importable task, misconfiguration, cold-start errors) happen outside any
130
+ task scope. The handler logs them at ERROR; with the host's
131
+ ``LoggingIntegration(event_level=ERROR)`` that log becomes the Sentry event.
132
+ Entering this context first tags an isolated scope so the resulting event is
133
+ distinguishable (``lambda_task.handler_error``) and carries the message id.
134
+
135
+ Usage::
136
+
137
+ with handler_error_scope(message_id=...):
138
+ logger.error("...", exc_info=True)
139
+
140
+ A no-op (returns a null context) when ``sentry_sdk`` is unavailable or
141
+ uninitialised.
137
142
  """
138
- if sentry_sdk and sentry_sdk.is_initialized():
139
- sentry_sdk.capture_exception()
143
+ if not (sentry_sdk and sentry_sdk.is_initialized()):
144
+ return contextlib.nullcontext()
145
+
146
+ @contextlib.contextmanager
147
+ def _scope() -> Iterator[None]:
148
+ with sentry_sdk.new_scope() as scope:
149
+ scope.set_tag("lambda_task.handler_error", True)
150
+ if message_id is not None:
151
+ scope.set_tag("lambda_task.message_id", message_id)
152
+ yield
153
+
154
+ return _scope()
140
155
 
141
156
 
142
157
  @contextlib.contextmanager
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
7
7
 
8
8
  [project]
9
9
  name = "django-lambda-tasks"
10
- version = "0.11.1"
10
+ version = "0.11.2"
11
11
  description = "Run async tasks in a lambda function"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"