Skip to content

Commit 52403b1

Browse files
committed
Split logs to stdout and stderr by default
1 parent 2a63270 commit 52403b1

File tree

8 files changed

+116
-14
lines changed

8 files changed

+116
-14
lines changed

plain/plain/logs/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,40 @@ app_logger.debug("This might not appear")
137137
with app_logger.force_debug():
138138
app_logger.debug("This will definitely appear", extra_data="debug_info")
139139
```
140+
141+
## Advanced usage
142+
143+
### Output streams
144+
145+
By default, Plain splits log output by severity level to ensure proper log classification on cloud platforms:
146+
147+
- **DEBUG, INFO**`stdout` (standard output)
148+
- **WARNING, ERROR, CRITICAL**`stderr` (error output)
149+
150+
This behavior ensures that platforms which automatically detect log severity based on output streams correctly classify logs as informational vs errors.
151+
152+
You can customize this behavior using the `PLAIN_LOG_STREAM` environment variable:
153+
154+
```bash
155+
# Default: split by level (INFO to stdout, WARNING+ to stderr)
156+
export PLAIN_LOG_STREAM=split
157+
158+
# Send all logs to stdout (simple, predictable)
159+
export PLAIN_LOG_STREAM=stdout
160+
161+
# Send all logs to stderr (legacy Python behavior)
162+
export PLAIN_LOG_STREAM=stderr
163+
```
164+
165+
## Logging settings
166+
167+
All logging settings can be configured via environment variables:
168+
169+
| Setting | Environment Variable | Default | Description |
170+
| --------------------- | --------------------------- | ------------ | -------------------------------------------------------- |
171+
| `FRAMEWORK_LOG_LEVEL` | `PLAIN_FRAMEWORK_LOG_LEVEL` | `"INFO"` | Log level for the `plain` logger |
172+
| `LOG_LEVEL` | `PLAIN_LOG_LEVEL` | `"INFO"` | Log level for the `app` logger |
173+
| `LOG_FORMAT` | `PLAIN_LOG_FORMAT` | `"keyvalue"` | Output format: `"json"`, `"keyvalue"`, or `"standard"` |
174+
| `LOG_STREAM` | `PLAIN_LOG_STREAM` | `"split"` | Output stream mode: `"split"`, `"stdout"`, or `"stderr"` |
175+
176+
**Log levels:** `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`

plain/plain/logs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .loggers import app_logger
1+
from .app import app_logger
22

33
__all__ = ["app_logger"]

plain/plain/logs/configure.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,85 @@
11
import logging
2+
import sys
3+
from typing import TextIO
24

5+
from .filters import DebugInfoFilter, WarningErrorCriticalFilter
36
from .formatters import JSONFormatter, KeyValueFormatter
47

58

9+
def attach_log_handlers(
10+
*,
11+
logger: logging.Logger,
12+
info_stream: TextIO,
13+
warning_stream: TextIO,
14+
formatter: logging.Formatter,
15+
) -> None:
16+
"""Attach two handlers to a logger that split by log level.
17+
18+
INFO and below go to info_stream, WARNING and above go to warning_stream.
19+
"""
20+
# DEBUG and INFO handler
21+
info_handler = logging.StreamHandler(info_stream)
22+
info_handler.addFilter(DebugInfoFilter())
23+
info_handler.setFormatter(formatter)
24+
logger.addHandler(info_handler)
25+
26+
# WARNING, ERROR, and CRITICAL handler
27+
warning_handler = logging.StreamHandler(warning_stream)
28+
warning_handler.addFilter(WarningErrorCriticalFilter())
29+
warning_handler.setFormatter(formatter)
30+
logger.addHandler(warning_handler)
31+
32+
633
def configure_logging(
7-
*, plain_log_level: int | str, app_log_level: int | str, app_log_format: str
34+
*,
35+
plain_log_level: int | str,
36+
app_log_level: int | str,
37+
app_log_format: str,
38+
log_stream: str = "split",
839
) -> None:
40+
# Determine which streams to use based on log_stream setting
41+
if log_stream == "split":
42+
info_stream = sys.stdout
43+
warning_stream = sys.stderr
44+
elif log_stream == "stdout":
45+
info_stream = sys.stdout
46+
warning_stream = sys.stdout
47+
else: # stderr (or any other value defaults to stderr for backwards compat)
48+
info_stream = sys.stderr
49+
warning_stream = sys.stderr
50+
951
# Create and configure the plain logger (uses standard Logger, not AppLogger)
1052
plain_logger = logging.getLogger("plain")
1153
plain_logger.setLevel(plain_log_level)
12-
plain_handler = logging.StreamHandler()
13-
plain_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
14-
plain_logger.addHandler(plain_handler)
54+
attach_log_handlers(
55+
logger=plain_logger,
56+
info_stream=info_stream,
57+
warning_stream=warning_stream,
58+
formatter=logging.Formatter("[%(levelname)s] %(message)s"),
59+
)
1560
plain_logger.propagate = False
1661

1762
# Configure the existing app_logger
18-
from .loggers import app_logger
63+
from .app import app_logger
1964

2065
app_logger.setLevel(app_log_level)
2166
app_logger.propagate = False
2267

23-
app_handler = logging.StreamHandler()
68+
# Determine formatter based on app_log_format
2469
match app_log_format:
2570
case "json":
26-
app_handler.setFormatter(JSONFormatter("%(json)s"))
71+
formatter = JSONFormatter("%(json)s")
2772
case "keyvalue":
28-
app_handler.setFormatter(
29-
KeyValueFormatter("[%(levelname)s] %(message)s %(keyvalue)s")
30-
)
73+
formatter = KeyValueFormatter("[%(levelname)s] %(message)s %(keyvalue)s")
3174
case _:
32-
app_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
75+
formatter = logging.Formatter("[%(levelname)s] %(message)s")
3376

34-
app_logger.addHandler(app_handler)
77+
attach_log_handlers(
78+
logger=app_logger,
79+
info_stream=info_stream,
80+
warning_stream=warning_stream,
81+
formatter=formatter,
82+
)
3583

3684
# Register the app_logger in the logging system so getLogger("app") returns it
3785
logging.root.manager.loggerDict["app"] = app_logger

plain/plain/logs/filters.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import logging
2+
3+
4+
class DebugInfoFilter(logging.Filter):
5+
"""Filter that only allows DEBUG and INFO log records."""
6+
7+
def filter(self, record: logging.LogRecord) -> bool:
8+
return record.levelno <= logging.INFO
9+
10+
11+
class WarningErrorCriticalFilter(logging.Filter):
12+
"""Filter that only allows WARNING, ERROR, and CRITICAL log records."""
13+
14+
def filter(self, record: logging.LogRecord) -> bool:
15+
return record.levelno >= logging.WARNING

plain/plain/runtime/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def setup() -> None:
6767
plain_log_level=settings.FRAMEWORK_LOG_LEVEL,
6868
app_log_level=settings.LOG_LEVEL,
6969
app_log_format=settings.LOG_FORMAT,
70+
log_stream=settings.LOG_STREAM,
7071
)
7172

7273
packages_registry.populate(settings.INSTALLED_PACKAGES)

plain/plain/runtime/global_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
FRAMEWORK_LOG_LEVEL: str = "INFO"
139139
LOG_LEVEL: str = "INFO"
140140
LOG_FORMAT: str = "keyvalue"
141+
LOG_STREAM: str = "split" # "split", "stdout", or "stderr"
141142

142143
# MARK: Assets
143144

plain/tests/test_logs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import pytest
66

77
from plain.logs import app_logger
8+
from plain.logs.app import AppLogger
89
from plain.logs.configure import configure_logging
910
from plain.logs.formatters import JSONFormatter, KeyValueFormatter
10-
from plain.logs.loggers import AppLogger
1111

1212

1313
class TestLoggingConfiguration:

0 commit comments

Comments
 (0)