Module 5: Structured Logging for AI Systems

8. Module 5 Summary and Troubleshooting

Description

This module transformed your app from "print() as logging" into an observability system that lets you answer any question about your app in production using only the logs. This capsule consolidates everything you learned into a decision map, the most common errors with their solutions, and a production checklist.


What you built in this module

Before:
  AI app
  └── print(response)  ← "logging"
  
  Debugging: "I add prints and redeploy"
  Cost: "no idea, I see it on the end-of-month bill"
  Error in prod: "I don't know what happened"

After:
  AI app
  ├── RequestTracingMiddleware   → request_id in all logs
  ├── logging_config.py         → structlog with JSON output
  ├── tracing.py                → contextvars (async-safe)
  ├── llm_wrapper.py            → tokens, cost, latency, alerts
  └── guardrails/               → activations logged
  
  Debugging: "I search request_id, see full history in 30s"
  Cost: "jq 'cost' logs.json | sum"
  Error in prod: "logs show error_type, duration, model, hash"

Module decision map

What should I log in this event?
│
├─ Is it the start/end of a request?
│   └─ INFO: request_id, path, method, duration, status_code
│
├─ Is it an LLM call?
│   └─ INFO: model, input/output_tokens, cost_usd, duration_ms, finish_reason
│       + WARNING if: latency > 10s, cost > $0.05, finish_reason == "length"
│       + ERROR if: exception — with prompt_preview and messages_hash
│
├─ Is it a guardrail activation?
│   └─ WARNING: guardrail_type, action (blocked/modified), layer
│       NOT the full input (it may be an attack or contain PII)
│
├─ Is it a parsing/validation error?
│   └─ WARNING: raw_preview, messages_hash — to correlate with the call
│
└─ Is it a fatal error?
    └─ ERROR + exc_info=True — with the full stack trace in JSON

With what level?
  └─ DEBUG: full prompt, full response — ONLY if debug_mode=True
  └─ INFO: request summary, metrics (always in prod)
  └─ WARNING: anomalies that require attention but are not a failure
  └─ ERROR: something failed and requires intervention

Quick reference: the module's tools

ToolWhat forWhen to use
structlog.contextvars.bind_contextvars()Bind request_id to the contextAt the start of each request
structlog.contextvars.clear_contextvars()Clean up when doneAt the end of each request (in finally)
structlog.contextvars.merge_contextvarsProcessor that injects the contextAlways in the processors list
structlog.processors.JSONRenderer()Output in JSON LinesIn production
structlog.dev.ConsoleRenderer()Readable outputIn development
log.bind(key=value)Bind fields to a local loggerFor subtasks with additional fields
log.exception("event")Log error + stack traceIn except blocks

The 7 most common errors and their solutions

Error 1: request_id does not appear in some logs

# Symptom: some logs have request_id, others don't

# Cause A: The merge_contextvars processor is not configured
# Fix:
structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,  # ← First in the list
        ...
    ]
)

# Cause B: There is a logger created BEFORE configuring structlog
# Fix: always call configure_logging() BEFORE creating any logger
# and use cache_logger_on_first_use=True (the default)

# Cause C: The context was cleared before it was logged
# Fix: make sure clear_contextvars() is in the middleware's finally,
# not in the try

Error 2: Mixed-up logs in async (request_id from another request)

# Symptom: the request_id in a log belongs to another concurrent request

# Cause: contextvars is not used — a global variable is used instead
# ❌ INCORRECT:
_current_request_id = None  # Global variable (does NOT work in async)

def set_request_id(rid): 
    global _current_request_id
    _current_request_id = rid

# ✅ CORRECT: use contextvars
import contextvars
_request_id_var = contextvars.ContextVar("request_id", default=None)

def set_request_id(rid):
    _request_id_var.set(rid)
    structlog.contextvars.bind_contextvars(request_id=rid)

# contextvars is task-scoped in async, one coroutine's request_id does not
# contaminate another

Error 3: Logs are plain text in production, not JSON

# Symptom: in production the logs are: "2024-01-15 INFO request_completed"

# Cause: ENVIRONMENT is not set, or configure_logging is not called
# Fix:
# 1. Set in docker/k8s: ENVIRONMENT=production
# 2. Make sure to call configure_logging() before any log:
configure_logging()  # ← At the start of main.py or app factory

# Verify:
print(os.getenv("ENVIRONMENT"))  # Must be "production"

Error 4: cost_usd is 0 or None in the logs

# Symptom: LLM request logs with no cost or with cost 0

# Cause A: response.usage is None (some endpoints don't return it)
# Fix:
if response.usage:
    cost = calculate_cost(model, response.usage.prompt_tokens,
                          response.usage.completion_tokens)
else:
    # Estimate with tiktoken
    input_tokens = count_tokens_in_messages(messages, model)
    cost = calculate_cost(model, input_tokens, 500)  # Estimate
    log.warning("usage_not_available", estimated=True)

# Cause B: The model is not in the pricing table
# Fix: add the model to MODEL_PRICING or check the fallback

Error 5: Huge logs (>1MB per entry)

# Symptom: log files grow very fast, logs with MB in size

# Cause: Logging large objects (full prompt in INFO, 
# very long responses, fully serialized Python objects)

# Fix 1: Use the truncate_long_values processor
def truncate_long_values(logger, method_name, event_dict, max_len=500):
    for key, value in event_dict.items():
        if isinstance(value, str) and len(value) > max_len:
            event_dict[key] = value[:max_len] + f"...[{len(value)}]"
    return event_dict

# Fix 2: Only log what you need
# ❌ log.info("response", full_response=response.model_dump())  # Large object
# ✅ log.info("response", tokens=response.usage.total_tokens, cost=cost)

Error 6: PII or secrets in the logs

# Symptom: emails, API keys, or personal data appear in the logs

# Cause: logging the user input without sanitizing
# log.info("request", input=user_text)  # user_text may contain PII

# Fix 1: sanitize_sensitive_fields processor (already implemented)
# Fix 2: Never log the full input in INFO
# ✅ log.info("request", input_length=len(user_text), input_hash=hash(user_text))

# Fix 3: If you need to log the input (for debugging):
# → Only in ERROR or DEBUG mode
# → Only the first 200-300 chars
# → Redact PII first with the PII guardrail from Module 4

Error 7: structlog does not capture logs from external libraries

# Symptom: logs from httpx, openai, etc. appear in a different format
# or don't appear in the JSON logs

# Cause: structlog and Python's standard logging are different systems
# Fix: configure the bridge between the two

import logging
import structlog

# Configure Python's standard logging to use the same handler
logging.basicConfig(format="%(message)s", level=logging.INFO)

# Configure structlog to process stdlib logs too
structlog.configure(
    processors=[
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_log_level,
        structlog.contextvars.merge_contextvars,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer()
    ],
    wrapper_class=structlog.stdlib.BoundLogger,
    logger_factory=structlog.stdlib.LoggerFactory(),  # ← stdlib, not PrintLoggerFactory
)

Diagnostic tree

PROBLEM: "I can't debug an error in production"
│
├─ Do you have the request_id?
│   ├─ NO → Does the response include the X-Request-ID header?
│   │        ├─ NO → Add request_id to the response (middleware)
│   │        └─ YES → Ask the user to report that header
│   │
│   └─ YES → jq 'select(.request_id == "XXX")' logs.json | jq -s 'sort_by(.timestamp)'
│           ├─ Do you see all the steps of the request?
│           │   └─ NO → Are all events logged? Check for gaps in the sequence
│           └─ Do you see the error?
│               └─ NO → Is the log level low enough? Check LOG_LEVEL

PROBLEM: "The logs are not JSON in production"
│
└─ Is ENVIRONMENT=production set?
    ├─ NO → Set it in docker-compose/k8s env
    └─ YES → Is configure_logging() called before the first log?
             ├─ NO → Move configure_logging() to the start of the app
             └─ YES → Does the app use cache_logger_on_first_use=True and reuse old loggers?
                      └─ Fix: structlog.reset_defaults() at startup (only in tests)

PROBLEM: "I don't know how much I spent yesterday on OpenAI"
│
├─ Do the logs have cost_usd?
│   ├─ NO → Does call_llm return metrics with cost_usd?
│   │        ├─ NO → Implement calculate_cost() and log it in call_llm
│   │        └─ YES → But the wrapper isn't used. Use call_llm() everywhere
│   └─ YES → jq -s '[.[].cost_usd // 0] | add' logs.json
│             or python scripts/analyze_logs.py logs/app.json 2024-01-15

PROBLEM: "The cost_usd is incorrect"
│
└─ Are the prices in MODEL_PRICING up to date?
    └─ Check at openai.com/pricing
       ├─ Update MODEL_PRICING in logging_config.py
       └─ Add a test that validates known prices

Module 5 production checklist

CONFIGURATION
[ ] configure_logging() is called at the start of the app
[ ] ENVIRONMENT=production is set in production
[ ] LOG_LEVEL=INFO in production (DEBUG only if necessary)
[ ] LOG_FILE is configured if you want logs in a file
[ ] Log rotation configured (backupCount=30)

TRACING
[ ] RequestTracingMiddleware registered in the FastAPI app
[ ] All endpoints use contextvars for request_id
[ ] Response headers include X-Request-ID
[ ] clear_contextvars() in the middleware's finally

LOGGING IN THE CODE
[ ] All llm calls go through call_llm()
[ ] All INFO logs have request_id (verify with a test)
[ ] No API keys or PII in the logs
[ ] sanitize_sensitive_fields processor active

GUARDRAILS
[ ] Guardrail activations logged with WARNING
[ ] guardrail_type and action in each guardrail log
[ ] The full input is not logged in guardrail WARNING

COST TRACKING
[ ] MODEL_PRICING has all models in use
[ ] cost_usd appears in each LLM call log
[ ] high_cost alerts active
[ ] analyze_logs.py script available to the team

TESTS
[ ] test: request_id appears in all logs
[ ] test: prompt does not appear in INFO logs
[ ] test: cost_usd is in the LLM call logs
[ ] test: warnings for finish_reason='length'

Module vocabulary

TermDefinition
Structured loggingLogging that produces JSON data instead of free text, machine-queryable
JSON Lines (JSONL)Format where each line is an independent JSON object, ideal for append-only logs
Correlation ID / request_idUnique identifier that connects all logs in a request's lifecycle
contextvarsPython module that stores per-coroutine state (async-safe, thread-safe)
bind_contextvars()structlog function to bind fields to the current coroutine's context
finish_reasonReason the LLM stopped generating: "stop", "length", "content_filter"
system_fingerprintIdentifier of the OpenAI server/model version, useful for detecting changes
reproducibility contextThe set of parameters (model, temperature, seed, prompt) needed to re-run an LLM call
seedParameter that asks the model to try to be reproducible for the same input
Log aggregationSystem that centralizes logs from multiple instances or services (ELK, Loki, Datadog)

Connection with Module 6

Module 6 (Code Quality Patterns for AI) organizes all the code you've built so far. You have:

  • Tests (Module 2, 3)
  • Guardrails (Module 4)
  • Logging (Module 5)

The problem is that they're probably intertwined: main.py imports directly from guardrails, llm_wrapper depends on logging_config, and the tests are hard to isolate because everything is coupled.

Module 6 applies clean architecture to separate concerns:

  • Domain layer: the business logic (what does a positive sentiment mean?)
  • Application layer: the use cases (analize_sentiment)
  • Infrastructure layer: guardrails, logging, calls to the OpenAI API
  • Interface layer: FastAPI endpoints

This separation makes the code maintainable, testable, and extensible — the transition from "code that works" to "production code."


Additional module resources

  1. structlog Documentation — The complete reference
  2. structlog contextvars guide — Async logging
  3. jq Manual — For ad-hoc queries over the logs
  4. OpenTelemetry for Python — For advanced distributed tracing
  5. Twelve-Factor App — Logs — The philosophy behind the module
  6. OpenAI Pricing — Up-to-date prices for MODEL_PRICING
  7. Loki (Grafana) — Log aggregation system optimized for JSON logs
  8. Datadog APM — Enterprise observability with support for LLM apps