Skip to content

Commit ee62364

Browse files
committed
feat(logging): structured JSON logs, request IDs, secret redaction
Rewrite logging_service with a JsonFormatter, a RedactionFilter that masks configured S3 credentials, and a RequestIdFilter closes #177
1 parent 56913c1 commit ee62364

5 files changed

Lines changed: 196 additions & 18 deletions

File tree

app/__init__.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,23 @@
77
from app.crates.ids import InvalidCrateId
88
from app.crates.resolver import CrateNotFound, AmbiguousCrate
99
from app.ro_crates.routes import v1_post_bp, v1_minio_post_bp, v1_minio_get_bp
10+
from app.services.logging_service import (
11+
new_request_id,
12+
set_request_id,
13+
get_request_id,
14+
)
1015
from app.storage.errors import StorageError
1116
from app.utils.config import (
1217
Settings,
1318
InvalidAPIUsage,
1419
make_celery,
1520
)
16-
from flask import jsonify
21+
from flask import jsonify, request
1722

1823
logger = logging.getLogger(__name__)
1924

25+
REQUEST_ID_HEADER = "X-Request-ID"
26+
2027

2128
def create_app(settings: Settings | None = None) -> APIFlask:
2229
"""
@@ -52,10 +59,14 @@ def create_app(settings: Settings | None = None) -> APIFlask:
5259
else:
5360
logger.info("Storage disabled: only metadata validation is available.")
5461

55-
if app.debug:
56-
print("URL Map:")
57-
for rule in app.url_map.iter_rules():
58-
print(rule)
62+
@app.before_request
63+
def assign_request_id():
64+
set_request_id(request.headers.get(REQUEST_ID_HEADER) or new_request_id())
65+
66+
@app.after_request
67+
def attach_request_id(response):
68+
response.headers[REQUEST_ID_HEADER] = get_request_id()
69+
return response
5970

6071
@app.errorhandler(InvalidAPIUsage)
6172
def invalid_api_usage(e):

app/services/logging_service.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,90 @@
1-
"""Logging service for the application."""
2-
3-
# Author: Alexander Hambley
4-
# License: MIT
5-
# Copyright (c) 2025 eScience Lab, The University of Manchester
1+
"""Structured JSON logging with request IDs and secret redaction."""
62

3+
import json
74
import logging
5+
import uuid
6+
7+
from contextvars import ContextVar
8+
from typing import Iterable, Optional
9+
10+
# correlation ID readable from any logging call in the same context. per request basis.
11+
# (request handler or Celery task). Defaults to "-" when unset.
12+
_request_id: ContextVar[str] = ContextVar("request_id", default="-")
13+
14+
15+
def new_request_id() -> str:
16+
"""Return a fresh, unique request ID."""
17+
return str(uuid.uuid4())
18+
19+
20+
def set_request_id(request_id: Optional[str]) -> None:
21+
"""Set the current request ID (``None`` resets to the default)."""
22+
_request_id.set(request_id or "-")
23+
24+
25+
def get_request_id() -> str:
26+
"""Return the current request ID, or ``"-"`` if unset."""
27+
return _request_id.get()
28+
29+
30+
class RequestIdFilter(logging.Filter):
31+
"""Attaches the current request ID to every log record."""
832

33+
def filter(self, record: logging.LogRecord) -> bool:
34+
record.request_id = get_request_id()
35+
return True
936

10-
def setup_logging(level: int = logging.INFO) -> None:
37+
38+
class RedactionFilter(logging.Filter):
39+
"""Masks known secret values wherever they appear in a log message."""
40+
41+
def __init__(self, secrets: Iterable[Optional[str]]):
42+
super().__init__()
43+
self._secrets = [s for s in secrets if s]
44+
45+
def filter(self, record: logging.LogRecord) -> bool:
46+
if self._secrets:
47+
message = record.getMessage()
48+
for secret in self._secrets:
49+
message = message.replace(secret, "***")
50+
record.msg = message
51+
record.args = None
52+
return True
53+
54+
55+
class JsonFormatter(logging.Formatter):
56+
"""Formats log records as single-line JSON."""
57+
58+
def format(self, record: logging.LogRecord) -> str:
59+
payload = {
60+
"timestamp": self.formatTime(record),
61+
"level": record.levelname,
62+
"logger": record.name,
63+
"message": record.getMessage(),
64+
"request_id": getattr(record, "request_id", "-"),
65+
}
66+
if record.exc_info:
67+
payload["exc_info"] = self.formatException(record.exc_info)
68+
return json.dumps(payload)
69+
70+
71+
def setup_logging(settings=None, level: int = logging.INFO) -> None:
1172
"""
12-
Configure the logging for the application.
73+
Configure root logging: JSON output, request IDs, and secret redaction.
1374
14-
:param level: The logging level to set. Defaults to INFO.
75+
:param settings: Optional Settings; its credentials are redacted from logs.
76+
:param level: The logging level to set.
1577
"""
16-
logging.basicConfig(
17-
level=level,
18-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
19-
)
78+
secrets = []
79+
if settings is not None:
80+
secrets = [settings.s3_secret_key, settings.s3_access_key]
81+
82+
handler = logging.StreamHandler()
83+
handler.setFormatter(JsonFormatter())
84+
handler.addFilter(RequestIdFilter())
85+
handler.addFilter(RedactionFilter(secrets))
86+
87+
root = logging.getLogger()
88+
root.handlers.clear()
89+
root.addHandler(handler)
90+
root.setLevel(level)

cratey.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from app.services.logging_service import setup_logging
99

1010
app = create_app()
11-
setup_logging()
11+
setup_logging(app.config["SETTINGS"])
1212

1313
if __name__ == "__main__":
1414
# Run the Flask development server:

tests/test_app_factory.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,25 @@ def test_storage_routes_registered_when_enabled():
5959
def test_profiles_path_exposed_to_app_config():
6060
app = create_app(settings=Settings.from_env({"PROFILES_PATH": "/custom/profiles"}))
6161
assert app.config["PROFILES_PATH"] == "/custom/profiles"
62+
63+
64+
def test_response_includes_generated_request_id_header():
65+
app = create_app(settings=Settings.from_env({}))
66+
client = app.test_client()
67+
68+
response = client.post("/v1/ro_crates/validate_metadata", json={"crate_json": "{}"})
69+
70+
assert response.headers.get("X-Request-ID")
71+
72+
73+
def test_incoming_request_id_is_echoed():
74+
app = create_app(settings=Settings.from_env({}))
75+
client = app.test_client()
76+
77+
response = client.post(
78+
"/v1/ro_crates/validate_metadata",
79+
json={"crate_json": "{}"},
80+
headers={"X-Request-ID": "caller-supplied-id"},
81+
)
82+
83+
assert response.headers["X-Request-ID"] == "caller-supplied-id"

tests/test_logging.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Tests for structured logging, request IDs, and secret redaction."""
2+
3+
import json
4+
import logging
5+
6+
from app.services.logging_service import (
7+
JsonFormatter,
8+
RedactionFilter,
9+
RequestIdFilter,
10+
set_request_id,
11+
get_request_id,
12+
new_request_id,
13+
)
14+
15+
16+
def _record(msg, args=None):
17+
return logging.LogRecord("svc", logging.INFO, "path", 1, msg, args, None)
18+
19+
20+
def test_json_formatter_emits_expected_fields():
21+
record = _record("hello")
22+
record.request_id = "r1"
23+
24+
payload = json.loads(JsonFormatter().format(record))
25+
26+
assert payload["level"] == "INFO"
27+
assert payload["logger"] == "svc"
28+
assert payload["message"] == "hello"
29+
assert payload["request_id"] == "r1"
30+
assert "timestamp" in payload
31+
32+
33+
def test_redaction_filter_masks_secret_values():
34+
redact = RedactionFilter(["supersecret", "AKIAEXAMPLE"])
35+
record = _record("connecting with key=%s token=%s", ("AKIAEXAMPLE", "supersecret"))
36+
37+
redact.filter(record)
38+
39+
message = record.getMessage()
40+
assert "supersecret" not in message
41+
assert "AKIAEXAMPLE" not in message
42+
assert message.count("***") == 2
43+
44+
45+
def test_redaction_filter_ignores_empty_secrets():
46+
redact = RedactionFilter([None, "", "real"])
47+
record = _record("value=real")
48+
redact.filter(record)
49+
assert record.getMessage() == "value=***"
50+
51+
52+
def test_request_id_filter_injects_current_id():
53+
set_request_id("abc-123")
54+
record = _record("anything")
55+
56+
RequestIdFilter().filter(record)
57+
58+
assert record.request_id == "abc-123"
59+
60+
61+
def test_request_id_filter_defaults_when_unset():
62+
set_request_id(None)
63+
record = _record("anything")
64+
RequestIdFilter().filter(record)
65+
assert record.request_id == "-"
66+
67+
68+
def test_new_request_id_is_unique():
69+
assert new_request_id() != new_request_id()
70+
71+
72+
def test_get_request_id_round_trips():
73+
set_request_id("xyz")
74+
assert get_request_id() == "xyz"

0 commit comments

Comments
 (0)