django-lambda-tasks 0.9.2__tar.gz → 0.10.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.
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/steering/product.md +0 -22
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/steering/structure.md +1 -3
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/steering/tech.md +0 -18
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/PKG-INFO +1 -13
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/handler.py +0 -19
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/tasks.py +5 -1
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/pyproject.toml +1 -18
- django_lambda_tasks-0.10.0/tests/test_batch_worker.py +77 -0
- django_lambda_tasks-0.9.2/lambda_tasks/tracing.py +0 -325
- django_lambda_tasks-0.9.2/tests/test_batch_worker.py +0 -138
- django_lambda_tasks-0.9.2/tests/test_tracing.py +0 -539
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.github/dependabot.yml +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.github/workflows/ci.yml +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.github/workflows/release.yml +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.gitignore +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/async-local-execution/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/async-local-execution/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/async-local-execution/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/async-local-execution/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/retry-delay/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/retry-delay/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/retry-delay/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/singleton-task/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/singleton-task/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/singleton-task/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ssm-environment-loader/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/task-retry/.config.kiro +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/task-retry/design.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/task-retry/requirements.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.kiro/specs/task-retry/tasks.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.pre-commit-config.yaml +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/.vscode/settings.json +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/README.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/docker-compose.yml +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/README.md +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/docker-compose.yml +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_app/__init__.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_app/apps.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_app/tasks.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_app/urls.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_app/views.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_project/__init__.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_project/settings.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_project/urls.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/example_project/wsgi.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/example/manage.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/__init__.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/admin.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/apps.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/checks.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/decorators.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/environment_loader.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/local_executor.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/logging.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/migrations/0001_initial.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/migrations/0003_taskrecord_taskrecord_kwargs_trgm.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/migrations/0004_remove_taskrecord_taskrecord_kwargs_trgm_and_more.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/migrations/__init__.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/models.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/secret_loader.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/settings.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/lambda_tasks/timeouts.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/conftest.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/settings.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_admin.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_batch.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_checks.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_decorator.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_decorators.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_deferred_enqueue.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_environment_loader.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_handler.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_kwargs_only.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_local_executor.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_logging.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_memory_limit.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_models.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_noop_execution.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_secret_loader.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_serializer.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_settings.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_tasks.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_timeout_validation.py +0 -0
- {django_lambda_tasks-0.9.2 → django_lambda_tasks-0.10.0}/tests/test_timeouts.py +0 -0
|
@@ -31,7 +31,6 @@ Key modules:
|
|
|
31
31
|
- `models.py` — `TaskRecord` (Django ORM), `SQSLambdaTaskMessage` (SQS schema + execution), `SQSLambdaTask` (routing + SQS publish, Batch submit, or local pool submit)
|
|
32
32
|
- `local_executor.py` — `ProcessPoolExecutor`-based async local execution for development
|
|
33
33
|
- `handler.py` — AWS Lambda entry point + AWS Batch container entry point (`main()`)
|
|
34
|
-
- `tracing.py` — optional OpenTelemetry tracing (import-time setup, direct X-Ray OTLP export, SigV4-signed)
|
|
35
34
|
- `logging.py` — `task_logger` for invocation-scoped log output
|
|
36
35
|
- `settings.py` — lazy `LambdaTasksSettings` reading from Django settings, queue type helpers
|
|
37
36
|
|
|
@@ -159,7 +158,6 @@ class SQSLambdaTaskMessage(BaseModel):
|
|
|
159
158
|
- Reads `LAMBDA_TASKS_MESSAGE` env var (the serialized task JSON, set by `submit_batch_job` via container overrides)
|
|
160
159
|
- Uses `AWS_BATCH_JOB_ID` as the `message_id` (falls back to UUID4 if not set)
|
|
161
160
|
- Constructs a synthetic SQS event and delegates to `handler()` — reuses the full cold-start and execution path
|
|
162
|
-
- Before returning, force-flushes and shuts down the global `TracerProvider` (if OTEL tracing is configured) so short-lived Batch jobs do not drop buffered spans
|
|
163
161
|
- Returns exit code 0 on success, 1 on failure
|
|
164
162
|
|
|
165
163
|
## Environment Loader
|
|
@@ -299,26 +297,6 @@ Relying on `atexit` alone loses this race in applications with a heavy shutdown
|
|
|
299
297
|
|
|
300
298
|
The checks are registered via `AppConfig.ready()` and tagged with `deploy=True` so they only fire during `--deploy` checks.
|
|
301
299
|
|
|
302
|
-
## OpenTelemetry Tracing (optional)
|
|
303
|
-
|
|
304
|
-
`lambda_tasks/tracing.py` adds OpenTelemetry tracing to the Lambda and Batch execution paths without the managed ADOT Lambda layer — the OTEL packages installed in the application image are the single source of instrumentation (reproducible-build friendly).
|
|
305
|
-
|
|
306
|
-
Setup lives in the handler module, **not** Django settings. `AwsLambdaInstrumentor` must wrap the handler named by `_HANDLER`/`ORIG_HANDLER` **before** the runtime invokes it, which is earlier than the cold-start `django.setup()` inside `handler()`. So `lambda_tasks.handler` calls `configure_tracing()` and `instrument_lambda_handler()` at **import time**, and the setup is driven purely by standard OTEL environment variables (settings are not configured yet at import time).
|
|
307
|
-
|
|
308
|
-
Behaviour:
|
|
309
|
-
- Gated on `OTEL_EXPORTER_OTLP_ENDPOINT` (or the per-signal `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`). Unset → everything is a no-op (local dev, tests, un-opted-in deployments).
|
|
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
|
-
- 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
|
-
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
318
|
-
- Best-effort and observable: tracing must never take down the worker. `configure_tracing()` and `instrument_lambda_handler()` catch setup failures (missing OTEL packages, provider build errors) and log a warning instead of raising — important because they run at the handler module's import time. The `lambda_tasks.tracing` logger also surfaces otherwise-silent conditions at `INFO`/`WARNING`: tracing configured / already configured / disabled, handler instrumented or skipped (not on Lambda), no resolvable AWS region, and no resolvable AWS credentials (which would make exports unsigned and rejected by X-Ray).
|
|
319
|
-
|
|
320
|
-
`configure_tracing()` returns the installed provider (or `None` when disabled or already configured — it is idempotent). Install the OTEL packages via the `otel` extra; the consuming application sets `OTEL_EXPORTER_OTLP_ENDPOINT` and declares the X-Ray packages it wants.
|
|
321
|
-
|
|
322
300
|
## Logging
|
|
323
301
|
|
|
324
302
|
Import `task_logger` to emit log records that are automatically prefixed with the active `message_id`:
|
|
@@ -23,7 +23,6 @@ django-lambda-tasks/
|
|
|
23
23
|
│ ├── local_executor.py # ProcessPoolExecutor for async local task execution
|
|
24
24
|
│ ├── tasks.py # Built-in tasks (cleanup_task_records, submit_batch_job)
|
|
25
25
|
│ ├── timeouts.py # TimeoutContext implementation
|
|
26
|
-
│ ├── tracing.py # Optional OpenTelemetry tracing (import-time setup, X-Ray OTLP export)
|
|
27
26
|
│ └── migrations/ # Django migrations for TaskRecord
|
|
28
27
|
├── tests/ # pytest test suite
|
|
29
28
|
│ ├── conftest.py
|
|
@@ -44,8 +43,7 @@ django-lambda-tasks/
|
|
|
44
43
|
- `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
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`)
|
|
46
45
|
- `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`.
|
|
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)
|
|
46
|
+
- `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()`
|
|
49
47
|
- `environment_loader.py` — loads env vars from a Secrets Manager secret at cold start; validates flat JSON format; idempotent via `_loaded` sentinel
|
|
50
48
|
- `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
49
|
- `logging.py` — `task_logger` singleton; `message_id` set/cleared around each task execution
|
|
@@ -17,24 +17,6 @@ inclusion: always
|
|
|
17
17
|
| `boto3` | `>= 1.42.69` | AWS SDK — SQS publishing, Lambda integration |
|
|
18
18
|
| `pydantic` | `>= 2.12.5` | Task argument serialization via `SQSLambdaTaskMessage` model |
|
|
19
19
|
|
|
20
|
-
## Optional Dependencies
|
|
21
|
-
|
|
22
|
-
Installed via the `otel` extra (`pip install django-lambda-tasks[otel]`). Enables OpenTelemetry tracing in `lambda_tasks/tracing.py`; the library stays generic without them. SigV4 signing reuses `botocore` (already a boto3 transitive dependency) — no extra signing package.
|
|
23
|
-
|
|
24
|
-
| Package | Purpose |
|
|
25
|
-
|---|---|
|
|
26
|
-
| `opentelemetry-sdk` | `TracerProvider`, `BatchSpanProcessor` |
|
|
27
|
-
| `opentelemetry-exporter-otlp-proto-http` | OTLP/HTTP span exporter (no gRPC, no meta-package) |
|
|
28
|
-
| `opentelemetry-instrumentation-aws-lambda` | Wraps the Lambda handler (`AwsLambdaInstrumentor`) |
|
|
29
|
-
| `opentelemetry-sdk-extension-aws` | X-Ray id generator (`AwsXRayIdGenerator`) |
|
|
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`) |
|
|
36
|
-
| `requests` | OTLP/HTTP transport session (SigV4-signed via `botocore`) |
|
|
37
|
-
|
|
38
20
|
## Dev Dependencies
|
|
39
21
|
|
|
40
22
|
| Package | Version | Purpose |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: django-lambda-tasks
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Run async tasks in a lambda function
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: awslambdaric
|
|
@@ -8,18 +8,6 @@ Requires-Dist: boto3
|
|
|
8
8
|
Requires-Dist: django
|
|
9
9
|
Requires-Dist: pydantic
|
|
10
10
|
Requires-Dist: redis
|
|
11
|
-
Provides-Extra: otel
|
|
12
|
-
Requires-Dist: opentelemetry-exporter-otlp-proto-http; extra == 'otel'
|
|
13
|
-
Requires-Dist: opentelemetry-instrumentation-aws-lambda; extra == 'otel'
|
|
14
|
-
Requires-Dist: opentelemetry-instrumentation-botocore; extra == 'otel'
|
|
15
|
-
Requires-Dist: opentelemetry-instrumentation-httpx; extra == 'otel'
|
|
16
|
-
Requires-Dist: opentelemetry-instrumentation-psycopg; extra == 'otel'
|
|
17
|
-
Requires-Dist: opentelemetry-instrumentation-redis; extra == 'otel'
|
|
18
|
-
Requires-Dist: opentelemetry-instrumentation-requests; extra == 'otel'
|
|
19
|
-
Requires-Dist: opentelemetry-propagator-aws-xray; extra == 'otel'
|
|
20
|
-
Requires-Dist: opentelemetry-sdk; extra == 'otel'
|
|
21
|
-
Requires-Dist: opentelemetry-sdk-extension-aws; extra == 'otel'
|
|
22
|
-
Requires-Dist: requests; extra == 'otel'
|
|
23
11
|
Description-Content-Type: text/markdown
|
|
24
12
|
|
|
25
13
|
# Django Lambda Tasks
|
|
@@ -26,11 +26,6 @@ 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.tracing import (
|
|
30
|
-
configure_tracing,
|
|
31
|
-
flush_tracing,
|
|
32
|
-
instrument_lambda_handler,
|
|
33
|
-
)
|
|
34
29
|
|
|
35
30
|
logger = logging.getLogger(__name__)
|
|
36
31
|
|
|
@@ -174,25 +169,11 @@ def main() -> int:
|
|
|
174
169
|
context=None,
|
|
175
170
|
)
|
|
176
171
|
|
|
177
|
-
# Flush any buffered spans before the one-shot process exits (no-op unless
|
|
178
|
-
# tracing is enabled — this never imports OTEL otherwise).
|
|
179
|
-
flush_tracing()
|
|
180
|
-
|
|
181
172
|
if result["batchItemFailures"]:
|
|
182
173
|
return 1
|
|
183
174
|
else:
|
|
184
175
|
return 0
|
|
185
176
|
|
|
186
177
|
|
|
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
|
-
|
|
197
178
|
if __name__ == "__main__":
|
|
198
179
|
sys.exit(main())
|
|
@@ -11,7 +11,11 @@ from lambda_tasks.models import SQSLambdaTaskMessage, TaskRecord
|
|
|
11
11
|
from lambda_tasks.settings import BatchQueueConfig, LambdaTasksSettings
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
@lambda_task
|
|
14
|
+
@lambda_task(
|
|
15
|
+
singleton=True,
|
|
16
|
+
# No need to retry here as this should be retried periodically
|
|
17
|
+
retry_singleton=False,
|
|
18
|
+
)
|
|
15
19
|
def cleanup_task_records(*, retention_days: int = 7) -> int:
|
|
16
20
|
"""Delete TaskRecord rows older than ``retention_days``.
|
|
17
21
|
|
|
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "django-lambda-tasks"
|
|
10
|
-
version = "0.
|
|
10
|
+
version = "0.10.0"
|
|
11
11
|
description = "Run async tasks in a lambda function"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -19,21 +19,6 @@ dependencies = [
|
|
|
19
19
|
"awslambdaric",
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
-
[project.optional-dependencies]
|
|
23
|
-
otel = [
|
|
24
|
-
"requests",
|
|
25
|
-
"opentelemetry-sdk",
|
|
26
|
-
"opentelemetry-exporter-otlp-proto-http",
|
|
27
|
-
"opentelemetry-instrumentation-aws-lambda",
|
|
28
|
-
"opentelemetry-sdk-extension-aws",
|
|
29
|
-
"opentelemetry-propagator-aws-xray",
|
|
30
|
-
"opentelemetry-instrumentation-psycopg",
|
|
31
|
-
"opentelemetry-instrumentation-botocore",
|
|
32
|
-
"opentelemetry-instrumentation-requests",
|
|
33
|
-
"opentelemetry-instrumentation-httpx",
|
|
34
|
-
"opentelemetry-instrumentation-redis",
|
|
35
|
-
]
|
|
36
|
-
|
|
37
22
|
[dependency-groups]
|
|
38
23
|
dev = [
|
|
39
24
|
"hypothesis",
|
|
@@ -46,8 +31,6 @@ dev = [
|
|
|
46
31
|
"pytest-cov",
|
|
47
32
|
"ty",
|
|
48
33
|
"psycopg",
|
|
49
|
-
"httpx",
|
|
50
|
-
"django-lambda-tasks[otel]",
|
|
51
34
|
]
|
|
52
35
|
|
|
53
36
|
[tool.ty.rules]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for lambda_tasks.batch_worker entry point."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from lambda_tasks.decorators import lambda_task
|
|
9
|
+
from lambda_tasks.handler import main
|
|
10
|
+
from lambda_tasks.models import SQSLambdaTaskMessage, TaskRecord
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _patch_close_old_connections():
|
|
15
|
+
"""Prevent close_old_connections from closing the test DB connection."""
|
|
16
|
+
with patch("lambda_tasks.handler.db.close_old_connections"):
|
|
17
|
+
yield
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
QUEUES = {
|
|
21
|
+
"default": {"queue_url": "https://sqs.us-east-1.amazonaws.com/000000000000/default"}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Module-level tasks for import_string resolution
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lambda_task
|
|
29
|
+
def _worker_task(*, value: int) -> str:
|
|
30
|
+
return "ok"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_missing_env_var_returns_1(monkeypatch):
|
|
34
|
+
monkeypatch.delenv("LAMBDA_TASKS_MESSAGE", raising=False)
|
|
35
|
+
assert main() == 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.django_db
|
|
39
|
+
def test_valid_message_executes_task(monkeypatch, settings):
|
|
40
|
+
settings.LAMBDA_TASKS_EAGER = True
|
|
41
|
+
settings.LAMBDA_TASKS_QUEUES = QUEUES
|
|
42
|
+
|
|
43
|
+
task_name = f"{_worker_task.__module__}.{_worker_task.__wrapped__.__qualname__}"
|
|
44
|
+
message = SQSLambdaTaskMessage(task_name=task_name, kwargs={"value": 1})
|
|
45
|
+
job_id = str(uuid.uuid4())
|
|
46
|
+
|
|
47
|
+
monkeypatch.setenv("LAMBDA_TASKS_MESSAGE", message.model_dump_json())
|
|
48
|
+
monkeypatch.setenv("AWS_BATCH_JOB_ID", job_id)
|
|
49
|
+
|
|
50
|
+
result = main()
|
|
51
|
+
|
|
52
|
+
assert result == 0
|
|
53
|
+
record = TaskRecord.objects.get(pk=job_id)
|
|
54
|
+
assert record.status == TaskRecord.TaskStatus.SUCCEEDED
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_invalid_json_returns_1(monkeypatch):
|
|
58
|
+
monkeypatch.setenv("LAMBDA_TASKS_MESSAGE", "not-valid-json")
|
|
59
|
+
monkeypatch.delenv("AWS_BATCH_JOB_ID", raising=False)
|
|
60
|
+
assert main() == 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.django_db
|
|
64
|
+
def test_uses_uuid_when_no_batch_job_id(monkeypatch, settings):
|
|
65
|
+
settings.LAMBDA_TASKS_EAGER = True
|
|
66
|
+
settings.LAMBDA_TASKS_QUEUES = QUEUES
|
|
67
|
+
|
|
68
|
+
task_name = f"{_worker_task.__module__}.{_worker_task.__wrapped__.__qualname__}"
|
|
69
|
+
message = SQSLambdaTaskMessage(task_name=task_name, kwargs={"value": 5})
|
|
70
|
+
|
|
71
|
+
monkeypatch.setenv("LAMBDA_TASKS_MESSAGE", message.model_dump_json())
|
|
72
|
+
monkeypatch.delenv("AWS_BATCH_JOB_ID", raising=False)
|
|
73
|
+
|
|
74
|
+
result = main()
|
|
75
|
+
|
|
76
|
+
assert result == 0
|
|
77
|
+
assert TaskRecord.objects.count() == 1
|
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
"""Optional OpenTelemetry tracing for the Lambda and Batch execution paths.
|
|
2
|
-
|
|
3
|
-
Initialised at import time of ``lambda_tasks.handler`` (before the Lambda
|
|
4
|
-
runtime invokes the handler, which is earlier than ``django.setup()``). Driven
|
|
5
|
-
entirely by standard OTEL environment variables so this package stays generic:
|
|
6
|
-
the consuming application declares the OTEL dependencies and sets
|
|
7
|
-
``OTEL_EXPORTER_OTLP_ENDPOINT`` to the regional AWS X-Ray OTLP traces endpoint
|
|
8
|
-
(``https://xray.<region>.amazonaws.com/v1/traces``).
|
|
9
|
-
|
|
10
|
-
Spans are exported directly to the X-Ray endpoint — no collector, no daemon,
|
|
11
|
-
no Lambda layer. Requests are SigV4-signed with the ambient AWS credentials
|
|
12
|
-
(the task / execution role), so there is no API key to manage. When no
|
|
13
|
-
credentials are resolvable (local dev / tests) the request is left unsigned.
|
|
14
|
-
|
|
15
|
-
The OpenTelemetry and ``requests`` imports are deferred into the functions
|
|
16
|
-
below so they are only reached when tracing is enabled (a non-empty
|
|
17
|
-
``OTEL_EXPORTER_OTLP_ENDPOINT`` / ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT``).
|
|
18
|
-
This keeps the OTEL packages a genuinely optional extra: importing
|
|
19
|
-
``lambda_tasks.handler`` without them installed is a no-op, not an
|
|
20
|
-
``ImportError``.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
import logging
|
|
24
|
-
import os
|
|
25
|
-
from typing import TYPE_CHECKING
|
|
26
|
-
|
|
27
|
-
if TYPE_CHECKING:
|
|
28
|
-
from collections.abc import Callable
|
|
29
|
-
|
|
30
|
-
import requests
|
|
31
|
-
from opentelemetry.sdk.trace import TracerProvider
|
|
32
|
-
|
|
33
|
-
logger = logging.getLogger(__name__)
|
|
34
|
-
|
|
35
|
-
_XRAY_SIGV4_SERVICE_NAME = "xray"
|
|
36
|
-
_LAMBDA_HANDLER = "lambda_tasks.handler.handler"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def tracing_is_enabled() -> bool:
|
|
40
|
-
"""Whether an OTLP traces endpoint has been configured.
|
|
41
|
-
|
|
42
|
-
Either the base ``OTEL_EXPORTER_OTLP_ENDPOINT`` or the per-signal
|
|
43
|
-
``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`` turns it on.
|
|
44
|
-
"""
|
|
45
|
-
return bool(
|
|
46
|
-
os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
|
47
|
-
or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "").strip()
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _running_on_lambda() -> bool:
|
|
52
|
-
return bool(os.environ.get("AWS_LAMBDA_FUNCTION_NAME"))
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _build_signing_session() -> "requests.Session":
|
|
56
|
-
import requests
|
|
57
|
-
import requests.auth
|
|
58
|
-
from botocore.auth import SigV4Auth
|
|
59
|
-
from botocore.awsrequest import AWSRequest
|
|
60
|
-
from botocore.session import Session as BotocoreSession
|
|
61
|
-
|
|
62
|
-
class _SigV4Auth(requests.auth.AuthBase):
|
|
63
|
-
"""SigV4-signs OTLP exports to the X-Ray endpoint."""
|
|
64
|
-
|
|
65
|
-
def __init__(
|
|
66
|
-
self, *, service_name: str, region: str, session: BotocoreSession
|
|
67
|
-
) -> None:
|
|
68
|
-
self._service_name = service_name
|
|
69
|
-
self._region = region
|
|
70
|
-
self._session = session
|
|
71
|
-
|
|
72
|
-
def __call__(self, r: "requests.PreparedRequest") -> "requests.PreparedRequest":
|
|
73
|
-
credentials = self._session.get_credentials()
|
|
74
|
-
|
|
75
|
-
if credentials is None:
|
|
76
|
-
logger.warning(
|
|
77
|
-
"No AWS credentials resolvable; sending OTLP span export to "
|
|
78
|
-
"%s unsigned. The X-Ray endpoint will reject unsigned "
|
|
79
|
-
"requests, so spans will be dropped.",
|
|
80
|
-
r.url,
|
|
81
|
-
)
|
|
82
|
-
return r
|
|
83
|
-
|
|
84
|
-
aws_request = AWSRequest(
|
|
85
|
-
method=r.method,
|
|
86
|
-
url=r.url,
|
|
87
|
-
data=r.body,
|
|
88
|
-
headers={"Content-Type": "application/x-protobuf"},
|
|
89
|
-
)
|
|
90
|
-
SigV4Auth(credentials, self._service_name, self._region).add_auth(
|
|
91
|
-
aws_request
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
r.headers.update(dict(aws_request.headers))
|
|
95
|
-
|
|
96
|
-
return r
|
|
97
|
-
|
|
98
|
-
region = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", ""))
|
|
99
|
-
|
|
100
|
-
if not region:
|
|
101
|
-
logger.warning(
|
|
102
|
-
"No AWS region resolvable from AWS_REGION / AWS_DEFAULT_REGION; "
|
|
103
|
-
"SigV4 signing of OTLP span exports may fail."
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
session = requests.Session()
|
|
107
|
-
session.auth = _SigV4Auth(
|
|
108
|
-
service_name=_XRAY_SIGV4_SERVICE_NAME,
|
|
109
|
-
region=region,
|
|
110
|
-
session=BotocoreSession(),
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
return session
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _build_tracer_provider() -> "TracerProvider":
|
|
117
|
-
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
118
|
-
OTLPSpanExporter,
|
|
119
|
-
)
|
|
120
|
-
from opentelemetry.sdk.extension.aws.trace import AwsXRayIdGenerator
|
|
121
|
-
from opentelemetry.sdk.resources import Resource
|
|
122
|
-
from opentelemetry.sdk.trace import TracerProvider
|
|
123
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
124
|
-
|
|
125
|
-
service_name = os.environ.get(
|
|
126
|
-
"OTEL_SERVICE_NAME",
|
|
127
|
-
os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "lambda-tasks"),
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
tracer_provider = TracerProvider(
|
|
131
|
-
resource=Resource.create(attributes={"service.name": service_name}),
|
|
132
|
-
id_generator=AwsXRayIdGenerator(),
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
tracer_provider.add_span_processor(
|
|
136
|
-
BatchSpanProcessor(
|
|
137
|
-
span_exporter=OTLPSpanExporter(session=_build_signing_session())
|
|
138
|
-
)
|
|
139
|
-
)
|
|
140
|
-
return tracer_provider
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _set_xray_propagator() -> None:
|
|
144
|
-
from opentelemetry import propagate
|
|
145
|
-
from opentelemetry.propagators.aws import (
|
|
146
|
-
AwsXRayLambdaPropagator,
|
|
147
|
-
AwsXRayPropagator,
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
propagate.set_global_textmap(
|
|
151
|
-
AwsXRayLambdaPropagator() if _running_on_lambda() else AwsXRayPropagator()
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def _instrument_libraries() -> None:
|
|
156
|
-
"""Instrument the libraries whose calls we want as spans.
|
|
157
|
-
|
|
158
|
-
SQL (psycopg 3), AWS SDK (botocore → boto3/aioboto3), outbound HTTP
|
|
159
|
-
(requests and httpx), and cache (redis). Each is wrapped independently and
|
|
160
|
-
is best-effort: a missing or failing instrumentor is logged, not raised, so
|
|
161
|
-
it cannot take down the worker. All are process-global and idempotent.
|
|
162
|
-
|
|
163
|
-
The OTLP span export itself goes out over ``requests`` to the X-Ray
|
|
164
|
-
endpoint; that URL is excluded from the requests instrumentor so the
|
|
165
|
-
exporter's own calls are not traced (which would be self-referential noise).
|
|
166
|
-
"""
|
|
167
|
-
|
|
168
|
-
def _try(name: str, instrument: "Callable[[], None]") -> None:
|
|
169
|
-
try:
|
|
170
|
-
instrument()
|
|
171
|
-
except Exception:
|
|
172
|
-
logger.warning(
|
|
173
|
-
"Failed to instrument %s for tracing; continuing without it.",
|
|
174
|
-
name,
|
|
175
|
-
exc_info=True,
|
|
176
|
-
)
|
|
177
|
-
else:
|
|
178
|
-
logger.info("%s instrumentation installed.", name)
|
|
179
|
-
|
|
180
|
-
def _psycopg() -> None:
|
|
181
|
-
from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
|
|
182
|
-
|
|
183
|
-
PsycopgInstrumentor().instrument()
|
|
184
|
-
|
|
185
|
-
def _botocore() -> None:
|
|
186
|
-
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
|
|
187
|
-
|
|
188
|
-
BotocoreInstrumentor().instrument()
|
|
189
|
-
|
|
190
|
-
def _requests() -> None:
|
|
191
|
-
from opentelemetry.instrumentation.requests import RequestsInstrumentor
|
|
192
|
-
|
|
193
|
-
# Do not trace the exporter's own POST to the X-Ray OTLP endpoint.
|
|
194
|
-
RequestsInstrumentor().instrument(excluded_urls="xray\\..*\\.amazonaws\\.com")
|
|
195
|
-
|
|
196
|
-
def _httpx() -> None:
|
|
197
|
-
from opentelemetry.instrumentation.httpx import (
|
|
198
|
-
HTTPXClientInstrumentor,
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
HTTPXClientInstrumentor().instrument()
|
|
202
|
-
|
|
203
|
-
def _redis() -> None:
|
|
204
|
-
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
|
205
|
-
|
|
206
|
-
RedisInstrumentor().instrument()
|
|
207
|
-
|
|
208
|
-
_try("psycopg", _psycopg)
|
|
209
|
-
_try("botocore", _botocore)
|
|
210
|
-
_try("requests", _requests)
|
|
211
|
-
_try("httpx", _httpx)
|
|
212
|
-
_try("redis", _redis)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def configure_tracing() -> "TracerProvider | None":
|
|
216
|
-
"""Install the global tracer provider. No-op unless enabled.
|
|
217
|
-
|
|
218
|
-
Returns the provider so callers (e.g. the Batch entry point) can flush it
|
|
219
|
-
on shutdown, or ``None`` when tracing is disabled or already configured.
|
|
220
|
-
|
|
221
|
-
Tracing is best-effort: if it is enabled but the OTEL packages are missing
|
|
222
|
-
or the provider cannot be built, the failure is logged and ``None`` is
|
|
223
|
-
returned rather than propagated — a broken observability setup must not
|
|
224
|
-
take down the worker (this runs at import time of the handler module).
|
|
225
|
-
"""
|
|
226
|
-
if not tracing_is_enabled():
|
|
227
|
-
logger.debug("OTEL tracing disabled (no OTLP endpoint configured).")
|
|
228
|
-
return None
|
|
229
|
-
|
|
230
|
-
try:
|
|
231
|
-
from opentelemetry import trace
|
|
232
|
-
from opentelemetry.sdk.trace import TracerProvider
|
|
233
|
-
|
|
234
|
-
if isinstance(trace.get_tracer_provider(), TracerProvider):
|
|
235
|
-
logger.info(
|
|
236
|
-
"A TracerProvider is already installed; skipping configuration."
|
|
237
|
-
)
|
|
238
|
-
return None
|
|
239
|
-
|
|
240
|
-
tracer_provider = _build_tracer_provider()
|
|
241
|
-
trace.set_tracer_provider(tracer_provider)
|
|
242
|
-
_set_xray_propagator()
|
|
243
|
-
_instrument_libraries()
|
|
244
|
-
except ImportError:
|
|
245
|
-
logger.warning(
|
|
246
|
-
"OTEL tracing is enabled but the OpenTelemetry packages are not "
|
|
247
|
-
"installed; tracing is disabled. Install the 'otel' extra to "
|
|
248
|
-
"enable it.",
|
|
249
|
-
exc_info=True,
|
|
250
|
-
)
|
|
251
|
-
return None
|
|
252
|
-
except Exception:
|
|
253
|
-
logger.warning(
|
|
254
|
-
"Failed to configure OTEL tracing; continuing without it.",
|
|
255
|
-
exc_info=True,
|
|
256
|
-
)
|
|
257
|
-
return None
|
|
258
|
-
|
|
259
|
-
logger.info("OTEL tracing configured (exporting spans to the OTLP endpoint).")
|
|
260
|
-
return tracer_provider
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def instrument_lambda_handler() -> None:
|
|
264
|
-
"""Wrap the Lambda handler for tracing. No-op unless enabled.
|
|
265
|
-
|
|
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.
|
|
268
|
-
"""
|
|
269
|
-
if not tracing_is_enabled():
|
|
270
|
-
return
|
|
271
|
-
|
|
272
|
-
if not _running_on_lambda():
|
|
273
|
-
logger.debug(
|
|
274
|
-
"Not running on Lambda (AWS_LAMBDA_FUNCTION_NAME unset); "
|
|
275
|
-
"skipping AwsLambdaInstrumentor."
|
|
276
|
-
)
|
|
277
|
-
return
|
|
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
|
-
|
|
285
|
-
try:
|
|
286
|
-
from opentelemetry.instrumentation.aws_lambda import (
|
|
287
|
-
AwsLambdaInstrumentor,
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
AwsLambdaInstrumentor().instrument()
|
|
291
|
-
except Exception:
|
|
292
|
-
logger.warning(
|
|
293
|
-
"Failed to instrument the Lambda handler for tracing; "
|
|
294
|
-
"continuing without handler spans.",
|
|
295
|
-
exc_info=True,
|
|
296
|
-
)
|
|
297
|
-
return
|
|
298
|
-
|
|
299
|
-
logger.info("AwsLambdaInstrumentor installed; Lambda handler is traced.")
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def flush_tracing() -> None:
|
|
303
|
-
"""Force-flush and shut down the global tracer provider. No-op unless enabled.
|
|
304
|
-
|
|
305
|
-
Called at the end of the one-shot Batch entry point so short-lived jobs do
|
|
306
|
-
not drop buffered spans. Gated on ``tracing_is_enabled()`` so the OTEL
|
|
307
|
-
imports are never reached when tracing is disabled (or the packages are not
|
|
308
|
-
installed).
|
|
309
|
-
"""
|
|
310
|
-
if not tracing_is_enabled():
|
|
311
|
-
return
|
|
312
|
-
|
|
313
|
-
from opentelemetry import trace
|
|
314
|
-
from opentelemetry.sdk.trace import TracerProvider
|
|
315
|
-
|
|
316
|
-
provider = trace.get_tracer_provider()
|
|
317
|
-
if isinstance(provider, TracerProvider):
|
|
318
|
-
logger.debug("Flushing and shutting down the TracerProvider before exit.")
|
|
319
|
-
provider.force_flush()
|
|
320
|
-
provider.shutdown()
|
|
321
|
-
else:
|
|
322
|
-
logger.debug(
|
|
323
|
-
"Tracing is enabled but no TracerProvider is installed; "
|
|
324
|
-
"nothing to flush."
|
|
325
|
-
)
|