Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"line_length": 120,
"code_blocks": false,
"tables": false
},
"MD024": {
"siblings_only": true
}
}
}
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## Unreleased

### Changed (breaking)

- The console is now plain text only; JSON is the syslog wire format alone. The
`json`/`json_traces`/`plain` formatters and the `console` handler are gone, replaced
by `console_app`, `console_siem` and `console_debug`, one per selected stream.
- `debug_logs_in_console` is removed; use `console_streams=["debug"]` instead.
- The console carries the SIEM stream by default (previously app only), so an
app-and-SIEM event now prints one line per stream. Set `console_streams=["app"]` for
the old behavior.
- Only the debug stream is bound to the root logger, so records logged outside the
application's logger tree no longer reach stdout by default.

### Added

- `console_streams` on `ConfigLogging` selects which streams (`app`, `siem`, `debug`)
reach stdout as readable text. An empty list silences stdout; an unknown stream name
fails at boot.
- `include_traces` now also controls tracebacks in the console formatters.

### Fixed

- A rejected logging setting is no longer misreported as an unreachable log server.

## 0.2.0 - 2026-09-01

### Changed (breaking)
Expand Down
16 changes: 12 additions & 4 deletions docs/STARTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ application's own config module makes every import of it from there fail.
| `syslog_path` | `host:port`; unset means console only |
| `application_id` | stamped on every JSON record so the log server can tell applications apart; omitted entirely when unset, so set it wherever `syslog_path` is set |
| `include_traces` | include tracebacks in the console stream |
| `debug_logs_in_console` | human-readable console output instead of JSON |
| `console_streams` | which streams reach stdout, from `app`, `siem` and `debug`; defaults to `["app", "siem"]`, and empty silences stdout |
| `correlation_id_expected` | log when a request arrives without a correlation id |
| `trust_forwarded_for` | read the client ip from `X-Forwarded-For`; only where a proxy rewrites it |
| `access_logs` | log a record per request; **off by default**, so nothing is access-logged until an application asks |
Expand Down Expand Up @@ -174,9 +174,17 @@ a stream that would otherwise have stayed quietly empty reports itself instead.

### What reaches the console

With `debug_logs_in_console = False` the console carries the app stream only, so
a SIEM-only event is not printed there. That is intended, but during development
it reads as "nothing was logged": check the stream, not the terminal.
The console is plain text; JSON goes to syslog. By default both the app and SIEM
streams reach stdout, each as a separate line with its own field allow-list.

```python
ConfigLogging(syslog_path="log-server:5514", console_streams=["app"]) # app only
ConfigLogging(syslog_path="log-server:5514", console_streams=[]) # silence stdout
```

An empty list silences stdout, leaving Python's own WARNING-and-above handler
on stderr. Silencing stdout with no `syslog_path` set leaves no handlers at all.

`configure()` builds its dict config through `LogConfigBuilder`, which is
exported for an application that needs to inspect or extend it.

Expand Down
3 changes: 2 additions & 1 deletion gfmodules/logging/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ def configure(
register_access_logs(config.access_logs)
register_catalogue(catalogue, access_logs=config.access_logs)
register_logger_root(logger_root)
document = LogConfigBuilder(logging_config=config, loglevel=level).build()
try:
dictConfig(LogConfigBuilder(logging_config=config, loglevel=level).build())
dictConfig(document)
except ValueError as exc:
if not config.syslog_path:
raise
Expand Down
4 changes: 3 additions & 1 deletion gfmodules/logging/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ class ConfigLogging(BaseModel):
syslog_path: str | None = Field(default=None)
application_id: str | None = Field(default=None)
include_traces: bool = Field(default=True)
debug_logs_in_console: bool = Field(default=False)
correlation_id_expected: bool = Field(default=False)
# Whether to include access logs per request.
access_logs: bool = Field(default=False)
# Only enable this where a proxy rewrites X-Forwarded-For; anywhere else the caller might set it.
trust_forwarded_for: bool = Field(default=False)
# Which streams reach stdout as readable text, from "app", "siem" and "debug".
# Empty leaves stdout silent.
console_streams: list[str] = Field(default=["app", "siem"])
102 changes: 69 additions & 33 deletions gfmodules/logging/config_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from gfmodules.logging.loggers import active_logger_root
from gfmodules.logging.streams import LoggingStreams

CONSOLE_STREAMS = ("app", "siem", "debug")


def _at_least(loglevel: str, floor: int) -> str:
numeric = logging.getLevelNamesMapping().get(loglevel.upper())
Expand All @@ -24,7 +26,7 @@ def __init__(
self.loglevel = loglevel
self.logging_config = logging_config

def _syslog_handler(self, path: str, formatter: str = "json", filters: list[str] | None = None) -> dict[str, Any]:
def _syslog_handler(self, path: str, formatter: str, filters: list[str] | None = None) -> dict[str, Any]:
host, port_str = path.rsplit(":", 1)
cfg: dict[str, Any] = {
"class": "logging.handlers.SysLogHandler",
Expand All @@ -36,22 +38,7 @@ def _syslog_handler(self, path: str, formatter: str = "json", filters: list[str]
return cfg

def build(self) -> dict[str, Any]:
if self.logging_config.debug_logs_in_console:
console: dict[str, Any] = {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "plain",
"stream": "ext://sys.stdout",
}
else:
console = {
"class": "logging.StreamHandler",
"level": self.loglevel,
"formatter": "json_traces" if self.logging_config.include_traces else "json",
"filters": ["app_filter"],
"stream": "ext://sys.stdout",
}

traces = self.logging_config.include_traces
conf: dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
Expand All @@ -61,14 +48,6 @@ def build(self) -> dict[str, Any]:
"public_inspect_filter": {"()": PublicInspectFilter},
},
"formatters": {
"json": {
"()": JsonFormatter,
"include_traces": False,
},
"json_traces": {
"()": JsonFormatter,
"include_traces": True,
},
# Only a formatter bound to a stream applies that stream's field
# allow-list, and its stream_id is how the log server splits the
# shared syslog channel again.
Expand All @@ -95,26 +74,38 @@ def build(self) -> dict[str, Any]:
"include_traces": True,
"stream_id": "debug",
},
"plain": {
"plain_app": {
"()": PlainTextFormatter,
"include_traces": traces,
"stream": LoggingStreams.APP,
"stream_id": "app",
},
"plain_siem": {
"()": PlainTextFormatter,
"include_traces": traces,
"stream": LoggingStreams.SIEM,
"stream_id": "siem",
},
"plain_debug": {
"()": PlainTextFormatter,
"include_traces": traces,
"stream_id": "debug",
},
},
"handlers": {
"console": console,
},
"handlers": {},
"loggers": {
active_logger_root(): {
"handlers": ["console"],
"handlers": [],
"level": self.loglevel,
"propagate": False,
},
"uvicorn": {
"handlers": ["console"],
"handlers": [],
"level": self.loglevel,
"propagate": False,
},
"uvicorn.error": {
"handlers": ["console"],
"handlers": [],
"level": self.loglevel,
"propagate": False,
},
Expand All @@ -131,18 +122,63 @@ def build(self) -> dict[str, Any]:
"propagate": True,
},
},
"root": {"handlers": ["console"], "level": self.loglevel},
"root": {"handlers": [], "level": self.loglevel},
}

if self.logging_config.application_id:
for formatter in conf["formatters"].values():
if formatter["()"] is JsonFormatter:
formatter["application_id"] = self.logging_config.application_id

self._add_console_streams(conf)
self._add_log_handlers(conf)

return conf

def _selected_console_streams(self) -> list[str]:
unknown = [name for name in self.logging_config.console_streams if name not in CONSOLE_STREAMS]
if unknown:
raise ValueError(f"unknown console_streams {unknown}, choose from {list(CONSOLE_STREAMS)}")

return list(dict.fromkeys(self.logging_config.console_streams))

def _add_console_streams(self, conf: dict[str, Any]) -> None:
app_logger_handlers = conf["loggers"][active_logger_root()]["handlers"]
uvicorn_handlers = conf["loggers"]["uvicorn"]["handlers"]
uvicorn_error_handlers = conf["loggers"]["uvicorn.error"]["handlers"]
root_handlers = conf["root"]["handlers"]

bindings: dict[str, tuple[str, str | None, str, list[list[str]]]] = {
"app": (
"plain_app",
"app_filter",
self.loglevel,
[app_logger_handlers, uvicorn_handlers, uvicorn_error_handlers],
),
"siem": ("plain_siem", "siem_filter", self.loglevel, [app_logger_handlers]),
"debug": (
"plain_debug",
None,
"DEBUG",
[app_logger_handlers, uvicorn_handlers, uvicorn_error_handlers, root_handlers],
),
}

for stream_name in self._selected_console_streams():
formatter, filter_name, level, logger_handler_lists = bindings[stream_name]
handler_name = f"console_{stream_name}"
handler: dict[str, Any] = {
"class": "logging.StreamHandler",
"level": level,
"formatter": formatter,
"stream": "ext://sys.stdout",
}
if filter_name:
handler["filters"] = [filter_name]
conf["handlers"][handler_name] = handler
for logger_handlers in logger_handler_lists:
logger_handlers.append(handler_name)

def _add_log_handlers(self, conf: dict[str, Any]) -> None:
path = self.logging_config.syslog_path
if not path:
Expand Down
14 changes: 11 additions & 3 deletions gfmodules/logging/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,28 @@ def format(self, record: logging.LogRecord) -> str:


class PlainTextFormatter(logging.Formatter):
def __init__(self, stream: LoggingStreams | None = None) -> None:
def __init__(
self,
include_traces: bool = True,
stream: LoggingStreams | None = None,
stream_id: str | None = None,
) -> None:
super().__init__()
self.include_traces = include_traces
self.stream = stream
self.stream_id = stream_id

def format(self, record: logging.LogRecord) -> str:
timestamp = datetime.fromtimestamp(record.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
event_id = getattr(record, "event_id", None) or "-"
base = f"{timestamp} {record.levelname:<8} {record.name} [{event_id}] {_sanitize_message(record.getMessage())}"
stream_tag = f" [{self.stream_id}]" if self.stream_id else ""
base = f"{timestamp}{stream_tag} {record.levelname:<8} {record.name} [{event_id}] {_sanitize_message(record.getMessage())}"

data = {**collect_context(), **_collect_extras(record)}
pairs = [f"{key}={value}" for key, value in _allowed_on(record, self.stream, data).items()]

out = base if not pairs else f"{base} {' '.join(pairs)}"

if record.exc_info:
if record.exc_info and self.include_traces:
out = f"{out}\n{self.formatException(record.exc_info)}"
return out
10 changes: 7 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,31 @@ def test_defaults_match_the_pre_extraction_behaviour() -> None:
assert config.syslog_path is None
assert config.application_id is None
assert config.include_traces is True
assert config.debug_logs_in_console is False
assert config.console_streams == ["app", "siem"]
assert config.correlation_id_expected is False


def test_access_logging_is_off_until_an_application_asks_for_it() -> None:
assert ConfigLogging().access_logs is False


def test_an_empty_console_selection_is_kept_rather_than_read_as_the_default() -> None:
assert ConfigLogging(console_streams=[]).console_streams == []


def test_accepts_an_application_supplied_configuration() -> None:
config = ConfigLogging(
syslog_path="syslog:5514",
application_id="example-service",
include_traces=False,
debug_logs_in_console=True,
console_streams=["app", "debug"],
correlation_id_expected=True,
access_logs=True,
)

assert config.syslog_path == "syslog:5514"
assert config.application_id == "example-service"
assert config.include_traces is False
assert config.debug_logs_in_console is True
assert config.console_streams == ["app", "debug"]
assert config.correlation_id_expected is True
assert config.access_logs is True
Loading
Loading