Module 5: Structured Logging for AI Systems
5. JSON Logs with structlog
Description
JSON logs aren't just a format — they're the interface between your app and any observability system (Datadog, Loki, CloudWatch, ELK). A well-formed JSON log is machine-queryable, indexable by log aggregation systems, and dashboardable with no additional configuration. This capsule implements the complete structlog configuration for production, the essential processors for AI apps, and the querying patterns that turn logs into a real diagnostic system.
JSON Lines: the standard format
Why "JSON Lines" and not pure JSON?
Pure JSON:
[
{"event": "request_1", "tokens": 450},
{"event": "request_2", "tokens": 320}
]
Problem: To add an entry, you must read the WHOLE file, parse, append, rewrite.
With millions of logs: impossible.
JSON Lines (JSONL):
{"event": "request_1", "tokens": 450}
{"event": "request_2", "tokens": 320}
Advantages:
→ Append-only: each log is one line, appended at the end
→ Incremental parsing: you can read one line without loading the whole file
→ Easy to grep, sed, awk
→ jq works perfectly
→ All log aggregation systems support it natively
→ Stream-friendly: stdout piped to another process
Complete structlog configuration
# src/logging_config.py
import logging
import os
import sys
import structlog
from typing import Any
def get_log_level() -> int:
"""Reads the log level from an env var or uses INFO by default."""
level_str = os.getenv("LOG_LEVEL", "INFO").upper()
return getattr(logging, level_str, logging.INFO)
def configure_structlog(
env: str = None,
log_level: int = None,
json_output: bool = None
) -> None:
"""
Configures structlog for the specified environment.
Args:
env: "production", "development", or "testing"
log_level: Override for the log level
json_output: Override for the format (True=JSON, False=Console)
"""
env = env or os.getenv("ENVIRONMENT", "development")
log_level = log_level or get_log_level()
# Determine whether to use the JSON or Console renderer
use_json = json_output if json_output is not None else (env == "production")
# ─── Processors to intercept Python's standard logging ──────────────────
# structlog can capture logs from libraries that use logging.getLogger()
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=log_level
)
# ─── Shared processors (dev and prod) ────────────────────────────────────
shared_processors = [
# Read request_id (and other fields) from the contextvars context
structlog.contextvars.merge_contextvars,
# Add level (info, warning, error, etc.)
structlog.processors.add_log_level,
# Timestamp in ISO 8601 format
structlog.processors.TimeStamper(fmt="iso"),
# Logger name (useful for identifying the module)
structlog.stdlib.add_logger_name,
# Render stack traces in exceptions
structlog.processors.StackInfoRenderer(),
]
if use_json:
# ─── Production: JSON Lines ──────────────────────────────────────────
processors = shared_processors + [
# Format the exception as a JSON object (not as text)
structlog.processors.format_exc_info,
# Serialize as JSON (one line per event)
structlog.processors.JSONRenderer()
]
else:
# ─── Development: readable Console ────────────────────────────────────
processors = shared_processors + [
# Colors and human-readable format for the terminal
structlog.dev.ConsoleRenderer(
colors=True,
exception_formatter=structlog.dev.plain_traceback
)
]
structlog.configure(
processors=processors,
# Filtering level: logs below log_level are ignored
wrapper_class=structlog.make_filtering_bound_logger(log_level),
# context_class: dict is enough for most cases
context_class=dict,
# Logger factory: uses print (stdout) by default
logger_factory=structlog.PrintLoggerFactory(),
# Cache the configuration for performance
cache_logger_on_first_use=True,
)
# Call at the start of the app:
# configure_structlog()
Custom processors for AI apps
# src/logging_config.py (continued)
import hashlib
from typing import MutableMapping
def add_app_metadata(
logger: Any,
method_name: str,
event_dict: MutableMapping[str, Any]
) -> MutableMapping[str, Any]:
"""
Adds app metadata to all logs.
Useful for identifying the app version in aggregation systems.
"""
event_dict["app"] = os.getenv("APP_NAME", "production-best-practices")
event_dict["version"] = os.getenv("APP_VERSION", "0.1.0")
event_dict["env"] = os.getenv("ENVIRONMENT", "development")
return event_dict
def sanitize_sensitive_fields(
logger: Any,
method_name: str,
event_dict: MutableMapping[str, Any]
) -> MutableMapping[str, Any]:
"""
Removes or masks fields that shouldn't appear in logs.
A safety net to prevent PII or secrets from reaching the logs.
"""
FIELDS_TO_REMOVE = {"password", "api_key", "secret", "token", "authorization"}
FIELDS_TO_HASH = {"user_id", "email", "phone"}
for field in list(event_dict.keys()):
field_lower = field.lower()
if any(sensitive in field_lower for sensitive in FIELDS_TO_REMOVE):
event_dict[field] = "[REDACTED]"
elif any(pii in field_lower for pii in FIELDS_TO_HASH):
# Hash for analytics without exposing the real value
value = str(event_dict[field])
event_dict[field] = hashlib.sha256(value.encode()).hexdigest()[:12]
return event_dict
def truncate_long_strings(
logger: Any,
method_name: str,
event_dict: MutableMapping[str, Any],
max_length: int = 500
) -> MutableMapping[str, Any]:
"""
Truncates long strings to avoid enormous logs.
In INFO, no string should be longer than 500 chars.
"""
for key, value in event_dict.items():
if isinstance(value, str) and len(value) > max_length:
event_dict[key] = value[:max_length] + f"...[truncated {len(value)} chars]"
return event_dict
# Configuration with custom processors:
def configure_structlog_with_custom_processors():
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_app_metadata, # Custom: app metadata
sanitize_sensitive_fields, # Custom: remove secrets
truncate_long_strings, # Custom: truncate long fields
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.stdlib.add_logger_name,
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer()
]
)
Logging exceptions correctly
import structlog
log = structlog.get_logger()
# ─── Incorrect way ───────────────────────────────────────────────────────────
try:
response = client.chat.completions.create(...)
except Exception as e:
log.error("failed", error=str(e)) # Only the message, no stack trace
# ─── Correct way ─────────────────────────────────────────────────────────────
# Option 1: exc_info=True (includes the full stack trace in the log)
try:
response = client.chat.completions.create(...)
except Exception as e:
log.error(
"llm_request_failed",
error_type=type(e).__name__,
exc_info=True # ← structlog includes the traceback in the log
)
# Option 2: log.exception (shorthand for log.error with exc_info=True)
try:
response = client.chat.completions.create(...)
except Exception as e:
log.exception("llm_request_failed", error_type=type(e).__name__)
# With JSONRenderer, the output will be:
# {
# "event": "llm_request_failed",
# "error_type": "RateLimitError",
# "exception": "Traceback (most recent call last):\n ...",
# "level": "error",
# "timestamp": "2024-01-15T10:30:00Z"
# }
Advanced querying with jq
# Install jq:
# macOS: brew install jq
# Ubuntu: sudo apt install jq
# ─── Basic queries ───────────────────────────────────────────────────────────
# See all unique events
jq -r '.event' logs.json | sort | uniq -c | sort -rn
# Filter by level
jq 'select(.level == "error")' logs.json
# Filter by event
jq 'select(.event == "llm_request_completed")' logs.json
# ─── Cost queries ───────────────────────────────────────────────────────────
# Total cost
jq -s '[.[].total_cost_usd // 0] | add' logs.json
# Most expensive requests (top 10)
jq -s 'map(select(.total_cost_usd != null)) | sort_by(-.total_cost_usd) | .[0:10] | .[] | {request_id, total_cost_usd, total_tokens, model}' logs.json
# Average cost by model
jq -s '
map(select(.event == "llm_request_completed"))
| group_by(.model)
| map({
model: .[0].model,
avg_cost: ([.[].total_cost_usd] | add / length),
count: length
})
' logs.json
# ─── Performance queries ────────────────────────────────────────────────────
# Slow requests (>5 seconds)
jq 'select(.duration_ms > 5000)' logs.json | jq -r '.request_id'
# Average latency
jq -s '[map(select(.duration_ms != null)) | .[].duration_ms] | add / length' logs.json
# ─── Guardrail queries ──────────────────────────────────────────────────────
# How many guardrails activated
jq 'select(.event == "guardrail_activated")' logs.json | wc -l
# By guardrail type
jq 'select(.event == "guardrail_activated") | .guardrail_type' logs.json | sort | uniq -c
# ─── Error queries ──────────────────────────────────────────────────────────
# All errors with their request_id
jq 'select(.level == "error") | {timestamp, request_id, event, error_type}' logs.json
# ─── Time-based queries ─────────────────────────────────────────────────────
# Logs from the last hour (timestamp is ISO 8601)
jq 'select(.timestamp > "2024-01-15T09:00:00Z")' logs.json
# ─── Tracing: the story of a request ────────────────────────────────────────
jq 'select(.request_id == "a1b2c3d4")' logs.json | jq -s 'sort_by(.timestamp) | .[]'
Python script for log analysis
# scripts/log_analyzer.py
# Alternative to jq when you need more complex logic
import json
import sys
from pathlib import Path
from collections import defaultdict
def read_jsonl(path: str) -> list:
"""Reads a JSON Lines file."""
logs = []
with open(path) as f:
for line in f:
line = line.strip()
if line:
try:
logs.append(json.loads(line))
except json.JSONDecodeError:
pass
return logs
def print_report(logs: list):
"""Generates a readable report from the logs."""
# Separate by log type
llm_logs = [l for l in logs if l.get("event") == "llm_request_completed"]
error_logs = [l for l in logs if l.get("level") == "error"]
guardrail_logs = [l for l in logs if l.get("event") == "guardrail_activated"]
print("=" * 60)
print("AI SYSTEM LOG ANALYSIS REPORT")
print("=" * 60)
# Summary
print(f"\n📊 SUMMARY")
print(f" Total LLM requests: {len(llm_logs)}")
print(f" Total errors: {len(error_logs)}")
print(f" Guardrail activations: {len(guardrail_logs)}")
# Cost summary
if llm_logs:
total_cost = sum(l.get("total_cost_usd", 0) for l in llm_logs)
avg_cost = total_cost / len(llm_logs)
max_cost_log = max(llm_logs, key=lambda l: l.get("total_cost_usd", 0))
print(f"\n💰 COST SUMMARY")
print(f" Total cost: ${total_cost:.6f}")
print(f" Avg cost/request: ${avg_cost:.8f}")
print(f" Most expensive: ${max_cost_log.get('total_cost_usd', 0):.6f}")
print(f" → request_id: {max_cost_log.get('request_id', 'unknown')}")
print(f" → tokens: {max_cost_log.get('total_tokens', 0)}")
print(f" → model: {max_cost_log.get('model', 'unknown')}")
# Performance summary
if llm_logs:
durations = [l.get("duration_ms", 0) for l in llm_logs]
avg_duration = sum(durations) / len(durations)
slow_requests = [l for l in llm_logs if l.get("duration_ms", 0) > 5000]
print(f"\n⚡ PERFORMANCE")
print(f" Avg duration: {avg_duration:.0f}ms")
print(f" Slow (>5s) requests: {len(slow_requests)}")
# Errors
if error_logs:
print(f"\n❌ ERRORS ({len(error_logs)} total)")
error_types = defaultdict(int)
for l in error_logs:
error_types[l.get("error_type", "unknown")] += 1
for error_type, count in sorted(error_types.items(), key=lambda x: -x[1]):
print(f" {error_type}: {count}")
# Guardrails
if guardrail_logs:
print(f"\n🛡️ GUARDRAILS ({len(guardrail_logs)} activations)")
guard_types = defaultdict(int)
for l in guardrail_logs:
guard_types[l.get("guardrail_type", "unknown")] += 1
for guard_type, count in sorted(guard_types.items(), key=lambda x: -x[1]):
print(f" {guard_type}: {count}")
print("\n" + "=" * 60)
if __name__ == "__main__":
log_file = sys.argv[1] if len(sys.argv) > 1 else "logs/app.json"
logs = read_jsonl(log_file)
print_report(logs)
Log rotation configuration
# To prevent log files from growing indefinitely:
import logging
from logging.handlers import TimedRotatingFileHandler
import structlog
def configure_file_logging(log_dir: str = "logs"):
"""
Configures logging to a file with daily rotation.
Retains logs from the last 30 days.
"""
import os
os.makedirs(log_dir, exist_ok=True)
# Handler for a file with daily rotation
file_handler = TimedRotatingFileHandler(
filename=f"{log_dir}/app.json",
when="midnight", # Rotate at midnight
interval=1, # Every 1 day
backupCount=30, # Retain 30 days
encoding="utf-8"
)
file_handler.setLevel(logging.INFO)
# Also log to stdout (for systems like Docker/Kubernetes)
# that collect logs from the container's stdout
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(logging.INFO)
logging.basicConfig(
handlers=[file_handler, stdout_handler],
format="%(message)s",
level=logging.INFO
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
],
logger_factory=structlog.stdlib.LoggerFactory(),
)
Testing the logging system
# tests/unit/test_json_logs.py
import json
import io
import pytest
import structlog
@pytest.fixture
def json_log_capture():
"""Captures logs as JSON for integrity tests."""
output = io.StringIO()
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
],
logger_factory=structlog.PrintLoggerFactory(file=output),
cache_logger_on_first_use=False, # Important: don't cache in tests
)
yield output
structlog.reset_defaults()
def parse_logs(output: io.StringIO) -> list:
"""Parses JSON Lines logs into a list of dicts."""
return [
json.loads(line)
for line in output.getvalue().strip().split("\n")
if line.strip()
]
def test_each_log_is_valid_json(json_log_capture):
"""Each log must be valid JSON."""
log = structlog.get_logger()
log.info("event_1", field_a="value_a")
log.warning("event_2", field_b=42)
log.error("event_3", field_c=True)
# Must not raise JSONDecodeError
logs = parse_logs(json_log_capture)
assert len(logs) == 3
def test_log_has_required_fields(json_log_capture):
"""Each log must have event, level, and timestamp."""
log = structlog.get_logger()
log.info("test_event", some_field="some_value")
logs = parse_logs(json_log_capture)
assert len(logs) == 1
entry = logs[0]
assert "event" in entry
assert "level" in entry
assert "timestamp" in entry
def test_context_vars_appear_in_all_logs(json_log_capture):
"""The context fields must appear in all logs."""
structlog.contextvars.bind_contextvars(request_id="test-req-99")
log = structlog.get_logger()
log.info("event_a")
log.info("event_b")
log.warning("event_c")
structlog.contextvars.clear_contextvars()
logs = parse_logs(json_log_capture)
for entry in logs:
assert entry.get("request_id") == "test-req-99", \
f"request_id missing from: {entry}"
def test_exception_logged_as_json(json_log_capture):
"""Exceptions must appear as a JSON field, not as loose text."""
log = structlog.get_logger()
try:
raise ValueError("test error")
except ValueError:
log.exception("something_failed")
logs = parse_logs(json_log_capture)
assert len(logs) == 1
entry = logs[0]
# The exception must be a field in the JSON
assert "exception" in entry or "exc_info" in entry
# It must not appear in the event field
assert "Traceback" not in entry.get("event", "")
Exercises
Exercise 1: Configure structlog for production
Write the complete structlog configuration for a production app that:
- Uses JSON Lines
- Includes the
request_idfrom the context - Adds
levelandtimestamp - Redacts fields with "password" or "secret" in the name
See solution
import structlog, os, hashlib
def redact_secrets(logger, method_name, event_dict):
for key in list(event_dict.keys()):
if any(s in key.lower() for s in ["password", "secret", "api_key"]):
event_dict[key] = "[REDACTED]"
return event_dict
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
redact_secrets,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer()
]
)
Exercise 2: jq one-liners
Write the jq commands to:
- Count how many requests completed successfully today
- List the request_ids of all errors
- Find the most used model
See solution
# 1. Requests completed today (assuming date 2024-01-15)
jq 'select(.event == "request_completed" and (.timestamp | startswith("2024-01-15")))' logs.json | wc -l
# 2. request_ids of errors
jq -r 'select(.level == "error") | .request_id' logs.json
# 3. Most used model
jq -r 'select(.event == "llm_request_completed") | .model' logs.json | sort | uniq -c | sort -rn | head -1
Summary
- JSON Lines: one line per event, append-only, stream-friendly — the standard format
- structlog + JSONRenderer for production, ConsoleRenderer for development
- Processors in order: contextvars → add_log_level → timestamp → (custom) → JSONRenderer
- Custom processors: sanitize secrets, truncate long strings, add app metadata
- jq is enough for ad-hoc queries; a Python script for recurring analyses
- Log rotation to avoid unlimited files — 30 days of retention as a starting point
Additional resources
- structlog Documentation — Complete documentation with examples
- jq Manual — Complete jq reference
- JSON Lines format — The format specification
- Loki — Log aggregation system that indexes JSON logs natively
- Structured Logging in Python (Real Python) — Complete tutorial