django-api-opentelemetry 0.1.0__py3-none-any.whl
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_api_opentelemetry/__init__.py +8 -0
- django_api_opentelemetry/_asgi.py +54 -0
- django_api_opentelemetry/_config.py +133 -0
- django_api_opentelemetry/_ninja.py +546 -0
- django_api_opentelemetry/_runtime.py +511 -0
- django_api_opentelemetry/_telemetry.py +626 -0
- django_api_opentelemetry/apps.py +47 -0
- django_api_opentelemetry/middleware.py +447 -0
- django_api_opentelemetry/py.typed +0 -0
- django_api_opentelemetry-0.1.0.dist-info/METADATA +298 -0
- django_api_opentelemetry-0.1.0.dist-info/RECORD +12 -0
- django_api_opentelemetry-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Optional ASGI lifespan wrapper for graceful telemetry shutdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from asgiref.typing import (
|
|
8
|
+
ASGI3Application,
|
|
9
|
+
ASGIReceiveCallable,
|
|
10
|
+
ASGISendCallable,
|
|
11
|
+
LifespanShutdownCompleteEvent,
|
|
12
|
+
LifespanStartupCompleteEvent,
|
|
13
|
+
Scope,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from . import _runtime
|
|
17
|
+
|
|
18
|
+
__all__ = ["instrument_asgi"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def instrument_asgi(application: ASGI3Application) -> ASGI3Application:
|
|
22
|
+
"""Wrap an ASGI application with minimal Django-compatible lifespan support.
|
|
23
|
+
|
|
24
|
+
Native Django ASGI applications do not implement lifespan. This wrapper
|
|
25
|
+
forwards every non-lifespan scope unchanged, acknowledges startup without
|
|
26
|
+
initializing exporters, and performs package-owned shutdown when the server
|
|
27
|
+
closes the lifespan. No Django class or signal is patched.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
async def instrumented(
|
|
31
|
+
scope: Scope,
|
|
32
|
+
receive: ASGIReceiveCallable,
|
|
33
|
+
send: ASGISendCallable,
|
|
34
|
+
) -> None:
|
|
35
|
+
if scope["type"] != "lifespan":
|
|
36
|
+
await application(scope, receive, send)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
while True:
|
|
40
|
+
message = await receive()
|
|
41
|
+
if message["type"] == "lifespan.startup":
|
|
42
|
+
startup_complete: LifespanStartupCompleteEvent = {
|
|
43
|
+
"type": "lifespan.startup.complete"
|
|
44
|
+
}
|
|
45
|
+
await send(startup_complete)
|
|
46
|
+
elif message["type"] == "lifespan.shutdown":
|
|
47
|
+
await asyncio.to_thread(_runtime.shutdown)
|
|
48
|
+
shutdown_complete: LifespanShutdownCompleteEvent = {
|
|
49
|
+
"type": "lifespan.shutdown.complete"
|
|
50
|
+
}
|
|
51
|
+
await send(shutdown_complete)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
return instrumented
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Read and validate the ``DJANGO_NINJA_OTEL`` Django setting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
12
|
+
from django.http import HttpRequest
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from opentelemetry._logs import LoggerProvider
|
|
16
|
+
from opentelemetry.metrics import MeterProvider
|
|
17
|
+
from opentelemetry.trace import TracerProvider
|
|
18
|
+
|
|
19
|
+
_ENV_AUTO_CONFIGURE = "DJANGO_NINJA_OTEL_AUTO_CONFIGURE"
|
|
20
|
+
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
21
|
+
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
|
|
22
|
+
|
|
23
|
+
_BOOLEAN_KEYS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"tracing",
|
|
26
|
+
"metrics",
|
|
27
|
+
"logs",
|
|
28
|
+
"operation_spans",
|
|
29
|
+
"http_instrumentation",
|
|
30
|
+
"auto_configure",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
_PROVIDER_KEYS = {
|
|
34
|
+
"tracer_provider": "get_tracer",
|
|
35
|
+
"meter_provider": "get_meter",
|
|
36
|
+
"logger_provider": "get_logger",
|
|
37
|
+
}
|
|
38
|
+
_ALLOWED_KEYS = _BOOLEAN_KEYS | {"exclude"} | set(_PROVIDER_KEYS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Config:
|
|
43
|
+
"""Normalized package configuration."""
|
|
44
|
+
|
|
45
|
+
tracing: bool = True
|
|
46
|
+
metrics: bool = True
|
|
47
|
+
logs: bool = True
|
|
48
|
+
operation_spans: bool = True
|
|
49
|
+
http_instrumentation: bool = True
|
|
50
|
+
auto_configure: bool = False
|
|
51
|
+
exclude: Callable[[HttpRequest], bool] | None = None
|
|
52
|
+
tracer_provider: TracerProvider | None = None
|
|
53
|
+
meter_provider: MeterProvider | None = None
|
|
54
|
+
logger_provider: LoggerProvider | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_config() -> Config:
|
|
58
|
+
"""Return the validated configuration from Django settings and environment."""
|
|
59
|
+
|
|
60
|
+
raw = getattr(settings, "DJANGO_NINJA_OTEL", None)
|
|
61
|
+
if raw is None:
|
|
62
|
+
raw = {}
|
|
63
|
+
if not isinstance(raw, Mapping):
|
|
64
|
+
raise ImproperlyConfigured(
|
|
65
|
+
"DJANGO_NINJA_OTEL must be a mapping of setting names to values."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
unknown = sorted(repr(key) for key in raw if key not in _ALLOWED_KEYS)
|
|
69
|
+
if unknown:
|
|
70
|
+
raise ImproperlyConfigured(
|
|
71
|
+
"DJANGO_NINJA_OTEL contains unsupported setting(s): "
|
|
72
|
+
+ ", ".join(unknown)
|
|
73
|
+
+ "."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
values: dict[str, Any] = {}
|
|
77
|
+
for key in _BOOLEAN_KEYS:
|
|
78
|
+
if key in raw:
|
|
79
|
+
values[key] = _validate_bool(key, raw[key])
|
|
80
|
+
|
|
81
|
+
auto_configure = (
|
|
82
|
+
values["auto_configure"]
|
|
83
|
+
if "auto_configure" in values
|
|
84
|
+
else _environment_bool(_ENV_AUTO_CONFIGURE)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
exclude = raw.get("exclude")
|
|
88
|
+
if exclude is not None and not callable(exclude):
|
|
89
|
+
raise ImproperlyConfigured(
|
|
90
|
+
"DJANGO_NINJA_OTEL['exclude'] must be a callable receiving HttpRequest."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
providers: dict[str, Any] = {}
|
|
94
|
+
for key, method in _PROVIDER_KEYS.items():
|
|
95
|
+
provider = raw.get(key)
|
|
96
|
+
if provider is not None and not callable(getattr(provider, method, None)):
|
|
97
|
+
raise ImproperlyConfigured(
|
|
98
|
+
f"DJANGO_NINJA_OTEL[{key!r}] must provide a callable {method}()."
|
|
99
|
+
)
|
|
100
|
+
providers[key] = provider
|
|
101
|
+
|
|
102
|
+
return Config(
|
|
103
|
+
tracing=values.get("tracing", True),
|
|
104
|
+
metrics=values.get("metrics", True),
|
|
105
|
+
logs=values.get("logs", True),
|
|
106
|
+
operation_spans=values.get("operation_spans", True),
|
|
107
|
+
http_instrumentation=values.get("http_instrumentation", True),
|
|
108
|
+
auto_configure=auto_configure,
|
|
109
|
+
exclude=exclude,
|
|
110
|
+
tracer_provider=providers["tracer_provider"],
|
|
111
|
+
meter_provider=providers["meter_provider"],
|
|
112
|
+
logger_provider=providers["logger_provider"],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_bool(key: str, value: Any) -> bool:
|
|
117
|
+
if not isinstance(value, bool):
|
|
118
|
+
raise ImproperlyConfigured(f"DJANGO_NINJA_OTEL[{key!r}] must be a boolean.")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _environment_bool(name: str) -> bool:
|
|
123
|
+
value = os.environ.get(name)
|
|
124
|
+
if value is None or value == "":
|
|
125
|
+
return False
|
|
126
|
+
normalized = value.strip().lower()
|
|
127
|
+
if normalized in _TRUE_VALUES:
|
|
128
|
+
return True
|
|
129
|
+
if normalized in _FALSE_VALUES:
|
|
130
|
+
return False
|
|
131
|
+
raise ImproperlyConfigured(
|
|
132
|
+
f"{name} must be one of: true, false (or 1/0, yes/no, on/off)."
|
|
133
|
+
)
|