django-lambda-tasks 0.9.1__tar.gz → 0.9.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 (113) hide show
  1. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/steering/product.md +2 -0
  2. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/steering/structure.md +1 -1
  3. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/steering/tech.md +5 -0
  4. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/PKG-INFO +1 -1
  5. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/handler.py +10 -7
  6. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/tracing.py +9 -2
  7. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/pyproject.toml +1 -1
  8. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_tracing.py +154 -0
  9. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.github/dependabot.yml +0 -0
  10. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.github/workflows/ci.yml +0 -0
  11. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.github/workflows/release.yml +0 -0
  12. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.gitignore +0 -0
  13. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  14. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/async-local-execution/design.md +0 -0
  15. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/async-local-execution/requirements.md +0 -0
  16. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/async-local-execution/tasks.md +0 -0
  17. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  18. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  19. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  20. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  21. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  22. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  23. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  24. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  25. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  26. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  27. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  28. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  29. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  30. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  31. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  32. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  33. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/retry-delay/.config.kiro +0 -0
  34. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/retry-delay/design.md +0 -0
  35. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/retry-delay/requirements.md +0 -0
  36. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/retry-delay/tasks.md +0 -0
  37. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  38. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks/design.md +0 -0
  39. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  40. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  41. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  42. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  43. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  44. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  45. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/singleton-task/.config.kiro +0 -0
  46. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/singleton-task/design.md +0 -0
  47. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/singleton-task/requirements.md +0 -0
  48. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/singleton-task/tasks.md +0 -0
  49. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  50. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  51. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  52. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  53. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/task-retry/.config.kiro +0 -0
  54. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/task-retry/design.md +0 -0
  55. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/task-retry/requirements.md +0 -0
  56. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.kiro/specs/task-retry/tasks.md +0 -0
  57. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.pre-commit-config.yaml +0 -0
  58. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/.vscode/settings.json +0 -0
  59. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/README.md +0 -0
  60. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/docker-compose.yml +0 -0
  61. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/README.md +0 -0
  62. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/docker-compose.yml +0 -0
  63. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_app/__init__.py +0 -0
  64. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_app/apps.py +0 -0
  65. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_app/tasks.py +0 -0
  66. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_app/urls.py +0 -0
  67. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_app/views.py +0 -0
  68. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_project/__init__.py +0 -0
  69. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_project/settings.py +0 -0
  70. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_project/urls.py +0 -0
  71. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/example_project/wsgi.py +0 -0
  72. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/example/manage.py +0 -0
  73. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/__init__.py +0 -0
  74. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/admin.py +0 -0
  75. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/apps.py +0 -0
  76. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/checks.py +0 -0
  77. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/decorators.py +0 -0
  78. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/environment_loader.py +0 -0
  79. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/local_executor.py +0 -0
  80. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/logging.py +0 -0
  81. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/migrations/0001_initial.py +0 -0
  82. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  83. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/migrations/0003_taskrecord_taskrecord_kwargs_trgm.py +0 -0
  84. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/migrations/0004_remove_taskrecord_taskrecord_kwargs_trgm_and_more.py +0 -0
  85. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/migrations/__init__.py +0 -0
  86. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/models.py +0 -0
  87. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/secret_loader.py +0 -0
  88. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/settings.py +0 -0
  89. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/tasks.py +0 -0
  90. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/lambda_tasks/timeouts.py +0 -0
  91. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/conftest.py +0 -0
  92. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/settings.py +0 -0
  93. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_admin.py +0 -0
  94. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_batch.py +0 -0
  95. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_batch_worker.py +0 -0
  96. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_checks.py +0 -0
  97. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_decorator.py +0 -0
  98. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_decorators.py +0 -0
  99. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_deferred_enqueue.py +0 -0
  100. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_environment_loader.py +0 -0
  101. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_handler.py +0 -0
  102. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_kwargs_only.py +0 -0
  103. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_local_executor.py +0 -0
  104. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_logging.py +0 -0
  105. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_memory_limit.py +0 -0
  106. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_models.py +0 -0
  107. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_noop_execution.py +0 -0
  108. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_secret_loader.py +0 -0
  109. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_serializer.py +0 -0
  110. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_settings.py +0 -0
  111. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_tasks.py +0 -0
  112. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_timeout_validation.py +0 -0
  113. {django_lambda_tasks-0.9.1 → django_lambda_tasks-0.9.2}/tests/test_timeouts.py +0 -0
@@ -310,6 +310,8 @@ Behaviour:
310
310
  - Builds a `TracerProvider` with an OTLP/HTTP span exporter via a `BatchSpanProcessor`. Spans go **directly** to the AWS X-Ray OTLP traces endpoint (`https://xray.<region>.amazonaws.com/v1/traces`) — no collector, no daemon, no Lambda layer.
311
311
  - The exporter's `requests` session SigV4-signs each export with the ambient AWS credentials (task / execution role) via `botocore` — no API key to manage. Unsigned when no credentials are resolvable.
312
312
  - Uses the AWS X-Ray id generator (`AwsXRayIdGenerator`) so the X-Ray backend accepts the trace IDs, and the X-Ray propagator (`AwsXRayLambdaPropagator` on Lambda, else `AwsXRayPropagator`). The X-Ray packages (`opentelemetry-sdk-extension-aws`, `opentelemetry-propagator-aws-xray`) are part of the `otel` extra.
313
+ - Library instrumentation: after the provider and propagator are installed, `configure_tracing()` calls `_instrument_libraries()`, which attaches the instrumentors for the libraries the tasks use — **psycopg 3** (SQL queries), **botocore** (every boto3 / aioboto3 call), **requests** and **httpx** (outbound HTTP), and **redis** (cache ops via `django_redis` → `redis-py`). Each instrumentor is wrapped independently and is best-effort: a missing or failing one is logged and skipped, never raised, so one broken package cannot stop the others or take down the worker. These are process-global and idempotent, and emit spans into the global provider (so it must exist first). Django request instrumentation (`DjangoInstrumentor`) is **not** installed here — a task has no Django request; the API (ECS) consumer installs that itself. sqlcommenter (`enable_commenter=True` on the psycopg instrumentor) is left off because it modifies the SQL text sent to PostgreSQL.
314
+ - Exporter self-trace suppression: the OTLP exporter POSTs spans over `requests` to the X-Ray endpoint, so the `requests` instrumentor is configured with `excluded_urls="xray\\..*\\.amazonaws\\.com"` to avoid tracing its own export calls (self-referential noise). The instrumentor also honours the `OTEL_PYTHON_REQUESTS_EXCLUDED_URLS` env var for per-environment overrides. botocore is not used by the exporter, so it needs no exclusion.
313
315
  - The OpenTelemetry / `requests` imports are **deferred into the functions** (module scope imports only `os`), so the OTEL packages remain a genuinely optional extra: importing `lambda_tasks.handler` with them uninstalled is a no-op, not an `ImportError`. The disabled path (endpoint unset) never reaches any OTEL import.
314
316
  - Lambda path: also calls `AwsLambdaInstrumentor().instrument()` (only when `AWS_LAMBDA_FUNCTION_NAME` is set). Batch path (`main()`): the instrumentor is skipped (no handler to wrap); the provider alone is configured.
315
317
  - Flush on shutdown: `main()` calls `TracerProvider.force_flush()` / `shutdown()` before the one-shot process exits so short-lived Batch jobs do not drop buffered spans. The Lambda path needs no manual flush — `AwsLambdaInstrumentor` force-flushes per invocation.
@@ -45,7 +45,7 @@ django-lambda-tasks/
45
45
  - `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`)
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
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`. At **import time** (before any invocation) it calls `configure_tracing()` and `instrument_lambda_handler()` from `tracing.py` so the Lambda instrumentor can wrap the handler before the runtime invokes it. 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, delegates to `handler()`, then force-flushes and shuts down the global `TracerProvider` (if any) before the one-shot process exits
48
- - `tracing.py` — optional OpenTelemetry tracing, driven purely by standard OTEL env vars (no Django settings) so the package stays generic; no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`) is set. All OpenTelemetry / `requests` imports are **deferred into the functions** (only `os` is imported at module scope) so the OTEL packages are a genuinely optional extra — importing `lambda_tasks.handler` without them is a no-op, not an `ImportError`. `configure_tracing()` installs a global `TracerProvider` using the X-Ray id generator (`AwsXRayIdGenerator`) with an OTLP/HTTP exporter whose `requests` session SigV4-signs each export to the AWS X-Ray traces endpoint (unsigned when no credentials resolve), and sets the X-Ray propagator (`AwsXRayLambdaPropagator` on Lambda, else `AwsXRayPropagator`). `instrument_lambda_handler()` calls `AwsLambdaInstrumentor().instrument()` only when tracing is enabled and `AWS_LAMBDA_FUNCTION_NAME` is set (Lambda path); skipped on the Batch path. `flush_tracing()` force-flushes and shuts down the provider (gated; used by the Batch entry point)
48
+ - `tracing.py` — optional OpenTelemetry tracing, driven purely by standard OTEL env vars (no Django settings) so the package stays generic; no-op unless `OTEL_EXPORTER_OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`) is set. All OpenTelemetry / `requests` imports are **deferred into the functions** (only `os` is imported at module scope) so the OTEL packages are a genuinely optional extra — importing `lambda_tasks.handler` without them is a no-op, not an `ImportError`. `configure_tracing()` installs a global `TracerProvider` using the X-Ray id generator (`AwsXRayIdGenerator`) with an OTLP/HTTP exporter whose `requests` session SigV4-signs each export to the AWS X-Ray traces endpoint (unsigned when no credentials resolve), and sets the X-Ray propagator (`AwsXRayLambdaPropagator` on Lambda, else `AwsXRayPropagator`), then calls `_instrument_libraries()` to attach the library instrumentors (psycopg 3, botocore, requests, httpx, redis) — each wrapped independently and best-effort (a missing or failing instrumentor is logged, not raised), and the `requests` instrumentor is given an `excluded_urls` regex for the X-Ray OTLP endpoint so the exporter's own export POSTs are not self-traced. `instrument_lambda_handler()` calls `AwsLambdaInstrumentor().instrument()` only when tracing is enabled and `AWS_LAMBDA_FUNCTION_NAME` is set (Lambda path); skipped on the Batch path. `flush_tracing()` force-flushes and shuts down the provider (gated; used by the Batch entry point)
49
49
  - `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
50
50
  - `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
51
51
  - `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
@@ -28,6 +28,11 @@ Installed via the `otel` extra (`pip install django-lambda-tasks[otel]`). Enable
28
28
  | `opentelemetry-instrumentation-aws-lambda` | Wraps the Lambda handler (`AwsLambdaInstrumentor`) |
29
29
  | `opentelemetry-sdk-extension-aws` | X-Ray id generator (`AwsXRayIdGenerator`) |
30
30
  | `opentelemetry-propagator-aws-xray` | X-Ray trace propagator |
31
+ | `opentelemetry-instrumentation-psycopg` | SQL query spans (psycopg 3) |
32
+ | `opentelemetry-instrumentation-botocore` | AWS SDK spans — every boto3 / aioboto3 call |
33
+ | `opentelemetry-instrumentation-requests` | Outbound HTTP spans via `requests` |
34
+ | `opentelemetry-instrumentation-httpx` | Outbound HTTP spans via `httpx` |
35
+ | `opentelemetry-instrumentation-redis` | Cache operation spans (`django_redis` → `redis-py`) |
31
36
  | `requests` | OTLP/HTTP transport session (SigV4-signed via `botocore`) |
32
37
 
33
38
  ## Dev Dependencies
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-lambda-tasks
3
- Version: 0.9.1
3
+ Version: 0.9.2
4
4
  Summary: Run async tasks in a lambda function
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: awslambdaric
@@ -34,13 +34,6 @@ from lambda_tasks.tracing import (
34
34
 
35
35
  logger = logging.getLogger(__name__)
36
36
 
37
- # Initialise tracing at import time so the Lambda instrumentor can wrap the
38
- # handler before the first invocation (which is earlier than the cold-start
39
- # django.setup() inside handler()). No-op unless OTEL_EXPORTER_OTLP_ENDPOINT
40
- # is set.
41
- configure_tracing()
42
- instrument_lambda_handler()
43
-
44
37
  _cold_start_done: bool = False
45
38
 
46
39
 
@@ -191,5 +184,15 @@ def main() -> int:
191
184
  return 0
192
185
 
193
186
 
187
+ # Initialise tracing at import time so the Lambda instrumentor can wrap the
188
+ # handler before the runtime invokes it (which is earlier than the cold-start
189
+ # django.setup() inside handler()). This MUST run after `handler` and `main`
190
+ # are defined: wrapt resolves the handler by attribute lookup on this module,
191
+ # and wrapping a name that does not exist yet raises AttributeError. No-op
192
+ # unless OTEL_EXPORTER_OTLP_ENDPOINT is set.
193
+ configure_tracing()
194
+ instrument_lambda_handler()
195
+
196
+
194
197
  if __name__ == "__main__":
195
198
  sys.exit(main())
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
33
33
  logger = logging.getLogger(__name__)
34
34
 
35
35
  _XRAY_SIGV4_SERVICE_NAME = "xray"
36
+ _LAMBDA_HANDLER = "lambda_tasks.handler.handler"
36
37
 
37
38
 
38
39
  def tracing_is_enabled() -> bool:
@@ -262,8 +263,8 @@ def configure_tracing() -> "TracerProvider | None":
262
263
  def instrument_lambda_handler() -> None:
263
264
  """Wrap the Lambda handler for tracing. No-op unless enabled.
264
265
 
265
- Must be called at import time of the handler module, before the runtime
266
- invokes the handler.
266
+ Must be called at import time of the handler module, AFTER `handler` is
267
+ defined (see the handler module), before the runtime invokes the handler.
267
268
  """
268
269
  if not tracing_is_enabled():
269
270
  return
@@ -275,6 +276,12 @@ def instrument_lambda_handler() -> None:
275
276
  )
276
277
  return
277
278
 
279
+ # AwsLambdaInstrumentor derives the handler to wrap from
280
+ # ORIG_HANDLER / _HANDLER. Container images launched via awslambdaric pass
281
+ # the handler as a CMD arg, which is not exported as _HANDLER, so default
282
+ # it here. setdefault() leaves any explicitly configured value untouched.
283
+ os.environ.setdefault("ORIG_HANDLER", _LAMBDA_HANDLER)
284
+
278
285
  try:
279
286
  from opentelemetry.instrumentation.aws_lambda import (
280
287
  AwsLambdaInstrumentor,
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
7
7
 
8
8
  [project]
9
9
  name = "django-lambda-tasks"
10
- version = "0.9.1"
10
+ version = "0.9.2"
11
11
  description = "Run async tasks in a lambda function"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -263,6 +263,43 @@ class TestInstrumentLambdaHandler:
263
263
  if instrumentor.is_instrumented_by_opentelemetry:
264
264
  instrumentor.uninstrument()
265
265
 
266
+ def test_defaults_orig_handler_when_unset(self, monkeypatch):
267
+ """With ORIG_HANDLER unset the package defaults it to its own handler
268
+ so container-image deployments need not set the env var."""
269
+ _clear_otel_env(monkeypatch)
270
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
271
+ monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-fn")
272
+ monkeypatch.delenv("ORIG_HANDLER", raising=False)
273
+ monkeypatch.delenv("_HANDLER", raising=False)
274
+
275
+ from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor
276
+
277
+ instrumentor = AwsLambdaInstrumentor()
278
+ try:
279
+ tracing.instrument_lambda_handler()
280
+ assert os.environ["ORIG_HANDLER"] == "lambda_tasks.handler.handler"
281
+ finally:
282
+ if instrumentor.is_instrumented_by_opentelemetry:
283
+ instrumentor.uninstrument()
284
+
285
+ def test_leaves_explicit_orig_handler_unchanged(self, monkeypatch):
286
+ """An explicitly configured ORIG_HANDLER wins — setdefault must not
287
+ overwrite a consumer that invokes the handler at a different path."""
288
+ _clear_otel_env(monkeypatch)
289
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
290
+ monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-fn")
291
+ monkeypatch.setenv("ORIG_HANDLER", "custom.module.my_handler")
292
+
293
+ from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor
294
+
295
+ instrumentor = AwsLambdaInstrumentor()
296
+ try:
297
+ tracing.instrument_lambda_handler()
298
+ assert os.environ["ORIG_HANDLER"] == "custom.module.my_handler"
299
+ finally:
300
+ if instrumentor.is_instrumented_by_opentelemetry:
301
+ instrumentor.uninstrument()
302
+
266
303
 
267
304
  class TestOptionalDependency:
268
305
  """The OTEL packages are a genuinely optional extra.
@@ -383,3 +420,120 @@ class TestLoggingDiagnostics:
383
420
  assert any(
384
421
  "No AWS region resolvable" in record.message for record in caplog.records
385
422
  )
423
+
424
+
425
+ class TestHandlerImportOrderingRegression:
426
+ """Regression test for the import-time ordering bug.
427
+
428
+ `instrument_lambda_handler()` must run AFTER `def handler` executes, because
429
+ AwsLambdaInstrumentor wraps the handler via wrapt attribute lookup on the
430
+ module. When the setup calls sat at the top of the module (before the def),
431
+ the lookup raised AttributeError, the best-effort wrapper swallowed it and
432
+ logged "Failed to instrument the Lambda handler...", and the handler was
433
+ never wrapped — so no SERVER span was produced.
434
+
435
+ Run in a subprocess so the fresh, fully-instrumented import cannot perturb
436
+ the module singletons the rest of the suite relies on.
437
+ """
438
+
439
+ def test_fresh_import_wraps_handler_without_warning(self):
440
+ program = (
441
+ "import logging\n"
442
+ "records = []\n"
443
+ "class _Capture(logging.Handler):\n"
444
+ " def emit(self, record):\n"
445
+ " records.append(record.getMessage())\n"
446
+ "logging.getLogger('lambda_tasks.tracing').addHandler(_Capture())\n"
447
+ "logging.getLogger('lambda_tasks.tracing').setLevel(logging.DEBUG)\n"
448
+ "import lambda_tasks.handler as h\n"
449
+ "from opentelemetry.instrumentation.aws_lambda import "
450
+ "AwsLambdaInstrumentor\n"
451
+ "assert AwsLambdaInstrumentor().is_instrumented_by_opentelemetry, "
452
+ "'handler was not wrapped'\n"
453
+ "assert not any('Failed to instrument the Lambda handler' in m "
454
+ "for m in records), records\n"
455
+ "print('ok')\n"
456
+ )
457
+ result = subprocess.run(
458
+ [sys.executable, "-c", program],
459
+ capture_output=True,
460
+ text=True,
461
+ env={
462
+ **os.environ,
463
+ "OTEL_EXPORTER_OTLP_ENDPOINT": _ENDPOINT,
464
+ "AWS_LAMBDA_FUNCTION_NAME": "my-fn",
465
+ "AWS_REGION": "us-east-1",
466
+ "ORIG_HANDLER": "lambda_tasks.handler.handler",
467
+ "DJANGO_SETTINGS_MODULE": "",
468
+ },
469
+ )
470
+ assert result.returncode == 0, result.stderr
471
+ assert "ok" in result.stdout
472
+
473
+
474
+ class TestHandlerSpanParenting:
475
+ """An invocation of the wrapped handler produces a SERVER span whose trace
476
+ contains the task's inner spans as descendants (single connected trace)."""
477
+
478
+ def test_inner_span_descends_from_handler_server_span(self, monkeypatch):
479
+ _clear_otel_env(monkeypatch)
480
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
481
+ monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-fn")
482
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
483
+ monkeypatch.setenv("ORIG_HANDLER", "lambda_tasks.handler.handler")
484
+
485
+ from opentelemetry import trace as trace_api
486
+ from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor
487
+ from opentelemetry.sdk.trace import TracerProvider
488
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
489
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
490
+ InMemorySpanExporter,
491
+ )
492
+
493
+ exporter = InMemorySpanExporter()
494
+ provider = TracerProvider()
495
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
496
+ trace_api.set_tracer_provider(provider)
497
+
498
+ instrumentor = AwsLambdaInstrumentor()
499
+
500
+ def _inner_handler(event, context):
501
+ tracer = trace_api.get_tracer("test")
502
+ with tracer.start_as_current_span("inner-db-span"):
503
+ pass
504
+ return {"batchItemFailures": []}
505
+
506
+ monkeypatch.setattr(
507
+ "lambda_tasks.handler.handler", _inner_handler, raising=False
508
+ )
509
+
510
+ class _Context:
511
+ function_name = "my-fn"
512
+ function_version = "$LATEST"
513
+ invoked_function_arn = (
514
+ "arn:aws:lambda:us-east-1:123456789012:function:my-fn"
515
+ )
516
+ memory_limit_in_mb = 128
517
+ aws_request_id = "req-1"
518
+
519
+ try:
520
+ instrumentor.instrument()
521
+ wrapped = __import__("lambda_tasks.handler", fromlist=["handler"]).handler
522
+ wrapped({"Records": []}, _Context())
523
+ finally:
524
+ if instrumentor.is_instrumented_by_opentelemetry:
525
+ instrumentor.uninstrument()
526
+
527
+ spans = exporter.get_finished_spans()
528
+ server_spans = [s for s in spans if s.kind == trace_api.SpanKind.SERVER]
529
+ inner_spans = [s for s in spans if s.name == "inner-db-span"]
530
+
531
+ assert server_spans, f"no SERVER span produced; got {[s.name for s in spans]}"
532
+ assert inner_spans, "inner task span was not recorded"
533
+
534
+ server = server_spans[0]
535
+ inner = inner_spans[0]
536
+
537
+ assert inner.context.trace_id == server.context.trace_id
538
+ assert inner.parent is not None
539
+ assert inner.parent.span_id == server.context.span_id