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.
@@ -0,0 +1,8 @@
1
+ """OpenTelemetry instrumentation for Django and Django Ninja."""
2
+
3
+ from ._asgi import instrument_asgi
4
+ from ._runtime import shutdown
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["__version__", "instrument_asgi", "shutdown"]
@@ -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
+ )