Skip to content

Commit 573a6b6

Browse files
authored
Merge pull request #49 from minvws/log
Added single stream logging
2 parents 5166ac5 + b355a1d commit 573a6b6

6 files changed

Lines changed: 139 additions & 59 deletions

File tree

app.conf.example

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,14 @@ aes_key_id = 12345678
1313
aes_mechanism = AES_CBC
1414

1515
[logging]
16-
# Syslog addresses in host:port format (UDP)
17-
# Application logs go to stdout and to handler
18-
app_path = syslog:5514
19-
# Siem is for security events
20-
siem_path = syslog:5515
21-
# Logs for public inspection
22-
public_inspect_path = syslog:5516
23-
# Logs for debugging purposes
24-
debug_path = syslog:5517
16+
# All keys are optional. When syslog_path is omitted, logs only go to stdout.
17+
# Single syslog channel in host:port format (UDP). All streams (app, siem,
18+
# public_inspect, debug) are sent here; each JSON record carries a stream_id
19+
# field so the log server can tell them apart.
20+
syslog_path = syslog:5514
21+
# Identifies this application in the JSON records (application_id field),
22+
# so the log server can tell apart applications sharing the channel.
23+
application_id = nationale-verwijsindex-crypto-service-api
2524
# Include exception/stack traces in JSON logs
2625
include_traces = True
2726
# Whether to display debug logs in the console

app/config.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,8 @@ class ConfigApp(BaseModel):
3333

3434

3535
class ConfigLogging(BaseModel):
36-
app_path: str | None = Field(default=None)
37-
siem_path: str | None = Field(default=None)
38-
public_inspect_path: str | None = Field(default=None)
39-
debug_path: str | None = Field(default=None)
36+
syslog_path: str | None = Field(default=None)
37+
application_id: str | None = Field(default=None)
4038
include_traces: bool = Field(default=True)
4139
debug_logs_in_console: bool = Field(default=False)
4240

app/logging/config_builder.py

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,29 @@ def build(self) -> dict[str, Any]:
6363
"()": JsonFormatter,
6464
"include_traces": True,
6565
},
66+
# Stream-bound formatters stamp the record with a stream_id so the
67+
# log server can tell the streams apart on the shared syslog channel.
68+
# APP == "stroom 2", SIEM == "stroom 3".
69+
"json_app": {
70+
"()": JsonFormatter,
71+
"include_traces": False,
72+
"stream_id": "app",
73+
},
74+
"json_siem": {
75+
"()": JsonFormatter,
76+
"include_traces": False,
77+
"stream_id": "siem",
78+
},
79+
"json_public_inspect": {
80+
"()": JsonFormatter,
81+
"include_traces": False,
82+
"stream_id": "public_inspect",
83+
},
84+
"json_debug": {
85+
"()": JsonFormatter,
86+
"include_traces": True,
87+
"stream_id": "debug",
88+
},
6689
"plain": {
6790
"()": PlainTextFormatter,
6891
},
@@ -96,40 +119,43 @@ def build(self) -> dict[str, Any]:
96119
"root": {"handlers": ["console"], "level": self.loglevel},
97120
}
98121

122+
# Stamp every JSON record with the configured application id so the
123+
# log server can tell apart applications sharing the syslog channel.
124+
if self.logging_config.application_id:
125+
for formatter in conf["formatters"].values():
126+
if formatter["()"] is JsonFormatter:
127+
formatter["application_id"] = self.logging_config.application_id
128+
99129
self._add_log_handlers(conf)
100130

101131
return conf
102132

103133
def _add_log_handlers(self, conf: dict[str, Any]) -> None:
134+
# All streams share one syslog channel; each handler stamps its records
135+
# with a stream_id via its formatter so the log server can split them.
136+
path = self.logging_config.syslog_path
137+
if not path:
138+
return
139+
104140
app_logger_handlers = conf["loggers"]["app"]["handlers"]
105141
uvicorn_handlers = conf["loggers"]["uvicorn"]["handlers"]
106142
uvicorn_error_handlers = conf["loggers"]["uvicorn.error"]["handlers"]
107143

108-
if self.logging_config.app_path:
109-
conf["handlers"]["app_syslog"] = self._syslog_handler(
110-
self.logging_config.app_path, filters=["app_filter"]
111-
)
112-
app_logger_handlers.append("app_syslog")
113-
uvicorn_handlers.append("app_syslog")
114-
uvicorn_error_handlers.append("app_syslog")
144+
conf["handlers"]["syslog_app"] = self._syslog_handler(path, formatter="json_app", filters=["app_filter"])
145+
app_logger_handlers.append("syslog_app")
146+
uvicorn_handlers.append("syslog_app")
147+
uvicorn_error_handlers.append("syslog_app")
115148

116-
if self.logging_config.siem_path:
117-
conf["handlers"]["siem"] = self._syslog_handler(
118-
self.logging_config.siem_path, filters=["siem_filter"]
119-
)
120-
app_logger_handlers.append("siem")
149+
conf["handlers"]["syslog_siem"] = self._syslog_handler(path, formatter="json_siem", filters=["siem_filter"])
150+
app_logger_handlers.append("syslog_siem")
121151

122-
if self.logging_config.public_inspect_path:
123-
conf["handlers"]["public_inspect"] = self._syslog_handler(
124-
self.logging_config.public_inspect_path, filters=["public_inspect_filter"]
125-
)
126-
app_logger_handlers.append("public_inspect")
152+
conf["handlers"]["syslog_public_inspect"] = self._syslog_handler(
153+
path, formatter="json_public_inspect", filters=["public_inspect_filter"]
154+
)
155+
app_logger_handlers.append("syslog_public_inspect")
127156

128-
if self.logging_config.debug_path:
129-
conf["handlers"]["debug"] = self._syslog_handler(
130-
self.logging_config.debug_path, formatter="json_traces"
131-
)
132-
app_logger_handlers.append("debug")
133-
uvicorn_handlers.append("debug")
134-
uvicorn_error_handlers.append("debug")
135-
conf["root"]["handlers"].append("debug")
157+
conf["handlers"]["syslog_debug"] = self._syslog_handler(path, formatter="json_debug")
158+
app_logger_handlers.append("syslog_debug")
159+
uvicorn_handlers.append("syslog_debug")
160+
uvicorn_error_handlers.append("syslog_debug")
161+
conf["root"]["handlers"].append("syslog_debug")

app/logging/formatter.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ def _collect_extras(record: logging.LogRecord) -> dict[str, Any]:
3939
class JsonFormatter(logging.Formatter):
4040
"""Structured JSON formatter for the debug-json view.
4141
42+
All streams share a single syslog channel; ``stream_id`` tells the log
43+
server which stream (app, siem, public_inspect, debug) a record belongs
44+
to, and ``application_id`` which application emitted it.
45+
4246
Example output:
4347
{
48+
"application_id": "nationale-verwijsindex-crypto-service-api",
49+
"stream_id": "app",
4450
"event_id": "100601",
4551
"timestamp": "2026-04-23T10:11:12Z",
4652
"level": "INFO",
@@ -54,9 +60,16 @@ class JsonFormatter(logging.Formatter):
5460
}
5561
}
5662
"""
57-
def __init__(self, include_traces: bool = True) -> None:
63+
def __init__(
64+
self,
65+
include_traces: bool = True,
66+
stream_id: str | None = None,
67+
application_id: str | None = None,
68+
) -> None:
5869
super().__init__()
5970
self.include_traces = include_traces
71+
self.stream_id = stream_id
72+
self.application_id = application_id
6073

6174
def format(self, record: logging.LogRecord) -> str:
6275
message: dict[str, Any] = {}
@@ -76,6 +89,10 @@ def format(self, record: logging.LogRecord) -> str:
7689
"event_description": _sanitize_message(record.getMessage()),
7790
"source": f"{record.module}:{record.lineno}",
7891
}
92+
if self.application_id is not None:
93+
log_record["application_id"] = self.application_id
94+
if self.stream_id is not None:
95+
log_record["stream_id"] = self.stream_id
7996
log_record["message"] = message
8097

8198
return json.dumps(log_record, default=str)

tests/unit/logging/test_config_builder.py

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,38 +46,59 @@ def test_build_console_uses_json_when_traces_excluded() -> None:
4646
assert conf["handlers"]["console"]["formatter"] == "json"
4747

4848

49+
_SYSLOG_HANDLERS = ("syslog_app", "syslog_siem", "syslog_public_inspect", "syslog_debug")
50+
51+
4952
@pytest.mark.parametrize(
50-
"field,handler_name,filter_name",
53+
"handler_name,filter_name",
5154
[
52-
("app_path", "app_syslog", "app_filter"),
53-
("siem_path", "siem", "siem_filter"),
54-
("public_inspect_path", "public_inspect", "public_inspect_filter"),
55+
("syslog_app", "app_filter"),
56+
("syslog_siem", "siem_filter"),
57+
("syslog_public_inspect", "public_inspect_filter"),
5558
],
5659
)
57-
def test_build_path_adds_handler_with_filter(field: str, handler_name: str, filter_name: str) -> None:
58-
conf = _build(**{field: "host:514"})
60+
def test_build_syslog_path_adds_handler_with_filter(handler_name: str, filter_name: str) -> None:
61+
conf = _build(syslog_path="host:514")
5962
assert handler_name in conf["handlers"]
6063
handler = conf["handlers"][handler_name]
6164
assert handler["address"] == ("host", 514)
6265
assert filter_name in handler["filters"]
6366

6467

65-
def test_build_debug_path_adds_handler_to_root_and_app() -> None:
66-
conf = _build(debug_path="host:516")
67-
assert "debug" in conf["handlers"]
68-
assert "debug" in conf["loggers"]["app"]["handlers"]
69-
assert "debug" in conf["root"]["handlers"]
70-
assert conf["handlers"]["debug"]["formatter"] == "json_traces"
68+
def test_build_syslog_debug_handler_added_to_root_and_app() -> None:
69+
conf = _build(syslog_path="host:516")
70+
assert "syslog_debug" in conf["handlers"]
71+
assert "syslog_debug" in conf["loggers"]["app"]["handlers"]
72+
assert "syslog_debug" in conf["root"]["handlers"]
73+
assert conf["handlers"]["syslog_debug"]["formatter"] == "json_debug"
74+
75+
76+
def test_all_streams_share_the_single_syslog_channel() -> None:
77+
conf = _build(syslog_path="logserver:514")
78+
79+
assert {"console", *_SYSLOG_HANDLERS} == set(conf["handlers"].keys())
80+
for name in _SYSLOG_HANDLERS:
81+
assert conf["handlers"][name]["address"] == ("logserver", 514)
82+
83+
formatters = {conf["handlers"][name]["formatter"] for name in _SYSLOG_HANDLERS}
84+
stream_ids = {conf["formatters"][formatter].get("stream_id") for formatter in formatters}
85+
assert stream_ids == {"app", "siem", "public_inspect", "debug"}
86+
87+
88+
def test_application_id_is_stamped_on_all_json_formatters() -> None:
89+
conf = _build(syslog_path="logserver:514", application_id="nationale-verwijsindex-crypto-service-api")
90+
91+
for name, formatter in conf["formatters"].items():
92+
if name == "plain":
93+
assert "application_id" not in formatter
94+
else:
95+
assert formatter["application_id"] == "nationale-verwijsindex-crypto-service-api"
7196

7297

73-
def test_build_all_paths_configured() -> None:
74-
conf = _build(
75-
app_path="h:514",
76-
siem_path="h:515",
77-
public_inspect_path="h:516",
78-
debug_path="h:517",
79-
)
80-
assert {"console", "app_syslog", "siem", "public_inspect", "debug"} == set(conf["handlers"].keys())
98+
def test_no_application_id_without_config() -> None:
99+
conf = _build(syslog_path="logserver:514")
100+
for formatter in conf["formatters"].values():
101+
assert "application_id" not in formatter
81102

82103

83104
def test_syslog_handler_parses_host_port_and_filters() -> None:

tests/unit/logging/test_formatter.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,22 @@ def test_plaintext_formatter_includes_basic_fields(context_vars: None) -> None:
9393
def test_plaintext_formatter_appends_exception() -> None:
9494
out = PlainTextFormatter().format(_exc_record())
9595
assert "ValueError" in out
96+
97+
98+
def test_json_formatter_stamps_stream_id_and_application_id() -> None:
99+
# On the shared syslog channel the log server tells streams and
100+
# applications apart by the stream_id/application_id stamped per record.
101+
out = json.loads(
102+
JsonFormatter(
103+
stream_id="app",
104+
application_id="nationale-verwijsindex-crypto-service-api",
105+
).format(_record())
106+
)
107+
assert out["stream_id"] == "app"
108+
assert out["application_id"] == "nationale-verwijsindex-crypto-service-api"
109+
110+
111+
def test_json_formatter_omits_stream_id_and_application_id_when_unset() -> None:
112+
out = json.loads(JsonFormatter().format(_record()))
113+
assert "stream_id" not in out
114+
assert "application_id" not in out

0 commit comments

Comments
 (0)