django-lambda-tasks 0.10.0__tar.gz → 0.11.0__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 (117) hide show
  1. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/steering/product.md +19 -1
  2. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/steering/structure.md +3 -1
  3. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/steering/tech.md +1 -0
  4. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/PKG-INFO +27 -1
  5. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/README.md +26 -0
  6. django_lambda_tasks-0.11.0/example/example_app/tasks.py +31 -0
  7. django_lambda_tasks-0.11.0/example/example_app/urls.py +7 -0
  8. django_lambda_tasks-0.11.0/example/example_app/views.py +17 -0
  9. django_lambda_tasks-0.11.0/example/example_project/sentry.py +50 -0
  10. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/example_project/settings.py +7 -0
  11. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/models.py +138 -118
  12. django_lambda_tasks-0.11.0/lambda_tasks/sentry.py +143 -0
  13. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/pyproject.toml +2 -1
  14. django_lambda_tasks-0.11.0/tests/test_sentry.py +448 -0
  15. django_lambda_tasks-0.10.0/example/example_app/tasks.py +0 -18
  16. django_lambda_tasks-0.10.0/example/example_app/urls.py +0 -6
  17. django_lambda_tasks-0.10.0/example/example_app/views.py +0 -8
  18. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.github/dependabot.yml +0 -0
  19. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.github/workflows/ci.yml +0 -0
  20. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.github/workflows/release.yml +0 -0
  21. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.gitignore +0 -0
  22. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  23. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/async-local-execution/design.md +0 -0
  24. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/async-local-execution/requirements.md +0 -0
  25. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/async-local-execution/tasks.md +0 -0
  26. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  27. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  28. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  29. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  30. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  31. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  32. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  33. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  34. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  35. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  36. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  37. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  38. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  39. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  40. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  41. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  42. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
  43. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/retry-delay/design.md +0 -0
  44. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/retry-delay/requirements.md +0 -0
  45. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/retry-delay/tasks.md +0 -0
  46. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  47. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
  48. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  49. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  50. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  51. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  52. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  53. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  54. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
  55. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/singleton-task/design.md +0 -0
  56. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/singleton-task/requirements.md +0 -0
  57. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/singleton-task/tasks.md +0 -0
  58. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  59. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  60. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  61. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  62. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/task-retry/.config.kiro +0 -0
  63. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/task-retry/design.md +0 -0
  64. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/task-retry/requirements.md +0 -0
  65. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.kiro/specs/task-retry/tasks.md +0 -0
  66. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.pre-commit-config.yaml +0 -0
  67. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/.vscode/settings.json +0 -0
  68. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/docker-compose.yml +0 -0
  69. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/README.md +0 -0
  70. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/docker-compose.yml +0 -0
  71. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/example_app/__init__.py +0 -0
  72. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/example_app/apps.py +0 -0
  73. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/example_project/__init__.py +0 -0
  74. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/example_project/urls.py +0 -0
  75. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/example_project/wsgi.py +0 -0
  76. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/example/manage.py +0 -0
  77. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/__init__.py +0 -0
  78. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/admin.py +0 -0
  79. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/apps.py +0 -0
  80. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/checks.py +0 -0
  81. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/decorators.py +0 -0
  82. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/environment_loader.py +0 -0
  83. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/handler.py +0 -0
  84. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/local_executor.py +0 -0
  85. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/logging.py +0 -0
  86. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/migrations/0001_initial.py +0 -0
  87. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  88. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/migrations/0003_taskrecord_taskrecord_kwargs_trgm.py +0 -0
  89. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/migrations/0004_remove_taskrecord_taskrecord_kwargs_trgm_and_more.py +0 -0
  90. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/migrations/__init__.py +0 -0
  91. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/secret_loader.py +0 -0
  92. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/settings.py +0 -0
  93. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/tasks.py +0 -0
  94. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/lambda_tasks/timeouts.py +0 -0
  95. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/conftest.py +0 -0
  96. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/settings.py +0 -0
  97. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_admin.py +0 -0
  98. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_batch.py +0 -0
  99. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_batch_worker.py +0 -0
  100. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_checks.py +0 -0
  101. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_decorator.py +0 -0
  102. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_decorators.py +0 -0
  103. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_deferred_enqueue.py +0 -0
  104. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_environment_loader.py +0 -0
  105. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_handler.py +0 -0
  106. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_kwargs_only.py +0 -0
  107. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_local_executor.py +0 -0
  108. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_logging.py +0 -0
  109. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_memory_limit.py +0 -0
  110. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_models.py +0 -0
  111. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_noop_execution.py +0 -0
  112. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_secret_loader.py +0 -0
  113. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_serializer.py +0 -0
  114. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_settings.py +0 -0
  115. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_tasks.py +0 -0
  116. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_timeout_validation.py +0 -0
  117. {django_lambda_tasks-0.10.0 → django_lambda_tasks-0.11.0}/tests/test_timeouts.py +0 -0
@@ -127,7 +127,7 @@ class SQSLambdaTaskMessage(BaseModel):
127
127
 
128
128
  ## Execution
129
129
 
130
- `SQSLambdaTaskMessage.execute_immediately(*, message_id: str)` in `models.py`:
130
+ `SQSLambdaTaskMessage.execute_immediately(*, message_id: str)` in `models.py` sets the `task_logger` `message_id` and enters `task_scope(...)` (Sentry transaction name + tags), then delegates the run to the private `_run(*, message_id)` method which:
131
131
  1. Checks for an existing `TaskRecord` with the same `pk` (`message_id`) via `get_or_create`
132
132
  2. If a record already exists (any status), logs and returns immediately — duplicate deliveries are silently skipped
133
133
  3. Resolves timeouts: decorator default → settings defaults (soft=270s, hard=300s), validated against queue max (900 for SQS, 3600 for Batch)
@@ -313,6 +313,24 @@ def my_task(*, user_id: int) -> None:
313
313
 
314
314
  `task_logger` is a `LoggerAdapter` wrapping the `lambda_tasks.task` logger. The executor sets the `message_id` before each task runs and clears it in a `finally` block. Using your own `logging.getLogger(__name__)` is fine — those records just won't carry the prefix.
315
315
 
316
+ ## Sentry
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 share one transaction (and a single invocation-level transaction is shared across a batch of SQS records); on AWS Batch there is no request and no Lambda integration, so the transaction and URL are `"--"` and `traces_sample_rate` produces no trace for the task body. Either way you cannot tell which task ran or raised.
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 a per-task root span/transaction** named after the task. This gives one trace per task on both Lambda and Batch. It records, both on the span and as scope tags:
321
+
322
+ - transaction/span **name** = the fully-qualified task name (e.g. `myapp.tasks.my_task`)
323
+ - `lambda_task` = task name
324
+ - `lambda_task.message_id` = the `TaskRecord` primary key
325
+ - `lambda_task.n_retries` = the retry counter
326
+ - `lambda_task.runtime` = the physical execution environment, read 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 present
327
+
328
+ The scope covers the success, retry, and failure paths, so task-logic exceptions (which the executor records as `FAILED` and does not re-raise to the handler) still carry the task name when captured by Sentry. The scope tags are kept in addition to span metadata so error events are attributed even when the performance span is not sampled.
329
+
330
+ **SDK version compatibility:** `task_scope` is future-proof across the sentry-sdk 2.x→3.0 boundary. On 2.x it uses `start_transaction` + `set_tag` (a root span must be a transaction to be sent); on 3.0 (stream mode, where transactions are removed and tags no longer propagate to spans) it uses `start_span` + `set_attribute`. The correct API is detected at runtime via `hasattr`, so no code change is needed when the host upgrades the SDK.
331
+
332
+ `task_scope` is a no-op 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, for the test suite. No custom Sentry integration is needed — per-task tracing at the one execution entry point covers both Lambda and Batch.
333
+
316
334
  ## Built-in Tasks
317
335
 
318
336
  `lambda_tasks.tasks` provides maintenance tasks that ship with the library:
@@ -16,6 +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 — per-task Sentry transaction name + tags
19
20
  │ ├── models.py # TaskRecord, SQSLambdaTaskMessage, SQSLambdaTask
20
21
  │ ├── settings.py # LambdaTasksSettings, constants, queue type helpers
21
22
  │ ├── secret_loader.py # Resolves LAMBDA_TASKS_SECRET_* env vars at cold start
@@ -41,12 +42,13 @@ django-lambda-tasks/
41
42
  ## Module Responsibilities
42
43
 
43
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
44
- - `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (Pydantic, SQS schema + execution logic), `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`)
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`)
45
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
46
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
48
  - `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
48
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
49
50
  - `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
51
+ - `sentry.py` — `task_scope` context manager; opens an isolated `new_scope` and a per-task root span/transaction named after the task, so both errors and performance traces are attributed per task (one trace per task on Lambda and Batch, not one invocation-level transaction shared across an SQS batch). Records `lambda_task` / `lambda_task.message_id` / `lambda_task.n_retries` / `lambda_task.runtime` as span metadata and scope tags; `lambda_task.runtime` is the execution environment read from `AWS_LAMBDA_FUNCTION_NAME` (Lambda) or `AWS_BATCH_JQ_NAME` (Batch). Future-proof across the SDK 2.x→3.0 boundary: it uses `start_transaction`+`set_tag` on 2.x and `start_span`+`set_attribute` on 3.0 (stream mode), detected at runtime via `hasattr`. A no-op when `sentry_sdk` is absent or uninitialised, so the library has no hard dependency on Sentry (`sentry-sdk` is a dev dependency only). Wrapped around the task body in `SQSLambdaTaskMessage.execute_immediately`
50
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
51
53
  - `admin.py` — Django admin registration for `TaskRecord`
52
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,6 +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` | present | Testing the `task_scope` Sentry integration; not a runtime dependency |
26
27
 
27
28
  ## Package Manager
28
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-lambda-tasks
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Run async tasks in a lambda function
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: awslambdaric
@@ -444,6 +444,32 @@ fields @timestamp, @message
444
444
  | sort @timestamp asc
445
445
  ```
446
446
 
447
+ ### Sentry
448
+
449
+ If your application uses Sentry, tasks are automatically attributed to their task name. Without this, Sentry's `AwsLambdaIntegration` names every event's transaction after the Lambda function (so all tasks share one transaction), and on AWS Batch there is no transaction at all (it shows as `"--"`) — so you cannot tell which task raised an error.
450
+
451
+ You do **not** need to add a custom integration or change your `sentry_sdk.init(...)`. Keep your existing setup:
452
+
453
+ ```python
454
+ sentry_sdk.init(
455
+ dsn=SENTRY_DSN,
456
+ integrations=[DjangoIntegration(), AwsLambdaIntegration()],
457
+ # ...
458
+ )
459
+ ```
460
+
461
+ When a task runs, `execute_immediately()` opens an isolated Sentry scope **and a per-task trace** (a root span/transaction named after the task). With `traces_sample_rate` set, you get one trace per task on both Lambda and Batch — rather than a single invocation-level transaction shared across a batch of SQS records. It sets, on both the span and the scope:
462
+
463
+ - the **transaction/span name** to the fully-qualified task name (e.g. `myapp.tasks.send_welcome_email`), overriding the Lambda function name on Lambda and replacing `"--"` on Batch
464
+ - `lambda_task` (task name), `lambda_task.message_id`, and `lambda_task.n_retries`
465
+ - `lambda_task.runtime` — the physical execution environment, derived from the AWS environment: the Lambda function name (`AWS_LAMBDA_FUNCTION_NAME`) on Lambda, or the Batch job queue name (`AWS_BATCH_JQ_NAME`) on Batch. Omitted when neither is set (e.g. local/eager execution).
466
+
467
+ This covers task failures too: the executor records failures as `FAILED` `TaskRecord`s rather than re-raising, so the trace wraps the task body to ensure Sentry error events still carry the task name.
468
+
469
+ The integration is future-proof across the sentry-sdk 2.x→3.0 boundary: it uses `start_transaction`/`set_tag` on 2.x and `start_span`/`set_attribute` on 3.0 (stream mode), chosen automatically at runtime — no change needed when you upgrade the SDK.
470
+
471
+ `sentry_sdk` is not a dependency of this library — if it is not installed or not initialised, the enrichment is a silent no-op.
472
+
447
473
  ---
448
474
 
449
475
  ## Automatic retries
@@ -432,6 +432,32 @@ fields @timestamp, @message
432
432
  | sort @timestamp asc
433
433
  ```
434
434
 
435
+ ### Sentry
436
+
437
+ If your application uses Sentry, tasks are automatically attributed to their task name. Without this, Sentry's `AwsLambdaIntegration` names every event's transaction after the Lambda function (so all tasks share one transaction), and on AWS Batch there is no transaction at all (it shows as `"--"`) — so you cannot tell which task raised an error.
438
+
439
+ You do **not** need to add a custom integration or change your `sentry_sdk.init(...)`. Keep your existing setup:
440
+
441
+ ```python
442
+ sentry_sdk.init(
443
+ dsn=SENTRY_DSN,
444
+ integrations=[DjangoIntegration(), AwsLambdaIntegration()],
445
+ # ...
446
+ )
447
+ ```
448
+
449
+ When a task runs, `execute_immediately()` opens an isolated Sentry scope **and a per-task trace** (a root span/transaction named after the task). With `traces_sample_rate` set, you get one trace per task on both Lambda and Batch — rather than a single invocation-level transaction shared across a batch of SQS records. It sets, on both the span and the scope:
450
+
451
+ - the **transaction/span name** to the fully-qualified task name (e.g. `myapp.tasks.send_welcome_email`), overriding the Lambda function name on Lambda and replacing `"--"` on Batch
452
+ - `lambda_task` (task name), `lambda_task.message_id`, and `lambda_task.n_retries`
453
+ - `lambda_task.runtime` — the physical execution environment, derived from the AWS environment: the Lambda function name (`AWS_LAMBDA_FUNCTION_NAME`) on Lambda, or the Batch job queue name (`AWS_BATCH_JQ_NAME`) on Batch. Omitted when neither is set (e.g. local/eager execution).
454
+
455
+ This covers task failures too: the executor records failures as `FAILED` `TaskRecord`s rather than re-raising, so the trace wraps the task body to ensure Sentry error events still carry the task name.
456
+
457
+ The integration is future-proof across the sentry-sdk 2.x→3.0 boundary: it uses `start_transaction`/`set_tag` on 2.x and `start_span`/`set_attribute` on 3.0 (stream mode), chosen automatically at runtime — no change needed when you upgrade the SDK.
458
+
459
+ `sentry_sdk` is not a dependency of this library — if it is not installed or not initialised, the enrichment is a silent no-op.
460
+
435
461
  ---
436
462
 
437
463
  ## Automatic retries
@@ -0,0 +1,31 @@
1
+ from time import sleep
2
+
3
+ from lambda_tasks.decorators import lambda_task
4
+ from lambda_tasks.logging import task_logger
5
+
6
+
7
+ @lambda_task()
8
+ def greet(*, name: str) -> str:
9
+ """A successful task — produces one per-task Sentry transaction named
10
+ ``example_app.tasks.greet``."""
11
+ task_logger.info(f"Running greet with: {name=}")
12
+
13
+ for ii in range(3):
14
+ task_logger.info(f"Sleeping: {ii=}")
15
+ sleep(1)
16
+
17
+ try:
18
+ raise RuntimeError("testing")
19
+ except Exception:
20
+ task_logger.error("Raising", exc_info=True)
21
+
22
+ return f"Hello, {name}!"
23
+
24
+
25
+ @lambda_task()
26
+ def boom(*, name: str) -> str:
27
+ """A failing task — the executor records it as a FAILED TaskRecord and the
28
+ error is reported to Sentry attributed to ``example_app.tasks.boom`` (not
29
+ the Lambda function name), inside its own per-task trace."""
30
+ task_logger.info(f"Running boom with: {name=}")
31
+ raise RuntimeError(f"boom for {name}")
@@ -0,0 +1,7 @@
1
+ from django.urls import path
2
+ from example_app.views import trigger, trigger_error
3
+
4
+ urlpatterns = [
5
+ path("trigger/", trigger),
6
+ path("trigger-error/", trigger_error),
7
+ ]
@@ -0,0 +1,17 @@
1
+ from django.http import HttpRequest, HttpResponse
2
+ from example_app.tasks import boom, greet
3
+
4
+
5
+ def trigger(request: HttpRequest) -> HttpResponse:
6
+ """Enqueue the successful task — produces a per-task Sentry transaction."""
7
+ name = request.GET.get("name", "world")
8
+ greet.execute_on_commit(name=name)
9
+ return HttpResponse(f"greet task triggered for: {name}")
10
+
11
+
12
+ def trigger_error(request: HttpRequest) -> HttpResponse:
13
+ """Enqueue the failing task — produces a Sentry error attributed to the
14
+ task name, inside its own per-task trace."""
15
+ name = request.GET.get("name", "world")
16
+ boom.execute_on_commit(name=name)
17
+ return HttpResponse(f"boom task triggered for: {name}")
@@ -0,0 +1,50 @@
1
+ """Optional Sentry initialisation for the example app.
2
+
3
+ Demonstrates how a host application wires Sentry so that
4
+ ``django-lambda-tasks`` can attribute per-task traces and errors. This mirrors
5
+ a typical production ``sentry_sdk.init(...)`` (Django + AWS Lambda integrations
6
+ and tracing), but is driven entirely by environment variables so the example
7
+ runs with or without Sentry configured.
8
+
9
+ ``init_sentry()`` is called from ``settings.py`` at import time. Because the
10
+ local process-pool workers call ``django.setup()`` (which imports settings) in
11
+ each child process, this runs in the web process *and* in every task worker —
12
+ which is exactly what is needed for the per-task trace to be captured where the
13
+ task actually executes.
14
+ """
15
+
16
+ import logging
17
+ import os
18
+
19
+
20
+ def init_sentry() -> None:
21
+ """Initialise Sentry if ``SENTRY_DSN`` is set; otherwise do nothing.
22
+
23
+ Idempotent enough for the example's purposes: ``sentry_sdk.init`` is safe to
24
+ call once per process, and this is only invoked from settings import.
25
+ """
26
+ dsn = os.environ.get("SENTRY_DSN")
27
+ if not dsn:
28
+ return
29
+
30
+ import sentry_sdk
31
+ from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
32
+ from sentry_sdk.integrations.django import DjangoIntegration
33
+ from sentry_sdk.integrations.logging import LoggingIntegration
34
+
35
+ sentry_sdk.init(
36
+ dsn=dsn,
37
+ integrations=[
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.
45
+ LoggingIntegration(event_level=logging.ERROR),
46
+ ],
47
+ # Sample every transaction so the per-task trace is always sent while
48
+ # you are testing. Lower this in a real deployment.
49
+ traces_sample_rate=float(os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "1.0")),
50
+ )
@@ -88,3 +88,10 @@ LOGGING = {
88
88
  "level": "INFO",
89
89
  },
90
90
  }
91
+
92
+ # Optional Sentry integration — set SENTRY_DSN to enable. Initialised here (at
93
+ # settings import) so it also runs inside each local process-pool worker, which
94
+ # imports settings via django.setup(). See example_project/sentry.py.
95
+ from example_project.sentry import init_sentry # noqa: E402
96
+
97
+ init_sentry()
@@ -21,6 +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
25
  from lambda_tasks.settings import (
25
26
  MAX_DELAY,
26
27
  BatchQueueConfig,
@@ -112,151 +113,170 @@ class SQSLambdaTaskMessage(BaseModel):
112
113
  Creates a TaskRecord, resolves timeouts, validates configuration,
113
114
  runs the task inside an atomic block with timeout enforcement, and
114
115
  persists the outcome.
116
+
117
+ Logging and Sentry context are scoped to the task for the duration of
118
+ the run so that exceptions are attributed to the task name rather than
119
+ to the Lambda function name or, on AWS Batch, to nothing at all.
115
120
  """
116
121
  task_logger.message_id = message_id
117
122
 
118
123
  try:
119
- task_logger.info(f"Received {self.task_name}")
124
+ with task_scope(
125
+ task_name=self.task_name,
126
+ message_id=message_id,
127
+ n_retries=self.n_retries,
128
+ ):
129
+ self._run(message_id=message_id)
130
+ finally:
131
+ task_logger.message_id = None
120
132
 
121
- # Local import to avoid circular dependency
122
- from lambda_tasks.decorators import LambdaTaskWrapper
133
+ def _run(self, *, message_id: str) -> None:
134
+ """Resolve, run, and persist the outcome of the task.
123
135
 
124
- wrapper = import_string(self.task_name)
136
+ Separated from :meth:`execute_immediately` so that the latter owns only
137
+ the logging and Sentry context management; this method holds the
138
+ execution logic and its early returns.
139
+ """
140
+ task_logger.info(f"Received {self.task_name}")
125
141
 
126
- if not isinstance(wrapper, LambdaTaskWrapper):
127
- raise TypeError(
128
- f"import_string('{self.task_name}') returned {type(wrapper)!r}, "
129
- f"expected LambdaTaskWrapper."
130
- )
142
+ # Local import to avoid circular dependency
143
+ from lambda_tasks.decorators import LambdaTaskWrapper
131
144
 
132
- # Resolve before creating the TaskRecord — ConfigurationError here means
133
- # misconfigured settings, not a task failure, so no record should be written.
134
- soft_timeout, hard_timeout = wrapper.resolved_timeouts
135
-
136
- record, created = (
137
- TaskRecord.objects.get_or_create( # ty: ignore[unresolved-attribute]
138
- pk=message_id,
139
- defaults={
140
- "task_name": self.task_name,
141
- "kwargs": self.model_dump(mode="json")["kwargs"],
142
- "n_retries": self.n_retries,
143
- "status": TaskStatus.RUNNING,
144
- "start_time": now(),
145
- },
146
- )
145
+ wrapper = import_string(self.task_name)
146
+
147
+ if not isinstance(wrapper, LambdaTaskWrapper):
148
+ raise TypeError(
149
+ f"import_string('{self.task_name}') returned {type(wrapper)!r}, "
150
+ f"expected LambdaTaskWrapper."
147
151
  )
148
152
 
149
- if not created:
150
- task_logger.warning(
151
- f"Skipping duplicate delivery (existing record status={record.status})"
152
- )
153
- return
153
+ # Resolve before creating the TaskRecord — ConfigurationError here means
154
+ # misconfigured settings, not a task failure, so no record should be written.
155
+ soft_timeout, hard_timeout = wrapper.resolved_timeouts
156
+
157
+ record, created = (
158
+ TaskRecord.objects.get_or_create( # ty: ignore[unresolved-attribute]
159
+ pk=message_id,
160
+ defaults={
161
+ "task_name": self.task_name,
162
+ "kwargs": self.model_dump(mode="json")["kwargs"],
163
+ "n_retries": self.n_retries,
164
+ "status": TaskStatus.RUNNING,
165
+ "start_time": now(),
166
+ },
167
+ )
168
+ )
154
169
 
155
- ignored_exception: BaseException | None = None
156
- ignored_traceback: str | None = None
157
- result = None
170
+ if not created:
171
+ task_logger.warning(
172
+ f"Skipping duplicate delivery (existing record status={record.status})"
173
+ )
174
+ return
158
175
 
159
- if wrapper.singleton:
160
- cache = caches[LambdaTasksSettings().SINGLETON_CACHE]
161
- lock_ctx = cache.lock(
162
- f"lambda_tasks.singleton_lock.{self.task_name}",
163
- blocking_timeout=0,
164
- timeout=hard_timeout,
165
- )
166
- if wrapper.retry_singleton:
167
- effective_retry_on = (LockError, *wrapper.retry_on)
168
- else:
169
- effective_retry_on = wrapper.retry_on
176
+ ignored_exception: BaseException | None = None
177
+ ignored_traceback: str | None = None
178
+ result = None
179
+
180
+ if wrapper.singleton:
181
+ cache = caches[LambdaTasksSettings().SINGLETON_CACHE]
182
+ lock_ctx = cache.lock(
183
+ f"lambda_tasks.singleton_lock.{self.task_name}",
184
+ blocking_timeout=0,
185
+ timeout=hard_timeout,
186
+ )
187
+ if wrapper.retry_singleton:
188
+ effective_retry_on = (LockError, *wrapper.retry_on)
170
189
  else:
171
- lock_ctx = contextlib.nullcontext()
172
190
  effective_retry_on = wrapper.retry_on
191
+ else:
192
+ lock_ctx = contextlib.nullcontext()
193
+ effective_retry_on = wrapper.retry_on
173
194
 
174
- try:
175
- with lock_ctx:
176
- with transaction.atomic():
177
- with TimeoutContext(
178
- soft_timeout=soft_timeout, hard_timeout=hard_timeout
179
- ):
180
- result = wrapper(**self.kwargs)
181
- except Exception as error:
182
- if (
183
- wrapper.singleton
184
- and not wrapper.retry_singleton
185
- and isinstance(error, LockError)
186
- ):
187
- ignored_exception = error
188
- ignored_traceback = traceback.format_exc()
189
- elif effective_retry_on and isinstance(error, effective_retry_on):
190
- conf = LambdaTasksSettings()
191
- if self.n_retries >= conf.MAX_RETRIES:
192
- record.status = TaskStatus.FAILED
193
- record.traceback = traceback.format_exc()
194
- record.end_time = now()
195
- record.save(update_fields=["status", "traceback", "end_time"])
196
-
197
- task_logger.warning(
198
- f"Failed due to MaxRetriesExceededError in {record.duration}"
199
- )
200
-
201
- raise MaxRetriesExceededError(
202
- task_name=self.task_name, n_retries=self.n_retries
203
- )
204
- else:
205
- record.status = TaskStatus.RETRIED
206
- record.traceback = traceback.format_exc()
207
- record.end_time = now()
208
- record.save(update_fields=["status", "traceback", "end_time"])
209
-
210
- task_logger.warning(
211
- f"Retrying (due to {type(error).__name__}) after {record.duration}"
212
- )
213
-
214
- delay = min(
215
- wrapper.retry_delay + round(random.uniform(1, 5)), MAX_DELAY
216
- )
217
-
218
- retry_task = SQSLambdaTask(
219
- message=SQSLambdaTaskMessage(
220
- task_name=self.task_name,
221
- kwargs=self.kwargs,
222
- n_retries=self.n_retries + 1,
223
- ),
224
- delay=delay,
225
- queue=wrapper.queue,
226
- )
227
- retry_task.execute_on_commit()
228
-
229
- return
230
- else:
231
- task_logger.error(error, exc_info=True)
232
-
195
+ try:
196
+ with lock_ctx:
197
+ with transaction.atomic():
198
+ with TimeoutContext(
199
+ soft_timeout=soft_timeout, hard_timeout=hard_timeout
200
+ ):
201
+ result = wrapper(**self.kwargs)
202
+ except Exception as error:
203
+ if (
204
+ wrapper.singleton
205
+ and not wrapper.retry_singleton
206
+ and isinstance(error, LockError)
207
+ ):
208
+ ignored_exception = error
209
+ ignored_traceback = traceback.format_exc()
210
+ elif effective_retry_on and isinstance(error, effective_retry_on):
211
+ conf = LambdaTasksSettings()
212
+ if self.n_retries >= conf.MAX_RETRIES:
213
+ capture_task_exception()
233
214
  record.status = TaskStatus.FAILED
234
215
  record.traceback = traceback.format_exc()
235
216
  record.end_time = now()
236
217
  record.save(update_fields=["status", "traceback", "end_time"])
237
218
 
238
- task_logger.warning(f"Failed in {record.duration}")
239
- return
219
+ task_logger.warning(
220
+ f"Failed due to MaxRetriesExceededError in {record.duration}"
221
+ )
240
222
 
241
- if ignored_exception is None:
242
- record.status = TaskStatus.SUCCEEDED
243
- record.result = result
244
- record.end_time = now()
245
- record.save(update_fields=["status", "result", "end_time"])
223
+ raise MaxRetriesExceededError(
224
+ task_name=self.task_name, n_retries=self.n_retries
225
+ )
226
+ else:
227
+ record.status = TaskStatus.RETRIED
228
+ record.traceback = traceback.format_exc()
229
+ record.end_time = now()
230
+ record.save(update_fields=["status", "traceback", "end_time"])
246
231
 
247
- task_logger.info(f"Succeeded in {record.duration}")
232
+ task_logger.warning(
233
+ f"Retrying (due to {type(error).__name__}) after {record.duration}"
234
+ )
235
+
236
+ delay = min(
237
+ wrapper.retry_delay + round(random.uniform(1, 5)), MAX_DELAY
238
+ )
239
+
240
+ retry_task = SQSLambdaTask(
241
+ message=SQSLambdaTaskMessage(
242
+ task_name=self.task_name,
243
+ kwargs=self.kwargs,
244
+ n_retries=self.n_retries + 1,
245
+ ),
246
+ delay=delay,
247
+ queue=wrapper.queue,
248
+ )
249
+ retry_task.execute_on_commit()
250
+
251
+ return
248
252
  else:
249
- record.status = TaskStatus.SUCCEEDED
250
- record.traceback = ignored_traceback
253
+ task_logger.error(error, exc_info=True)
254
+
255
+ capture_task_exception()
256
+ record.status = TaskStatus.FAILED
257
+ record.traceback = traceback.format_exc()
251
258
  record.end_time = now()
252
259
  record.save(update_fields=["status", "traceback", "end_time"])
253
260
 
254
- task_logger.info(
255
- f"Succeeded (ignored {type(ignored_exception).__name__}) in {record.duration}"
256
- )
261
+ task_logger.warning(f"Failed in {record.duration}")
262
+ return
257
263
 
258
- finally:
259
- task_logger.message_id = None
264
+ if ignored_exception is None:
265
+ record.status = TaskStatus.SUCCEEDED
266
+ record.result = result
267
+ record.end_time = now()
268
+ record.save(update_fields=["status", "result", "end_time"])
269
+
270
+ task_logger.info(f"Succeeded in {record.duration}")
271
+ else:
272
+ record.status = TaskStatus.SUCCEEDED
273
+ record.traceback = ignored_traceback
274
+ record.end_time = now()
275
+ record.save(update_fields=["status", "traceback", "end_time"])
276
+
277
+ task_logger.info(
278
+ f"Succeeded (ignored {type(ignored_exception).__name__}) in {record.duration}"
279
+ )
260
280
 
261
281
 
262
282
  class SQSLambdaTask(BaseModel):