CLI

La config por defecto es condor.yaml en el cwd; se cambia con -c/--config. Los comandos operativos hablan con el admin_addr de una instancia viva.

$ condor check  -c condor.yaml   # valida y sale (exit 0 si es válida)
$ condor start  -c condor.yaml   # inicia listeners + endpoints de gestión
$ condor reload -c condor.yaml   # POST /reload al admin_addr de la config
$ condor targets                 # estado por target: salud, drenaje, en vuelo
$ condor drain app-pool nodo1    # drena (--full = retiro total)
$ condor undrain app-pool nodo1  # reactiva
$ condor purge [ruta]            # vacía el cache de respuestas

El reload también se dispara con SIGHUP al proceso (systemctl reload condor).

secretos y variables de entorno

Cualquier valor del YAML admite ${VAR} y ${VAR:-default}, que se expanden desde el entorno del proceso antes de parsear. Los secretos viven en el entorno del servicio, no en el archivo versionado.

middleware:
  - type: auth
    mode: api_key
    api_keys:
      - "${CONDOR_API_KEY}"          # obligatoria: sin ella, Condor no arranca
upstreams:
  - name: app
    timeout_ms: ${APP_TIMEOUT_MS:-30000}   # con default

Con systemd, los valores se cargan desde un archivo fuera del repositorio:

# /etc/condor/condor.service
EnvironmentFile=-/etc/condor/condor.env
  • Una variable ausente o vacía sin default es un error de arranque, no una cadena vacía: una API key vacía dejaría la ruta abierta de par en par.
  • El - de EnvironmentFile=- hace opcional el archivo, así que los despliegues sin secretos arrancan igual.
  • Un $ que no abre ${ se deja intacto: los paths y regex de las rutas no se ven afectados.
  • condor check no hereda el entorno de systemd; para validar a mano hay que cargarlo primero: set -a; . /etc/condor/condor.env; set +a.

global

Ajustes de proceso: log y endpoints de gestión, más los límites de robustez (anti-DoS) y el apagado ordenado. Todos los límites traen defaults sanos.

global:
  log_level: info            # debug | info | warn | error
  log_format: json           # json | pretty
  metrics_addr: "127.0.0.1:9090" # /health, /metrics (bindealo local salvo scrape externo)
  admin_addr:   "127.0.0.1:9091" # + /config, /reload (solo local)
  watch_config: true           # vigila el archivo y recarga en caliente al cambiar

  # --- límites de conexión / robustez (anti-DoS) ---
  max_connections: 10000           # tope global de conexiones simultáneas
  max_connections_per_ip: 100      # tope por IP de ORIGEN (socket TCP, no XFF)
  tls_handshake_timeout_secs: 10   # completar el handshake TLS
  header_read_timeout_secs: 30     # enviar headers completos (anti-slowloris)
  max_request_body_mb: 100          # cuerpo máximo de request → 413
  max_header_count: 100             # nº máximo de headers (header flood)
  request_timeout_secs: 60         # tiempo hasta la respuesta del upstream → 408
  graceful_shutdown_enabled: true  # drenar conexiones en vuelo al apagar
  shutdown_timeout_secs: 30        # máximo para drenar antes de forzar
RUST_LOG sobreescribe log_level si está presente. Con watch_config: true (por defecto) Condor vigila tanto condor.yaml como los certificados en modo file, y recarga sin reiniciar. Los límites de robustez no se recargan en caliente (requieren reinicio).
  • max_connections_per_ip cuenta la IP del socket TCP, no X-Forwarded-For. Condor se asume en el borde (ve la IP real). Detrás de un CDN/LB que no preserve la IP, el cupo se contabiliza contra la IP del balanceador — súbelo o desactívalo.
  • request_timeout_secs acota el tiempo hasta la cabecera de la respuesta del upstream, no el streaming del cuerpo: SSE y descargas largas no se cortan.

Compresión (estilo nginx gzip), opt-in bajo global.compression:

compression:
  enable: true                # default false
  min_length: 1024            # no comprimir respuestas más chicas (bytes)
  level: default            # fast | default | best
  algorithms: [gzip]        # [gzip, br] requiere compilar --features brotli
  content_types: [text/, application/json, …]

Streaming (no bufferiza el cuerpo). Salta automáticamente respuestas ya comprimidas, 204/304/206, HEAD, text/event-stream (SSE) y Cache-Control: no-transform. Respeta los q-values de Accept-Encoding y añade Vary. gRPC (application/grpc) no se comprime por defecto.

listeners & TLS

Cada listener enlaza una dirección. La presencia de la clave tls activa HTTPS. Sin ella, es HTTP en claro. El listener detecta HTTP/1.1 y HTTP/2 automáticamente.

listeners:
  - addr: "0.0.0.0:80"          # HTTP en claro

  - addr: "0.0.0.0:443"
    tls:
      mode: file              # file | acme | self_signed
      cert: /etc/condor/tls/fullchain.pem
      key:  /etc/condor/tls/privkey.pem
  • mode: file — carga cert + key en PEM. Soporta SNI multi-dominio. Hot-reload: con watch_config: true, al reescribir los archivos Condor recarga el certificado sin reiniciar el listener (rotación sin downtime).
  • mode: self_signed — genera un cert autofirmado en memoria (dev / interno).
  • mode: acme — emite con Let's Encrypt (ver abajo).

ACME / Let's Encrypt

Con mode: acme, Condor obtiene y renueva certificados vía desafío HTTP-01. Requiere un listener en el puerto 80 accesible públicamente (sirve /.well-known/acme-challenge/) y DNS apuntando al host.

tls:
  mode: acme
  acme_email: ops@example.com
  acme_domains: ["api.example.com", "www.example.com"]
  cache_dir: /var/lib/condor/certs   # account.json + cert.pem + key.pem

El certificado se cachea en cache_dir y se renueva en segundo plano cuando le quedan menos de 30 días de validez (según el notAfter real del certificado, parseado del x509). El resolver SNI se actualiza en caliente cuando llega el cert nuevo.

mTLS (autenticación de cliente)

Por listener TLS, client_auth exige que el cliente presente un certificado firmado por una CA de confianza. La verificación es 100% rustls (sin OpenSSL).

tls:
  mode: file
  cert: /etc/condor/tls/fullchain.pem
  key:  /etc/condor/tls/privkey.pem
  client_auth:
    ca: /etc/condor/tls/clients-ca.pem   # CA(s) que firman los certs de cliente
    optional: false     # false = exige cert | true = lo permite
  • optional: false — el handshake falla si el cliente no presenta un cert válido firmado por la CA. Ninguna petición sin cert llega al backend.
  • optional: true — admite conexiones sin cert; el backend decide leyendo X-Client-Cert-Verify (útil para migraciones graduales).

Tras un handshake correcto, Condor reenvía la identidad del certificado al backend en headers autoritativos. Anti-spoofing: antes elimina cualquier X-Client-Cert-* que el cliente intente falsificar, en todos los listeners (en claro incluido, donde Verify queda en NONE).

  • X-Client-Cert-VerifySUCCESS si presentó cert verificado, NONE si no.
  • X-Client-Cert-Subject — subject DN del cert (RFC 2253), ej. CN=alice,O=falp.
  • X-Client-Cert-Fingerprint — huella SHA-256 del cert (DER), en hexadecimal.

routes

Las rutas se resuelven por especificidad: host exacto → prefijo de path (el más largo gana) → host wildcard (*.dominio) → catch-all.

routes:
  - name: api
    match_host: api.example.com     # opcional; admite *.example.com
    match_path: /v1/*     # exacto (/health) o prefijo (/x/*)
    upstream: app-pool    # debe existir en upstreams (o usar split, ver abajo)
    middleware: [ ... ]

Al reenviar, Condor agrega X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host y X-Request-Id (ULID), elimina los headers hop-by-hop, y propaga o genera un traceparent W3C (correlación de trazas con el backend).

traffic splitting (canary)

Una ruta puede repartir su tráfico entre varios upstreams por peso, en lugar de apuntar a uno fijo. split y upstream son excluyentes.

routes:
  - name: app
    match_path: /app/*
    split:
      - upstream: app-estable
        weight: 90
      - upstream: app-canario
        weight: 10          # weight: 0 = preparado, sin tráfico
  • El reparto usa smooth weighted round-robin: proporciones exactas (9 de cada 10 al estable) e intercaladas, sin ráfagas.
  • weight: 0 deja la entrada lista sin recibir tráfico: para activar el canary o hacer el cutover basta editar los pesos y hacer hot reload — sin reiniciar ni cortar conexiones.
  • El upstream que atendió cada petición queda registrado en las métricas (condor_requests_total{upstream}) y en el audit log: el canary se observa por separado.
  • La elección ocurre después de la auth y el rate limit de la ruta (una petición rechazada no consume turno del reparto).

afinidad de sesión

Para aplicaciones con estado en memoria (Tomcat/JSF/ZK, apps legacy): con strategy: session_affinity una sesión existente conserva su target y las sesiones nuevas se asignan con la estrategia fallback.

upstreams:
  - name: app-cluster
    strategy: session_affinity
    session_affinity:
      cookie: CONDOR_ROUTE       # nombre de la cookie (default)
      secret: "compartido-entre-instancias-ha"  # firma HMAC-SHA256 (mín. 16 chars)
      fallback: least_conn       # estrategia para sesiones NUEVAS
      on_lost: reassign          # target caído/drenado: reassign | error (503)
      same_site: lax             # lax | strict | none · + secure, http_only, path
      jvm_route_compat: true     # leer sufijo `.{id}` del JSESSIONID (Tomcat)
    targets:
      - addr: "10.0.1.11:8080"
        id: nodo1                # identidad estable (alias: route)
      - addr: "10.0.1.12:8080"
        id: nodo2
  • La cookie lleva el id del target firmado con HMAC-SHA256: una cookie manipulada se rechaza (verificación en tiempo constante) y la sesión se reasigna con cookie nueva. El secreto compartido hace que la afinidad funcione igual entre varias instancias de Condor en HA.
  • id es la identidad estable del target: sobrevive a cambios de addr y es la clave del drenaje. Default: el addr como texto.
  • on_lost define qué pasa si el target de una sesión ya no está disponible: reassign (default) la mueve a otro nodo y refresca la cookie; error responde 503 explícito.
  • jvm_route_compat lee el sufijo .{id} del JSESSIONID (formato jvmRoute de Tomcat) cuando aún no hay cookie propia — migración transparente desde Apache/mod_jk. La primera respuesta fija la cookie firmada.
  • La afinidad aplica también a túneles WebSocket/Upgrade.
  • Outcome por petición en condor_affinity_total{upstream,outcome}: hit, new, reassigned, tampered, lost.

drenaje de targets

Cada target tiene un estado administrativo controlado por API/CLI, para retirar nodos sin cortar sus sesiones vivas. El estado sobrevive al hot reload.

EstadoSesiones existentesSesiones nuevas
active
drainingno
drainednono
$ condor drain app-cluster nodo1          # mantenimiento: las sesiones vivas se quedan
$ condor targets                          # observar salud, drenaje y peticiones en vuelo
$ condor drain app-cluster nodo1 --full   # retiro total (reassign o 503 según on_lost)
$ condor undrain app-cluster nodo1        # reactivar
  • El target se identifica por su id estable o por su addr (ip:puerto).
  • En pools sin afinidad, draining equivale a sacar el target de la rotación (cada petición cuenta como nueva).
  • Estado visible en GET /health ("healthy (draining)"), en GET /targets (detalle con peticiones en vuelo) y en la métrica condor_target_admin_state.

middleware

Lista ordenada por ruta. Un middleware que rechaza corta el flujo con el status correspondiente.

middleware:
  - type: auth
    mode: jwt                  # jwt | api_key
    jwks_url: "https://auth.example.com/.well-known/jwks.json"
    issuer: "https://auth.example.com"   # valida iss (recomendado)
    audience: "api"                      # valida aud (recomendado)
    algorithms: ["RS256", "ES256"]        # permitidos; default = set asimétrico
    # mode: api_key → api_keys: ["k1", "k2"]  (header X-Api-Key)
    # mode: basic   → realm + users: {alice: "plain:..", bob: "$2b$.."} (bcrypt)

  - type: ip_acl               # control de acceso por IP (ponlo PRIMERO)
    allow: ["10.0.0.0/8"]       # CIDR v4/v6; default-deny fuera del allow
    deny:  ["10.6.6.6"]         # deny gana siempre → 403

  - type: ratelimit
    requests_per_second: 500
    burst: 100                  # token bucket por IP → 429 + Retry-After

  - type: audit                 # registra la petición (ver audit)

  - type: response_headers      # headers de RESPUESTA al cliente
    set:
      Strict-Transport-Security: "max-age=31536000"
      X-Content-Type-Options: "nosniff"
    remove: [server]              # oculta el Server del backend

  - type: redirect             # respuesta 3xx (no toca el upstream)
    to: "https://nuevo.example.com/"
    status: 308                # 301 | 302 | 307 | 308 (default 308)

  - type: rewrite              # reescribe el prefijo del path
    strip_prefix: /api        # /api/users → /users
    add_prefix: /v2          # → /v2/users (tras el strip)

  - type: waf                  # firewall de aplicación (ver sección WAF)
    mode: block                # detect (audita) | block (403)
    rulesets: [sqli, xss, path_traversal, cmd_injection]
  • auth jwt — valida el Bearer contra el JWKS (refrescado en background, con timeout y límite de tamaño); el sub queda como user_id en el audit. El algoritmo se fija por config (no se toma del token → sin confusión de algoritmo); si configurás issuer/audience se exigen y validan. Falla → 401/403.
  • auth api_key — compara el header X-Api-Key contra la lista.
  • auth basic — HTTP Basic (RFC 7617). Credenciales por usuario: plain:<pass> (dev) o hash bcrypt $2… (htpasswd, prod). Comparación en tiempo constante; 401 con WWW-Authenticate.
  • ip_aclallow/deny por IP de origen con CIDR (IPv4/IPv6). deny gana; un allow no vacío hace default-deny. Usa la IP del socket TCP (no XFF); las IPv4-mapeadas se canonicalizan. 403 al denegar.
  • waf — firmas de ataque sobre path/query/headers (ver sección WAF). Rulesets integrados + reglas propias; detect/block.
  • ratelimit — token bucket por IP de origen; excedido → 429.
  • redirect — emite 301/302/307/308 con Location sin reenviar al upstream (la auth de la ruta se evalúa antes).
  • rewrite — reescribe el prefijo del path (strip_prefix/add_prefix) antes de reenviar.
  • headers — set/remove sobre los headers de la petición al upstream.
  • response_headers — set/remove sobre los headers de la respuesta al cliente (HSTS, nosniff, ocultar Server…).

cache de respuestas

Cache en memoria por ruta, estilo proxy_cache de nginx: LRU por bytes con TTL y captura en streaming (el cliente no espera al cache). Semántica conservadora por defecto.

middleware:
  - type: cache
    ttl_secs: 60            # vida de cada entrada
    max_size_mb: 64         # presupuesto de la ruta (eviction LRU)
    max_object_kb: 1024     # objetos mayores pasan de largo (streaming)
    # respect_cache_control: true   # el Cache-Control del upstream manda
  • Solo se cachea GET + 200 sin Set-Cookie, sin Content-Encoding y sin Vary (salvo Accept-Encoding). no-store/private del upstream nunca se cachean; su max-age acota el TTL.
  • Peticiones con Authorization hacen bypass completo (contenido potencialmente por-usuario). La auth y el rate limit de la ruta corren igual en los hits.
  • Se cachea el cuerpo crudo: la compresión gzip sigue negociándose por petición según el Accept-Encoding de cada cliente.
  • Respuestas marcadas X-Cache: HIT|MISS + header Age. La clave es host + path + query.
  • Purga: condor purge [ruta] o POST /cache/purge[/{ruta}] en el admin. Un hot reload arranca con el cache frío.
  • Métricas: condor_cache_events_total{route,event} (hit/miss/store/evict/purge) y condor_cache_bytes{route}.

WAF

El middleware waf inspecciona la línea de petición (path, query) y las cabeceras contra firmas de ataque, y rechaza (o solo audita) las coincidencias. Nativo, no un plugin dinámico: las reglas se compilan en el binario, sin cargar código de terceros en runtime.

middleware:
  - type: waf
    mode: block                # detect (solo audita) | block (rechaza 403)
    rulesets: [sqli, xss, path_traversal, cmd_injection]
    rules:                    # reglas propias (opcional), pattern = regex
      - name: bloquea-shell
        pattern: "/bin/(ba)?sh"
        targets: [path, query]  # path | query | headers (default: los 3)
  • Rulesets integrados, curados para alta precisión (pocas firmas de alta confianza, no recall exhaustivo): sqli, xss, path_traversal, cmd_injection.
  • Motor regex de tiempo lineal, sin backtracking → el WAF no puede volverse vulnerable a ReDoS. Un patrón propio que no compila falla al cargar (fail-closed).
  • Normalización anti-evasión: path y query se percent-decodifican una vez antes de inspeccionar, así un ../ escrito %2e%2e%2f se detecta igual.
  • Modos: detect cuenta y audita dejando pasar la petición (para estrenar sin romper tráfico); block además responde 403. Recomendado: arrancar en detect, revisar falsos positivos en el audit, pasar a block.
  • Cada match emite condor_waf_matched_total{route,rule,action} y una línea de log con la regla y la IP de origen.
Inspecciona path/query/headers, no el cuerpo de la petición (evita bufferizarlo y romper el streaming) — cubre la superficie de URL y cabeceras, por donde llega la mayoría de las inyecciones. Como todo middleware, el orden importa: declaralo temprano (idealmente tras ip_acl).

upstreams

Pool de targets con estrategia de balanceo, health checks y circuit breaker.

upstreams:
  - name: app-pool
    strategy: round_robin   # round_robin | least_conn | random | ip_hash | session_affinity (ver abajo)
    http2: false             # true = HTTP/2 hacia el upstream (h2c, o h2 vía ALPN si tls)
    tls: false               # true = conectar por https:// al upstream
    tls_server_name: backend.internal  # SNI/verificación si el target es IP (requiere tls)
    tls_insecure: false      # true = no verificar el cert (solo backends internos)
    timeout_ms: 30000        # timeout por petición al upstream
    retries: 0                # reintentos ante fallo de transporte (idempotentes)
    targets:
      - { addr: "10.0.0.11:8080", weight: 1 }
      - { addr: "10.0.0.12:8080", weight: 2 }
    health_check:
      path: /health
      interval_secs: 5
      timeout_ms: 2000
      healthy_threshold: 2
      unhealthy_threshold: 3
    circuit_breaker:
      failure_threshold: 50     # % de error en la ventana para abrir
      recovery_secs: 30         # tiempo en Open antes de HalfOpen
    outlier_detection:           # eyección pasiva per-target (ver abajo)
      consecutive_5xx: 5
  • tls / tls_server_name / tls_insecure — HTTPS hacia el upstream; tls_server_name fija el SNI y el nombre a verificar cuando el target es una IP.
  • timeout_ms — corta la petición al upstream si excede; retries reintenta solo en fallos de transporte (conexión rechazada / timeout) y solo en métodos idempotentes, eligiendo otro target del pool.

estrategias de balanceo

strategyreparto
round_robinSmooth weighted round-robin (algoritmo de nginx), ponderado por weight
least_connMenos peticiones en vuelo, normalizado por weight
randomAleatorio ponderado
ip_hashRendezvous hashing ponderado por IP de cliente
session_affinityCookie firmada (ver afinidad de sesión)

ip_hash — rendezvous hashing (HRW)

Una IP cae siempre en el mismo target, sin estado compartido entre instancias de Condor. Para cada target sano se calcula -weight / ln(u), donde u es el hash del par (IP, target.id) normalizado a (0,1), y gana el máximo.

Eso da la propiedad que importa: el ganador depende sólo del par. Cuando un target cae —o vuelve, o se agrega uno nuevo— únicamente se remapean las IPs que lo tenían como ganador, y el resto conserva su destino. Un hash(ip) % n reasignaría casi todas las claves ante cualquier cambio del pool, que es justo lo que ip_hash existe para evitar.

  • El score se ancla al id del target (el mismo que usan afinidad y drenaje), no a su posición: reordenar la lista targets: en el YAML no remapea nada.
  • Los pesos se respetan: la probabilidad de ganar es proporcional a weight.
  • Es O(n) por decisión, sin memoria adicional. Con pools de decenas de targets son unos pocos hashes frente a un round-trip TCP; el anillo de réplicas virtuales estilo ketama sólo compensa con miles de targets.
  • Peticiones sin IP de cliente conocida caen al reparto aleatorio ponderado.

outlier detection (eyección pasiva)

Complementa al health check activo y al circuit breaker (que opera sobre el upstream completo): observa las respuestas del tráfico real y saca de la rotación al target que acumula errores consecutivos (5xx o fallo de transporte).

outlier_detection:
  consecutive_5xx: 5        # errores seguidos para eyectar (default 5)
  base_ejection_secs: 30    # duración base; crece linealmente al reincidir
  max_ejection_secs: 300    # tope del backoff
  max_ejection_percent: 50  # % máximo del pool eyectado a la vez
  • Pasada la eyección, el target se readmite automáticamente; si reincide, la siguiente eyección dura más (backoff lineal, acotado por max_ejection_secs).
  • Dos guardas evitan que la detección empeore un incidente: nunca se eyecta el último target servible (mejor degradado que apagado) y max_ejection_percent acota cuántos targets pueden estar fuera a la vez.
  • Estado visible en GET /health (el target figura como "ejected") y en las métricas condor_outlier_*.
  • Los errores atribuibles al cliente (p. ej. cuerpo que excede el límite → 413) no cuentan contra el target ni contra el circuit breaker.

WebSocket / Upgrade

Automático, sin configuración. Toda petición HTTP/1.1 con Connection: Upgrade (WebSocket u otro protocolo) se tuneliza: Condor preserva los headers Connection y Upgrade hacia el upstream y, ante un 101 Switching Protocols, establece un túnel bidireccional crudo entre cliente y backend. La auth y el rate limit de la ruta se aplican igual al handshake. El túnel no está sujeto a request_timeout (que solo acota el tiempo hasta el 101). Soportado sobre upstreams HTTP/1.1; no hacia upstreams con http2: true.

audit

Log append-only en JSONL, una línea por petición. Nunca modifica líneas existentes (compatible con sinks WORM).

audit:
  enabled: true
  path: /var/log/condor/audit.jsonl
  rotate: daily             # daily | never → audit-YYYY-MM-DD.jsonl
  fields: [timestamp, request_id, client_ip, method, host,
           path, status, upstream, duration_ms, user_id, trace_id]

Cada línea, con claves compactas (tid = trace-id W3C, para correlacionar con el tracing del backend):

{"ts":"2026-05-24T03:14:15.926Z","rid":"01J...","cip":"1.2.3.4",
 "m":"GET","host":"api.example.com","path":"/v1/patients",
 "st":200,"up":"app-pool","dur_ms":12,"uid":"alice","tid":"0af765..."}
Cambiar audit.fields requiere reiniciar el servicio (no se recarga en caliente). El resto (rutas, upstreams, middleware, certificados file) sí.

endpoints de gestión

  • GET /health — estado y salud de upstreams (JSON). En metrics_addr y admin_addr.
  • GET /metrics — formato de texto Prometheus.
  • GET /config — config actual sin secretos. Solo en admin_addr.
  • POST /reload — dispara hot reload. Solo en admin_addr.
  • GET /targets — detalle por target: id, salud, eyección, drenaje y peticiones en vuelo. Solo admin.
  • POST /drain/{upstream}/{target} — drena el target (?full=truedrained). Solo admin.
  • POST /undrain/{upstream}/{target} — reactiva el target. Solo admin.
  • POST /cache/purge[/{ruta}] — vacía el cache de respuestas. Solo admin.

métricas

condor_requests_total{route,upstream,status,method}
condor_request_duration_seconds{route,upstream}
condor_upstream_health{upstream,target}
condor_circuit_state{upstream}            # 0=closed 1=open 2=half
condor_active_connections{listener}
condor_tls_handshakes_total{listener,result}
condor_ratelimit_rejected_total{route}
condor_upstream_pool_size{upstream}
condor_connections_rejected_total{listener,scope}  # scope=global|per_ip
condor_http_limit_rejected_total{kind}             # kind=body|timeout
condor_active_upgrades{upstream}                    # túneles WebSocket activos
condor_ipacl_rejected_total{route}                  # rechazos por IP ACL (403)
condor_redirects_total{route,status}                # redirects 3xx emitidos
condor_responses_compressed_total{encoding}         # respuestas comprimidas
condor_outlier_ejections_total{upstream,target}     # eyecciones por outlier detection
condor_outlier_ejected{upstream,target}             # 1 = eyectado ahora mismo
condor_waf_matched_total{route,rule,action}         # coincidencias del WAF (detect|block)
condor_affinity_total{upstream,outcome}             # afinidad: hit|new|reassigned|tampered|lost
condor_target_admin_state{upstream,target}          # 0=active 1=draining 2=drained
condor_cache_events_total{route,event}              # cache: hit|miss|store|evict|purge
condor_cache_bytes{route}                           # bytes en el cache de respuestas