Files
Autoparts-DB/pos/services/prom_metrics.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

256 lines
10 KiB
Python

"""Expositor Prometheus mínimo, sin dependencias externas.
El Python de producción no trae ``prometheus_client`` (y no se instala), así que
el formato de texto de Prometheus 0.0.4 se arma a mano.
El registro vive en memoria del proceso y es thread-safe (``threading.Lock``);
con gunicorn multiproceso cada worker lleva sus propios contadores y Prometheus
scrapea al que responda. Para tasas y latencias eso es suficiente; si algún día
se necesita un contador global exacto hay que habilitar el modo multiproceso con
un directorio compartido.
Uso::
from services.prom_metrics import metrics
metrics.configure('pos')
metrics.request_started() # before_request
metrics.request_finished(method, path, status, duration) # after_request
metrics.record_exception() # teardown_request (exc)
metrics.request_aborted() # teardown si no hubo after_request
body = metrics.render() # GET /metrics
"""
import os
import threading
import time
CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
DEFAULT_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
# Tope de series distintas por métrica; lo que se salga de aquí se agrupa en
# path="/otras" para que una ruta inesperada no infle la memoria.
MAX_SERIES = 400
# La consulta a pg_stat_activity es best-effort y se cachea unos segundos.
DB_GAUGE_TTL = 5.0
def escape_label(value):
return str(value).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
def format_labels(pairs):
if not pairs:
return ''
body = ','.join(f'{k}="{escape_label(v)}"' for k, v in pairs)
return '{' + body + '}'
def normalize_path(rule, path):
"""Colapsa una petición a un valor de label acotado.
Si Flask resolvió una regla (``request.url_rule.rule``) se usa tal cual, que
ya viene en forma plantilla (``/pos/api/inventory/items/<int:item_id>``).
Para 404 (sin regla) sólo se conserva el primer segmento.
"""
if rule:
return rule
segments = [s for s in (path or '/').split('/') if s]
if not segments:
return '/'
if len(segments) == 1:
return '/' + segments[0]
return '/' + segments[0] + '/...'
def _master_dsn():
dsn = os.environ.get('MASTER_DB_URL') or os.environ.get('DATABASE_URL')
if dsn:
return dsn
try:
from config import MASTER_DB_URL
return MASTER_DB_URL
except Exception:
return None
class Metrics:
def __init__(self, app='app', version=None, buckets=DEFAULT_BUCKETS):
self._lock = threading.Lock()
self._app = app
self._version = version or os.environ.get('NEXUS_APP_VERSION', 'dev')
self._buckets = tuple(sorted(buckets))
self._start = time.time()
self._requests = {} # (method, path, status_class) -> count
self._errors = {} # (method, path, status_class) -> count (4xx/5xx)
self._durations = {} # (method, path) -> [sum, count, [bucket counts]]
self._exceptions = 0
self._in_flight = 0
self._db_cache = (0.0, None)
# ─── Instrumentación ────────────────────────────────────────────────
def configure(self, app=None, version=None):
with self._lock:
if app:
self._app = app
if version:
self._version = version
def request_started(self):
with self._lock:
self._in_flight += 1
def request_finished(self, method, path, status, duration):
method = method or 'GET'
path = path or '/'
try:
status_class = f'{int(status) // 100}xx'
except (TypeError, ValueError):
status_class = 'other'
duration = max(0.0, float(duration or 0.0))
with self._lock:
self._in_flight = max(0, self._in_flight - 1)
key = (method, path, status_class)
if key not in self._requests and len(self._requests) >= MAX_SERIES:
key = (method, '/otras', status_class)
self._requests[key] = self._requests.get(key, 0) + 1
if status_class in ('4xx', '5xx'):
ekey = (method, path, status_class)
if ekey not in self._errors and len(self._errors) >= MAX_SERIES:
ekey = (method, '/otras', status_class)
self._errors[ekey] = self._errors.get(ekey, 0) + 1
dkey = (method, path)
if dkey not in self._durations and len(self._durations) >= MAX_SERIES:
dkey = (method, '/otras')
entry = self._durations.get(dkey)
if entry is None:
entry = [0.0, 0, [0] * len(self._buckets)]
self._durations[dkey] = entry
entry[0] += duration
entry[1] += 1
for i, upper in enumerate(self._buckets):
if duration <= upper:
entry[2][i] += 1
def request_aborted(self):
"""Petición que no pasó por after_request (excepción que no se convirtió en respuesta)."""
with self._lock:
self._in_flight = max(0, self._in_flight - 1)
def record_exception(self):
with self._lock:
self._exceptions += 1
# ─── Gauge de conexiones (consulta en el momento del scrape) ────────
def _db_connections(self):
now = time.time()
cached_at, cached_value = self._db_cache
if now - cached_at < DB_GAUGE_TTL:
return cached_value
value = None
dsn = _master_dsn()
if dsn:
try:
import psycopg2
conn = psycopg2.connect(dsn, connect_timeout=2)
try:
conn.autocommit = True
cur = conn.cursor()
cur.execute('SET statement_timeout = 2000')
cur.execute('SELECT count(*) FROM pg_stat_activity')
row = cur.fetchone()
cur.close()
if row:
value = int(row[0])
finally:
conn.close()
except Exception:
value = None
with self._lock:
self._db_cache = (now, value)
return value
# ─── Render ─────────────────────────────────────────────────────────
def render(self):
with self._lock:
app = self._app
version = self._version
start = self._start
requests = dict(self._requests)
errors = dict(self._errors)
durations = {k: (v[0], v[1], list(v[2])) for k, v in self._durations.items()}
exceptions = self._exceptions
in_flight = self._in_flight
db_connections = self._db_connections()
now = time.time()
out = []
def head(name, kind, help_text):
out.append(f'# HELP {name} {help_text}')
out.append(f'# TYPE {name} {kind}')
head('nexus_app_info', 'gauge', 'Application build information.')
out.append(f'nexus_app_info{format_labels([("app", app), ("version", version)])} 1')
head('nexus_uptime_seconds', 'gauge', 'Seconds since the process started.')
out.append(f'nexus_uptime_seconds{format_labels([("app", app)])} {now - start:.3f}')
head('nexus_process_start_time_seconds', 'gauge', 'Unix time the process started.')
out.append(f'nexus_process_start_time_seconds{format_labels([("app", app)])} {start:.3f}')
head('nexus_http_requests_in_flight', 'gauge', 'Requests currently being processed.')
out.append(f'nexus_http_requests_in_flight{format_labels([("app", app)])} {in_flight}')
head('nexus_http_requests_total', 'counter',
'HTTP requests by method, normalized path and status class.')
for method, path, status_class in sorted(requests):
labels = format_labels([('app', app), ('method', method),
('path', path), ('status', status_class)])
out.append(f'nexus_http_requests_total{labels} {requests[(method, path, status_class)]}')
head('nexus_http_errors_total', 'counter',
'HTTP responses with a 4xx or 5xx status class.')
for method, path, status_class in sorted(errors):
labels = format_labels([('app', app), ('method', method),
('path', path), ('status', status_class)])
out.append(f'nexus_http_errors_total{labels} {errors[(method, path, status_class)]}')
if durations:
head('nexus_http_request_duration_seconds', 'histogram',
'HTTP request latency by method and normalized path.')
for method, path in sorted(durations):
total, count, buckets = durations[(method, path)]
base = [('app', app), ('method', method), ('path', path)]
for i, upper in enumerate(self._buckets):
labels = format_labels(base + [('le', str(upper))])
out.append(f'nexus_http_request_duration_seconds_bucket{labels} {buckets[i]}')
inf = format_labels(base + [('le', '+Inf')])
out.append(f'nexus_http_request_duration_seconds_bucket{inf} {count}')
labels = format_labels(base)
out.append(f'nexus_http_request_duration_seconds_sum{labels} {total:.6f}')
out.append(f'nexus_http_request_duration_seconds_count{labels} {count}')
head('nexus_app_exceptions_total', 'counter',
'Unhandled exceptions raised while serving a request.')
out.append(f'nexus_app_exceptions_total{format_labels([("app", app)])} {exceptions}')
if db_connections is not None:
head('nexus_db_connections', 'gauge',
'Open connections on the master database (pg_stat_activity).')
labels = format_labels([('app', app), ('database', 'master')])
out.append(f'nexus_db_connections{labels} {db_connections}')
return '\n'.join(out) + '\n'
metrics = Metrics()