Errors & Robustness

Logging

Logging

When an agent misbehaves in production, you cannot ask it what it was thinking. You can only read what it wrote down. Logging is how it writes things down.

This page is short because the library is simple. It matters because the alternative — print everywhere — falls apart the moment anything runs unattended.

Why not just use print

print is fine while you are learning. It stops being fine quickly.

printlogging
Switch off in productiondelete every linechange one setting
Levels of importancenonedebug, info, warning, error
Timestampsyou add them by handautomatic
Where it goesthe screen, alwaysscreen, file, or a log service
Which module it came fromunknownrecorded

The deciding one is the first. You will scatter print calls while debugging, and then either leave them in, where they clutter production output, or hunt them all down. With logging you change one line and the noise disappears.

The smallest useful setup

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)

logger = logging.getLogger(__name__)

logger.info("agent started")
logger.warning("retrying after timeout")
logger.error("tool call failed")
2026-09-02 09:14:01,203 INFO myagent.graph - agent started
2026-09-02 09:14:02,551 WARNING myagent.graph - retrying after timeout

Two habits in that snippet are worth copying exactly:

  • basicConfig once, at the entry point of your program, never in a library module
  • getLogger(__name__) in each module, so every line records where it came from

The levels, and what each is for

LevelUse it for
debugdetail you want while diagnosing — full prompts, raw responses
infothe normal story — started, finished, chose this tool
warningsomething unexpected but survivable — a retry, a fallback
errorsomething failed and you could not recover
criticalthe process cannot continue

Setting level=logging.INFO means debug messages are computed but not shown. Switch to logging.DEBUG while investigating and the detail appears with no code change. That is the entire point of levels.

Logging an exception properly

try:
    result = call_tool(name, args)
except Exception:
    logger.exception("tool %s failed", name)

logger.exception records the message and the full traceback, and it only works inside an except block. It is almost always what you want there — logger.error alone tells you something failed but not where.

Note the comma rather than an f-string. Passing %s and the value separately means the string is only built if the message is actually emitted, and log services can group identical messages. It is a small habit with real benefit at volume.

Writing to a file

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s - %(message)s",
    handlers=[
        logging.FileHandler("agent.log", encoding="utf-8"),
        logging.StreamHandler(),
    ],
)

That writes to both the file and the screen. Note encoding="utf-8" on the file handler, for the same reason as everywhere else.

What to log in an agent

Agents are hard to debug because the interesting decisions happen inside a model. Log the boundaries.

logger.info("run started | question=%s", question)
logger.info("model chose tool=%s args=%s", tool_name, args)
logger.info("tool returned | tool=%s chars=%d", tool_name, len(result))
logger.warning("retry %d of %d", attempt, max_attempts)
logger.info("run finished | steps=%d tokens=%d", steps, tokens)

A run's log should let you answer, without re-running anything:

  • what was asked
  • which tools were chosen, in what order, with what arguments
  • what each tool gave back
  • how many steps it took, and why it stopped
  • what it cost

That last one matters more than beginners expect. An agent that loops is an agent spending money, and the log is where you notice.

Never log secrets

logger.info("calling API with key=%s", api_key)

Do not do this. Logs get copied into tickets, pasted into chats, and shipped to third-party services. An API key in a log is a leaked key.

The same applies to personal data. Log the ticket id, not the customer's full record.

logger.info("calling API | key=***%s", api_key[-4:])

Turning down a noisy library

Some libraries log heavily at INFO. Quiet them individually rather than raising your own level:

logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)

Logging is not tracing

Worth being clear about, because they solve neighbouring problems.

  • Logging is a flat list of lines you wrote deliberately
  • Tracing records the nested structure of a run — this call inside that step inside this run — with timings and token counts

For agents you will eventually want both, and tools like LangSmith give you the second. Logging is what you have on day one, works everywhere, and needs no account.

Coming from Java or C#

This will be familiar territory.

ConceptJavaC#Python
LibrarySLF4J + Logback, Log4jILogger, Seriloglogging, built in
Get a loggerLoggerFactory.getLogger(X.class)injected ILogger<T>logging.getLogger(__name__)
Levelstrace, debug, info, warn, errorsamedebug, info, warning, error, critical
ConfigurationXML or properties fileappsettings.jsonbasicConfig, or dictConfig
Placeholders{}{Name}%s

Two differences: Python's logging is in the standard library, so there is nothing to add; and configuration is usually code rather than a file, which is simpler for small projects and less tidy for large ones.

Common mistakes

  • print in code that runs unattended.
  • basicConfig in a library module, which hijacks the configuration of whatever imports it.
  • logger.error inside except where logger.exception would have given you the traceback.
  • f-strings in log calls, which build the message even when it will not be shown.
  • Logging secrets or personal data.
  • Logging nothing at all until something breaks, at which point there is nothing to read.

Practise this

  • Set up basicConfig and log one line at each of the five levels. Change the level to WARNING and see which survive.
  • Catch an exception and log it with logger.exception. Confirm the traceback appears.
  • Add logging to a function that calls something external, recording what went in and what came back.
  • Write a log line that includes only the last four characters of a secret.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# Logging, not print. One setting turns the noise off in production.
2
3import logging
4
5logging.basicConfig(
6 level=logging.INFO, # change to DEBUG to see more, no code change
7 format="%(asctime)s %(levelname)-8s %(name)s - %(message)s",
8 force=True, # so it works when re-run in a notebook
9)
10
11logger = logging.getLogger("myagent.graph") # normally logging.getLogger(__name__)
12
13# --- The levels ---
14logger.debug("full prompt: you are a triage assistant...") # hidden at INFO
15logger.info("agent started")
16logger.warning("retrying after timeout")
17logger.error("tool call failed")
18logger.critical("cannot continue")
19print()
20
21# --- Logging an exception properly ---
22def risky():
23 return 10 / 0
24
25try:
26 risky()
27except ZeroDivisionError:
28 logger.exception("tool %s failed", "calculator") # includes the traceback
29print()
30
31# Note the comma, not an f-string: the message is only built if it is emitted,
32# and log services can group identical messages.
33name = "get_weather"
34logger.info("model chose tool=%s", name)
35
36# --- What to log in an agent: the boundaries ---
37question = "why is the server down?"
38tool_name, args, result = "get_ticket", {"ticket_id": 4471}, "P2 - network"
39
40logger.info("run started | question=%s", question)
41logger.info("model chose tool=%s args=%s", tool_name, args)
42logger.info("tool returned | tool=%s chars=%d", tool_name, len(result))
43logger.warning("retry %d of %d", 1, 3)
44logger.info("run finished | steps=%d tokens=%d", 3, 812)
45print()
46
47# --- NEVER log secrets ---
48api_key = "sk-proj-8f3a91c04b7e4d2fa6c15e08b39d7a42"
49# logger.info("key=%s", api_key) # <- never do this
50logger.info("using key ***%s", api_key[-4:]) # this is fine
51print()
52
53# --- Quieten a noisy library rather than raising your own level ---
54logging.getLogger("httpx").setLevel(logging.WARNING)
55
56# --- Turning the level up to see debug output ---
57logger.setLevel(logging.DEBUG)
58logger.debug("now you can see debug lines, with no code change")
59
60# --- Writing to a file as well as the screen ---
61# logging.basicConfig(
62# level=logging.INFO,
63# format="%(asctime)s %(levelname)s %(name)s - %(message)s",
64# handlers=[
65# logging.FileHandler("agent.log", encoding="utf-8"),
66# logging.StreamHandler(),
67# ],
68# force=True,
69# )