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.
- django_api_registry-0.1.0/MANIFEST.in +1 -0
- django_api_registry-0.1.0/PKG-INFO +14 -0
- django_api_registry-0.1.0/README.md +246 -0
- django_api_registry-0.1.0/django_api_registry.egg-info/PKG-INFO +14 -0
- django_api_registry-0.1.0/django_api_registry.egg-info/SOURCES.txt +41 -0
- django_api_registry-0.1.0/django_api_registry.egg-info/dependency_links.txt +1 -0
- django_api_registry-0.1.0/django_api_registry.egg-info/requires.txt +12 -0
- django_api_registry-0.1.0/django_api_registry.egg-info/top_level.txt +1 -0
- django_api_registry-0.1.0/pyproject.toml +38 -0
- django_api_registry-0.1.0/registry_client/__init__.py +0 -0
- django_api_registry-0.1.0/registry_client/aggregator.py +227 -0
- django_api_registry-0.1.0/registry_client/apps.py +47 -0
- django_api_registry-0.1.0/registry_client/conf.py +49 -0
- django_api_registry-0.1.0/registry_client/context.py +16 -0
- django_api_registry-0.1.0/registry_client/integrations/__init__.py +0 -0
- django_api_registry-0.1.0/registry_client/integrations/celery.py +22 -0
- django_api_registry-0.1.0/registry_client/management/__init__.py +0 -0
- django_api_registry-0.1.0/registry_client/management/commands/__init__.py +0 -0
- django_api_registry-0.1.0/registry_client/management/commands/registry_dump.py +291 -0
- django_api_registry-0.1.0/registry_client/management/commands/registry_install_skills.py +102 -0
- django_api_registry-0.1.0/registry_client/middleware.py +27 -0
- django_api_registry-0.1.0/registry_client/normalize.py +65 -0
- django_api_registry-0.1.0/registry_client/otel.py +44 -0
- django_api_registry-0.1.0/registry_client/processor.py +90 -0
- django_api_registry-0.1.0/registry_client/runtime_guard.py +40 -0
- django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/SKILL.md +137 -0
- django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/anotacion.md +232 -0
- django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/dependencias.md +77 -0
- django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/descubrimiento.md +121 -0
- django_api_registry-0.1.0/registry_client/skills/documentar-endpoint/references/entrevista.md +62 -0
- django_api_registry-0.1.0/registry_client/transport.py +63 -0
- django_api_registry-0.1.0/setup.cfg +4 -0
- django_api_registry-0.1.0/tests/test_aggregator.py +250 -0
- django_api_registry-0.1.0/tests/test_apps.py +92 -0
- django_api_registry-0.1.0/tests/test_conf.py +88 -0
- django_api_registry-0.1.0/tests/test_context.py +31 -0
- django_api_registry-0.1.0/tests/test_middleware.py +73 -0
- django_api_registry-0.1.0/tests/test_normalize.py +75 -0
- django_api_registry-0.1.0/tests/test_processor.py +169 -0
- django_api_registry-0.1.0/tests/test_registry_dump.py +255 -0
- django_api_registry-0.1.0/tests/test_runtime_guard.py +69 -0
- django_api_registry-0.1.0/tests/test_skills.py +94 -0
- 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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
registry_client
|
|
@@ -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)
|