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.
- django_api_registry-0.1.0.dist-info/METADATA +14 -0
- django_api_registry-0.1.0.dist-info/RECORD +26 -0
- django_api_registry-0.1.0.dist-info/WHEEL +5 -0
- django_api_registry-0.1.0.dist-info/top_level.txt +1 -0
- registry_client/__init__.py +0 -0
- registry_client/aggregator.py +227 -0
- registry_client/apps.py +47 -0
- registry_client/conf.py +49 -0
- registry_client/context.py +16 -0
- registry_client/integrations/__init__.py +0 -0
- registry_client/integrations/celery.py +22 -0
- registry_client/management/__init__.py +0 -0
- registry_client/management/commands/__init__.py +0 -0
- registry_client/management/commands/registry_dump.py +291 -0
- registry_client/management/commands/registry_install_skills.py +102 -0
- registry_client/middleware.py +27 -0
- registry_client/normalize.py +65 -0
- registry_client/otel.py +44 -0
- registry_client/processor.py +90 -0
- registry_client/runtime_guard.py +40 -0
- registry_client/skills/documentar-endpoint/SKILL.md +137 -0
- registry_client/skills/documentar-endpoint/references/anotacion.md +232 -0
- registry_client/skills/documentar-endpoint/references/dependencias.md +77 -0
- registry_client/skills/documentar-endpoint/references/descubrimiento.md +121 -0
- registry_client/skills/documentar-endpoint/references/entrevista.md +62 -0
- registry_client/transport.py +63 -0
|
@@ -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 @@
|
|
|
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
|
+
})
|
registry_client/apps.py
ADDED
|
@@ -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)
|
registry_client/conf.py
ADDED
|
@@ -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
|