django-lambda-tasks 0.9.0__tar.gz → 0.9.1__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.0 → django_lambda_tasks-0.9.1}/PKG-INFO +6 -1
  2. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/tracing.py +63 -0
  3. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/pyproject.toml +7 -1
  4. django_lambda_tasks-0.9.1/tests/test_tracing.py +385 -0
  5. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.github/dependabot.yml +0 -0
  6. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.github/workflows/ci.yml +0 -0
  7. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.github/workflows/release.yml +0 -0
  8. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.gitignore +0 -0
  9. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  10. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/async-local-execution/design.md +0 -0
  11. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/async-local-execution/requirements.md +0 -0
  12. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/async-local-execution/tasks.md +0 -0
  13. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  14. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  15. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  16. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  17. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  18. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  19. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  20. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  21. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  22. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  23. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  24. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  25. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  26. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  27. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  28. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  29. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/retry-delay/.config.kiro +0 -0
  30. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/retry-delay/design.md +0 -0
  31. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/retry-delay/requirements.md +0 -0
  32. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/retry-delay/tasks.md +0 -0
  33. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  34. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks/design.md +0 -0
  35. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  36. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  37. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  38. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  39. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  40. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  41. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/singleton-task/.config.kiro +0 -0
  42. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/singleton-task/design.md +0 -0
  43. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/singleton-task/requirements.md +0 -0
  44. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/singleton-task/tasks.md +0 -0
  45. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  46. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  47. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  48. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  49. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/task-retry/.config.kiro +0 -0
  50. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/task-retry/design.md +0 -0
  51. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/task-retry/requirements.md +0 -0
  52. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/specs/task-retry/tasks.md +0 -0
  53. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/steering/product.md +0 -0
  54. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/steering/structure.md +0 -0
  55. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.kiro/steering/tech.md +0 -0
  56. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.pre-commit-config.yaml +0 -0
  57. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/.vscode/settings.json +0 -0
  58. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/README.md +0 -0
  59. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/docker-compose.yml +0 -0
  60. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/README.md +0 -0
  61. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/docker-compose.yml +0 -0
  62. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_app/__init__.py +0 -0
  63. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_app/apps.py +0 -0
  64. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_app/tasks.py +0 -0
  65. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_app/urls.py +0 -0
  66. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_app/views.py +0 -0
  67. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_project/__init__.py +0 -0
  68. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_project/settings.py +0 -0
  69. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_project/urls.py +0 -0
  70. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/example_project/wsgi.py +0 -0
  71. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/example/manage.py +0 -0
  72. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/__init__.py +0 -0
  73. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/admin.py +0 -0
  74. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/apps.py +0 -0
  75. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/checks.py +0 -0
  76. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/decorators.py +0 -0
  77. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/environment_loader.py +0 -0
  78. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/handler.py +0 -0
  79. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/local_executor.py +0 -0
  80. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/logging.py +0 -0
  81. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/migrations/0001_initial.py +0 -0
  82. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  83. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/migrations/0003_taskrecord_taskrecord_kwargs_trgm.py +0 -0
  84. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/migrations/0004_remove_taskrecord_taskrecord_kwargs_trgm_and_more.py +0 -0
  85. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/migrations/__init__.py +0 -0
  86. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/models.py +0 -0
  87. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/secret_loader.py +0 -0
  88. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/settings.py +0 -0
  89. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/tasks.py +0 -0
  90. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/lambda_tasks/timeouts.py +0 -0
  91. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/conftest.py +0 -0
  92. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/settings.py +0 -0
  93. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_admin.py +0 -0
  94. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_batch.py +0 -0
  95. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_batch_worker.py +0 -0
  96. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_checks.py +0 -0
  97. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_decorator.py +0 -0
  98. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_decorators.py +0 -0
  99. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_deferred_enqueue.py +0 -0
  100. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_environment_loader.py +0 -0
  101. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_handler.py +0 -0
  102. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_kwargs_only.py +0 -0
  103. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_local_executor.py +0 -0
  104. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_logging.py +0 -0
  105. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_memory_limit.py +0 -0
  106. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_models.py +0 -0
  107. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_noop_execution.py +0 -0
  108. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_secret_loader.py +0 -0
  109. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_serializer.py +0 -0
  110. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_settings.py +0 -0
  111. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_tasks.py +0 -0
  112. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_timeout_validation.py +0 -0
  113. {django_lambda_tasks-0.9.0 → django_lambda_tasks-0.9.1}/tests/test_timeouts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-lambda-tasks
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Run async tasks in a lambda function
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: awslambdaric
@@ -11,6 +11,11 @@ Requires-Dist: redis
11
11
  Provides-Extra: otel
12
12
  Requires-Dist: opentelemetry-exporter-otlp-proto-http; extra == 'otel'
13
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'
14
19
  Requires-Dist: opentelemetry-propagator-aws-xray; extra == 'otel'
15
20
  Requires-Dist: opentelemetry-sdk; extra == 'otel'
16
21
  Requires-Dist: opentelemetry-sdk-extension-aws; extra == 'otel'
@@ -25,6 +25,8 @@ import os
25
25
  from typing import TYPE_CHECKING
26
26
 
27
27
  if TYPE_CHECKING:
28
+ from collections.abc import Callable
29
+
28
30
  import requests
29
31
  from opentelemetry.sdk.trace import TracerProvider
30
32
 
@@ -149,6 +151,66 @@ def _set_xray_propagator() -> None:
149
151
  )
150
152
 
151
153
 
154
+ def _instrument_libraries() -> None:
155
+ """Instrument the libraries whose calls we want as spans.
156
+
157
+ SQL (psycopg 3), AWS SDK (botocore → boto3/aioboto3), outbound HTTP
158
+ (requests and httpx), and cache (redis). Each is wrapped independently and
159
+ is best-effort: a missing or failing instrumentor is logged, not raised, so
160
+ it cannot take down the worker. All are process-global and idempotent.
161
+
162
+ The OTLP span export itself goes out over ``requests`` to the X-Ray
163
+ endpoint; that URL is excluded from the requests instrumentor so the
164
+ exporter's own calls are not traced (which would be self-referential noise).
165
+ """
166
+
167
+ def _try(name: str, instrument: "Callable[[], None]") -> None:
168
+ try:
169
+ instrument()
170
+ except Exception:
171
+ logger.warning(
172
+ "Failed to instrument %s for tracing; continuing without it.",
173
+ name,
174
+ exc_info=True,
175
+ )
176
+ else:
177
+ logger.info("%s instrumentation installed.", name)
178
+
179
+ def _psycopg() -> None:
180
+ from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
181
+
182
+ PsycopgInstrumentor().instrument()
183
+
184
+ def _botocore() -> None:
185
+ from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
186
+
187
+ BotocoreInstrumentor().instrument()
188
+
189
+ def _requests() -> None:
190
+ from opentelemetry.instrumentation.requests import RequestsInstrumentor
191
+
192
+ # Do not trace the exporter's own POST to the X-Ray OTLP endpoint.
193
+ RequestsInstrumentor().instrument(excluded_urls="xray\\..*\\.amazonaws\\.com")
194
+
195
+ def _httpx() -> None:
196
+ from opentelemetry.instrumentation.httpx import (
197
+ HTTPXClientInstrumentor,
198
+ )
199
+
200
+ HTTPXClientInstrumentor().instrument()
201
+
202
+ def _redis() -> None:
203
+ from opentelemetry.instrumentation.redis import RedisInstrumentor
204
+
205
+ RedisInstrumentor().instrument()
206
+
207
+ _try("psycopg", _psycopg)
208
+ _try("botocore", _botocore)
209
+ _try("requests", _requests)
210
+ _try("httpx", _httpx)
211
+ _try("redis", _redis)
212
+
213
+
152
214
  def configure_tracing() -> "TracerProvider | None":
153
215
  """Install the global tracer provider. No-op unless enabled.
154
216
 
@@ -177,6 +239,7 @@ def configure_tracing() -> "TracerProvider | None":
177
239
  tracer_provider = _build_tracer_provider()
178
240
  trace.set_tracer_provider(tracer_provider)
179
241
  _set_xray_propagator()
242
+ _instrument_libraries()
180
243
  except ImportError:
181
244
  logger.warning(
182
245
  "OTEL tracing is enabled but the OpenTelemetry packages are not "
@@ -7,7 +7,7 @@ packages = ["lambda_tasks"]
7
7
 
8
8
  [project]
9
9
  name = "django-lambda-tasks"
10
- version = "0.9.0"
10
+ version = "0.9.1"
11
11
  description = "Run async tasks in a lambda function"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -27,6 +27,11 @@ otel = [
27
27
  "opentelemetry-instrumentation-aws-lambda",
28
28
  "opentelemetry-sdk-extension-aws",
29
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",
30
35
  ]
31
36
 
32
37
  [dependency-groups]
@@ -41,6 +46,7 @@ dev = [
41
46
  "pytest-cov",
42
47
  "ty",
43
48
  "psycopg",
49
+ "httpx",
44
50
  "django-lambda-tasks[otel]",
45
51
  ]
46
52
 
@@ -0,0 +1,385 @@
1
+ """Tests for lambda_tasks.tracing.
2
+
3
+ These paths are driven entirely by standard OTEL environment variables and do
4
+ not read Django settings, so environment is manipulated with monkeypatch and
5
+ override_settings is never used.
6
+
7
+ Each test resets the global tracer provider so installation is observable and
8
+ isolated. The OTEL SDK forbids overwriting a real TracerProvider once set, so
9
+ the suite swaps in a fresh default proxy provider before each test.
10
+ """
11
+
12
+ import builtins
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from unittest.mock import patch
17
+
18
+ import pytest
19
+ from opentelemetry import trace
20
+ from opentelemetry.sdk.trace import TracerProvider
21
+
22
+ from lambda_tasks import tracing
23
+
24
+ _ENDPOINT = "https://xray.us-east-1.amazonaws.com/v1/traces"
25
+
26
+
27
+ @pytest.fixture(autouse=True)
28
+ def _reset_global_tracer_provider():
29
+ """Reset OTEL global state so each test starts from a clean default provider."""
30
+ import opentelemetry.trace as trace_module
31
+
32
+ original_provider = trace_module._TRACER_PROVIDER
33
+ original_set = trace_module._TRACER_PROVIDER_SET_ONCE
34
+ trace_module._TRACER_PROVIDER = None
35
+ trace_module._TRACER_PROVIDER_SET_ONCE = trace_module.Once()
36
+ try:
37
+ yield
38
+ finally:
39
+ trace_module._TRACER_PROVIDER = original_provider
40
+ trace_module._TRACER_PROVIDER_SET_ONCE = original_set
41
+
42
+
43
+ def _clear_otel_env(monkeypatch):
44
+ monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
45
+ monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False)
46
+
47
+
48
+ def _library_instrumentors():
49
+ """The five instrumentor instances configure_tracing() should install."""
50
+ from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
51
+ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
52
+ from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
53
+ from opentelemetry.instrumentation.redis import RedisInstrumentor
54
+ from opentelemetry.instrumentation.requests import RequestsInstrumentor
55
+
56
+ return {
57
+ "Psycopg": PsycopgInstrumentor(),
58
+ "Botocore": BotocoreInstrumentor(),
59
+ "Requests": RequestsInstrumentor(),
60
+ "HTTPXClient": HTTPXClientInstrumentor(),
61
+ "Redis": RedisInstrumentor(),
62
+ }
63
+
64
+
65
+ @pytest.fixture
66
+ def _uninstrument_libraries():
67
+ """Uninstrument the library instrumentors after a test.
68
+
69
+ The instrumentors are process-global singletons; leaving them instrumented
70
+ would leak into other tests. Uninstrument before and after so the test
71
+ starts from a clean slate regardless of ordering.
72
+ """
73
+ instrumentors = _library_instrumentors()
74
+ for instrumentor in instrumentors.values():
75
+ if instrumentor.is_instrumented_by_opentelemetry:
76
+ instrumentor.uninstrument()
77
+ try:
78
+ yield instrumentors
79
+ finally:
80
+ for instrumentor in instrumentors.values():
81
+ if instrumentor.is_instrumented_by_opentelemetry:
82
+ instrumentor.uninstrument()
83
+
84
+
85
+ class TestTracingIsEnabled:
86
+ def test_disabled_when_no_endpoint(self, monkeypatch):
87
+ _clear_otel_env(monkeypatch)
88
+ assert tracing.tracing_is_enabled() is False
89
+
90
+ def test_enabled_by_base_endpoint(self, monkeypatch):
91
+ _clear_otel_env(monkeypatch)
92
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
93
+ assert tracing.tracing_is_enabled() is True
94
+
95
+ def test_enabled_by_per_signal_endpoint(self, monkeypatch):
96
+ _clear_otel_env(monkeypatch)
97
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", _ENDPOINT)
98
+ assert tracing.tracing_is_enabled() is True
99
+
100
+ def test_disabled_when_endpoint_blank(self, monkeypatch):
101
+ _clear_otel_env(monkeypatch)
102
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", " ")
103
+ assert tracing.tracing_is_enabled() is False
104
+
105
+
106
+ class TestConfigureTracing:
107
+ def test_noop_when_disabled(self, monkeypatch):
108
+ _clear_otel_env(monkeypatch)
109
+ result = tracing.configure_tracing()
110
+ assert result is None
111
+ assert not isinstance(trace.get_tracer_provider(), TracerProvider)
112
+
113
+ def test_installs_provider_when_enabled(self, monkeypatch):
114
+ _clear_otel_env(monkeypatch)
115
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
116
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
117
+
118
+ result = tracing.configure_tracing()
119
+
120
+ assert isinstance(result, TracerProvider)
121
+ assert trace.get_tracer_provider() is result
122
+
123
+ def test_idempotent_on_second_call(self, monkeypatch):
124
+ _clear_otel_env(monkeypatch)
125
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
126
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
127
+
128
+ first = tracing.configure_tracing()
129
+ second = tracing.configure_tracing()
130
+
131
+ assert isinstance(first, TracerProvider)
132
+ assert second is None
133
+ assert trace.get_tracer_provider() is first
134
+
135
+
136
+ class TestInstrumentLibraries:
137
+ def test_instruments_all_libraries_when_enabled(
138
+ self, monkeypatch, _uninstrument_libraries
139
+ ):
140
+ _clear_otel_env(monkeypatch)
141
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
142
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
143
+
144
+ tracing.configure_tracing()
145
+
146
+ for name, instrumentor in _uninstrument_libraries.items():
147
+ assert instrumentor.is_instrumented_by_opentelemetry is True, name
148
+
149
+ def test_noop_when_disabled(self, monkeypatch, _uninstrument_libraries):
150
+ _clear_otel_env(monkeypatch)
151
+
152
+ result = tracing.configure_tracing()
153
+
154
+ assert result is None
155
+ for name, instrumentor in _uninstrument_libraries.items():
156
+ assert instrumentor.is_instrumented_by_opentelemetry is False, name
157
+
158
+ def test_one_failing_instrumentor_does_not_block_the_others(
159
+ self, monkeypatch, caplog, _uninstrument_libraries
160
+ ):
161
+ _clear_otel_env(monkeypatch)
162
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
163
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
164
+
165
+ from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
166
+
167
+ def _boom(self, **kwargs):
168
+ raise RuntimeError("botocore instrumentation failed")
169
+
170
+ monkeypatch.setattr(BotocoreInstrumentor, "_instrument", _boom)
171
+
172
+ with caplog.at_level("WARNING", logger="lambda_tasks.tracing"):
173
+ tracing.configure_tracing()
174
+
175
+ instrumentors = _uninstrument_libraries
176
+ assert instrumentors["Botocore"].is_instrumented_by_opentelemetry is False
177
+ for name in ("Psycopg", "Requests", "HTTPXClient", "Redis"):
178
+ assert instrumentors[name].is_instrumented_by_opentelemetry is True, name
179
+
180
+ assert any(
181
+ "Failed to instrument botocore" in record.message
182
+ for record in caplog.records
183
+ )
184
+
185
+ def test_requests_instrumentor_excludes_xray_endpoint(
186
+ self, monkeypatch, _uninstrument_libraries
187
+ ):
188
+ """A POST to the X-Ray OTLP endpoint must produce no span.
189
+
190
+ The requests instrumentor is configured with an excluded_urls regex
191
+ matching the X-Ray host so the exporter's own export calls are not
192
+ traced (self-referential noise).
193
+ """
194
+ _clear_otel_env(monkeypatch)
195
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
196
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
197
+
198
+ import requests
199
+ from opentelemetry.sdk.trace import TracerProvider
200
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
201
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
202
+ InMemorySpanExporter,
203
+ )
204
+
205
+ exporter = InMemorySpanExporter()
206
+ provider = TracerProvider()
207
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
208
+
209
+ with patch.object(tracing, "_build_tracer_provider", return_value=provider):
210
+ tracing.configure_tracing()
211
+
212
+ # Issue a request to the excluded X-Ray endpoint; the underlying
213
+ # socket is patched so no real network call is made. The instrumentor
214
+ # decides whether to create a span before the transport runs.
215
+ with patch.object(
216
+ requests.adapters.HTTPAdapter,
217
+ "send",
218
+ side_effect=requests.exceptions.ConnectionError("blocked"),
219
+ ):
220
+ with pytest.raises(requests.exceptions.ConnectionError):
221
+ requests.post(_ENDPOINT, data=b"body")
222
+
223
+ assert exporter.get_finished_spans() == ()
224
+
225
+
226
+ class TestInstrumentLambdaHandler:
227
+ def test_noop_off_lambda(self, monkeypatch):
228
+ _clear_otel_env(monkeypatch)
229
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
230
+ monkeypatch.delenv("AWS_LAMBDA_FUNCTION_NAME", raising=False)
231
+
232
+ from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor
233
+
234
+ instrumentor = AwsLambdaInstrumentor()
235
+ assert instrumentor.is_instrumented_by_opentelemetry is False
236
+ tracing.instrument_lambda_handler()
237
+ assert instrumentor.is_instrumented_by_opentelemetry is False
238
+
239
+ def test_noop_when_disabled(self, monkeypatch):
240
+ _clear_otel_env(monkeypatch)
241
+ monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-fn")
242
+
243
+ from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor
244
+
245
+ instrumentor = AwsLambdaInstrumentor()
246
+ assert instrumentor.is_instrumented_by_opentelemetry is False
247
+ tracing.instrument_lambda_handler()
248
+ assert instrumentor.is_instrumented_by_opentelemetry is False
249
+
250
+ def test_instruments_on_lambda_when_enabled(self, monkeypatch):
251
+ _clear_otel_env(monkeypatch)
252
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
253
+ monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-fn")
254
+ monkeypatch.setenv("ORIG_HANDLER", "lambda_tasks.handler.handler")
255
+
256
+ from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor
257
+
258
+ instrumentor = AwsLambdaInstrumentor()
259
+ try:
260
+ tracing.instrument_lambda_handler()
261
+ assert instrumentor.is_instrumented_by_opentelemetry is True
262
+ finally:
263
+ if instrumentor.is_instrumented_by_opentelemetry:
264
+ instrumentor.uninstrument()
265
+
266
+
267
+ class TestOptionalDependency:
268
+ """The OTEL packages are a genuinely optional extra.
269
+
270
+ Importing lambda_tasks.handler / lambda_tasks.tracing must not require
271
+ opentelemetry or requests to be installed, and the disabled no-op path
272
+ must never reach those imports.
273
+ """
274
+
275
+ @staticmethod
276
+ def _block_otel_and_requests():
277
+ """Return an __import__ replacement that hides OTEL and requests."""
278
+ real_import = builtins.__import__
279
+
280
+ def fake_import(name, *args, **kwargs):
281
+ if name.split(".")[0] in ("opentelemetry", "requests"):
282
+ raise ModuleNotFoundError(f"No module named {name!r}")
283
+ return real_import(name, *args, **kwargs)
284
+
285
+ return fake_import
286
+
287
+ def test_handler_imports_without_otel_installed(self):
288
+ """lambda_tasks.handler imports cleanly with OTEL/requests absent.
289
+
290
+ Run in a subprocess so blocking the imports cannot perturb module
291
+ singletons the rest of the suite relies on.
292
+ """
293
+ program = (
294
+ "import builtins\n"
295
+ "_real = builtins.__import__\n"
296
+ "def _fake(name, *a, **k):\n"
297
+ " if name.split('.')[0] in ('opentelemetry', 'requests'):\n"
298
+ " raise ModuleNotFoundError('No module named ' + name)\n"
299
+ " return _real(name, *a, **k)\n"
300
+ "builtins.__import__ = _fake\n"
301
+ "import os\n"
302
+ "os.environ.pop('OTEL_EXPORTER_OTLP_ENDPOINT', None)\n"
303
+ "os.environ.pop('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', None)\n"
304
+ "import lambda_tasks.handler as h\n"
305
+ "assert h.configure_tracing() is None\n"
306
+ "assert h.instrument_lambda_handler() is None\n"
307
+ "assert h.flush_tracing() is None\n"
308
+ "print('ok')\n"
309
+ )
310
+ result = subprocess.run(
311
+ [sys.executable, "-c", program],
312
+ capture_output=True,
313
+ text=True,
314
+ env={**os.environ, "DJANGO_SETTINGS_MODULE": ""},
315
+ )
316
+ assert result.returncode == 0, result.stderr
317
+ assert "ok" in result.stdout
318
+
319
+ def test_disabled_entry_points_do_not_import_otel(self, monkeypatch):
320
+ """With tracing disabled, configure/instrument are no-ops that never
321
+ touch opentelemetry — so they work with the packages absent."""
322
+ _clear_otel_env(monkeypatch)
323
+ monkeypatch.delenv("AWS_LAMBDA_FUNCTION_NAME", raising=False)
324
+ monkeypatch.setattr(builtins, "__import__", self._block_otel_and_requests())
325
+
326
+ assert tracing.tracing_is_enabled() is False
327
+ assert tracing.configure_tracing() is None
328
+ assert tracing.instrument_lambda_handler() is None
329
+ assert tracing.flush_tracing() is None
330
+
331
+ def test_enabled_but_otel_missing_logs_and_does_not_raise(
332
+ self, monkeypatch, caplog
333
+ ):
334
+ """configure_tracing() is enabled but OTEL is absent → logged no-op,
335
+ not an ImportError that would crash handler import."""
336
+ _clear_otel_env(monkeypatch)
337
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", _ENDPOINT)
338
+ monkeypatch.setattr(builtins, "__import__", self._block_otel_and_requests())
339
+
340
+ with caplog.at_level("WARNING", logger="lambda_tasks.tracing"):
341
+ result = tracing.configure_tracing()
342
+
343
+ assert result is None
344
+ assert any(
345
+ "OpenTelemetry packages are not installed" in record.message
346
+ for record in caplog.records
347
+ )
348
+
349
+
350
+ class TestLoggingDiagnostics:
351
+ def test_missing_credentials_logs_warning(self, monkeypatch, caplog):
352
+ """A span export with no resolvable AWS credentials logs a warning
353
+ (otherwise the unsigned request is silently rejected by X-Ray)."""
354
+ monkeypatch.setenv("AWS_REGION", "us-east-1")
355
+
356
+ session = tracing._build_signing_session()
357
+
358
+ prepared = (
359
+ __import__("requests")
360
+ .Request(method="POST", url=_ENDPOINT, data=b"body")
361
+ .prepare()
362
+ )
363
+
364
+ with (
365
+ patch(
366
+ "botocore.session.Session.get_credentials",
367
+ return_value=None,
368
+ ),
369
+ caplog.at_level("WARNING", logger="lambda_tasks.tracing"),
370
+ ):
371
+ session.auth(prepared)
372
+
373
+ assert any("unsigned" in record.message for record in caplog.records)
374
+
375
+ def test_missing_region_logs_warning(self, monkeypatch, caplog):
376
+ """Building the signing session with no region resolvable logs a warning."""
377
+ monkeypatch.delenv("AWS_REGION", raising=False)
378
+ monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
379
+
380
+ with caplog.at_level("WARNING", logger="lambda_tasks.tracing"):
381
+ tracing._build_signing_session()
382
+
383
+ assert any(
384
+ "No AWS region resolvable" in record.message for record in caplog.records
385
+ )