Lesson 7.2.3 · 75 min

Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats

A production system does many things every night and almost nobody watches it while it does them. This lesson chains its tasks in a graph with retries, state and idempotent backfill, leaves a JSON log that can be queried like a table, exposes Prometheus metrics and evaluates the alerting rules against the real outages of the level 4 system, until it finds out that for: 1m does not mean “more than one minute”.

Fig. — The seven connection outages of the level-4 system against the BrokerDesconectado rule with for: 1m, evaluated every 15 s: those of 92 s or more fire, always 74 s after they start; the 72 s one, which the 60 s rule of lesson 4.5.4 does count, does not fire
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control

What happens when nobody is watching

Lesson 4.5.4 scheduled the tasks of the level 4 system with cron and APScheduler, tested the calendar with a simulated clock and built an alert router to Telegram, Slack and email. That works as long as each task is independent and the problem is “at what time”. In production other questions appear: the price download failed at 22:30, were the features computed with yesterday's data?; the broker published the statement late, who reruns the reconciliation?; the bot has done nothing for ten minutes, how do you know if nobody is looking at the screen?

The two halves of this lesson answer that. Orchestration decides what runs, in what order, what is retried and what is rerun. Monitoring says what is happening: logs that can be queried, metrics that can be aggregated and alerts that arrive when something requires action, including when what fails is the monitoring system itself.

BotJSON logsMetricsPrometheusAlertmanagerTelegramWatchdog
Mermaid source
flowchart TB
  B[Bot] --> L[JSON logs]
  B --> M[Metrics]
  M --> P[Prometheus]
  P --> A[Alertmanager]
  A --> T[Telegram]
  B --> V[Watchdog]

The bot writes structured logs and exposes its metrics on /metrics; Prometheus scrapes them every few seconds, evaluates the alerting rules and sends the ones that fire to Alertmanager, which groups them, inhibits the redundant ones and sends them to the channel. The watchdog is an external service that expects a periodic heartbeat from the bot and warns if it stops arriving. Grafana, which draws the metrics, does not appear: looking at a dashboard is not monitoring.

A task graph with state

The nightly tasks of a daily system are not a list, they are a graph: the features depend on the validated prices, the signal on the features, the reconciliation on the broker's statement and the report on both. If one fails, what depends on it must not run with stale data. A minimal orchestrator needs three things: the topological order of the graph (graphlib ships with Python), retries per task with a growing wait and a state per task and partition (here, the session) stored on disk, so that rerunning the night does not repeat what already finished well.

preciosvalidarextractoconciliarfeaturessenalinforme
Mermaid source
flowchart TB
  P[precios] --> V[validar]
  E[extracto] --> C[conciliar]
  V --> F[features]
  F --> S[senal]
  S --> I[informe]
  C --> I
IN [01]python
import graphlib
import sqlite3
from datetime import UTC, datetime, timedelta

import pandas as pd

class Reloj:
    """Injected clock: `dormir` advances the simulated time."""

    def __init__(self, inicio):
        self.t = inicio

    def ahora(self):
        return self.t

    def dormir(self, segundos):
        self.t += timedelta(seconds=segundos)

class Orquestador:
    """Tasks with dependencies, retries with exponential backoff
    and state per (task, partition) in SQLite."""

    def __init__(self, reloj, ruta):
        self.reloj, self.tareas = reloj, {}
        self.db = sqlite3.connect(ruta)
        self.db.execute("""CREATE TABLE IF NOT EXISTS ejecuciones (
            tarea TEXT, particion TEXT, intento INTEGER,
            estado TEXT, hora TEXT, error TEXT)""")

    def tarea(self, deps=(), reintentos=0, espera=60):
        def registrar(fn):
            self.tareas[fn.__name__] = (fn, deps, reintentos, espera)
            return fn
        return registrar

    def hecha(self, tarea, particion):
        return self.db.execute(
            "SELECT 1 FROM ejecuciones WHERE tarea = ? AND "
            "particion = ? AND estado = 'ok'",
            (tarea, particion)).fetchone() is not None

    def anotar(self, tarea, particion, intento, estado, error=None):
        hora = self.reloj.ahora().strftime("%H:%M:%S")
        with self.db:                       # one transaction
            self.db.execute(
                "INSERT INTO ejecuciones VALUES (?, ?, ?, ?, ?, ?)",
                (tarea, particion, intento, estado, hora, error))

    def ejecutar(self, particion):
        grafo = {n: set(t[1]) for n, t in self.tareas.items()}
        estado = {}
        for n in graphlib.TopologicalSorter(grafo).static_order():
            fn, deps, reintentos, espera = self.tareas[n]
            if self.hecha(n, particion):
                estado[n] = "done"
                continue
            if any(estado[d] not in ("ok", "done") for d in deps):
                estado[n] = "skipped"
                self.anotar(n, particion, 0, "skipped")
                continue
            for intento in range(1, reintentos + 2):
                try:
                    fn(particion)
                except Exception as e:
                    estado[n] = "failed"
                    self.anotar(n, particion, intento, "failed",
                                type(e).__name__)
                    if intento <= reintentos:
                        self.reloj.dormir(espera * 2 ** (intento - 1))
                else:
                    estado[n] = "ok"
                    self.anotar(n, particion, intento, "ok")
                    break
        return estado

The night of November 24, 2025, simulated: the price provider does not respond the first two times and the broker publishes the statement at 23:10, later than usual. Each task consumes a few seconds of the simulated clock:

IN [02]python
reloj = Reloj(datetime(2025, 11, 24, 22, 30, tzinfo=UTC))
orq = Orquestador(reloj, "orquestador.db")
EXTRACTO_LISTO = datetime(2025, 11, 24, 23, 10, tzinfo=UTC)
pendientes = {"precios": 2}           # failures still to come

@orq.tarea(reintentos=3)
def precios(p):
    reloj.dormir(40)
    if pendientes["precios"]:
        pendientes["precios"] -= 1
        raise ConnectionError("the provider is not responding")

@orq.tarea(reintentos=2, espera=300)
def extracto(p):
    reloj.dormir(5)
    if reloj.ahora() < EXTRACTO_LISTO:
        raise FileNotFoundError(f"statement {p} not published")

@orq.tarea(deps=("precios",))
def validar(p):
    reloj.dormir(5)

@orq.tarea(deps=("validar",))
def features(p):
    reloj.dormir(20)

@orq.tarea(deps=("features",))
def senal(p):
    reloj.dormir(2)

@orq.tarea(deps=("extracto",))
def conciliar(p):
    reloj.dormir(15)

@orq.tarea(deps=("senal", "conciliar"))
def informe(p):
    reloj.dormir(3)

orq.ejecutar("2025-11-24")
reloj.t = datetime(2025, 11, 24, 23, 30, tzinfo=UTC)
segunda = orq.ejecutar("2025-11-24")
print(pd.read_sql("SELECT tarea, intento, estado, hora, error "
                  "FROM ejecuciones", orq.db)
      .fillna("").to_string(index=False))
print(segunda)
OUT [02]The night of November 24, in two passes
    tarea  intento  estado     hora             error
  precios        1  failed 22:30:40   ConnectionError
  precios        2  failed 22:32:20   ConnectionError
  precios        3      ok 22:35:00
 extracto        1  failed 22:35:05 FileNotFoundError
 extracto        2  failed 22:40:10 FileNotFoundError
 extracto        3  failed 22:50:15 FileNotFoundError
  validar        1      ok 22:50:20
conciliar        0 skipped 22:50:20
 features        1      ok 22:50:40
    senal        1      ok 22:50:42
  informe        0 skipped 22:50:42
 extracto        1      ok 23:30:05
conciliar        1      ok 23:30:20
  informe        1      ok 23:30:23
{'precios': 'done', 'extracto': 'ok', 'validar': 'done', 'conciliar': 'ok', 'features': 'done', 'senal': 'done', 'informe': 'ok'}

The first pass, at 22:30, downloads the prices on the third attempt (22:35:00, after waiting 60 and 120 s) and the statement fails three times (22:35, 22:40 and 22:50, with waits of 300 and 600 s) because the broker has not published it yet. Validation, features and signal finish at 22:50:42; conciliar and informe are skipped: they do not run with a statement that does not exist. The cell's orchestrator runs serially, which is why the price branch waits for the statement to exhaust its retries; a real one runs independent branches in parallel. The second pass, at 23:30, repeats nothing that was already fine (the four done) and only runs the statement, the reconciliation and the report. Without state per task, rerunning the night would have downloaded prices again and recomputed the features.

Three design decisions hold this up. The first: retry only what is safe to repeat. Downloading prices or reading a file can be done ten times; sending orders cannot (lesson 4.5.5). That is why order submission is not in this graph: the live system does it with idempotency keys, and an orchestrator at most triggers it once. The second: the wait grows (60, 120, 240 s), because a provider that is down does not recover because you insist faster. The third: the state is stored per partition. The November 24 session is a unit that is either completed or not, and rerunning it tomorrow, or a month from now, must not touch the one for the 25th.

Backfill: rerun without duplicating

The orchestrator's state avoids repeating tasks, but it is not enough if a task dies halfway through writing its result: the file is left half-written and the next task reads it. The rule is the same as in lesson 4.5.5 with transactions: each task writes its partition whole or not at all. On a file system, that means writing to a temporary file and renaming it with os.replace, which on POSIX replaces the destination atomically if both are on the same file system. A backfill (filling in past partitions, for example after fixing a bug in the features) is then launching the same graph for each date:

IN [03]python
import os
from pathlib import Path

LAGO = Path("lago")

def escribir_atomico(ruta, texto):
    ruta.parent.mkdir(parents=True, exist_ok=True)
    tmp = ruta.with_name(ruta.name + ".tmp")
    tmp.write_text(texto)
    os.replace(tmp, ruta)        # readers see the old or the new one

orq2 = Orquestador(Reloj(datetime(2025, 11, 22, 8, tzinfo=UTC)),
                   "backfill.db")
CORRUPTO = {"2025-11-20"}            # the provider sent garbage

@orq2.tarea(reintentos=1)
def descargar(p):
    escribir_atomico(LAGO / f"precios/fecha={p}.csv", "ORUS,717.1\n")

@orq2.tarea(deps=("descargar",))
def validar(p):
    if p in CORRUPTO:
        raise ValueError(f"{p}: prices out of range")

@orq2.tarea(deps=("validar",))
def features(p):
    escribir_atomico(LAGO / f"features/fecha={p}.csv", "x\n")

orq2.ejecutar("2025-11-19")          # it was already done before
sesiones = ["2025-11-17", "2025-11-18", "2025-11-19",
            "2025-11-20", "2025-11-21"]
for vuelta in (1, 2):                # the backfill, launched twice
    print(vuelta, [orq2.ejecutar(p)["features"] for p in sesiones])
print(sorted(f.name for f in (LAGO / "features").iterdir()))
print(len(list((LAGO / "precios").iterdir())), "price files;",
      len(list(LAGO.rglob("*.tmp"))), "temporary")
print(dict(orq2.db.execute(
    "SELECT particion, COUNT(*) FROM ejecuciones "
    "WHERE tarea = 'descargar' GROUP BY particion")))
OUT [03]An idempotent one-week backfill
1 ['ok', 'ok', 'done', 'skipped', 'ok']
2 ['done', 'done', 'done', 'skipped', 'done']
['fecha=2025-11-17.csv', 'fecha=2025-11-18.csv', 'fecha=2025-11-19.csv', 'fecha=2025-11-21.csv']
5 price files; 0 temporary
{'2025-11-17': 1, '2025-11-18': 1, '2025-11-19': 1, '2025-11-20': 1, '2025-11-21': 1}

The first round computes the features of four sessions: the 19th was already done and is not touched, and the 20th is left without features because validation rejects its prices (the raw prices are stored, so they can be investigated). The second round does nothing new: everything is done except the 20th, which fails validation again, as it should, until someone fixes the data. Each partition was downloaded only once, there are no temporary files left and there is no half-written features file for the 20th. If the process died between write_text and os.replace, an orphan .tmp would remain that the next run overwrites, never a corrupt final file.

Real orchestrators formalize this idea. Airflow 3 calls catchup the automatic creation of runs for past intervals that have not been run, and in version 3.3 it is off by default (scheduler.catchup_by_default=False); the backfill of a date range is launched by hand from the UI or with airflow backfill create, with the option to reprocess only what failed1Note 1 · Apache Airflow 3.3.2, “Dag Runs” (https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dag-run.html): catchup off by default with scheduler.catchup_by_default=False, catchup=True on the Dag to enable it, and airflow backfill create --dag-id … --from-date … --to-date … --reprocess-behavior failed. The TaskFlow API (from airflow.sdk import dag, task) and @task(retries=…, retry_delay=…) follow the examples in the same documentation. Accessed on October 23, 2026.. Dagster approaches it from the data: an asset with DailyPartitionsDefinition is a table with one partition per day, and the backfill consists of materializing the missing ones2Note 2 · Dagster, “Partitioning assets” (https://docs.dagster.io/guides/build/partitions-and-backfills/partitioning-assets): dg.DailyPartitionsDefinition(start_date=…), @dg.asset(partitions_def=…, deps=[…]) and context.partition_key. Accessed on October 23, 2026.. Prefect adds result caching by task inputs (cache_policy=INPUTS)3Note 3 · Prefect 3, “Caching” (https://docs.prefect.io/v3/concepts/caching): the default policy uses the inputs, the task code and the flow run identifier; INPUTS uses only the inputs. Accessed on October 23, 2026.. Underneath, the condition is the cell's: a task that can be repeated without changing the result.

Prefect, Airflow and Dagster

All three solve the same problem with a different model. None is installed in the course environment, so the code is not run and follows the documentation checked on October 23, 2026 (Airflow 3.3.2).

PrefectAirflowDagster
Unitflow (@flow) that calls tasks (@task)Dag with tasks (@dag, @task)assets (tables, models) and their dependencies
Graphbuilt by the Python code as it runsdeclared and parsed before runninginferred from the dependencies between assets
Pastrerun with parameters; cache by inputscatchup and airflow backfill createpartitions and backfill of the missing ones
FitsPython flows with dynamic logicmany scheduled, heterogeneous jobsdata platforms where the product is the table

Lesson 4.5.4 already showed a Prefect flow with retries and New York scheduling. The same nightly graph in Airflow, with the TaskFlow API of version 3:

IN [04]dags/nocturno.pypython
from datetime import timedelta

import pendulum
from airflow.sdk import dag, task


@dag(schedule="30 22 * * 1-5",
     start_date=pendulum.datetime(2025, 11, 3, tz="UTC"),
     catchup=False, tags=["trading"])
def nocturno():
    @task(retries=3, retry_delay=timedelta(minutes=1))
    def precios(): ...

    @task(retries=2, retry_delay=timedelta(minutes=5))
    def extracto(): ...

    @task
    def validar(p): ...

    @task
    def features(v): ...

    @task
    def senal(f): ...

    @task
    def conciliar(e): ...

    @task
    def informe(s, c): ...

    informe(senal(features(validar(precios()))),
            conciliar(extracto()))


nocturno()

And the features as a partitioned Dagster asset, which is the natural way to think about a backfill:

IN [05]defs/features.pypython
import dagster as dg

diarias = dg.DailyPartitionsDefinition(start_date="2025-01-02")


@dg.asset(partitions_def=diarias)
def precios_validados(context: dg.AssetExecutionContext) -> None:
    fecha = context.partition_key          # "2025-11-24"
    ...


@dg.asset(partitions_def=diarias, deps=[precios_validados])
def features(context: dg.AssetExecutionContext) -> None:
    ...

An orchestrator is one more service: database, scheduler, workers, UI and upgrades. For a personal bot with five nightly tasks, the cell's orchestrator (or systemd with timers) and good monitoring are enough. It pays off when there are dozens of chained jobs, several people and the need to see the history of every run.

Structured logs

Lesson 0.4.5 configured logging with levels, handlers and a text file. A text log reads well and queries badly: to find out what happened to an order you have to search for its identifier in sentences written by different people. A structured log is one object per line (JSON) with fixed fields: time, level, event and the context (account, session, signal, order). It is queried like a table, aggregated and sent to any log system without regular expressions. structlog does it with a chain of processors; the time comes from an injected clock, which here is the timestamp of each event in the data:

IN [06]python
import json
import structlog

RELOJ_LOG = {"t": None}               # the event sets the time
SECRETOS = {"api_key", "token", "password"}

def hora(logger, metodo, ev):
    ev["ts"] = RELOJ_LOG["t"]
    return ev

def tapar_secretos(logger, metodo, ev):
    for k in SECRETOS & ev.keys():
        ev[k] = "***"
    return ev

salida = open("bot.jsonl", "w")
structlog.configure(
    processors=[structlog.contextvars.merge_contextvars,
                structlog.processors.add_log_level, hora,
                tapar_secretos,
                structlog.processors.JSONRenderer(sort_keys=True)],
    logger_factory=structlog.WriteLoggerFactory(file=salida),
    cache_logger_on_first_use=False)
log = structlog.get_logger()

DIA, CUENTA = "2025-11-13", "U-0418272"
con = pd.read_csv("vivo4_conexion.csv")
ords = pd.read_csv("vivo4_ordenes.csv")
evb = pd.read_csv("vivo4_eventos_broker.csv")
con = con[(con["modo"] == "real") & con["ts"].str.startswith(DIA)]
ords = ords[(ords["cuenta"] == CUENTA) & (ords["fecha"] == DIA)]
evb = evb[(evb["cuenta"] == CUENTA)
          & evb["ts_recibido"].str.startswith(DIA)]
senal_de = ords.set_index("id_orden_cliente")["id_senal"]

filas = ([(r.ts, "con", r) for r in con.itertuples()]
         + [(r.ts_envio, "orden", r) for r in ords.itertuples()]
         + [(r.ts_recibido, "broker", r) for r in evb.itertuples()])
structlog.contextvars.bind_contextvars(sesion=DIA, cuenta=CUENTA)
for ts, tipo, r in sorted(filas, key=lambda f: pd.Timestamp(f[0])):
    t = pd.Timestamp(ts).isoformat(timespec="milliseconds")
    RELOJ_LOG["t"] = t.replace("+00:00", "Z")
    if tipo == "con":
        nivel = ("info" if r.evento == "conectado"
                 or r.detalle == "fin de sesión" else "warning")
        getattr(log, nivel)(r.evento, detalle=r.detalle
                            if isinstance(r.detalle, str) else None)
    elif tipo == "orden":
        log.info("orden_enviada", id_orden=r.id_orden_cliente,
                 id_senal=r.id_senal, instrumento=r.instrumento,
                 lado=r.lado, cantidad=int(r.cantidad),
                 motivo=r.motivo)
    else:
        log.info(f"broker_{r.evento}", id_orden=r.id_orden_cliente,
                 id_senal=senal_de[r.id_orden_cliente], seq=r.seq)
log.info("conexion_api", api_key="clave-de-juguete-123")
salida.close()

lineas = Path("bot.jsonl").read_text().splitlines()
print(len(lineas), "lines")
for linea in lineas[:2] + lineas[-1:]:
    print(linea)
OUT [06]The November 13 log, in JSON
30 lines
{"cuenta": "U-0418272", "detalle": null, "event": "conectado", "level": "info", "sesion": "2025-11-13", "ts": "2025-11-13T14:00:00.000Z"}
{"cuenta": "U-0418272", "detalle": "sin latido del broker en 3 s", "event": "latido_perdido", "level": "warning", "sesion": "2025-11-13", "ts": "2025-11-13T20:49:59.000Z"}
{"api_key": "***", "cuenta": "U-0418272", "event": "conexion_api", "level": "info", "sesion": "2025-11-13", "ts": "2025-11-13T21:20:00.000Z"}

Thirty lines for the session: connection, lost heartbeat, outage and reconnection, the eight orders sent, their acceptances and fills, the end of session and a test line with an API key, which comes out masked. Each line is an object with the same keys, sorted: ts in UTC with milliseconds, level, event and the context. In production it is written to standard output or to a file collected by an agent, and it is queried with whatever is at hand: jq, DuckDB or a log system.

With the log in JSON, the question of lesson 4.5.5 (“what happened to signal S00215?”) is a filter on a field. The bound context (sesion, cuenta) goes on every line without repeating it in each call; the signal identifier is the correlation identifier that ties together the original order, the resent one and the broker's responses:

IN [07]python
reg = pd.read_json("bot.jsonl", lines=True, dtype=False)
inc = reg[(reg["id_senal"] == "S00215")
          | (reg["level"] == "warning")]
print(inc[["ts", "level", "event", "id_orden"]]
      .fillna("").to_string(index=False))
print(reg["level"].value_counts().to_dict())
OUT [07]Signal S00215, reconstructed from the log
                      ts   level            event id_orden
2025-11-13T20:49:59.000Z warning   latido_perdido
2025-11-13T20:50:00.246Z    info    orden_enviada   R00113
2025-11-13T20:50:02.000Z warning     desconectado
2025-11-13T20:51:14.000Z warning      reconectado
2025-11-13T20:51:14.296Z    info    orden_enviada   R00114
2025-11-13T20:51:14.375Z    info  broker_aceptada   R00114
2025-11-13T20:51:14.422Z    info broker_ejecutada   R00114
2025-11-13T20:51:14.586Z    info  broker_aceptada   R00113
2025-11-13T20:51:14.706Z    info broker_ejecutada   R00113
{'info': 27, 'warning': 3}

The filter returns the story in order: the bot loses the broker heartbeat at 20:49:59, sends R00113 at 20:50:00.246, the connection drops at 20:50:02 and comes back at 20:51:14. Three tenths of a second after reconnecting, the bot resends the signal as R00114; the broker accepts and fills it, and only afterwards do the delayed acceptance and fill of R00113 arrive. The order of the log says it all: the bot resent before receiving the pending events, which is the bug that lesson 4.5.5 fixes with idempotency keys. With a text log, the same reconstruction requires knowing which sentences to search for; here a field is enough.

Three rules for the logs of a trading system. Fixed event names (orden_enviada, broker_ejecutada), not sentences: they can be counted and searched. Context, not interpolation: log.info("orden_enviada", id_orden=…) and not log.info(f"Sent order {id}"). And nothing secret: the tapar_secretos processor is the last safety net, not the first; the first is never passing a credential to a log (lesson 4.4.5). The time goes in UTC and in ISO 8601, which is what the systems that collect logs expect.

Metrics with Prometheus

A log records each event; a metric is a number sampled at intervals and stored as a time series. Prometheus works by pulling: every interval (scrape_interval, 1 minute by default4Note 4 · Prometheus, “Configuration” (https://prometheus.io/docs/prometheus/latest/configuration/configuration/): scrape_interval and evaluation_interval, 1 minute by default. Accessed on October 23, 2026.) it reads the text the process publishes on /metrics. There are three basic types5Note 5 · Prometheus, “Metric types” (https://prometheus.io/docs/concepts/metric_types/). Accessed on October 23, 2026.: the counter only goes up (or resets to zero when the process restarts) and is for what accumulates, such as orders sent or errors; the gauge goes up and down (position, connection, time of the last heartbeat); the histogram counts observations in cumulative intervals (buckets), and is the type for latencies. The level 4 system, instrumented with the real broker events:

IN [08]python
from prometheus_client import (CollectorRegistry, Counter, Gauge,
                               Histogram, generate_latest)

reg_m = CollectorRegistry()           # without the process metrics
EVENTOS = Counter("bot_eventos_broker", "Broker events",
                  ["cuenta", "evento"], registry=reg_m)
RETRASO = Histogram("bot_retraso_evento_segundos",
                    "From the broker to the bot", ["cuenta"],
                    buckets=(0.025, 0.05, 0.1, 0.25, 1, 5, 10, 60, 120),
                    registry=reg_m)
CONECTADO = Gauge("bot_conectado", "1 if connected to the broker",
                  ["cuenta"], registry=reg_m)

todo = pd.read_csv("vivo4_eventos_broker.csv",
                   parse_dates=["ts_broker", "ts_recibido"])
todo["retraso"] = (todo["ts_recibido"] - todo["ts_broker"]
                   ).dt.total_seconds()
for r in todo.itertuples():
    EVENTOS.labels(r.cuenta, r.evento).inc()
    RETRASO.labels(r.cuenta).observe(r.retraso)
CONECTADO.labels("U-0418272").set(1)

texto = generate_latest(reg_m).decode()
for linea in texto.splitlines():
    if ("U-0418272" in linea and not linea.startswith("#")
            and "_sum" not in linea):
        print(linea)
OUT [08]The bot's metrics in its own registry
bot_eventos_broker_total{cuenta="U-0418272",evento="aceptada"} 262.0
bot_eventos_broker_total{cuenta="U-0418272",evento="parcial"} 120.0
bot_eventos_broker_total{cuenta="U-0418272",evento="ejecutada"} 232.0
bot_eventos_broker_total{cuenta="U-0418272",evento="rechazada"} 1.0
bot_eventos_broker_total{cuenta="U-0418272",evento="cancelada"} 34.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="0.025"} 10.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="0.05"} 432.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="0.1"} 576.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="0.25"} 576.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="1.0"} 576.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="5.0"} 576.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="10.0"} 576.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="60.0"} 593.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="120.0"} 645.0
bot_retraso_evento_segundos_bucket{cuenta="U-0418272",le="+Inf"} 649.0
bot_retraso_evento_segundos_count{cuenta="U-0418272"} 649.0
bot_conectado{cuenta="U-0418272"} 1.0

Six hundred forty-nine events of the real account, by type, and their delay from the broker to the bot in a histogram: 576 arrive in less than 0.1 s, none between 0.1 and 10 s, and 73 take more than 10 s. They are the ones the broker resends after each reconnection, with the delay of the outage. The _bucket lines are cumulative (le means “less than or equal to”) and end at +Inf, which matches _count; the cell omits _sum, which is used to compute the mean. The names follow the Prometheus conventions: system prefix, the unit in the name and _total on counters, which the library adds by itself.

Quantiles from buckets and cardinality

A histogram does not store the latencies, only the counts per bucket. Prometheus estimates a quantile with histogram_quantile, which finds the bucket where it falls and, in classic histograms, interpolates linearly inside it, as if the observations were spread uniformly; if the quantile falls in the last bucket (+Inf), it returns the bound of the second-to-last one6Note 6 · Prometheus, “Query functions”, histogram_quantile() (https://prometheus.io/docs/prometheus/latest/querying/functions/), and “Histograms and summaries” (https://prometheus.io/docs/practices/histograms/), with the example of 220 ms real estimated as 295 ms with buckets at 200 and 300 ms. Accessed on October 23, 2026.. The function, written by hand and compared with the exact quantiles:

IN [09]python
import numpy as np

def histogram_quantile(q, cubos):
    """cubos: [(le, cumulative count), ...] with le = inf at the end,
    as in the Prometheus exposition format."""
    total = cubos[-1][1]
    objetivo = q * total
    abajo, n_abajo = 0.0, 0
    for le, n in cubos:
        if n >= objetivo:
            if le == float("inf"):
                return abajo              # the second-to-last bound
            return abajo + (le - abajo) * (objetivo - n_abajo) / (
                n - n_abajo)
        abajo, n_abajo = le, n

real = todo[todo["cuenta"] == "U-0418272"]["retraso"]
cubos = [(float(s.labels["le"]), s.value)
         for m in reg_m.collect() if m.name == RETRASO._name
         for s in m.samples if s.name.endswith("_bucket")
         and s.labels["cuenta"] == "U-0418272"]
for q in (0.5, 0.9, 0.95, 0.99):
    print(f"p{q * 100:g}: estimated {histogram_quantile(q, cubos):7.3f}"
          f" s · exact {real.quantile(q):7.3f} s")

# the documentation's example: everything at 0.22 s, buckets 0.2 and 0.3
print(histogram_quantile(0.95, [(0.1, 0), (0.2, 0), (0.3, 1000),
                                (float("inf"), 1000)]))
OUT [09]histogram_quantile by hand
p50: estimated   0.044 s · exact   0.044 s
p90: estimated  33.824 s · exact  57.984 s
p95: estimated  87.173 s · exact  92.976 s
p99: estimated 117.127 s · exact 118.039 s
0.295

The median comes out exact (0.044 s) because it falls in a narrow bucket, from 0.025 to 0.05 s. The p90 does not: the estimate gives 33.8 s and the exact value 58.0 s, because it falls in the 10 to 60 s bucket, wide and almost empty, and linear interpolation assumes the observations are spread evenly inside it. The p95 and p99 come closer (87.2 versus 93.0 and 117.1 versus 118.0) because the 60 and 120 s buckets are close to where the resent events pile up. The documentation's example comes out the same: 0.295 s estimated for latencies that are all 0.22 s. The practical lesson is to put buckets at the thresholds that matter: the alerting rule in the next section looks at the p95 against 5 s, and that is why the histogram has a bucket at 5.

The second risk of metrics is cardinality. Each combination of label values is a new time series, with its cost in memory and disk; the Prometheus documentation asks you not to use labels with many possible values, such as user identifiers7Note 7 · Prometheus, “Metric and label naming” (https://prometheus.io/docs/practices/naming/): “Do not use labels to store dimensions with high cardinality (many different label values), such as user IDs, email addresses, or other unbounded sets of values”. Accessed on October 23, 2026.. In trading, the typical mistake is labeling by order:

IN [10]python
def series(etiquetas):
    r = CollectorRegistry()
    c = Counter("ordenes", "Orders", list(etiquetas), registry=r)
    o = pd.read_csv("vivo4_ordenes.csv")
    for fila in o.itertuples():
        c.labels(*(getattr(fila, e) for e in etiquetas)).inc()
    return sum(1 for m in r.collect() for s in m.samples
               if s.name == "ordenes_total")

print("cuenta, lado, instrumento:",
      series(("cuenta", "lado", "instrumento")))
print("id_orden_cliente:", series(("id_orden_cliente",)))
OUT [10]Bounded labels versus one per order
cuenta, lado, instrumento: 40
id_orden_cliente: 398

With account, side and instrument there are 40 series (2 accounts, 2 sides and 10 instruments), and they do not grow over time. With the order identifier there are 398, one per order, and one more for each new order: over a few years of a system that sends hundreds of orders a day there would be hundreds of thousands of series that nobody aggregates. The order identifier belongs in the log; the metric answers “how many” and “how long they take”, and the log answers “which one”.

Alerting rules and the meaning of for

An alerting rule is a PromQL expression that, when it returns any result, makes the alert active for those labels. With for, Prometheus waits for it to stay active for that time in every evaluation before firing it: in the meantime it is pending. Without for, it fires on the first evaluation. With keep_firing_for, it keeps firing for a while after the condition stops being met, against flapping alerts8Note 8 · Prometheus, “Alerting rules” (https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/). Accessed on October 23, 2026.. The bot's rules, in Prometheus format and checked on load:

IN [11]python
import yaml

REGLAS = """
groups:
  - name: bot
    rules:
      - alert: BrokerDesconectado
        expr: bot_conectado == 0
        for: 1m
        labels: {severity: critical}
        annotations:
          summary: "{{ $labels.cuenta }} has no session with the broker"
          runbook: "docs/runbooks/broker.md"
      - alert: BucleParado
        expr: time() - bot_ultimo_latido_segundos > 120
        labels: {severity: critical}
        annotations:
          summary: "No heartbeat from the main loop for 2 min"
          runbook: "docs/runbooks/bucle.md"
      - alert: EventosRetrasados
        expr: >
          histogram_quantile(0.95, sum by (le, cuenta) (
          rate(bot_retraso_evento_segundos_bucket[5m]))) > 5
        for: 2m
        labels: {severity: warning}
        annotations:
          summary: "p95 of the event delay above 5 s"
          runbook: "docs/runbooks/latencia.md"
"""

def validar_reglas(texto):
    errores = []
    for g in yaml.safe_load(texto)["groups"]:
        for r in g["rules"]:
            nombre = r.get("alert", "?")
            if r.get("labels", {}).get("severity") not in (
                    "critical", "warning"):
                errores.append(f"{nombre}: severity")
            if "runbook" not in r.get("annotations", {}):
                errores.append(f"{nombre}: no runbook")
            if "for" not in r and "time()" not in r["expr"]:
                errores.append(f"{nombre}: no for")
    return errores

reglas = yaml.safe_load(REGLAS)["groups"][0]["rules"]
print([(r["alert"], r.get("for", "-"), r["labels"]["severity"])
       for r in reglas])
print(validar_reglas(REGLAS))
OUT [11]The bot's rules, validated
[('BrokerDesconectado', '1m', 'critical'), ('BucleParado', '-', 'critical'), ('EventosRetrasados', '2m', 'warning')]
[]

The three rules load and pass the checks: a known severity, a runbook (the page with what to do when it fires, the first thing whoever receives the alert should read) and a for on the ones that need it. BucleParado does not have one because the expression already includes the two-minute wait. In a real project, promtool check rules also checks the PromQL syntax; the cell's function adds the house rules and can run in continuous integration (lesson 7.2.2).

The BrokerDesconectado rule with for: 1m seems to say “alert if the outage lasts more than one minute”, which is the rule lesson 4.5.4 applied with pandas to the seven outages in the data. It does not say exactly that. Prometheus does not see the outage when it happens, but at the next scrape, and it counts the minute from the first evaluation that sees it. With scraping and evaluation every 15 seconds, aligned at seconds 0, 15, 30 and 45, and the rule's state machine:

InactivePendingFiring
Mermaid source
stateDiagram-v2
  [*] --> Inactive
  Inactive --> Pending
  Pending --> Inactive
  Pending --> Firing
  Firing --> Inactive
IN [12]python
con_t = pd.read_csv("vivo4_conexion.csv", parse_dates=["ts"])
cortes = con_t[con_t["detalle"] == "conexión cerrada por el broker"]
vueltas = con_t[con_t["evento"] == "reconectado"]

def evaluar(caida, vuelta, paso=15, para=60):
    """State of the rule at each evaluation (every `paso` s)."""
    t = caida.floor(f"{paso}s") + pd.Timedelta(seconds=paso)
    pendiente = disparada = None
    while t <= vuelta + pd.Timedelta(seconds=paso):
        activa = caida <= t < vuelta     # bot_conectado=0 is seen
        if activa and pendiente is None:
            pendiente = t
        if activa and (t - pendiente).total_seconds() >= para:
            disparada = disparada or t
        t += pd.Timedelta(seconds=paso)
    return pendiente, disparada

filas = []
for (_, c), (_, v) in zip(cortes.iterrows(), vueltas.iterrows()):
    p, d = evaluar(c["ts"], v["ts"])
    filas.append({"fecha": c["ts"].strftime("%Y-%m-%d"),
                  "modo": c["modo"],
                  "corte_s": int((v["ts"] - c["ts"]).total_seconds()),
                  "pendiente": p.strftime("%H:%M:%S"),
                  "dispara": d.strftime("%H:%M:%S") if d else "no",
                  "retraso_s": int((d - c["ts"]).total_seconds())
                  if d else None})
tabla = pd.DataFrame(filas)
print(tabla.to_string(index=False))
OUT [12]Evaluating for: 1m on the real outages
     fecha  modo  corte_s pendiente  dispara  retraso_s
2025-09-23 paper       42  19:50:15       no        NaN
2025-10-21  real       92  19:50:15 19:51:15       74.0
2025-11-03  real      137  20:50:15 20:51:15       74.0
2025-11-13  real       72  20:50:15       no        NaN
2025-12-03  real      117  20:50:15 20:51:15       74.0
2025-12-09  real       57  20:50:15       no        NaN
2025-12-15  real      107  20:50:15 20:51:15       74.0

Four of the seven outages fire, all four 74 s after they start: the outage arrives at second 1 or 2, the first scrape that sees it is the one at second 15 and the alert fires at second 15 of the following minute. The 42 and 57 s outages do not fire, as they should not. The 72 s one does not either.

The figure at the top sorts them by duration. The pandas rule of lesson 4.5.4 (“more than 60 s”) and the Prometheus one disagree in one case, the 72 s outage of November 13: it started at 20:50:02, the first scrape that saw it was the one at 20:50:15, and at 20:51:15, when the minute was up, the connection had been restored for one second. The real threshold of a rule with for is for plus the time it takes for the condition to be seen, between 0 and one scrape interval (here, from 60 to 75 s), and the delay until the notification is of that order plus Alertmanager's group_wait. If a 72 s outage must alert, the rule has to ask for less (for: 45s) or measure something else (a counter of seconds without connection that the bot itself increments).

Heartbeats: a live process is not a live loop

The up metric, which Prometheus creates for each target, is 1 if it could read /metrics. But the prometheus_client metrics server runs in a separate thread: if the bot's main loop gets stuck waiting for a response that never arrives, /metrics keeps responding and up stays at 1. Lesson 7.2.1 solved the same thing for Docker with a heartbeat file. Here, the heartbeat is a gauge that only the loop updates, and the BucleParado rule looks at its age. With a real local server and the injected clock:

IN [13]python
import urllib.request
from prometheus_client import start_http_server

reg_v = CollectorRegistry()
LATIDO = Gauge("bot_ultimo_latido_segundos",
               "Time (epoch) of the last loop iteration",
               registry=reg_v)
servidor, hilo = start_http_server(0, addr="127.0.0.1",
                                   registry=reg_v)
URL = f"http://127.0.0.1:{servidor.server_port}/metrics"

def recoger():
    with urllib.request.urlopen(URL, timeout=2) as r:
        valores = {}
        for linea in r.read().decode().splitlines():
            if not linea.startswith("#"):
                k, v = linea.rsplit(" ", 1)
                valores[k] = float(v)
        return r.status, valores

t0 = datetime(2025, 11, 13, 20, 49, tzinfo=UTC).timestamp()
for vuelta in range(4):              # the loop beats every 15 s
    LATIDO.set(t0 + 15 * vuelta)
ultimo = t0 + 45                     # and on iteration 4 it hangs
for ahora in (t0 + 60, t0 + 150, t0 + 180):
    estado, v = recoger()
    edad = ahora - v["bot_ultimo_latido_segundos"]
    print(f"+{ahora - t0:3.0f} s · HTTP {estado} · up=1 · "
          f"heartbeat {edad:3.0f} s ago · BucleParado={edad > 120}")
servidor.shutdown()
servidor.server_close()
OUT [13]The exporter responds even though the loop has stopped
+ 60 s · HTTP 200 · up=1 · heartbeat  15 s ago · BucleParado=False
+150 s · HTTP 200 · up=1 · heartbeat 105 s ago · BucleParado=False
+180 s · HTTP 200 · up=1 · heartbeat 135 s ago · BucleParado=True

The server always responds with HTTP 200, so for Prometheus the target is alive (up = 1) throughout the hang. What gives the problem away is the age of the heartbeat: 15 s at the first scrape, 105 s at the second and 135 s at the third, when the BucleParado rule (more than 120 s) fires. The threshold is chosen with the pace of the loop: a bot that does an iteration every 15 s but can wait a minute for data in a quiet market needs some margin to avoid false alarms.

There is a gap left: if the whole machine goes down, or Prometheus does, nobody evaluates BucleParado. Against that there is the external watchdog (dead man's switch): a service somewhere else that expects a periodic signal and alerts when it does not arrive. Healthchecks.io, for example, defines a period and a grace time for each check, goes through the states new, up, late (the period expired, not the grace time) and down (the grace time expired too), and alerts on the transition to down9Note 9 · Healthchecks.io, documentation (https://healthchecks.io/docs/): states New, Up, Late, Down and Paused, period and Grace Time; it alerts on the transition to Down. Accessed on October 23, 2026.. The same, locally and with the injected clock:

IN [14]python
class Vigilante:
    """Expects a ping every `periodo` s; alerts if a further
    `gracia` s pass without it. Only alerts on state changes."""

    def __init__(self, periodo, gracia):
        self.periodo, self.gracia = periodo, gracia
        self.ultimo, self.estado, self.avisos = None, "new", []

    def ping(self, t):
        if self.estado == "down":
            self.avisos.append((t, "recovered"))
        self.ultimo, self.estado = t, "up"

    def revisar(self, t):
        if self.ultimo is None:
            return
        edad = t - self.ultimo
        nuevo = ("up" if edad <= self.periodo else
                 "late" if edad <= self.periodo + self.gracia
                 else "down")
        if nuevo == "down" and self.estado != "down":
            self.avisos.append((t, f"down: {edad} s without ping"))
        self.estado = nuevo

v = Vigilante(periodo=60, gracia=120)
pings = [t for t in range(0, 900, 60)
         if not 240 <= t <= 420]     # 4 in a row are lost
for t in range(0, 900, 30):          # checks every 30 s
    if t in pings:
        v.ping(t)
    v.revisar(t)
print(v.avisos, v.estado)
OUT [14]An external watchdog: period and grace time
[(390, 'down: 210 s without ping'), (480, 'recovered')] up

The pings arrive every minute until second 180, and the next four are lost. At 270 s the check goes to “late”, still within the grace time; the alert goes out at the 390 s check, when 210 s have passed since the last ping (more than the 60 of the period and the 120 of grace), and it goes out only once. When the ping comes back, at 480 s, the watchdog sends the recovery: without it, whoever received the alert does not know whether the problem is still open.

The watchdog also protects the monitoring itself if you turn it around: a Prometheus rule that always fires (expr: vector(1)) and that Alertmanager forwards every few minutes to an external watchdog. As long as the whole chain works, pings arrive; if Prometheus, Alertmanager or the network goes down, they stop arriving and the alert goes out through a path that does not depend on them.

Alertmanager: grouping and inhibition

Prometheus decides which alerts are firing; Alertmanager decides what is notified, to whom and when. Its three tools are routes (by labels, to a receiver: Telegram, Slack, email, a webhook), grouping (alerts with the same group_by labels go in one message; it waits group_wait, 30 s by default, before the first one, and group_interval, 5 min, between updates; it repeats every repeat_interval, 4 h) and inhibition (one alert silences others that match it on the equal labels)10Note 10 · Alertmanager, “Configuration” (https://prometheus.io/docs/alerting/latest/configuration/): group_wait 30 s, group_interval 5 min and repeat_interval 4 h by default; inhibit_rules with source_matchers, target_matchers and equal; telegram_configs (with bot_token_file) and slack_configs receivers. Accessed on October 23, 2026.. Lesson 4.5.4 built a router by severity; what Alertmanager adds is that a single incident does not produce five messages. The December 3 outage, 117 s long, with twelve orders sent just before:

IN [15]python
AM = """
route:
  receiver: slack
  group_by: [alertname, cuenta]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: ['severity = "critical"']
      receiver: telegram
receivers:
  - name: telegram
    telegram_configs:
      - bot_token_file: /run/secrets/telegram_token
        chat_id: -1001234567890
  - name: slack
    slack_configs:
      - api_url_file: /run/secrets/slack_webhook
        channel: "#bot"
inhibit_rules:
  - source_matchers: ['alertname = "BrokerDesconectado"']
    target_matchers: ['severity = "warning"']
    equal: [cuenta]
"""
am = yaml.safe_load(AM)

def coincide(alerta, matchers):
    for m in matchers:
        k, v = (x.strip().strip('"') for x in m.split("="))
        if alerta.get(k) != v:
            return False
    return True

def inhibidas(alertas, reglas):
    fuera = set()
    for r in reglas:
        for i, a in enumerate(alertas):
            if not coincide(a, r["target_matchers"]):
                continue
            if any(coincide(s, r["source_matchers"]) and s is not a
                   and all(s.get(e) == a.get(e) for e in r["equal"])
                   for s in alertas):
                fuera.add(i)
    return fuera

def receptor(alerta, ruta):
    for hija in ruta.get("routes", []):
        if coincide(alerta, hija["matchers"]):
            return hija["receiver"]
    return ruta["receiver"]

alertas = [
    {"alertname": "LatidoBrokerPerdido", "severity": "warning"},
    {"alertname": "BrokerDesconectado", "severity": "critical"},
    {"alertname": "OrdenesSinAcuse", "severity": "warning"},
    {"alertname": "EventosRetrasados", "severity": "warning"},
    {"alertname": "EventosRetrasados", "severity": "warning",
     "cuenta": "DP-0418273"},
]
for a in alertas:
    a.setdefault("cuenta", "U-0418272")
callar = inhibidas(alertas, am["inhibit_rules"])
grupos = {}
for i, a in enumerate(alertas):
    if i not in callar:
        clave = tuple(a[k] for k in am["route"]["group_by"])
        grupos.setdefault((receptor(a, am["route"]), clave), []
                          ).append(a["alertname"])
print("without Alertmanager:", len(alertas), "messages")
print("inhibited:", [alertas[i]["alertname"] for i in sorted(callar)])
for (dest, clave), nombres in grupos.items():
    print(f"{dest:8} {clave}")
OUT [15]The December 3 outage in Alertmanager
without Alertmanager: 5 messages
inhibited: ['LatidoBrokerPerdido', 'OrdenesSinAcuse', 'EventosRetrasados']
telegram ('BrokerDesconectado', 'U-0418272')
slack    ('EventosRetrasados', 'DP-0418273')

Five alerts active at once: without Alertmanager there would be five messages, four of them for the same cause. The inhibition rule silences the three warnings (warning) for the real account while BrokerDesconectado is active, because they are consequences of the outage, and one message to Telegram remains for the critical alert. The EventosRetrasados warning for the paper account is not silenced: equal: [cuenta] requires the account to match, and there is no outage on paper, so it is a different problem and goes to Slack. When the critical alert is resolved, the warnings still active will be notified as usual.

Two configuration details. The Telegram token and the Slack webhook URL are read from files (bot_token_file, api_url_file) mounted as secrets, not written in the YAML (lesson 7.2.1). And inhibition works because the rules put the same labels (cuenta) on the source and target alerts: a rule that forgets the label falls outside equal and is not silenced, or, if both lack it, everything is silenced.

Grafana, finally, is where the metrics are looked at: dashboards with the p95 of the delay, orders per minute, position and connection. Its dashboards can be versioned as JSON and loaded at startup from a folder (provisioning), just like the alerting rules11Note 11 · Grafana, “Provision Grafana” (https://grafana.com/docs/grafana/latest/administration/provisioning/): dashboard providers loaded from the file system at startup. Accessed on October 23, 2026.. A dashboard helps investigate an incident; it does not replace an alert, because nobody looks at it at three in the morning.

Exercises

  1. Add to Orquestador a method limpiar(tarea, particion) that deletes the ok state of that task and of all the tasks that depend on it (like Airflow's clear with downstream). Use it to recompute the features of November 24 and check which tasks run again.
Solution
IN [16]python
def limpiar(self, tarea, particion):
    hijos = {n: {m for m, t in self.tareas.items() if n in t[1]}
             for n in self.tareas}
    pendientes, borrar = [tarea], set()
    while pendientes:
        n = pendientes.pop()
        if n not in borrar:
            borrar.add(n)
            pendientes.extend(hijos[n])
    with self.db:
        self.db.executemany(
            "DELETE FROM ejecuciones WHERE tarea = ? AND "
            "particion = ?", [(n, particion) for n in borrar])
    return sorted(borrar)

Orquestador.limpiar = limpiar
print(orq.limpiar("features", "2025-11-24"))
reloj.t = datetime(2025, 11, 25, 7, 0, tzinfo=UTC)
print(orq.ejecutar("2025-11-24"))
OUT [16]Clearing a task and what depends on it
['features', 'informe', 'senal']
{'precios': 'done', 'extracto': 'done', 'validar': 'done', 'conciliar': 'done', 'features': 'ok', 'senal': 'ok', 'informe': 'ok'}

limpiar deletes the state of features and of what depends on it (senal and informe), and the next run only redoes those three; prices, statement, validation and reconciliation remain done. It is what you need after fixing a bug in the feature computation: recompute from there downstream, not the whole night.

  1. A service level objective (SLO) for the bot: the closing signal goes out before 15:50 New York time in 99% of the sessions of a year (252). Google's SRE Workbook proposes alerting when 2% of the error budget is consumed in one hour, with a short check window12Note 12 · Google, The Site Reliability Workbook, “Alerting on SLOs” (https://sre.google/workbook/alerting-on-slos/): burn rate and the recommendation to alert when 2% of the budget is consumed in one hour or 5% in six. Accessed on October 23, 2026.. For an SLO by sessions, adapt the idea: how many failed sessions does the budget allow, and at what consumption rate (burn rate) over a 20-session window would you alert? Compute the rate of a streak of 3 failures in 20 sessions.
Solution
IN [17]python
slo, sesiones = 0.99, 252
presupuesto = (1 - slo) * sesiones
print(f"budget: {presupuesto:.2f} failed sessions per year")

def ritmo(fallos, ventana, slo=slo):
    """Burn rate: observed error rate / allowed rate."""
    return (fallos / ventana) / (1 - slo)

for f in (1, 3):
    r = ritmo(f, 20)
    gasto = f / presupuesto
    print(f"{f} of 20: rate {r:.0f}x, uses {gasto:.0%} "
          f"of the annual budget")
OUT [17]Error budget by sessions
budget: 2.52 failed sessions per year
1 of 20: rate 5x, uses 40% of the annual budget
3 of 20: rate 15x, uses 119% of the annual budget

The budget is 2.52 failed sessions a year. One failure in 20 sessions is a rate of 5 times the allowed one and uses 40% of the annual budget; three failures in 20 sessions are a rate of 15 and use 119%: the year's objective has already been missed. With so few events, the Workbook's threshold (a rate of 14.4 over one hour) does not carry over as is, because a single failure already consumes 40% of the budget. For an SLO by sessions, the sensible thing is to alert on every failure (each one is an incident to review) and look at the rate in the monthly review: a rate sustained above 1 says the objective will not be met.

  1. Choose five metrics for the level 4 bot (besides the lesson's) and, for each one, give its type (counter, gauge or histogram), its labels and whether it deserves an alerting rule that wakes someone up.
Solution

One proposal. bot_ordenes_total{cuenta, lado, resultado} (counter; the rate of rejections over orders sent, with for: 10m, does deserve an alert). bot_posicion{cuenta, instrumento} (gauge; it does not alert on its own, but it feeds the comparison with the statement). bot_diferencias_conciliacion{cuenta, tipo} (gauge written by the reconciliation of lesson 4.5.5; any non-zero value without a known signature is critical). bot_duracion_tarea_segundos{tarea} (histogram; an informe that goes from 3 to 300 s is a symptom, alert during office hours) and bot_ultima_ejecucion_ok_segundos{tarea} (gauge with the time of the last success of each nightly task; if the one for senal is more than one business day old, critical). None carries order or signal identifiers: those go in the logs, where one value per line costs nothing.

Footnotes

  1. Apache Airflow 3.3.2, “Dag Runs” (https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dag-run.html): catchup off by default with scheduler.catchup_by_default=False, catchup=True on the Dag to enable it, and airflow backfill create --dag-id … --from-date … --to-date … --reprocess-behavior failed. The TaskFlow API (from airflow.sdk import dag, task) and @task(retries=…, retry_delay=…) follow the examples in the same documentation. Accessed on October 23, 2026. ↩

  2. Dagster, “Partitioning assets” (https://docs.dagster.io/guides/build/partitions-and-backfills/partitioning-assets): dg.DailyPartitionsDefinition(start_date=…), @dg.asset(partitions_def=…, deps=[…]) and context.partition_key. Accessed on October 23, 2026. ↩

  3. Prefect 3, “Caching” (https://docs.prefect.io/v3/concepts/caching): the default policy uses the inputs, the task code and the flow run identifier; INPUTS uses only the inputs. Accessed on October 23, 2026. ↩

  4. Prometheus, “Configuration” (https://prometheus.io/docs/prometheus/latest/configuration/configuration/): scrape_interval and evaluation_interval, 1 minute by default. Accessed on October 23, 2026. ↩

  5. Prometheus, “Metric types” (https://prometheus.io/docs/concepts/metric_types/). Accessed on October 23, 2026. ↩

  6. Prometheus, “Query functions”, histogram_quantile() (https://prometheus.io/docs/prometheus/latest/querying/functions/), and “Histograms and summaries” (https://prometheus.io/docs/practices/histograms/), with the example of 220 ms real estimated as 295 ms with buckets at 200 and 300 ms. Accessed on October 23, 2026. ↩

  7. Prometheus, “Metric and label naming” (https://prometheus.io/docs/practices/naming/): “Do not use labels to store dimensions with high cardinality (many different label values), such as user IDs, email addresses, or other unbounded sets of values”. Accessed on October 23, 2026. ↩

  8. Prometheus, “Alerting rules” (https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/). Accessed on October 23, 2026. ↩

  9. Healthchecks.io, documentation (https://healthchecks.io/docs/): states New, Up, Late, Down and Paused, period and Grace Time; it alerts on the transition to Down. Accessed on October 23, 2026. ↩

  10. Alertmanager, “Configuration” (https://prometheus.io/docs/alerting/latest/configuration/): group_wait 30 s, group_interval 5 min and repeat_interval 4 h by default; inhibit_rules with source_matchers, target_matchers and equal; telegram_configs (with bot_token_file) and slack_configs receivers. Accessed on October 23, 2026. ↩

  11. Grafana, “Provision Grafana” (https://grafana.com/docs/grafana/latest/administration/provisioning/): dashboard providers loaded from the file system at startup. Accessed on October 23, 2026. ↩

  12. Google, The Site Reliability Workbook, “Alerting on SLOs” (https://sre.google/workbook/alerting-on-slos/): burn rate and the recommendation to alert when 2% of the budget is consumed in one hour or 5% in six. Accessed on October 23, 2026. ↩

Each answer is folded under its question

Self-assessment

  1. The extracto task fails three times in a row because the broker has not yet published the statement file. What does the lesson's orchestrator do with conciliar and informe?

    • AIt runs them anyway with the previous day's statement
    • BIt retries the whole graph from the start
    • CIt marks them as skipped; in the second pass the tasks already done are skipped and only extracto, conciliar and informe run
    • DIt stops and deletes the night's state
    Show answer

    Answer C. It marks them as skipped; in the second pass the tasks already done are skipped and only extracto, conciliar and informe run

    A task whose dependencies did not finish well is not run: it is recorded as skipped. The state per (task, partition) makes it possible to rerun the night later without downloading prices again or recomputing features, which were already fine.

  2. The BrokerDesconectado rule has for: 1m and Prometheus evaluates every 15 s. The November 13 outage lasted 72 s. Why did it not fire?

    • ABecause for counts from the first evaluation that sees the condition (20:50:15), not from the outage (20:50:02); at 20:51:15 the connection was already back
    • BBecause Prometheus ignores outages shorter than 90 s
    • CBecause the rule was badly written
    • DBecause Alertmanager inhibited it
    Show answer

    Answer A. Because for counts from the first evaluation that sees the condition (20:50:15), not from the outage (20:50:02); at 20:51:15 the connection was already back

    Between the real outage and the first sample that sees it, up to one scrape interval passes, and the alert only fires if the condition is still true in an evaluation for or more after that first one. The effective threshold ends up between 60 and 75 s depending on where the outage falls: the 72 s one stayed pending.

  3. The bot's main loop hangs, but the thread serving /metrics is still alive. Which alert detects it?

    • Aup == 0, because Prometheus cannot scrape metrics
    • BNone: a live process is a healthy process
    • CAn error counter
    • Dtime() - bot_ultimo_latido_segundos > 120: only the loop updates the gauge, so its age grows even though scraping works
    Show answer

    Answer D. time() - bot_ultimo_latido_segundos > 120: only the loop updates the gauge, so its age grows even though scraping works

    up measures whether Prometheus could read /metrics, and a separate thread does that. The heartbeat is written by the loop itself on every iteration: if it stops moving, the loop has stopped, even if everything else responds.

  4. A classic histogram has buckets at 0.2 and 0.3 s and all latencies are 0.22 s. What does histogram_quantile(0.95, …) return?

    • A0.22 s, the real value
    • B0.295 s: it interpolates linearly inside the 0.2 to 0.3 bucket as if the observations were spread uniformly
    • C0.3 s, the upper bound
    • DNaN, because the quantile falls in a single bucket
    Show answer

    Answer B. 0.295 s: it interpolates linearly inside the 0.2 to 0.3 bucket as if the observations were spread uniformly

    The function only knows how many observations fall in each bucket and assumes they are spread uniformly inside it. It is the example in the Prometheus documentation: 295 ms estimated versus 220 ms real. Buckets are chosen around the thresholds that matter.

  5. During an outage with the broker, BrokerDesconectado (critical) fires along with three warnings for the same account: heartbeat lost, orders without acknowledgment and delayed events. What does the lesson's inhibition rule do?

    • AIt silences the three warnings for that account while the critical alert is active: one notification arrives instead of four
    • BIt deletes the three warnings from Prometheus
    • CIt delays the critical alert until the warnings are resolved
    • DIt silences the warnings for all accounts
    Show answer

    Answer A. It silences the three warnings for that account while the critical alert is active: one notification arrives instead of four

    Inhibition mutes the warnings that are a consequence of a more serious alert with the same equal labels (here, cuenta). The alerts still exist in Prometheus; what changes is what gets notified.

Key points

  • An orchestrator adds three things to cron: dependencies (what fails upstream does not run downstream), retries per task and a state per partition that makes it possible to rerun a night without redoing what already finished.
  • Each task writes its whole partition or nothing (temporary file and os.replace) and checks whether it is already done: that way a backfill can be launched twice. Orders do not go into a graph with retries.
  • JSON logs with context (account, signal, order) are queried like a table: the incident of signal S00215 is reconstructed by filtering a field, not by searching text.
  • Metrics: counters for what accumulates, gauges for what goes up and down, histograms for latencies; no labels with unbounded values, such as the order id.
  • for: 1m counts from the first evaluation that sees the condition: with scraping every 15 s the real threshold ranges from 60 to 75 s, and the 72 s outage of November 13 did not fire.
  • A live process is not a live loop: the loop writes the heartbeat, an external watchdog warns if it stops arriving, and Alertmanager groups and inhibits so that one outage is one notification.

Resources