
In Part 40 you debugged by watching — tracebacks, print(), the VS Code debugger. That works while you sit in front of the code. But in production you can't: the program runs on a server, ran days ago, with nobody watching. Logging is how a program tells you what happened — after it happened.
The one idea: logging is
print()with a volume dial. You leave all the messages in your code permanently, each tagged with a severity level, and one setting controls how much you see — everything in development, only the important parts in production.
print() problem | Logging fixes it with |
|---|---|
| no severity — can't tell info from disaster | 5 levels: DEBUG, INFO, WARNING, ERROR, CRITICAL |
| no timestamp — when did it happen? | %(asctime)s |
| all-or-nothing — can't filter | the level dial |
| vanishes when the program exits | write to a file |
| must hunt and delete every debug print | leave them in; the dial hides them |
Every message gets a severity, from lowest to highest:
| Level | Value | Use for |
|---|---|---|
DEBUG | 10 | detailed diagnostics (development only) |
INFO | 20 | normal events — started, user logged in |
WARNING | 30 | unexpected but not broken — low disk, deprecated call |
ERROR | 40 | something failed, app continues — API call failed |
CRITICAL | 50 | severe — app may not continue — database down |
import logging
logging.debug("x = 42")
logging.info("user 'alice' logged in")
logging.warning("config missing, using defaults")
logging.error("failed to save record")
logging.critical("database is down")
The dial = the lowest level you want to see. It shows that level and everything above it. So lowering the dial shows more, not fewer errors:
| Dial set to | You see |
|---|---|
DEBUG | everything — DEBUG, INFO, WARNING, ERROR, CRITICAL |
INFO | INFO and above (errors still show — only DEBUG is hidden) |
WARNING (default) | WARNING, ERROR, CRITICAL |
ERROR | ERROR, CRITICAL only |
Two things to lock in: the default dial is WARNING (so INFO and DEBUG are hidden until you lower it), and setting the dial to INFO never hides your errors — ERROR is higher than INFO, so it always gets through. The dial only ever hides the less serious levels below it.
import logging
logging.basicConfig(
level=logging.INFO, # the dial
format="%(asctime)s — %(levelname)s — %(message)s",
filename="app.log", # omit this → log to console
filemode="a", # append (default)
encoding="utf-8",
)
logging.info("Application started")
# 2026-03-19 14:30:15 — INFO — Application started
Main knobs: level (the dial), format, and filename/filemode (file vs console).
Common format placeholders: %(asctime)s time · %(levelname)s level · %(name)s logger · %(message)s text · %(filename)s / %(lineno)d source location.
Gotcha: a bare
filename="app.log"is created in the current working directory — wherever you ranpythonfrom, not where the.pyfile lives (the Part 38 path lesson). For anything real, give an absolute path (often from an env var likeLOG_FILE).basicConfigis also for simple scripts — it only configures logging once; see the production setup below.
Above we called logging.info() directly — that uses the one shared root logger. Real projects instead give every file its own logger, named after the module:
# utils.py
import logging
logger = logging.getLogger(__name__) # name becomes "utils"
Now each line shows which module produced it — essential when 50 files all log into one place. Use getLogger(__name__) at the top of every module; never call logging.info() directly in real code.
This is the direct upgrade from Part 40. Using the module logger from above, the debug lines now stay in the code forever:
import logging
logger = logging.getLogger(__name__)
def calculate_total(items):
logger.debug(f"items received: {items}") # shown only when dial = DEBUG
total = sum(i["price"] * i["quantity"] for i in items)
logger.info(f"calculated total: {total}") # shown in production too
return total
Development → set the dial to DEBUG, see everything. Production → set it to INFO, the debug lines are silently skipped. No code changes, no deleting prints.
Want full debug detail in a file but only important lines on screen? Handlers route messages to different places, each with its own dial:
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) # MASTER gate — let every level through; handlers filter next
# Handler 1 → the screen: keep it clean, only the important lines
console = logging.StreamHandler()
console.setLevel(logging.INFO) # screen shows INFO and above (hides DEBUG noise)
# Handler 2 → a file: keep the full story for later
file = logging.FileHandler("app.log", encoding="utf-8")
file.setLevel(logging.DEBUG) # file keeps EVERYTHING, down to DEBUG
# One shared format, attached to both handlers
fmt = logging.Formatter("%(asctime)s — %(levelname)s — %(name)s — %(message)s")
console.setFormatter(fmt)
file.setFormatter(fmt)
logger.addHandler(console) # plug both handlers into the logger
logger.addHandler(file)
Why two different levels? Every message passes through two gates: first the logger's level, then each handler's level. The logger is the master gate — set it to DEBUG so nothing is blocked early; then each handler decides what it keeps. That's how the same logger.debug(...) line can land in the file but stay off the screen: the file handler's dial is DEBUG, the console handler's is INFO.
If you set the logger to
INFO, DEBUG messages die at the first gate and never reach the file — even though the file handler asked for DEBUG. Master gate first, handler gates second.
Before sending logs anywhere fancy, understand where they go right now when you run the script. Your program doesn't write to "the screen" — it writes to two numbered channels the OS hands every program (remember Part 36: print() really writes to sys.stdout, a file connected to your terminal — and Part 38's f.fileno() returns that channel's number):
| Channel | Number | Carries | Written by |
|---|---|---|---|
| stdout | 1 | the program's real results | print() |
| stderr | 2 | errors, warnings, logs | logging, tracebacks |
Both show on screen by default, so they look the same — until you redirect them by number:
python 06_production.py > out.txt # channel 1 only — just print() output (logs stay on screen)
python 06_production.py 2> errs.txt # channel 2 only — just the logs (print output stays on screen)
python 06_production.py > all.txt 2>&1 # both — "send channel 2 to wherever channel 1 goes"
Gotcha: Python's logging writes to stderr (2), not stdout. So a plain
> out.txtcaptures yourprint()results but not your logs — you need2>or2>&1for those.
Why this matters next: in the cloud you never type > by hand. Your app just writes to stdout/stderr, and the platform captures both automatically (like a built-in 2>&1) and ships them away. That one idea — the app just writes; something outside decides where it goes — is the whole foundation of the production setup below.
basicConfig is fine for scripts. Real apps need two more things, and there are two standard patterns.
Problem 1 — a plain log file grows forever and fills the disk. Fix: a rotating handler that caps the size and keeps a few old files:
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"/var/log/myapp/app.log", # absolute path, not CWD
maxBytes=10_000_000, # 10 MB per file
backupCount=5, # keep 5 old files, then delete the oldest
encoding="utf-8",
)
Problem 2 — configuring handlers by hand gets messy. Fix: dictConfig — one dictionary (often loaded from a YAML/JSON file, your Part 39 skills) that defines levels, formats, and handlers in one place. This is the production standard.
The two real-world patterns (which one you pick depends on where the app runs):
| Where it runs | What you do | Read more |
|---|---|---|
| Containers / cloud (Docker, Kubernetes) | log to the console (no file); the platform collects stdout — the idea from the section above | 12-Factor logs · Kubernetes logging |
| Traditional server | write to an absolute path with a RotatingFileHandler so the disk never fills | RotatingFileHandler docs |
And the dial stays in config, not code — level=os.getenv("LOG_LEVEL", "INFO") — so you change it and restart, no redeploy.
As we just saw, in the cloud your app only writes to the console (stdout) — it doesn't open log files. A log platform then grabs every line, stores it, and lets you search, filter, and alert across thousands of servers in one place. The big ones:
All three do the same core job: take your console logs and organize them so you can search and alert — no SSH-ing into servers to read files.
Read this top to bottom — each row adds one piece, from your bare script to a fully monitored production app:
| Stage | What you add | Who sends the logs / data | What you get |
|---|---|---|---|
| 1. Your code | the script itself (06_production.py) | nobody — print() only | output flashes on screen, then it's gone |
| 2. Logging | Python logging (levels + handlers) | the app writes to console + logs/app.log | timestamped, leveled logs saved to a file |
| 3. + Grafana Loki (you push) | the python-logging-loki library | the app itself pushes logs to Loki | logs in an online Grafana dashboard, searchable |
| 4. Production (an agent pushes) | an **agent: Promtail / Grafana Alloy** | the app just writes to the console; the agent reads it and sends to Loki | same dashboard — but the app stays fast & safe |
| 5a. Beyond logs — metrics | Prometheus | it collects numbers from your app (CPU, requests/sec) | graphs of how the system is performing |
| 5b. Beyond logs — errors | Sentry | it catches exceptions / crashes with the full traceback | instant alerts — "what broke, where, and how often" |
Three signals, three tools: Loki = logs (text) · Prometheus = metrics (numbers) · Sentry = errors (crashes) — all viewed around Grafana. The next sections walk through stages 3 and 4; stages 5a/5b are a future topic — monitoring.
So far our logs live on screen and in logs/app.log. In real teams they land in a web dashboard you can search and filter. The journey is always the same three stops:
your code ──► Loki (stores the logs) ──► Grafana (the dashboard you search)
Loki is the storage; Grafana is the screen. The only thing that changes between a simple setup and production is who delivers the logs to Loki.
There's a tiny library that turns "send to Loki" into just another handler (the same addHandler pattern from Files 05/06). The runnable version is 07_grafana_loki.py.
Step 1 — install the library:
uv add python-logging-loki # or: pip install python-logging-loki
Step 2 — get your Loki URL + User (inside Grafana): sign up / log in at grafana.com (free tier), then open your Grafana stack and go to Connections → Data sources:
grafanacloud-<stack>-logs).https://logs-prod-XXX.grafana.net → your push URL is that + /loki/api/v1/push.1660256) — this is your username.Step 3 — create the token (grafana.com portal): go to Security → Access Policies:
**logs:write** scope → Create.Watch out: don't use the Grafana instance ID — Loki has its own numeric User shown on the data-source page. Keep the token in
.env(LOKI_TOKEN), not hard-coded.
Step 4 — add the handler (the runnable file reads these from .env):
import logging_loki
logging_loki.emitter.LokiEmitter.level_tag = "level" # so Grafana color-codes by level
loki = logging_loki.LokiHandler(
url="https://logs-prod-XXX.grafana.net/loki/api/v1/push", # URL + /loki/api/v1/push
tags={"app": "part41"}, # query later with {app="part41"}
auth=("USER_ID", "API_TOKEN"), # (numeric User, access-policy token)
version="1",
)
logger.addHandler(loki)
Step 5 — run it and look: uv run 07_grafana_loki.py, then in Grafana → Explore → pick your Loki data source → set the time picker to Last 15 minutes → run a query:
| Use case | Query |
|---|---|
| All logs | {app="part41"} |
| Errors only | `{app="part41"} |
| Search text | `{app="part41"} |
The same INFO/ERROR lines appear online, searchable.
Here the app itself opens a connection and pushes every log — quick to set up, but the app is now doing delivery work too.
In production we flip it. The app does not push to Loki. It only writes to stdout (the 2>&1 idea above) and forgets. A separate program — an agent — reads that output and ships it to Loki. The app never waits on the network.
You push it: app ──(pushes over HTTP itself)──► Loki ──► Grafana
Production: app ──writes to stdout──► [agent] ──► Loki ──► Grafana
a separate program, not your code
The agent is a ready-made tool you install and run next to your app — you don't write it:
Why flip it? Three plain reasons:
| App pushes its own logs | Production (agent delivers) | |
|---|---|---|
| Speed | every log = an HTTP call inside the app | writing to stdout is instant; the agent does the network work |
| Safety | if Loki is down, the app's logging suffers | the agent buffers & retries; the app keeps running |
| Flexibility | change log system → change app code | swap Loki for Datadog → the app never changes |
Name trap: the log agent is Promtail / Alloy — not Prometheus. Prometheus is the metrics (numbers) sibling — CPU %, requests/sec — and it also feeds Grafana, but it has nothing to do with shipping your text logs. Same family, different jobs.
The one line for it all: who delivers the logs — the app, or a separate agent? When you push them yourself, the app does it. In production, an agent does it. Everything else (Loki, Grafana, searching, filtering) is identical.
Inside an except block (Parts 33–35), logger.exception() logs your message plus the full traceback from Part 40 — the single most useful line in production:
def process_file(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
logger.error(f"file not found: {path}") # message only — expected case
return None
except Exception:
logger.exception(f"unexpected error reading {path}") # message + full traceback
raise
The log then contains the exact bottom-up traceback you learned to read in Part 40 — but captured automatically, with no one watching:
2026-03-19 14:30:15 — ERROR — unexpected error reading data.csv
Traceback (most recent call last):
File "app.py", line 9, in process_file
return f.read()
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff ...
logger.exception() only works inside an except block. Anywhere else, use logger.error("...", exc_info=True).
# 1. Include context — what, who, why
logger.error(f"request failed — user_id={uid}, endpoint={url}, status={code}")
# 2. Match the level to the severity
logger.info("user logged in") # not WARNING
logger.critical("database is down") # not WARNING
# 3. NEVER log secrets
logger.debug(f"api key: {api_key[:4]}****") # masked, never the full key
Passwords, tokens, API keys, personal data — never in logs. Logs are shared and stored where many people can read them.
ERROR/CRITICAL and fire alerts.Add logging to your note app from Part 36:
add_note() → INFO; view_notes() → DEBUG (note count); clear_notes() → WARNING (destructive); file-not-found → ERROR; unexpected errors → logger.exception().INFO) and file notes_app.log (DEBUG), using a module-level logger.%(asctime)s — %(levelname)s — %(message)s.notes_app.log and confirm: DEBUG appears only in the file, INFO and above in both, and the log tells the complete story of the run.Save as src/notes_app_logged.py.
Phase 4 complete. Your code now handles errors, reads/writes every file format (text, JSON, JSONL, CSV, Markdown, PDF, Excel, images), debugs systematically, and logs for production.
Encapsulation Truth No One Taught You | OOP Master Flow | Part-44
Part 44
Encapsulation Truth No One Taught You | OOP Master Flow | Part-44
Part 44