Test and Observe

Build a Chatbot: From API Call to Deployed Bot

Chapter 11 ยท Logging, Testing and Observability

A bot that works on your laptop will, once deployed, do things you did not plan for, to people you cannot see, while you are asleep. Two habits decide whether you find out: observing the running bot (what did it do, how long did it take, what did it cost, did anything go wrong) and testing it so that changes do not quietly break it. Both have a trap specific to chatbots: the most useful thing to record is what people said, and that is exactly what you should not keep.

What was run, and what was not
Everything ran locally on Python 3.14.0 and the project now has 268 passing tests. The log lines, metrics and alerts shown are real output from the real code, driven by the fake provider, so the numbers (such as zero seconds) are those of a fake, not of a real model. No log was shipped anywhere, no dashboard exists, and nothing was tested against a real model. The one external source is OWASP's Logging Cheat Sheet, which I read; the Telegram, Slack and Discord facts reused here come from earlier chapters.

What You Want to Know About a Running Bot

  • What happened to this one message? A person says "it ignored me". You need the trail for that one interaction, through the channel, the core and the provider.
  • Is it getting worse? Error rate, slowness, a provider that has started failing, a spike of refused messages.
  • What is it costing? Tokens and money, per day (chapter 7 already records them).
  • Did something go wrong that nobody saw? The catch-all handlers in the adapters keep the bot running when one event is malformed; without a log, that failure is silent. Chapter 10's first Slack demo was silent for exactly that reason.

What OWASP Says to Log, and Not to

OWASP's Logging Cheat Sheet (read for this chapter) gives rules that translate directly:

OWASP guidance (condensed)In this bot
Log validation failures, authorisation failures, application errors, attempts to exceed limits, but treat validation failures as "suspicious events, not proof of an attack"Every flag the bot has recorded (input_too_long, moderated:..., rate_limited, muted, event_error...) is now a log line at INFO, and caught exceptions are at ERROR
Do not log session ids (or hash them), access tokens, passwords, keys, payment data, sensitive personal data; remove, mask, hash or pseudonymise itSession and user ids are logged only as keyed hashes (pseudonyms); tokens and keys are scrubbed; message text and replies are never logged
Sanitise untrusted event data against log injection (CR, LF and delimiters); encode for the log formatOne JSON object per line: JSON escapes newlines and control characters, so a message cannot start a fake log line (demonstrated below)
An interaction identifier should link all events from one user interaction; use a consistent formatA correlation id per message, carried by contextvars, on every line
Protect logs in transit and at rest, detect tampering, restrict and review accessNot done here: file permissions, shipping and tamper detection are deployment topics (chapter 12)
Keep logs no longer than requiredChoose a log retention that is no longer than your message retention (30 days by default); rotating files do this
Logging failures must not crash the application or leak informationObservers are wrapped so a failing observer cannot break a message; exceptions are logged by class name only
Synchronise timeTimestamps are UTC with milliseconds; keep the server's clock synchronised

The Code

"""Structured logging, correlation ids, metrics and alerts: knowing what the bot is doing without recording what people said.""" import contextlib import contextvars import hashlib import hmac import json import logging import re import secrets import time from datetime import datetime, timezone from bot.privacy import redact from bot.providers import Message, Provider, ProviderError, Reply correlation_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("correlation_id", default=None) # --- correlation ids ---------------------------------------------------------------------------------------------- @contextlib.contextmanager def correlation(prefix: str = "msg"): """Give everything that happens inside this block (including awaited calls and tasks it starts) one shared id. If an id is already set, for example by the channel adapter, it is kept: one user interaction, one id.""" if correlation_id.get() is not None: yield correlation_id.get() return token = correlation_id.set(f"{prefix}-{secrets.token_hex(4)}") try: yield correlation_id.get() finally: correlation_id.reset(token) def pseudonym(value: str, key: bytes) -> str: """A stable, non-reversible label for an id: the same user always gets the same label under one key, so patterns are visible in the logs, but the label does not reveal the id. Change the key and the labels change (and cannot be linked).""" return "u_" + hmac.new(key, value.encode(), hashlib.sha256).hexdigest()[:12] # --- keeping secrets and personal data out of log lines ---------------------------------------------------------------- SECRET_PATTERNS = [ (re.compile(r"(?<!\d)\d{6,}:[A-Za-z0-9_-]{30,}"), "[telegram-token]"), # no \b: it must also match inside /bot<token>/getMe (re.compile(r"\bxox[a-zA-Z]-[A-Za-z0-9-]{10,}"), "[slack-token]"), (re.compile(r"\bsk-[A-Za-z0-9_-]{16,}"), "[api-key]"), (re.compile(r"\b[\w-]{23,28}\.[\w-]{6,7}\.[\w-]{27,}\b"), "[discord-token]"), (re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer [token]"), (re.compile(r"(?i)\bx-api-key\W+[A-Za-z0-9._-]{8,}"), "x-api-key [key]"), ] def scrub(text: str) -> str: """Replace anything that looks like a token or key, then the obvious personal identifiers (emails, long numbers).""" for pattern, replacement in SECRET_PATTERNS: text = pattern.sub(replacement, text) return redact(text) class ScrubFilter(logging.Filter): """Runs scrub() over the message and over every string field attached to a record, as a last line of defence.""" STANDARD = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"} def filter(self, record: logging.LogRecord) -> bool: record.msg = scrub(record.getMessage()) record.args = () for name, value in list(record.__dict__.items()): if name not in self.STANDARD and isinstance(value, str): setattr(record, name, scrub(value)) return True class JsonFormatter(logging.Formatter): """One JSON object per line. JSON escapes newlines and control characters, so text cannot forge extra log lines.""" def format(self, record: logging.LogRecord) -> str: entry = {"time": datetime.fromtimestamp(record.created, timezone.utc).isoformat(timespec="milliseconds"), "level": record.levelname, "logger": record.name, "message": record.getMessage(), "correlation_id": correlation_id.get()} for name, value in record.__dict__.items(): if name not in ScrubFilter.STANDARD and not name.startswith("_"): entry[name] = value if record.exc_info: entry["exception"] = type(record.exc_info[1]).__name__ # the class, not the message (which may hold data) return json.dumps(entry, default=str) def setup_logging(handler: logging.Handler | None = None, level: int = logging.INFO, name: str = "chatbot") -> logging.Logger: """A logger that writes scrubbed JSON lines. Pass a RotatingFileHandler for a file; the default is the console.""" logger = logging.getLogger(name) logger.setLevel(level) logger.handlers.clear() handler = handler or logging.StreamHandler() handler.setFormatter(JsonFormatter()) handler.addFilter(ScrubFilter()) logger.addHandler(handler) logger.propagate = False return logger # --- events: the lists every chapter has been appending to ------------------------------------------------------------ class EventLog(list): """A list of events that tells its observers about each new one. Replaces the plain lists used so far, so the flags recorded all through the course (rate_limited, moderated:..., event_error:...) become log lines and counters.""" def __init__(self, *observers) -> None: super().__init__() self.observers = list(observers) def _notify(self, item) -> None: for observer in self.observers: try: observer(item) except Exception: # noqa: BLE001 - logging must never break the bot pass def append(self, item) -> None: super().append(item) self._notify(item) def extend(self, items) -> None: for item in items: self.append(item) def __iadd__(self, items): self.extend(items) return self def log_observer(logger: logging.Logger, key: bytes): """Turn events into log lines. Events from the Bot are (session, flag); adapters record just a flag.""" def observe(item) -> None: session, flag = item if isinstance(item, tuple) else (None, item) level = logging.ERROR if flag.split(":")[0] in {"event_error", "update_error", "stream_error", "send_failed"} else logging.INFO logger.log(level, "event", extra={"flag": flag, "session": pseudonym(session, key) if session else None}) return observe # --- metrics --------------------------------------------------------------------------------------------------------- class Metrics: """Counters and timing samples in memory. Enough for a small bot; a bigger one would export them to a metrics system.""" def __init__(self, keep: int = 5000) -> None: self.counters: dict[str, float] = {} self.samples: dict[str, list[float]] = {} self.keep = keep def inc(self, name: str, amount: float = 1.0) -> None: self.counters[name] = self.counters.get(name, 0.0) + amount def observe(self, name: str, value: float) -> None: samples = self.samples.setdefault(name, []) samples.append(value) if len(samples) > self.keep: del samples[: len(samples) - self.keep] @staticmethod def percentile(values: list[float], q: float) -> float: if not values: return 0.0 ordered = sorted(values) return ordered[min(len(ordered) - 1, int(q * len(ordered)))] def snapshot(self) -> dict: out = dict(self.counters) for name, values in self.samples.items(): out[f"{name}_p50"] = self.percentile(values, 0.50) out[f"{name}_p95"] = self.percentile(values, 0.95) out[f"{name}_count"] = len(values) return out def observer(self): def observe(item) -> None: flag = item[1] if isinstance(item, tuple) else item self.inc("events_total") self.inc("event:" + flag.split(":")[0]) return observe def check_alerts(metrics: Metrics, *, min_messages: int = 20, max_error_rate: float = 0.05, max_p95_seconds: float = 10.0, max_fallbacks: int = 5) -> list[str]: """A few rules worth waking someone for. Rates need enough traffic to mean anything, hence min_messages.""" snap, alerts = metrics.snapshot(), [] messages = snap.get("messages_total", 0) errors = snap.get("provider_errors_total", 0) if messages >= min_messages and errors / messages > max_error_rate: alerts.append(f"provider error rate {errors / messages:.0%} over {int(messages)} messages (limit {max_error_rate:.0%})") if snap.get("handle_seconds_p95", 0) > max_p95_seconds: alerts.append(f"p95 latency {snap['handle_seconds_p95']:.1f} s (limit {max_p95_seconds:g} s)") if snap.get("event:event_error", 0) + snap.get("event:update_error", 0) > 0: alerts.append("unexpected exceptions caught by an adapter (event_error / update_error)") if snap.get("event:global_budget", 0) > 0: alerts.append("the daily spending budget was reached") if snap.get("fallbacks_total", 0) > max_fallbacks: alerts.append(f"{int(snap['fallbacks_total'])} fallbacks to the backup provider (limit {max_fallbacks})") return alerts # --- a provider wrapper that logs every call ------------------------------------------------------------------------------ class LoggedProvider: """Logs one line per provider call: how long it took, which model, how many tokens, whether it failed and how. It never logs the messages or the reply.""" def __init__(self, inner: Provider, logger: logging.Logger, metrics: Metrics | None = None, clock=time.perf_counter) -> None: self.inner, self.logger, self.metrics, self.clock = inner, logger, metrics, clock self.name = inner.name def _record(self, started: float, reply: Reply | None, error: ProviderError | None, streamed: bool, first_piece: float | None = None) -> None: seconds = self.clock() - started fields = {"provider": self.inner.name, "seconds": round(seconds, 3), "streamed": streamed} if first_piece is not None: fields["first_piece_seconds"] = round(first_piece, 3) if reply is not None: fields.update(model=reply.model, input_tokens=reply.usage.input_tokens, output_tokens=reply.usage.output_tokens, stop_reason=reply.stop_reason, vendor_request_id=reply.request_id, served_by=reply.served_by) if error is not None: fields.update(error=type(error).__name__, status=error.status, retryable=error.retryable, vendor_request_id=error.request_id) self.logger.log(logging.WARNING if error else logging.INFO, "provider_call", extra=fields) if self.metrics: self.metrics.inc("provider_calls_total") self.metrics.observe("provider_seconds", seconds) if error: self.metrics.inc("provider_errors_total") if reply is not None: self.metrics.inc("tokens_total", reply.usage.input_tokens + reply.usage.output_tokens) async def complete(self, messages: list[Message], *, system: str | None = None, max_tokens: int = 1024) -> Reply: started = self.clock() try: reply = await self.inner.complete(messages, system=system, max_tokens=max_tokens) except ProviderError as e: self._record(started, None, e, False) raise self._record(started, reply, None, False) return reply async def stream(self, messages: list[Message], *, system: str | None = None, max_tokens: int = 1024): started, first = self.clock(), None final = None try: async for item in self.inner.stream(messages, system=system, max_tokens=max_tokens): if first is None: first = self.clock() - started if isinstance(item, Reply): final = item yield item except ProviderError as e: self._record(started, None, e, True, first) raise self._record(started, final, None, True, first)

The Pieces

  • Correlation ids. correlation() sets an id in a contextvar, which Python propagates through awaits and into tasks created inside the block; the log formatter adds it to every line. If an id already exists (set by a channel adapter), it is kept, so one interaction has one id. Tests check it follows awaits, tasks and gather, that 20 concurrent messages get 20 different ids, and that nothing is left behind afterwards.
  • Pseudonyms. pseudonym(id, key) is an HMAC of the id under a secret key: the same person always gets the same label (so you can see one user hitting a limit repeatedly) but the label does not reveal the id, and changing the key breaks the link. Pseudonymised data can still be personal data in law (I have not read the ICO's guidance on this for the chapter), so treat these logs as sensitive too. The label starts with u_ for a practical reason found by a test: without it, a hash made only of digits could be mistaken for a phone number by the scrubber.
  • Scrubbing as a last line of defence. ScrubFilter runs scrub() over the message and over every string field of every record, whatever put it there: tokens for Telegram, Slack, Discord and API-key shapes, Bearer headers, then chapter 6's email, phone and card patterns. The real output on realistic strings:
scrub() on text that might end up in a log: in : calling https://api.telegram.org/bot123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw1/sendMessage out: calling https://api.telegram.org/bot[telegram-token]/sendMessage in : Authorization: Bearer abcDEF123456.xyz-789 out: Authorization: Bearer [token] in : slack token xoxb-1234567890-abcdefghijklmnop failed out: slack token [slack-token] failed in : key sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123 rejected out: key [api-key] rejected in : contact ada@example.com or +44 131 496 0000 (card 4111 1111 1111 1111) out: contact [email] or [number] (card [number]) in : Ada Lovelace lives at 14 Rose Street, Edinburgh out: Ada Lovelace lives at 14 Rose Street, Edinburgh in : request req_011CSHoEeqs5C35K2UUqR7Fy took 1.234 s out: request req_011CSHoEeqs5C35K2UUqR7Fy took 1.234 s A message that tries to forge log lines: number of physical lines written: 1 the line: {"time": "2026-10-10T16:12:24.821+00:00", "level": "INFO", "logger": "inj", "message": "user_text_demo", "correlation_id": null, "text": "hello\n{\"ti ... pseudonyms: u_0adbcc0793ef u_0adbcc0793ef u_d482bdc4b167 u_cc4027792bf5

Read the last lines honestly. The name and street address pass through untouched, and the vendor request id is deliberately left alone (it is useful for support tickets). Patterns catch shapes you thought of; they over-match too (Exercise 1 shows TCK-2026-0042 becoming TCK-[number]). The log injection demonstration is the line "number of physical lines written: 1": the attack text contained a newline and a fake JSON "all clear" line, and it arrived as one line with the newline escaped inside a string. The pseudonyms at the bottom show the same id mapping to the same label under one key and to different labels under another.

  • The event log. Since chapter 5 the bot has appended flags to bot.events. That list is now an EventLog that tells observers about each new item, so the flags become log lines and counters without changing any of the code that records them. (Chapter 5's code adds flags with += in one place, and list.__iadd__ bypasses append, so EventLog overrides all three ways of adding.) Adapters use the same class, so Telegram's duplicate_update and Slack's event_error appear in the same log.
  • LoggedProvider wraps any provider (and its streaming) and logs one line per call: provider, seconds, model, token counts, the vendor's request id, and on failure the error class, status and whether it is retryable. It never touches the message text. Put it inside the retry and breaker wrappers to see every attempt, or outside to see only the final outcome.
  • One line per message. The core logs who (as pseudonyms), how many characters came in, how long it took, which flags were raised and the outcome, for both normal and streaming replies, even if the user closes the stream half-way:
async def handle(self, session_id: str, text: str, *, user_id: str | None = None) -> str: """Answer one message with one complete reply.""" user_id = user_id or session_id with correlation(): started, mark, outcome, state = time.perf_counter(), len(self.events), "error", {} try: reply = await self._handle(session_id, text, user_id, state) outcome = "answered" if state.get("model") else "local" # "local": answered without calling the model return reply finally: self._log_message(session_id, user_id, text, outcome, started, mark, streamed=False)
def _log_message(self, session_id: str, user_id: str, text: str, outcome: str, started: float, mark: int, *, streamed: bool) -> None: """One line per message: who (as pseudonyms), how big, how long, what happened. Never the text itself.""" seconds = time.perf_counter() - started flags = [flag for _, flag in self.events[mark:]] if self.metrics: self.metrics.inc("messages_total") self.metrics.inc(f"outcome:{outcome}") self.metrics.observe("handle_seconds", seconds) if self.logger: self.logger.info("message", extra={"session": pseudonym(session_id, self.log_key), "user": pseudonym(user_id, self.log_key), "input_chars": len(text), "outcome": outcome, "flags": flags, "seconds": round(seconds, 3), "streamed": streamed})

A Conversation, As the Log Sees It

Five messages through a bot with a logged provider, a moderator and a tight rate limit: an ordinary question containing an email address, an insult, a question during a provider outage (the fake raises a 529), and two more that hit the rate limit. The model's first reply and the user's personal details were all in the data; look for them in the log:

(the bot raised ServerError to its caller, as designed: the channel adapter turns it into an apology) THE LOG (one JSON object per line): {"level": "INFO", "message": "provider_call", "correlation_id": "msg-XXXXXXXX", "provider": "fake", "seconds": 0.0, "streamed": false, "model": "example-small", "input_tokens": 40, "output_tokens": 8, "stop_reason": "end_turn", "vendor_request_id": "req_0123", "served_by": ""} {"level": "INFO", "message": "event", "correlation_id": "msg-XXXXXXXX", "flag": "unpriced:example-small", "session": "u_ce4708db6eae"} {"level": "INFO", "message": "message", "correlation_id": "msg-XXXXXXXX", "session": "u_ce4708db6eae", "user": "u_ce4708db6eae", "input_chars": 46, "outcome": "answered", "flags": ["unpriced:example-small"], "seconds": 0.001, "streamed": false} {"level": "INFO", "message": "event", "correlation_id": "msg-XXXXXXXX", "flag": "moderated:harassment", "session": "u_ce4708db6eae"} {"level": "INFO", "message": "message", "correlation_id": "msg-XXXXXXXX", "session": "u_ce4708db6eae", "user": "u_ce4708db6eae", "input_chars": 9, "outcome": "local", "flags": ["moderated:harassment"], "seconds": 0.0, "streamed": false} {"level": "WARNING", "message": "provider_call", "correlation_id": "msg-XXXXXXXX", "provider": "fake", "seconds": 0.0, "streamed": false, "error": "ServerError", "status": 529, "retryable": true, "vendor_request_id": null} {"level": "INFO", "message": "message", "correlation_id": "msg-XXXXXXXX", "session": "u_ce4708db6eae", "user": "u_ce4708db6eae", "input_chars": 46, "outcome": "error", "flags": [], "seconds": 0.0, "streamed": false} {"level": "INFO", "message": "provider_call", "correlation_id": "msg-XXXXXXXX", "provider": "fake", "seconds": 0.0, "streamed": false, "model": "fake", "input_tokens": 16, "output_tokens": 11, "stop_reason": "end_turn", "vendor_request_id": null, "served_by": ""} {"level": "INFO", "message": "event", "correlation_id": "msg-XXXXXXXX", "flag": "unpriced:fake", "session": "u_ce4708db6eae"} {"level": "INFO", "message": "message", "correlation_id": "msg-XXXXXXXX", "session": "u_ce4708db6eae", "user": "u_ce4708db6eae", "input_chars": 8, "outcome": "answered", "flags": ["unpriced:fake"], "seconds": 0.0, "streamed": false} {"level": "INFO", "message": "event", "correlation_id": "msg-XXXXXXXX", "flag": "rate_limited", "session": "u_ce4708db6eae"} {"level": "INFO", "message": "message", "correlation_id": "msg-XXXXXXXX", "session": "u_ce4708db6eae", "user": "u_ce4708db6eae", "input_chars": 11, "outcome": "local", "flags": ["rate_limited"], "seconds": 0.0, "streamed": false} Does any private text appear in the log? no Metrics: {'event:moderated': 1.0, 'event:rate_limited': 1.0, 'event:unpriced': 2.0, 'events_total': 4.0, 'handle_seconds_p50': 0.0, 'handle_seconds_p95': 0.001, 'messages_total': 5.0, 'outcome:answered': 2.0, 'outcome:error': 1.0, 'outcome:local': 2.0, 'provider_calls_total': 3.0, 'provider_errors_total': 1.0, 'provider_seconds_p50': 0.0, 'provider_seconds_p95': 0.0, 'tokens_total': 75.0}

What the log lets you answer. Which message failed? The third: outcome: "error", and the line before it shows error: "ServerError", status: 529, retryable: true, with the same correlation id (the ids are randomised in the text above). What happened to the insult? It raised moderated:harassment and the outcome was local, meaning the bot answered itself without calling the model. Is anyone being refused a lot? The same pseudonym appears on every line, and the last message was rate_limited. The metrics at the bottom count the same events for alerting. And the line "Does any private text appear in the log? no" is a check I ran on the raw output: the email, the card number, the question text, the real session id and the insult were all absent.

The outcome field went through a revision worth noting. At first every message that returned text was logged as answered, including refusals, commands and rate limits, which would make a dashboard say the bot was fine when it was refusing people. It is now answered (the model replied), local (the bot replied without calling the model), error (a failure reached the caller) or cancelled (the user walked away mid-stream).

Metrics and Alerts: Few, and Chosen

A small bot needs a handful of numbers, not a wall of charts. Metrics keeps counters and recent timing samples in memory and can report percentiles; check_alerts() turns a snapshot into the sentences that should reach a human:

MetricWhy it mattersAlert rule in the code
Provider error rateThe provider is failing or we are being limitedMore than 5% of messages (only once there are at least 20, so three failures in three messages do not page anyone)
p95 handling timeUsers are waiting; averages hide the slow tailAbove 10 seconds
Caught exceptions (event_error, update_error)Something is broken and the bot is hiding it to stay upAny at all
Daily budget reachedThe bot is about to stop for everybodyAny
Fallbacks to the backup providerThe primary is struggling even if users are still servedMore than 5

The top half of this output is the simulation described later in the chapter; the bottom half is the alerts.

24 kinds of awkward message in the pool, e.g. 'xxxxxxxxxx'..., '\x00\x01\x02 control characters', 'a\nb\nc\nd', '/help' bot mode messages provider errors violations seconds healthy one by one 2020 152 0 0.34 healthy concurrent 2020 152 0 0.38 deliberately leaky one by one 2020 152 2851 0.32 first violation: user0's secret reached user1's prompt deliberately lopsided one by one 2020 152 20 0.61 first violation: sim:user0: history does not alternate user/assistant alerts with no traffic : none alerts at a 2% error rate : none alerts after a bad hour : - provider error rate 12% over 200 messages (limit 5%) - p95 latency 14.0 s (limit 10 s) - the daily spending budget was reached - 8 fallbacks to the backup provider (limit 5)

The bottom half of that output is the alerts: nothing with no traffic or at a 2% error rate, and four sentences after a bad hour. The thresholds are my guesses; tune them against your own traffic after a week of real use, and decide where the alerts go (an email, or a message to yourself on one of the channels you built). The metrics here live in one process's memory; a service you intend to graph would export them to a metrics system, which this chapter does not set up.

Testing: Several Layers, Each Catching Something Different

LayerWhat it catchesWhat it cannotWhere
Unit and contract testsA function or provider that misbehavesHow pieces interactChapters 1 to 10 (268 tests)
Golden conversationsAny change to what the bot says around the modelWhether a real model behavesThis chapter
Simulated usersCrashes, malformed history and data leaking between users under messy inputWhether answers are any goodThis chapter
Scenarios against a real modelPersona drift, jailbreaks, refusals (repeat runs, look at the failure rate)Everything outside the scripted turnsChapter 5; the general evaluation methods are in Advanced AI Workflows & the Claude API 9
Browser and platform checksThe page or adapter in the real clientPlatforms you did not tryChapters 8 to 10
A live smoke testThe deployed bot answers at allAnything detailedChapter 12

Golden Conversations

A golden conversation is a reviewed transcript that must keep coming out the same. A small JSON file holds the scripted model replies, the bot's settings (persona, guards, moderation, limits), and a list of turns, each with what the user types and the exact reply expected. Everything around the model is deterministic with a fake model, so these catch the changes that otherwise slip through: a reworded refusal, a command that stopped working, a limit that moved. Three ship with the project:

ok guardrails.json 7 turns ok memory_and_commands.json 10 turns ok safety_and_limits.json 8 turns Now someone 'improves' a reply in the bot without updating the golden file. Simulated by editing one expectation: memory_and_commands.json turn 2 ('/remember I prefer short answe'): expected: 'Noted!' got: 'Remembered as #1.'

The failure display is the point: it names the turn and shows expected and actual side by side. py -m bot.golden --update file.json rewrites the expectations to match the code, which is dangerous in one way: it will happily record a regression as the new truth. Always read the diff before committing an update. I followed my own rule when I created these files: I generated the expectations, then read all 25 replies. Among them, the crisis-reply turn contains the Samaritans number and the mute turn shows a polite question being refused after two strikes, which are the behaviours I wanted frozen.

Simulated Users

A simulation sends a seeded, repeatable stream of messages from many made-up users: polite ones, and awkward ones (empty, 5,000 characters, control codes, hundreds of emoji, HTML, injection phrases, mentions, malformed commands, SQL-looking text, template syntax, non-English text). The provider fails about one time in ten. Afterwards it checks invariants, promises that must hold for any input: nothing but a provider error escapes the bot; every stored conversation alternates user and assistant starting with the user; no empty message is stored; stored notes are one line and within the limit; and, most importantly, one user's secret never appears in another user's prompt or history (each simulated user stores a unique secret note, and the checking provider looks for everyone else's secret in every call).

A test that cannot fail proves nothing, so I tested the tester. The table in the output above has four runs of 20 users and 100 steps (2,020 messages each, about 150 injected provider failures): the healthy bot passed both one-by-one and with all users at once; a bot with a deliberate bug that leaks users' notes to each other produced 2,851 violations, the first being "user0's secret reached user1's prompt"; and a bot with a deliberate history bug produced 20, the first being "history does not alternate". Exercise 3 adds a third mutant. The simulation took well under a second per run on this machine.

Be clear about what it is not. The users are scripts: the simulation checks the bot's own promises, not whether real people would find it useful, and not how a real model responds to the awkward messages.

Mistakes while building this
  • My Telegram-token pattern missed the place tokens actually appear. I wrote the pattern with a word boundary in front of the digits, which works in "using 123456789:AAH..." but not in a URL, where the token follows the letters "bot": /bot123456789:AAH.../getMe has no boundary between "t" and "1". The first version of the "secrets never reach the output" test used a realistic URL, failed, and showed the full token in the log line. The fix was a lookbehind that only requires "not preceded by a digit". The takeaway: test redaction with real-shaped examples, in the places the secrets really occur. This particular failure would have put the bot token in every log line that mentioned a request URL.
  • "Answered" meant too much. Described above: refusals were being counted as answers. A metric is only as good as its definition.
  • A test that could not fail. In the pseudonym test I first wrote an assertion that ended in ... or True, which passes whatever happens. It sat in the file until I re-read it; I replaced it with a real check. The takeaway: re-read your tests for assertions that cannot fail, and try to break the code on purpose to see them go red, as the simulation mutants do.
  • Test-order surprise from a shared list. The scrub patterns are a module-level list, and Exercise 1 appends to it. In a longer test run that would leak a custom pattern into other tests. The exercise does it in its own process; in a test suite, copy and restore the list.

What This Chapter Leaves Out

  • Log storage and shipping. Rotating files, forwarding to a log service, tamper detection, access control. Chapter 12 puts logs on a server.
  • Distributed tracing and dashboards (OpenTelemetry and the like). One process, one log, is enough here.
  • Real-model quality. Nothing here says whether the bot's answers are good.
  • Deleting a person from logs. /deleteme erases the database. Pseudonymised log lines for that person remain until the logs expire, which is a reason to keep log retention short and to say so in the privacy notice.

Hands-On Exercises

Exercise 1

Log four lines that contain things you would not want in a log (an order number, a Slack token, a ticket number with an email, a password written in free text). Show what reaches the output, add a pattern for your own order-number shape, and show again. Name what is still not caught and the real fix.

๐Ÿ“„ View solution
Exercise 2

Write a golden file of your own with four turns, one of which makes the scripted model fail. Record its expectations with the update option, check that it passes, then change one expected reply by hand and show the difference the checker prints. What must you do before committing a file produced by --update?

๐Ÿ“„ View solution
Exercise 3

Prove the simulation can fail: run it on the normal bot, then on a bot with a deliberate bug (turn off the fact sanitiser by replacing it in bot.core and restoring it afterwards) and show the violations it reports.

๐Ÿ“„ View solution

Chapter 11 Quick Reference

  • Log flags, errors and timings; never message text, replies, tokens or raw ids; log pseudonyms (HMAC of the id under a secret key, prefixed u_); log exceptions by class only
  • One JSON object per line (newlines and control characters are escaped, so text cannot forge lines); UTC timestamps; a correlation id per interaction, carried by contextvars
  • ScrubFilter + scrub(): Telegram/Slack/Discord/API-key shapes, Bearer headers, emails, long numbers; it misses names and addresses and over-matches some ids; log less free text
  • EventLog: the flags recorded since chapter 5 become log lines and counters (observers see append, extend and +=)
  • Message outcome: answered (model replied), local (no model call), error, cancelled; LoggedProvider logs each provider call and never the content
  • Alerts: error rate (with a minimum traffic), p95 latency, caught exceptions, daily budget, fallbacks; tune thresholds on real traffic
  • Test layers: unit/contract, golden conversations (py -m bot.golden files; review every --update diff), simulation with invariants (including cross-user leaks), scenarios on a real model, browser/platform checks, a live smoke test
  • Test the tests: break the code on purpose and see them fail; re-read assertions for ones that cannot fail
Coming next
Build a Chatbot: From API Call to Deployed Bot 12 is the capstone: put the finished bot on a server. A Dockerfile (non-root user, health check), configuration and secrets, running it as a service with systemd on Debian, HTTPS for the web page and webhooks behind a reverse proxy, graceful shutdown, backups and the encryption-at-rest gap from chapter 9, a runbook, and a checklist of what was verified and what was not. Course complete.