django-api-registry 0.1.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.
Files changed (43) hide show
  1. django_api_registry-0.1.0/MANIFEST.in +1 -0
  2. django_api_registry-0.1.0/PKG-INFO +14 -0
  3. django_api_registry-0.1.0/README.md +246 -0
  4. django_api_registry-0.1.0/django_api_registry.egg-info/PKG-INFO +14 -0
  5. django_api_registry-0.1.0/django_api_registry.egg-info/SOURCES.txt +41 -0
  6. django_api_registry-0.1.0/django_api_registry.egg-info/dependency_links.txt +1 -0
  7. django_api_registry-0.1.0/django_api_registry.egg-info/requires.txt +12 -0
  8. django_api_registry-0.1.0/django_api_registry.egg-info/top_level.txt +1 -0
  9. django_api_registry-0.1.0/pyproject.toml +38 -0
  10. django_api_registry-0.1.0/registry_client/__init__.py +0 -0
  11. django_api_registry-0.1.0/registry_client/aggregator.py +227 -0
  12. django_api_registry-0.1.0/registry_client/apps.py +47 -0
  13. django_api_registry-0.1.0/registry_client/conf.py +49 -0
  14. django_api_registry-0.1.0/registry_client/context.py +16 -0
  15. django_api_registry-0.1.0/registry_client/integrations/__init__.py +0 -0
  16. django_api_registry-0.1.0/registry_client/integrations/celery.py +22 -0
  17. django_api_registry-0.1.0/registry_client/management/__init__.py +0 -0
  18. django_api_registry-0.1.0/registry_client/management/commands/__init__.py +0 -0
  19. django_api_registry-0.1.0/registry_client/management/commands/registry_dump.py +291 -0
  20. django_api_registry-0.1.0/registry_client/management/commands/registry_install_skills.py +102 -0
  21. django_api_registry-0.1.0/registry_client/middleware.py +27 -0
  22. django_api_registry-0.1.0/registry_client/normalize.py +65 -0
  23. django_api_registry-0.1.0/registry_client/otel.py +44 -0
  24. django_api_registry-0.1.0/registry_client/processor.py +90 -0
  25. django_api_registry-0.1.0/registry_client/runtime_guard.py +40 -0
  26. django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/SKILL.md +137 -0
  27. django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/anotacion.md +232 -0
  28. django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/dependencias.md +77 -0
  29. django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/descubrimiento.md +121 -0
  30. django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/entrevista.md +62 -0
  31. django_api_registry-0.1.0/registry_client/transport.py +63 -0
  32. django_api_registry-0.1.0/setup.cfg +4 -0
  33. django_api_registry-0.1.0/tests/test_aggregator.py +250 -0
  34. django_api_registry-0.1.0/tests/test_apps.py +92 -0
  35. django_api_registry-0.1.0/tests/test_conf.py +88 -0
  36. django_api_registry-0.1.0/tests/test_context.py +31 -0
  37. django_api_registry-0.1.0/tests/test_middleware.py +73 -0
  38. django_api_registry-0.1.0/tests/test_normalize.py +75 -0
  39. django_api_registry-0.1.0/tests/test_processor.py +169 -0
  40. django_api_registry-0.1.0/tests/test_registry_dump.py +255 -0
  41. django_api_registry-0.1.0/tests/test_runtime_guard.py +69 -0
  42. django_api_registry-0.1.0/tests/test_skills.py +94 -0
  43. django_api_registry-0.1.0/tests/test_transport.py +108 -0
@@ -0,0 +1 @@
1
+ recursive-include registry_client/skills *.md
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-api-registry
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: django>=4.2
6
+ Requires-Dist: drf-spectacular>=0.27
7
+ Provides-Extra: runtime
8
+ Requires-Dist: opentelemetry-sdk>=1.27; extra == "runtime"
9
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.48b0; extra == "runtime"
10
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0; extra == "runtime"
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=8.0; extra == "test"
13
+ Requires-Dist: pytest-django>=4.8; extra == "test"
14
+ Requires-Dist: djangorestframework>=3.14; extra == "test"
@@ -0,0 +1,246 @@
1
+ # django-api-registry — registry_client
2
+
3
+ Componente **runtime** del paquete `django-api-registry`: instrumenta las
4
+ llamadas HTTP salientes de un backend Django con OpenTelemetry, las atribuye
5
+ al endpoint entrante que las originó, y las reporta agregadas a un hub para
6
+ construir el árbol de dependencias entre servicios.
7
+
8
+ El diseño completo (motivación, dev vs. producción, esquema del hub) está en
9
+ [`docs/planning/captura_de_datos.md`](docs/planning/captura_de_datos.md). Este
10
+ documento describe el código ya implementado en `registry_client/`.
11
+
12
+ El paquete tiene un segundo componente, **Manifest** (`registry_dump`), que es
13
+ independiente del runtime: genera el OpenAPI del servicio por introspección
14
+ estática y lo envía al hub. Mientras el runtime dice qué *consume* cada
15
+ endpoint, el manifest dice qué *expone* y **para qué sirve**. Ver
16
+ [Manifest](#manifest) más abajo.
17
+
18
+ ---
19
+
20
+ ## Instalación
21
+
22
+ ```bash
23
+ pip install django-api-registry # sin runtime, modo silencioso
24
+ pip install "django-api-registry[runtime]" # con OpenTelemetry
25
+ ```
26
+
27
+ `opentelemetry-sdk` y los instrumentors de `requests`/`httpx` son un extra
28
+ (`[runtime]`), no una dependencia base. Si `RUNTIME_ENABLED=1` pero el extra
29
+ no está instalado, la app arranca igual y solo se emite un `warning`.
30
+
31
+ ## Configuración
32
+
33
+ ```python
34
+ # settings.py de tu proyecto Django
35
+ INSTALLED_APPS = [..., "registry_client"]
36
+ MIDDLEWARE = [..., "registry_client.middleware.InboundRouteMiddleware"]
37
+
38
+ REGISTRY = {
39
+ "SERVICE": "billing", # requerido
40
+ "ENVIRONMENT": os.environ.get("REGISTRY_ENV", "dev"),
41
+ "HUB_URL": os.environ.get("REGISTRY_HUB_URL", ""),
42
+ "HUB_TOKEN": os.environ.get("REGISTRY_HUB_TOKEN", ""),
43
+ "HOSTS": ["billing.internal", "billing"], # requerido
44
+ "RUNTIME_ENABLED": os.environ.get("REGISTRY_RUNTIME", "0") == "1",
45
+ "SAMPLE_RATE": float(os.environ.get("REGISTRY_SAMPLE_RATE", "1.0")),
46
+ "FLUSH_INTERVAL_SECONDS": int(os.environ.get("REGISTRY_FLUSH", "60")),
47
+ "MAX_KEYS": 5000,
48
+ "CAPTURE_HOSTS": [],
49
+ "NORMALIZE_PATHS": True,
50
+ "CAPTURE_CALL_SITE": False,
51
+ }
52
+ ```
53
+
54
+ | Clave | Default | Qué controla |
55
+ |---|---|---|
56
+ | `SERVICE` | — (requerido) | Nombre del servicio, viaja en cada reporte. |
57
+ | `ENVIRONMENT` | `"dev"` | Se declara desde el cliente, no se infiere en el hub. |
58
+ | `HUB_URL` / `HUB_TOKEN` | `""` | Sin `HUB_URL` el reporte se descarta silenciosamente. |
59
+ | `HOSTS` | `[]` | Nombres de red de **este** servicio. Sin ellos el hub no puede resolver `host` → servicio y toda arista que apunte aquí entra como host desconocido. |
60
+ | `RUNTIME_ENABLED` | `False` | Atado a una env var explícita, nunca a `DEBUG`. |
61
+ | `SAMPLE_RATE` | `1.0` | Se decide una vez por proceso, en el agregador, y se vuelve a decidir en cada worker tras un `fork`. |
62
+ | `FLUSH_INTERVAL_SECONDS` | `60` | 60s en dev, 300s recomendado en producción. |
63
+ | `MAX_KEYS` | `5000` | Cota de memoria del agregador; ver `dropped_keys`. |
64
+ | `CAPTURE_HOSTS` | `[]` | Filtra qué destinos **salientes** se observan. No confundir con `HOSTS`. Vacío = todos. |
65
+ | `NORMALIZE_PATHS` | `True` | Opcional en dev, obligatorio en prod (evita saturar `MAX_KEYS`). |
66
+ | `CAPTURE_CALL_SITE` | `False` | Útil en dev (da `file:line`); desactivar en prod (cuesta por llamada). |
67
+
68
+ ## Arquitectura
69
+
70
+ ```
71
+ registry_client/
72
+ ├── apps.py # AppConfig.ready() — punto de arranque, todo fail-safe
73
+ ├── runtime_guard.py # distingue el supervisor de runserver del hijo
74
+ ├── conf.py # RegistryConfig: valida REGISTRY una sola vez
75
+ ├── context.py # contextvar con el endpoint entrante
76
+ ├── middleware.py # captura request.resolver_match.route
77
+ ├── otel.py # TracerProvider aislado + instrumentors
78
+ ├── processor.py # SpanProcessor propio: span CLIENT → registro
79
+ ├── normalize.py # colapso de segmentos de alta cardinalidad
80
+ ├── aggregator.py # agregación en memoria, muestreo y hooks de fork
81
+ ├── transport.py # POST al hub con urllib (no requests/httpx)
82
+ ├── integrations/celery.py # atribución para tasks (opt-in, no autocargado)
83
+ ├── management/commands/
84
+ │ ├── registry_dump.py # Manifest: OpenAPI -> hub
85
+ │ └── registry_install_skills.py # copia los skills a .claude/skills/
86
+ └── skills/documentar-endpoint/ # skill de documentación de endpoints
87
+ ```
88
+
89
+ Recorrido de una llamada saliente:
90
+
91
+ 1. `InboundRouteMiddleware.process_view` guarda `"GET /api/invoices/<int:pk>/"`
92
+ en un contextvar (`context.py`).
93
+ 2. El instrumentor de OTel abre un span `CLIENT` al hacer la llamada saliente.
94
+ 3. `RegistrySpanProcessor.on_end` lee el contextvar, arma la clave
95
+ `(inbound, host, method, path)` y llama a `aggregator.record(...)`.
96
+ 4. `CallAggregator` acumula en memoria; cada `FLUSH_INTERVAL_SECONDS` un hilo
97
+ daemon llama a `transport.send_report`.
98
+ 5. El hub resuelve host→servicio y path→template contra el OpenAPI de cada
99
+ destino, y hace upsert de la arista.
100
+
101
+ Decisiones no obvias (documentadas también como docstrings/comentarios en el
102
+ código donde aplica):
103
+
104
+ - **TracerProvider propio**, no el global — no pisa un tracing que el equipo
105
+ consumidor ya tenga configurado.
106
+ - **`urllib` en `transport.py`**, no `requests`/`httpx` — ambos están
107
+ instrumentados por este mismo paquete; reportar con ellos generaría spans
108
+ que el processor volvería a capturar.
109
+ - **Todo `apps.ready()` y `processor.on_end()` atrapan excepciones** — corren
110
+ en el arranque y en el camino del request respectivamente; una herramienta
111
+ de observabilidad no puede tumbar lo que observa.
112
+ - **Muestreo por proceso, no por request** — evita sesgar los contadores
113
+ agregados. Vive en el agregador y no en `apps.ready()`, porque con
114
+ `gunicorn --preload` `ready()` corre en el master antes del `fork` y todos
115
+ los workers heredarían la misma decisión.
116
+ - **El agregador sobrevive al `fork`** — `fork()` no clona hilos, así que un
117
+ worker de `--preload` nacería sin el hilo de flush y no reportaría nunca. Hay
118
+ hooks `os.register_at_fork` que recrean el lock, descartan los contadores
119
+ heredados y arrancan un hilo nuevo, más una comprobación de `os.getpid()` en
120
+ el camino caliente para uWSGI, que bifurca desde C y nunca dispara los hooks.
121
+ - **Solo se normaliza lo que tiene forma de identificador** — un falso positivo
122
+ (`/api/customers/search/` → `/api/customers/{}/`) haría que el hub emparejara
123
+ la arista con el template equivocado; un falso negativo solo gasta claves, y
124
+ eso está acotado por `MAX_KEYS` y medido por `dropped_keys`.
125
+
126
+ ## Manifest
127
+
128
+ El runtime mide **cuánto** tráfico hace cada endpoint y **a quién** llama. El
129
+ manifest aporta lo que el tráfico no dice: **para qué sirve** cada endpoint, qué
130
+ requiere y de qué depende.
131
+
132
+ Esa semántica no vive en un archivo aparte —se desincronizaría— sino en el
133
+ código de las vistas, vía docstrings y `@extend_schema` de drf-spectacular. El
134
+ skill `documentar-endpoint` es quien la escribe.
135
+
136
+ ```bash
137
+ # 1. Instalar el skill en el proyecto consumidor
138
+ python manage.py registry_install_skills # -> .claude/skills/
139
+
140
+ # 2. Documentar endpoints (desde Claude Code)
141
+ /documentar-endpoint miapp/views.py
142
+
143
+ # 3. Enviar el manifest al hub
144
+ python manage.py registry_dump # o --file / --print
145
+ ```
146
+
147
+ El skill infiere del código lo que el código demuestra (autenticación,
148
+ idempotencia, efectos, llamadas salientes) y **pregunta** lo que no puede
149
+ inferir —sobre todo el caso de uso de negocio—, en vez de inventarlo o dejarlo
150
+ en blanco. No documenta consumidores ni criticidad: eso el hub ya lo deduce del
151
+ runtime.
152
+
153
+ La anotación queda en el schema bajo la extensión `x-registry`:
154
+
155
+ ```python
156
+ @extend_schema(
157
+ summary="Autoriza un cargo contra el emisor",
158
+ extensions={"x-registry": {
159
+ "version": 1,
160
+ "caso-de-uso": "Checkout de la app móvil.",
161
+ "requiere": {"auth": "Bearer, scope pagos:write", "idempotente": False,
162
+ "efectos": ["reserva fondos por 7 días"]},
163
+ "depende-de": [{"servicio": "emisor", "llamada": "POST /auth/"}],
164
+ }},
165
+ )
166
+ def post(self, request): ...
167
+ ```
168
+
169
+ La documentación del **servicio** no se anota: sale del bloque `info` del
170
+ OpenAPI, que a su vez sale de `SPECTACULAR_SETTINGS`. Es la ficha del catálogo,
171
+ y `CONTACT` es el equipo dueño por defecto de todos sus endpoints:
172
+
173
+ ```python
174
+ SPECTACULAR_SETTINGS = {
175
+ "TITLE": "Facturación",
176
+ "DESCRIPTION": "Emisión y timbrado de CFDI.",
177
+ "CONTACT": {"name": "equipo-facturacion"},
178
+ "VERSION": "1.0.0",
179
+ }
180
+ ```
181
+
182
+ `registry_dump` avisa si falta alguno. No falla: un catálogo sin nombre sigue
183
+ sirviendo, pero nadie sabrá a quién preguntarle.
184
+
185
+ El payload lleva el OpenAPI completo más una tabla `routes` que une cada
186
+ operación con las dos formas en que el runtime la nombrará:
187
+
188
+ ```json
189
+ {"method": "POST",
190
+ "openapi_path": "/api/facturas/{id}/timbrar/",
191
+ "shape": "/api/facturas/{}/timbrar/",
192
+ "inbound": "POST /api/facturas/<int:pk>/timbrar/",
193
+ "operation_id": "facturas_timbrar_create"}
194
+ ```
195
+
196
+ Los dos campos sirven a los dos extremos de una arista, y los dos existen para
197
+ que el join sea por igualdad y no por heurística:
198
+
199
+ - **`inbound`** es idéntico, carácter por carácter, al endpoint entrante que
200
+ reporta el middleware. Resuelve el extremo *origen*: qué operación de este
201
+ servicio hizo la llamada.
202
+ - **`shape`** es el path del OpenAPI pasado por la misma regla que
203
+ `normalize.normalize_path` aplica a los paths salientes. Resuelve el extremo
204
+ *destino*: `/api/facturas/7/timbrar/` llega al hub ya colapsado como
205
+ `/api/facturas/{}/timbrar/`, que es exactamente esta cadena.
206
+
207
+ `shape` se calcula aquí, y no en el hub, para que la regla tenga una sola
208
+ implementación. Si el hub la reprodujera por su cuenta, cualquier cambio en
209
+ `normalize.py` desalinearía las dos mitades sin producir ningún error: solo
210
+ aristas sin resolver, indistinguibles de un servicio que no reporta.
211
+
212
+ A diferencia del runtime, `registry_dump` **falla ruidosamente**: corre en
213
+ build/CI, donde un envío que falla en silencio deja el pipeline en verde con el
214
+ hub desactualizado.
215
+
216
+ > **Antes de anotar**, revisa si el proyecto sirve `/api/schema/` sin
217
+ > autenticación: `x-registry` expone nombres de servicios internos, settings de
218
+ > URLs y efectos de negocio. Ver `SPECTACULAR_SETTINGS["SERVE_PERMISSIONS"]`.
219
+
220
+ ## Integración con Celery
221
+
222
+ ```python
223
+ # en algún módulo que se importe al arrancar (p. ej. celery.py de tu proyecto)
224
+ import registry_client.integrations.celery # noqa: F401 — activa las señales
225
+ ```
226
+
227
+ No se importa por defecto porque `celery` no es una dependencia del paquete.
228
+
229
+ ## Testing
230
+
231
+ ```bash
232
+ pip install -e ".[runtime,test]"
233
+ pytest
234
+ ```
235
+
236
+ Los tests de `processor.py`, `otel.py` y `apps.py` requieren el extra
237
+ `[runtime]` instalado (usan `opentelemetry-sdk` real). `tests/settings.py` es
238
+ el settings mínimo de Django para correr la suite — no es un ejemplo de
239
+ configuración real, ver la sección "Configuración" arriba para eso.
240
+
241
+ ## Desarrollo vs. producción
242
+
243
+ En desarrollo, los contadores no tienen significado (tráfico artificial) y
244
+ solo importa si la arista existe o no. La migración a producción es un cambio
245
+ de configuración, no de código: ver la sección 17 de
246
+ [`captura_de_datos.md`](docs/planning/captura_de_datos.md#17-migración-a-producción).
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-api-registry
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: django>=4.2
6
+ Requires-Dist: drf-spectacular>=0.27
7
+ Provides-Extra: runtime
8
+ Requires-Dist: opentelemetry-sdk>=1.27; extra == "runtime"
9
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.48b0; extra == "runtime"
10
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0; extra == "runtime"
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=8.0; extra == "test"
13
+ Requires-Dist: pytest-django>=4.8; extra == "test"
14
+ Requires-Dist: djangorestframework>=3.14; extra == "test"
@@ -0,0 +1,41 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ django_api_registry.egg-info/PKG-INFO
5
+ django_api_registry.egg-info/SOURCES.txt
6
+ django_api_registry.egg-info/dependency_links.txt
7
+ django_api_registry.egg-info/requires.txt
8
+ django_api_registry.egg-info/top_level.txt
9
+ registry_client/__init__.py
10
+ registry_client/aggregator.py
11
+ registry_client/apps.py
12
+ registry_client/conf.py
13
+ registry_client/context.py
14
+ registry_client/middleware.py
15
+ registry_client/normalize.py
16
+ registry_client/otel.py
17
+ registry_client/processor.py
18
+ registry_client/runtime_guard.py
19
+ registry_client/transport.py
20
+ registry_client/integrations/__init__.py
21
+ registry_client/integrations/celery.py
22
+ registry_client/management/__init__.py
23
+ registry_client/management/commands/__init__.py
24
+ registry_client/management/commands/registry_dump.py
25
+ registry_client/management/commands/registry_install_skills.py
26
+ registry_client/skills/documentar-endpoint/SKILL.md
27
+ registry_client/skills/documentar-endpoint/references/anotacion.md
28
+ registry_client/skills/documentar-endpoint/references/dependencias.md
29
+ registry_client/skills/documentar-endpoint/references/descubrimiento.md
30
+ registry_client/skills/documentar-endpoint/references/entrevista.md
31
+ tests/test_aggregator.py
32
+ tests/test_apps.py
33
+ tests/test_conf.py
34
+ tests/test_context.py
35
+ tests/test_middleware.py
36
+ tests/test_normalize.py
37
+ tests/test_processor.py
38
+ tests/test_registry_dump.py
39
+ tests/test_runtime_guard.py
40
+ tests/test_skills.py
41
+ tests/test_transport.py
@@ -0,0 +1,12 @@
1
+ django>=4.2
2
+ drf-spectacular>=0.27
3
+
4
+ [runtime]
5
+ opentelemetry-sdk>=1.27
6
+ opentelemetry-instrumentation-requests>=0.48b0
7
+ opentelemetry-instrumentation-httpx>=0.48b0
8
+
9
+ [test]
10
+ pytest>=8.0
11
+ pytest-django>=4.8
12
+ djangorestframework>=3.14
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "django-api-registry"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.10"
5
+ dependencies = [
6
+ "django>=4.2",
7
+ "drf-spectacular>=0.27",
8
+ ]
9
+
10
+ [project.optional-dependencies]
11
+ runtime = [
12
+ "opentelemetry-sdk>=1.27",
13
+ "opentelemetry-instrumentation-requests>=0.48b0",
14
+ "opentelemetry-instrumentation-httpx>=0.48b0",
15
+ ]
16
+ test = [
17
+ "pytest>=8.0",
18
+ "pytest-django>=4.8",
19
+ # El manifest se prueba generando un OpenAPI real, no un mock: hace falta
20
+ # DRF para que haya vistas que enumerar.
21
+ "djangorestframework>=3.14",
22
+ ]
23
+
24
+ [build-system]
25
+ requires = ["setuptools>=68", "wheel"]
26
+ build-backend = "setuptools.build_meta"
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["registry_client*"]
30
+
31
+ [tool.setuptools.package-data]
32
+ # `packages.find` solo descubre paquetes; `skills/` no lo es (no tiene
33
+ # __init__.py), así que necesita declararse aquí para viajar en el wheel.
34
+ registry_client = ["skills/**/*.md"]
35
+
36
+ [tool.pytest.ini_options]
37
+ DJANGO_SETTINGS_MODULE = "tests.settings"
38
+ python_files = ["test_*.py"]
File without changes
@@ -0,0 +1,227 @@
1
+ import atexit
2
+ import logging
3
+ import os
4
+ import random
5
+ import socket
6
+ import threading
7
+ import uuid
8
+ from collections import defaultdict
9
+ from datetime import datetime, timezone
10
+
11
+ from .transport import send_report
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class _Bucket:
17
+ __slots__ = ("count", "errors", "total_ms", "max_ms", "call_site")
18
+
19
+ def __init__(self):
20
+ self.count = 0
21
+ self.errors = 0
22
+ self.total_ms = 0.0
23
+ self.max_ms = 0.0
24
+ self.call_site = None
25
+
26
+
27
+ class CallAggregator:
28
+ """Agrega llamadas salientes en memoria por (inbound, host, method, path) y las
29
+ reporta al hub cada `flush_interval_seconds` en un hilo daemon propio.
30
+
31
+ Es también el dueño de la decisión de muestreo. Está aquí y no en `apps.py`
32
+ porque es el único punto que sabe re-evaluarla después de un fork: con
33
+ `gunicorn --preload` la app se importa en el master y los workers heredan lo
34
+ que se hubiera decidido allí.
35
+ """
36
+
37
+ def __init__(self, config):
38
+ self.config = config
39
+ self._lock = threading.Lock()
40
+ self._buckets = defaultdict(_Bucket)
41
+ self._window_start = self._now()
42
+ self._dropped = 0
43
+ self._stop_event = threading.Event()
44
+ self._thread = None
45
+ self._pid = os.getpid()
46
+ self._sampled = self._decidir_muestreo()
47
+ self.instance = self._nombre_de_instancia()
48
+ self._registrar_hooks_de_fork()
49
+
50
+ @staticmethod
51
+ def _now():
52
+ return datetime.now(timezone.utc)
53
+
54
+ def _nombre_de_instancia(self):
55
+ return f"{socket.gethostname()}/pid-{self._pid}"
56
+
57
+ def _decidir_muestreo(self):
58
+ """Una decisión por proceso, nunca por request: muestrear por request
59
+ sesgaría los contadores agregados."""
60
+ if self.config.sample_rate >= 1.0:
61
+ return True
62
+ return random.random() < self.config.sample_rate
63
+
64
+ # ---------------------------------------------------------------- fork
65
+
66
+ def _registrar_hooks_de_fork(self):
67
+ """`fork()` no clona hilos: solo sobrevive el que llama.
68
+
69
+ Sin esto, bajo `gunicorn --preload` el hilo de flush arranca en el master
70
+ y los workers nacen sin él. Agregan en memoria hasta `MAX_KEYS`, empiezan
71
+ a descartar, y no reportan nunca.
72
+ """
73
+ if not hasattr(os, "register_at_fork"): # Windows
74
+ return
75
+ os.register_at_fork(
76
+ before=self._antes_del_fork,
77
+ after_in_parent=self._despues_del_fork_en_el_padre,
78
+ after_in_child=self._despues_del_fork_en_el_hijo,
79
+ )
80
+
81
+ def _antes_del_fork(self):
82
+ """Toma el lock antes de bifurcar, y lo suelta a ambos lados.
83
+
84
+ Si el hilo de flush tuviera el lock tomado en el instante del fork, el
85
+ hijo nacería con un lock bloqueado y sin ningún hilo capaz de soltarlo:
86
+ el primer `record()` del worker se colgaría dentro de un request, para
87
+ siempre. Tomarlo antes garantiza que nadie más lo tiene.
88
+ """
89
+ self._lock.acquire()
90
+
91
+ def _despues_del_fork_en_el_padre(self):
92
+ self._lock.release()
93
+
94
+ def _despues_del_fork_en_el_hijo(self):
95
+ self._lock.release()
96
+ self._adoptar_proceso()
97
+
98
+ def _adoptar_proceso(self):
99
+ """Reinicia el estado heredado de otro proceso tras un fork.
100
+
101
+ Los contadores del padre no son de este worker: sumarlos los duplicaría
102
+ en el hub. El muestreo se vuelve a tirar para que `SAMPLE_RATE` signifique
103
+ una fracción de los procesos y no un todo-o-nada por pod.
104
+ """
105
+ # El lock heredado puede venir en cualquier estado; se descarta.
106
+ self._lock = threading.Lock()
107
+ with self._lock:
108
+ if os.getpid() == self._pid:
109
+ return
110
+ self._pid = os.getpid()
111
+ self._buckets = defaultdict(_Bucket)
112
+ self._window_start = self._now()
113
+ self._dropped = 0
114
+ self._sampled = self._decidir_muestreo()
115
+ self.instance = self._nombre_de_instancia()
116
+ self._stop_event = threading.Event()
117
+ self._thread = None
118
+
119
+ if self._sampled:
120
+ self.start()
121
+
122
+ # -------------------------------------------------------------- registro
123
+
124
+ def record(self, inbound, host, method, path, status, duration_ms, call_site=None):
125
+ """Incrementa el bucket de esta clave. Si `max_keys` ya se alcanzó y la clave
126
+ es nueva, se descarta y se cuenta en `_dropped` (viaja como `dropped_keys`).
127
+
128
+ Descartar claves nuevas y no evictar las viejas es deliberado: evictar por
129
+ menor uso tiraría justo las aristas raras, que son las que interesan.
130
+ """
131
+ # uWSGI bifurca desde C, así que `os.register_at_fork` nunca dispara y el
132
+ # camino caliente tiene que detectar el fork por su cuenta.
133
+ if os.getpid() != self._pid:
134
+ self._adoptar_proceso()
135
+
136
+ if not self._sampled:
137
+ return
138
+
139
+ key = (inbound, host, method, path)
140
+ with self._lock:
141
+ if key not in self._buckets and len(self._buckets) >= self.config.max_keys:
142
+ self._dropped += 1
143
+ return
144
+ bucket = self._buckets[key]
145
+ bucket.count += 1
146
+ bucket.total_ms += duration_ms
147
+ bucket.max_ms = max(bucket.max_ms, duration_ms)
148
+ if status is not None and status >= 400:
149
+ bucket.errors += 1
150
+ if call_site and bucket.call_site is None:
151
+ bucket.call_site = call_site
152
+
153
+ # ----------------------------------------------------------------- hilo
154
+
155
+ def start(self):
156
+ if not self._sampled:
157
+ logger.info("registry: proceso no muestreado, no se reportará")
158
+ return
159
+ if self._thread is not None and self._thread.is_alive():
160
+ return
161
+ self._thread = threading.Thread(
162
+ target=self._loop, name="registry-flush", daemon=True
163
+ )
164
+ self._thread.start()
165
+ atexit.register(self.stop)
166
+
167
+ def _loop(self):
168
+ interval = self.config.flush_interval_seconds
169
+ while not self._stop_event.wait(interval):
170
+ try:
171
+ self.flush()
172
+ except Exception:
173
+ logger.debug("registry: flush falló", exc_info=True)
174
+
175
+ def stop(self):
176
+ # Idempotente: `atexit` puede tener varias registraciones tras un fork.
177
+ if self._stop_event.is_set():
178
+ return
179
+ self._stop_event.set()
180
+ try:
181
+ self.flush()
182
+ except Exception:
183
+ logger.debug("registry: flush final falló", exc_info=True)
184
+
185
+ # ---------------------------------------------------------------- flush
186
+
187
+ def _drain(self):
188
+ with self._lock:
189
+ buckets, self._buckets = self._buckets, defaultdict(_Bucket)
190
+ window_start, self._window_start = self._window_start, self._now()
191
+ dropped, self._dropped = self._dropped, 0
192
+ return buckets, window_start, dropped
193
+
194
+ def flush(self):
195
+ buckets, window_start, dropped = self._drain()
196
+ if not buckets:
197
+ return
198
+
199
+ calls = []
200
+ for (inbound, host, method, path), b in buckets.items():
201
+ entry = {
202
+ "inbound": inbound,
203
+ "host": host,
204
+ "method": method,
205
+ "path_observed": path,
206
+ "count": b.count,
207
+ "errors": b.errors,
208
+ "avg_ms": round(b.total_ms / b.count, 2),
209
+ "max_ms": round(b.max_ms, 2),
210
+ }
211
+ if b.call_site:
212
+ entry["call_site"] = b.call_site
213
+ calls.append(entry)
214
+
215
+ send_report(self.config, {
216
+ # Identifica la ventana, no el envío: si un reintento repite el
217
+ # mismo lote, el hub lo reconoce y no duplica los contadores.
218
+ "batch_id": uuid.uuid4().hex,
219
+ "service": self.config.service,
220
+ "environment": self.config.environment,
221
+ "instance": self.instance,
222
+ "sample_rate": self.config.sample_rate,
223
+ "window_start": window_start.isoformat(),
224
+ "window_end": self._now().isoformat(),
225
+ "dropped_keys": dropped,
226
+ "calls": calls,
227
+ })
@@ -0,0 +1,47 @@
1
+ import logging
2
+
3
+ from django.apps import AppConfig
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class RegistryClientConfig(AppConfig):
9
+ name = "registry_client"
10
+ verbose_name = "API Registry Client"
11
+
12
+ def ready(self):
13
+ """Instrumenta el proceso si corresponde. Cualquier fallo se degrada a
14
+ warning/log — esta herramienta de observabilidad nunca debe tumbar la app
15
+ que observa."""
16
+ from .conf import RegistryConfig
17
+ from .runtime_guard import is_reloader_supervisor
18
+
19
+ config = RegistryConfig.from_settings()
20
+
21
+ if not config.runtime_enabled:
22
+ return
23
+
24
+ # El autoreloader de runserver arranca dos procesos; solo instrumenta
25
+ # el hijo que sirve requests.
26
+ if is_reloader_supervisor():
27
+ return
28
+
29
+ # El muestreo NO se decide aquí. Bajo `gunicorn --preload`, ready() corre
30
+ # en el master antes del fork y todos los workers heredarían la misma
31
+ # decisión, dejando el pod entero dentro o fuera y volviendo inútil un
32
+ # SAMPLE_RATE de 0.1. Lo decide el agregador, que sabe re-evaluarlo en
33
+ # cada proceso hijo.
34
+
35
+ try:
36
+ from .otel import install
37
+ except ImportError:
38
+ logger.warning(
39
+ "registry: RUNTIME_ENABLED=1 pero los extras no están "
40
+ "instalados. Usa django-api-registry[runtime]."
41
+ )
42
+ return
43
+
44
+ try:
45
+ install(config)
46
+ except Exception:
47
+ logger.warning("registry: fallo al instrumentar", exc_info=True)