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"},
    )

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"}

Standard format

A minimal format that omits context data entirely.

export APP_LOG_FORMAT=standard
[INFO] User logged in

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: json, keyvalue, or standard
PLAIN_LOG_STREAM split Output stream: split, stdout, or stderr

Valid log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL

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