{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "---\n", "title: \"Part 21: Logging for ML Projects\"\n", "---" ] }, { "cell_type": "markdown", "id": "1", "metadata": {}, "source": [ "[](https://colab.research.google.com/github/sambaiga/ds-mlops-path/blob/main/tutorials/02-dev-tools/09-logging.ipynb) [](https://raw.githubusercontent.com/sambaiga/ds-mlops-path/main/tutorials/02-dev-tools/09-logging.ipynb)" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "**DS-MLOps Dev Tools**\n", "\n", "**Python 3.12+ | Author: Anthony Faustine**\n", "\n", "## Before you begin\n", "\n", "This notebook assumes you have completed [Part 19: Data Validation with Pydantic](07-pydantic-validation.ipynb). Logging is the complement to validation: Pydantic stops bad data at the boundary; logging tells you what happened after it was let in.\n", "\n", "The `grade-predictor` project appears throughout as a concrete reference. You will see how a library, an experiment script, a production API, and a notebook each use a different tool from the same logging ecosystem.\n", "\n", "> Callout markers used throughout this notebook are explained on the [book cover page](../../index.qmd#callout-guide).\n", "\n", "::: {.callout-note collapse=\"true\" icon=false}\n", "## Topics covered\n", "\n", "| Topic | Why it matters |\n", "| --- | --- |\n", "| **Python logging hierarchy** | Understand loggers, handlers, formatters as one system |\n", "| **NullHandler pattern** | Write library code that never hijacks the caller |\n", "| **Handlers and formatters** | Route logs to console and file with different detail levels |\n", "| **LogContext and log_section** | Attach context to a block of calls without string formatting |\n", "| **Loguru** | Experiment scripts with half the boilerplate |\n", "| **structlog** | Structured JSON logs for production services |\n", "| **Rich handler** | Beautiful notebook and CLI output |\n", ":::" ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "::: {.callout-note collapse=\"true\" icon=false}\n", "## Learning Objectives\n", "\n", "By the end of Part 21 you will be able to:\n", "\n", "| # | Skill | Covered in |\n", "| --- | --- | --- |\n", "| 1 | Explain why `print()` is not a substitute for logging in production code | Sec. 0 |\n", "| 2 | Use `get_logger(__name__)` and `NullHandler` to write library-safe logging | Sec. 2 |\n", "| 3 | Build a `configure()` function with colour console and file handlers | Sec. 3 |\n", "| 4 | Attach per-call context with `LogContext` and mark pipeline stages with `log_section` | Sec. 4 |\n", "| 5 | Replace stdlib ceremony with loguru in experiment scripts | Sec. 5 |\n", "| 6 | Emit structured JSON logs with structlog for production services | Sec. 6 |\n", "| 7 | Drop in `RichHandler` for pretty notebook output | Sec. 7 |\n", "| 8 | Choose the right tool for each layer of an ML project | Sec. 8 |\n", ":::" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "## 0. The Print That Vanished\n", "\n", "It is 11 PM. A 12-hour cross-validation run finishes. You open the terminal to check results. There is nothing there, because the window scrolled past the output hours ago. You did not redirect stdout to a file. Fold 3 crashed with a shape mismatch, silently recovered to a default, and corrupted the metric average. You have no record of when it happened, which model it was, or what the stack trace said.\n", "\n", "This is the `print()` problem. It is not that `print()` is slow or ugly. It is that `print()` has no:\n", "\n", "- **Severity**: you cannot ask \"show me only errors\" after the fact\n", "- **Routing**: you cannot send errors to a file while keeping the console quiet\n", "- **Timestamps**: you cannot tell which fold took 40 minutes\n", "- **Persistence**: if the terminal closes, the output is gone\n", "- **Silencing**: you cannot turn off a dependency's output without patching its source\n", "\n", "Python's logging module solves all five. You do not need to replace every `print()` with a log call. You need to understand when you are debugging versus when you are operating, and log accordingly.\n", "\n", "### Install\n", "\n", "Python's `logging` module is stdlib. The other tools used in this notebook:\n", "\n", "```bash\n", "uv add loguru structlog rich\n", "```" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "## 1. Python's Logging Hierarchy" ] }, { "cell_type": "raw", "id": "6", "metadata": { "raw_mimetype": "text/markdown" }, "source": [ "```{mermaid}\n", "flowchart LR\n", " A[\"your code\\nlog.info('Computing grades')\"] --> B[\"Logger\\n'grade_predictor.core'\"]\n", " B -->|\"propagate=True\"| C[\"Logger\\n'grade_predictor'\"]\n", " C -->|\"propagate=True\"| D[\"Logger\\n'' (root)\"]\n", " D --> E[\"Handler\\nStreamHandler / FileHandler\"]\n", " E --> F[\"Formatter\\n'%(asctime)s %(levelname)s %(message)s'\"]\n", " F --> G[\"output\\nconsole / file\"]\n", "\n", " style A fill:#EAF3FA,stroke:#0369A1,color:#0C4A6E\n", " style B fill:#F5F3FF,stroke:#7C3AED,color:#3B0764\n", " style G fill:#EBF5F0,stroke:#059669,color:#065F46\n", "```" ] }, { "cell_type": "markdown", "id": "7", "metadata": {}, "source": [ "Python's logging system has four objects that work together as one pipeline:\n", "\n", "- **Logger**: the object your code calls (`log.info(...)`, `log.warning(...)`). Every logger has a name, typically `__name__`, which creates a dotted hierarchy: `grade_predictor.core` is a child of `grade_predictor`, which is a child of the root logger.\n", "- **Handler**: decides where a log record goes: console, file, HTTP endpoint. A logger can have multiple handlers.\n", "- **Formatter**: decides how a record looks: plain text, JSON, coloured terminal output.\n", "- **Level**: a numeric threshold (`DEBUG=10`, `INFO=20`, `WARNING=30`, `ERROR=40`, `CRITICAL=50`). Records below the threshold are discarded.\n", "\n", "The key insight is **propagation**: when `grade_predictor.core` logs a record, it walks up the hierarchy towards the root logger, passing the record to every handler it encounters. This means:\n", "\n", "- The same `log.info(\"Computing grades\")` inside `grade_predictor.core` can reach a console handler on the root logger AND a file handler on `grade_predictor`, with different formatters on each\n", "- A caller can silence `grade_predictor.core` by setting its level to `CRITICAL`, without touching any code inside the library\n", "- A library can opt out of all output by default, leaving every routing decision to the caller\n", "\n", "Here is the minimal configuration most tutorials show first:" ] }, { "cell_type": "code", "execution_count": null, "id": "8", "metadata": {}, "outputs": [], "source": [ "import logging\n", "\n", "# basicConfig attaches a StreamHandler to the root logger\n", "logging.basicConfig(level=logging.INFO, format=\"%(asctime)s %(levelname)s %(message)s\")\n", "\n", "log = logging.getLogger(__name__)\n", "log.info(\"Training started\")\n", "log.warning(\"Fold 3 has only 12 samples\")\n", "log.debug(\"This is below INFO threshold -- not shown\")" ] }, { "cell_type": "markdown", "id": "9", "metadata": {}, "source": [ "This works for a script you run yourself. The problem is `basicConfig()`. It configures the **root logger** at import time, which means every library in your Python process that uses `logging` will suddenly emit output through your format. Section 2 explains why library code must never call it." ] }, { "cell_type": "markdown", "id": "10", "metadata": {}, "source": [ "## 2. The Library/Application Boundary" ] }, { "cell_type": "raw", "id": "11", "metadata": { "raw_mimetype": "text/markdown" }, "source": [ "```{mermaid}\n", "flowchart LR\n", " subgraph lib [\"Library side (grade_predictor, any pip package)\"]\n", " L1[\"logging.getLogger(__name__)\"]\n", " L2[\"NullHandler\\n(silent by default)\"]\n", " L1 --> L2\n", " end\n", " subgraph app [\"Application side (script, notebook, service)\"]\n", " A1[\"configure()\\ncalled once at startup\"]\n", " A2[\"StreamHandler\\n+ ColourFormatter\"]\n", " A3[\"FileHandler\\n+ PlainFormatter\"]\n", " A1 --> A2\n", " A1 --> A3\n", " end\n", " lib -->|\"log records propagate\"| app\n", "\n", " style lib fill:#FEF2F2,stroke:#DC2626,color:#991B1B\n", " style app fill:#EBF5F0,stroke:#059669,color:#065F46\n", "```" ] }, { "cell_type": "markdown", "id": "12", "metadata": {}, "source": [ "The single most important rule in Python logging is the **library/application boundary**.\n", "\n", "A **library** is any code you publish as a package: `grade_predictor`, or any pip-installable project. Its job is to be imported and used. It should never decide where its log output goes, because its caller's environment is unknown. The correct pattern at the library level:\n", "\n", "1. In `__init__.py`, add a `NullHandler` to the top-level logger. This silences the \"No handler found\" warning without emitting any actual output.\n", "2. In every module, use `logging.getLogger(__name__)` to get a logger named after the module's dotted path.\n", "3. Never call `logging.basicConfig()` anywhere in library code.\n", "\n", "An **application** is code you run: a training script, a notebook, a FastAPI service. It owns the process and decides where logs go. It calls `configure()` once at startup to attach real handlers.\n", "\n", "Here is how `grade_predictor` would be structured:" ] }, { "cell_type": "code", "execution_count": null, "id": "13", "metadata": {}, "outputs": [], "source": [ "import logging\n", "\n", "# grade_predictor/__init__.py -- the only NullHandler call in the entire library\n", "logging.getLogger(\"grade_predictor\").addHandler(logging.NullHandler())\n", "\n", "\n", "# grade_predictor/core.py -- every module gets its own logger\n", "log = logging.getLogger(\"grade_predictor.core\")\n", "\n", "\n", "def fit(X, y):\n", " log.info(\"Fitting model on %d samples\", len(X))\n", " # ... training logic ...\n", " log.debug(\"Gradient norm: %.4f\", 0.0023) # detail only visible at DEBUG level\n", " log.info(\"Fit complete\")\n", "\n", "\n", "# Without configure(), this produces no output -- the NullHandler swallows everything\n", "fit([1, 2, 3], [0, 1, 0])" ] }, { "cell_type": "markdown", "id": "14", "metadata": {}, "source": [ "
logging.basicConfig() inside a library module silently configures the root logger the moment the library is imported. Every other library and application in the same process suddenly emits output through your format, at your level, with no way for the caller to undo it without reaching into your code. This is one of the most common complaints about Python logging in third-party packages. The fix is one line: logging.getLogger(\"yourlib\").addHandler(logging.NullHandler()) in __init__.py, and nothing else.\n",
"root.setLevel(logging.DEBUG) and applying level thresholds on the individual handlers means you only need one configure() call to achieve: console shows INFO and above, file captures everything from DEBUG upwards. If you set the root level to INFO instead, DEBUG records are discarded before they reach any handler, even the file handler that wanted them. Always set the root to the lowest level any handler needs.\n",
"train_fold(fold: int, epochs: int, model_name: str) -> None that simulates training by logging one line per epoch. Wrap the function body in a LogContext so that every log line automatically carries fold, model, and epochs. Call it for folds 1 and 2 with model \"LSTM\" and confirm the context fields appear in every line.\n",
"log = logging.getLogger(\"grade_predictor.core\")\n",
"\n",
"def train_fold(fold: int, epochs: int, model_name: str) -> None:\n",
" with LogContext(log, fold=fold, model=model_name, epochs=epochs):\n",
" for epoch in range(1, epochs + 1):\n",
" log.info(\"epoch %d complete\", epoch)\n",
"logger is a global singleton shared across your entire process. If you use it inside a library (grade_predictor, ark, any package you pip-install), your library will emit log output the moment it is imported, in your format, at your level, with no way for the caller to silence it. This is the same problem as calling basicConfig() in a library, but worse because loguru does not integrate with the stdlib propagation chain. Use loguru only in code you run yourself: scripts, notebooks, and applications you own end to end.\n",
"processors list is a pipeline: each processor receives the event dict, transforms it, and passes it on. The last processor is the renderer. To switch from JSON (production) to pretty-printed console output (development), swap only the last processor: structlog.dev.ConsoleRenderer() instead of structlog.processors.JSONRenderer(). Nothing else in your code changes, because the calling convention (log.info(\"event\", key=value)) is identical regardless of output format.\n",
"RichHandler is a stdlib logging.Handler subclass, so it obeys the library/application boundary perfectly: add it in your configure() call (application side) and never in library code. For notebooks, the simplest setup is logging.basicConfig(handlers=[RichHandler(rich_tracebacks=True)]) at the top of your notebook. This replaces the default handler and gives you pretty output for the rest of the session.\n",
"grade_predictor library. The mock library should use NullHandler and getLogger(__name__). The notebook cell should use RichHandler. Write a separate script string (as a Python string literal) showing how an experiment script would use loguru to import and call the library. Write a second script string showing how a FastAPI endpoint would use structlog. You do not need to run the scripts, just define them clearly as code strings.\n",
"