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