Logs
Structured logging with sensible defaults and zero configuration.
Overview
Python's logging module is powerful but notoriously difficult to configure. Plain provides a ready-to-use logging setup that works out of the box while supporting structured logging for production environments.
You get two pre-configured loggers: plain (for framework internals) and app (for your application code). Both default to the INFO level and can be adjusted via environment variables.
from plain.logs import app_logger
# Simple message logging
app_logger.info("Application started")
# Structured logging with context data
app_logger.info("User logged in", context={"user_id": 123, "method": "oauth"})
# All log levels work the same way
app_logger.warning("Rate limit approaching", context={"requests": 95, "limit": 100})
app_logger.error("Payment failed", context={"order_id": "abc-123", "reason": "insufficient_funds"})
Using app_logger
Basic logging
The app_logger supports all standard logging levels: debug, info, warning, error, and critical.
app_logger.debug("Entering validation step")
app_logger.info("Request processed successfully")
app_logger.warning("Cache miss, falling back to database")
app_logger.error("Failed to connect to external service")
app_logger.critical("Database connection pool exhausted")
Adding context
Pass structured data using the context parameter. This data appears in your log output based on your chosen format.
app_logger.info("Order placed", context={
"order_id": "ord-456",
"items": 3,
"total": 99.99,
})
You can also include exception tracebacks:
try:
process_payment()
except PaymentError:
app_logger.error(
"Payment processing failed",
exc_info=True,
context={"order_id": "ord-456"},
)
Message format
Log messages should be capitalized sentence fragments — short, stable, and greppable. Pass variable data through context={} instead of embedding it in the message string.
# Good: message is stable, data is structured
app_logger.info("Order placed", context={"order_id": "ord-456", "total": 99.99})
app_logger.warning("Rate limit approaching", context={"requests": 95, "limit": 100})
app_logger.error("Webhook delivery failed", context={"url": "https://example.com", "attempts": 3})
This keeps the message and data visually distinct in output. The message stays greppable across environments, and the structured data is available for filtering in log aggregation tools.
# Bad: snake_case token as message
app_logger.info("order_placed", context={"order_id": "ord-456"})
# Bad: variable data embedded in the message via f-string
app_logger.info(f"Order {order_id} placed")
# Bad: inline key=value in the message string
app_logger.info(f"Order placed order_id={order_id} total={total}")
Output formats
Control the log format with the APP_LOG_FORMAT environment variable.
Key-value format
The default format. Context data appears as key=value pairs, easy for humans to read and machines to parse.
export APP_LOG_FORMAT=keyvalue
[INFO] User logged in user_id=123 method=oauth
[ERROR] Payment failed order_id="abc-123" reason="insufficient_funds"
JSON format
Each log entry is a single JSON object. Ideal for log aggregation services like Datadog, Splunk, or CloudWatch.
export APP_LOG_FORMAT=json
{"timestamp": "2024-01-15 10:30:00,123", "level": "INFO", "message": "User logged in", "logger": "app", "user_id": 123, "method": "oauth"}
Context management
Persistent context
Add context that applies to all subsequent log calls by modifying the context dict directly.
# Set context at the start of a request
app_logger.context["request_id"] = "req-789"
app_logger.context["user_id"] = 42
app_logger.info("Starting request") # Includes request_id and user_id
app_logger.info("Fetching data") # Includes request_id and user_id
# Clear when done
app_logger.context.clear()
Temporary context
Use include_context() when you need context for a specific block of code.
app_logger.context["user_id"] = 42
with app_logger.include_context(operation="checkout", cart_id="cart-123"):
app_logger.info("Starting checkout") # Has user_id, operation, cart_id
app_logger.info("Validating items") # Has user_id, operation, cart_id
app_logger.info("Checkout complete") # Only has user_id
Debug mode
When you need to temporarily see debug-level logs (even if the logger is set to INFO), use force_debug().
# These won't show if log level is INFO
app_logger.debug("Detailed trace info")
# Temporarily enable debug output
with app_logger.force_debug():
app_logger.debug("This will appear")
app_logger.debug("So will this", context={"step": "validation"})
# Back to normal
app_logger.debug("This won't show again")
The DebugMode class handles reference counting, so nested force_debug() calls work correctly.
Output streams
By default, Plain splits log output by severity:
- DEBUG, INFO go to
stdout - WARNING, ERROR, CRITICAL go to
stderr
This helps cloud platforms automatically classify log severity. You can change this behavior with PLAIN_LOG_STREAM:
# Default: split by level
export PLAIN_LOG_STREAM=split
# All logs to stdout
export PLAIN_LOG_STREAM=stdout
# All logs to stderr (traditional Python behavior)
export PLAIN_LOG_STREAM=stderr
Settings reference
All logging settings use environment variables:
| Environment Variable | Default | Description |
|---|---|---|
PLAIN_FRAMEWORK_LOG_LEVEL |
INFO |
Log level for the plain logger |
PLAIN_LOG_LEVEL |
INFO |
Log level for the app logger |
PLAIN_LOG_FORMAT |
keyvalue |
Output format: keyvalue or json |
PLAIN_LOG_STREAM |
split |
Output stream: split, stdout, or stderr |
Valid log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
Server access log
The server writes access logs to a separate plain.server.access logger that always outputs to stdout, regardless of the LOG_STREAM setting. This keeps access logs separate from application logs.
Access logs use the same LOG_FORMAT setting as the app logger, producing structured output with request fields:
[INFO] Request method=GET path="/" status=200 duration_ms=12 size=1234 ip="127.0.0.1" user_agent="Mozilla/5.0..." referer="https://example.com"
In JSON format:
{"timestamp": "2024-01-15 10:30:00,123", "level": "INFO", "message": "Request", "logger": "plain.server.access", "method": "GET", "path": "/", "status": 200, "duration_ms": 12, "size": 1234, "ip": "127.0.0.1", "user_agent": "Mozilla/5.0...", "referer": "https://example.com"}
Additional fields beyond the defaults are controlled by SERVER_ACCESS_LOG_FIELDS (see the server docs).
Access logging is controlled by the SERVER_ACCESS_LOG setting (see the server docs). Individual responses can opt out by setting response.log_access = False.
FAQs
How do I use a custom logger instead of app_logger?
You can use Python's standard logging.getLogger() for additional loggers. They won't have the context features of app_logger, but they'll use Plain's output configuration.
Can I use app_logger in library code?
The app_logger is designed for application code. If you're writing a reusable library, use logging.getLogger(__name__) to allow users to configure logging independently.
Why are my debug logs not showing?
The default log level is INFO. Set PLAIN_LOG_LEVEL=DEBUG in your environment or use app_logger.force_debug() temporarily.
How do I add context to exception logs?
Pass both exc_info=True and context to include both the traceback and structured data:
except Exception:
app_logger.error("Operation failed", exc_info=True, context={"operation": "sync"})
Installation
plain.logs is included with Plain by default. No additional installation is required.
To adjust log levels for development, add environment variables to your shell or .env file:
PLAIN_LOG_LEVEL=DEBUG
PLAIN_FRAMEWORK_LOG_LEVEL=WARNING