django-api-registry 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,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,26 @@
1
+ registry_client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ registry_client/aggregator.py,sha256=CVMU1Lw7f81JClGPhUkTuySRuQvP13s6ZuhFHi8Naz8,8460
3
+ registry_client/apps.py,sha256=zM9GgXpvj86kjNOHkgx030q1PtUBw2dLQzUlxEEHWUU,1610
4
+ registry_client/conf.py,sha256=7M4G5deBPs9rKnG1AVDkVVovTgwqhM5WVouZLHsQThg,2048
5
+ registry_client/context.py,sha256=hCNa_yTmeiiYc5XhN5Z2yInalCU__LiaX3h_yzo4K30,409
6
+ registry_client/middleware.py,sha256=iL0TCOYCY2Y0QZiSEsKDOBtjXZG8bhHKXD9rIe1PaxM,1083
7
+ registry_client/normalize.py,sha256=YhI0x9optcHxTpJ5A8Ey9ZQ2Chf1A6_1aXwnPsOwF6U,2613
8
+ registry_client/otel.py,sha256=ZgAaaqiBS61OtmEdn4rPwqRelSiyPxO7nfxGA0fb1xE,1410
9
+ registry_client/processor.py,sha256=qinBpWA5Wc_ycYehCQK-3ImcU7OZbD1lZB9WC6_SRVg,3190
10
+ registry_client/runtime_guard.py,sha256=LPVgbvoy7SxuoaiXQ6-Mz-hshjlJRJgp0VGh5MzIsU4,1644
11
+ registry_client/transport.py,sha256=fyqAkMUmSiX7IgsVDiW8f3Xig4rwTMYNT8W-QZxwZAg,2304
12
+ registry_client/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ registry_client/integrations/celery.py,sha256=fO9vJ30kJddYIjq9NTO25ba_k0ljPs1hhgyhUmXTYxo,699
14
+ registry_client/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ registry_client/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ registry_client/management/commands/registry_dump.py,sha256=c-lKeEpzfeAkniW7TWZjM2wy-ITHWDlr_8EnPDy7LDc,11736
17
+ registry_client/management/commands/registry_install_skills.py,sha256=NJAbJsm6XpYpE2wGeJK0BvCBGRtK9HJd2HvPhLq0ACc,3660
18
+ registry_client/skills/documentar-endpoint/SKILL.md,sha256=cBI9qwB7J6cUEE1SSYX_3xLYmNcnOo3FzPN2HzG4KDk,6169
19
+ registry_client/skills/documentar-endpoint/references/anotacion.md,sha256=RpLYBBCrRKfbC4fNxEtZkgd5QOBCDMDfGD-mZjhsnq8,9700
20
+ registry_client/skills/documentar-endpoint/references/dependencias.md,sha256=IXGY0RZV-uWClkFVoRdtLlffeNGBwDZ8nSIq2a0nE9Q,3061
21
+ registry_client/skills/documentar-endpoint/references/descubrimiento.md,sha256=MuxHd8Jq8QSCOgb5fWQ22mTw7z6a2I2gLODfrPapt1A,4904
22
+ registry_client/skills/documentar-endpoint/references/entrevista.md,sha256=44wtUFHJTKqXkRDG6-OG6svzpGzbx0Re6f5UKnyBzJs,2720
23
+ django_api_registry-0.1.0.dist-info/METADATA,sha256=iu1_mt8VmROmLsMl3sZD60N_Vx484BbAubW4JcsPMjI,583
24
+ django_api_registry-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
+ django_api_registry-0.1.0.dist-info/top_level.txt,sha256=Hza1abk4V6JqJhGsn2LA3Ff1SD_YvKL4AQkCh4uccok,16
26
+ django_api_registry-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ registry_client
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)
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from django.conf import settings
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class RegistryConfig:
8
+ """Vista tipada del dict `settings.REGISTRY`, validada una sola vez al arrancar.
9
+
10
+ `HOSTS` y `CAPTURE_HOSTS` se parecen y no son lo mismo:
11
+
12
+ - `HOSTS` declara bajo qué nombres de red se conoce a *este* servicio. Viaja
13
+ en el manifest y es lo que le permite al hub resolver el `host` que ve en
14
+ un reporte ajeno —`crm-internal`— al servicio `crm`. Sin esta declaración
15
+ toda arista entrante queda como host desconocido.
16
+ - `CAPTURE_HOSTS` filtra qué destinos *salientes* se observan.
17
+ """
18
+
19
+ service: str
20
+ environment: str = "dev"
21
+ hub_url: str = ""
22
+ hub_token: str = ""
23
+ hosts: tuple[str, ...] = field(default_factory=tuple)
24
+ runtime_enabled: bool = False
25
+ sample_rate: float = 1.0
26
+ flush_interval_seconds: int = 60
27
+ max_keys: int = 5000
28
+ capture_hosts: tuple[str, ...] = field(default_factory=tuple)
29
+ normalize_paths: bool = True
30
+ capture_call_site: bool = False
31
+
32
+ @classmethod
33
+ def from_settings(cls) -> "RegistryConfig":
34
+ """Construye la config desde `settings.REGISTRY`. Lanza KeyError si falta SERVICE."""
35
+ raw = getattr(settings, "REGISTRY", {}) or {}
36
+ return cls(
37
+ service=raw["SERVICE"],
38
+ environment=raw.get("ENVIRONMENT", "dev"),
39
+ hub_url=raw.get("HUB_URL", ""),
40
+ hub_token=raw.get("HUB_TOKEN", ""),
41
+ hosts=tuple(raw.get("HOSTS", ())),
42
+ runtime_enabled=bool(raw.get("RUNTIME_ENABLED", False)),
43
+ sample_rate=float(raw.get("SAMPLE_RATE", 1.0)),
44
+ flush_interval_seconds=int(raw.get("FLUSH_INTERVAL_SECONDS", 60)),
45
+ max_keys=int(raw.get("MAX_KEYS", 5000)),
46
+ capture_hosts=tuple(raw.get("CAPTURE_HOSTS", ())),
47
+ normalize_paths=bool(raw.get("NORMALIZE_PATHS", True)),
48
+ capture_call_site=bool(raw.get("CAPTURE_CALL_SITE", False)),
49
+ )
@@ -0,0 +1,16 @@
1
+ import contextlib
2
+ from contextvars import ContextVar
3
+
4
+ inbound_operation: ContextVar[str | None] = ContextVar(
5
+ "registry_inbound_operation", default=None
6
+ )
7
+
8
+
9
+ @contextlib.contextmanager
10
+ def label(name: str):
11
+ """Uso: with registry.label("task:facturacion_mensual"): ..."""
12
+ token = inbound_operation.set(name)
13
+ try:
14
+ yield
15
+ finally:
16
+ inbound_operation.reset(token)
File without changes
@@ -0,0 +1,22 @@
1
+ """Atribución automática para tasks de Celery: cada llamada saliente hecha dentro
2
+ de una task queda etiquetada como `task:<nombre>`, sin tocar cada task una por una.
3
+ Importar este módulo activa las señales; no se importa por defecto.
4
+ """
5
+
6
+ from celery.signals import task_postrun, task_prerun
7
+
8
+ from ..context import inbound_operation
9
+
10
+ _tokens = {}
11
+
12
+
13
+ @task_prerun.connect
14
+ def _registry_task_start(task_id=None, task=None, **kwargs):
15
+ _tokens[task_id] = inbound_operation.set(f"task:{task.name}")
16
+
17
+
18
+ @task_postrun.connect
19
+ def _registry_task_end(task_id=None, **kwargs):
20
+ token = _tokens.pop(task_id, None)
21
+ if token is not None:
22
+ inbound_operation.reset(token)
File without changes
File without changes