Skip to content

Commit fa5ae28

Browse files
add lambda request id in logs events (#248)
* add lambda request id in logs events * linting fix * revoked custom formatter and reapplied JsonFormatter * revoked formatting * added wrapper for registering requestid * Adds unit tests for log format --------- Co-authored-by: Shweta <216860557+shweta-nhs@users.noreply.github.com>
1 parent 50cf200 commit fa5ae28

7 files changed

Lines changed: 172 additions & 17 deletions

File tree

src/eligibility_signposting_api/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from eligibility_signposting_api import audit, repos, services
1111
from eligibility_signposting_api.common.error_handler import handle_exception
1212
from eligibility_signposting_api.common.request_validator import validate_request_params
13-
from eligibility_signposting_api.config.config import config, init_logging
13+
from eligibility_signposting_api.config.config import config
14+
from eligibility_signposting_api.logging.logs_manager import add_request_id_to_logs, init_logging
1415
from eligibility_signposting_api.views import eligibility_blueprint
1516

1617
init_logging()
@@ -23,6 +24,7 @@ def main() -> None: # pragma: no cover
2324
app.run(debug=config()["log_level"] == logging.DEBUG)
2425

2526

27+
@add_request_id_to_logs()
2628
@validate_request_params()
2729
def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover
2830
"""Run the Flask app as an AWS Lambda."""

src/eligibility_signposting_api/audit/audit_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ class AuditCondition(CamelCaseBaseModel):
8686

8787
class ResponseAuditData(CamelCaseBaseModel):
8888
response_id: UUID | None = None
89-
last_updated: str | None = None
89+
last_updated: datetime | None = None
9090
condition: list[AuditCondition] = Field(default_factory=list)
9191

9292

src/eligibility_signposting_api/config/config.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import logging
22
import os
3-
from collections.abc import Sequence
43
from functools import cache
54
from typing import Any, NewType
65

7-
from pythonjsonlogger.json import JsonFormatter
86
from yarl import URL
97

108
from eligibility_signposting_api.repos.campaign_repo import BucketName
@@ -57,16 +55,3 @@ def config() -> dict[str, Any]:
5755
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
5856
"log_level": log_level,
5957
}
60-
61-
62-
def init_logging(quieten: Sequence[str] = ("asyncio", "botocore", "boto3", "mangum", "urllib3")) -> None:
63-
log_format = "%(asctime)s %(levelname)-8s %(name)s %(module)s.py:%(funcName)s():%(lineno)d %(message)s"
64-
formatter = JsonFormatter(log_format)
65-
handler = logging.StreamHandler()
66-
handler.setFormatter(formatter)
67-
logging.root.handlers = [] # Clear any existing handlers
68-
logging.root.setLevel(LOG_LEVEL) # Set log level
69-
logging.root.addHandler(handler) # Add handler
70-
71-
for q in quieten:
72-
logging.getLogger(q).setLevel(logging.WARNING)

src/eligibility_signposting_api/logging/__init__.py

Whitespace-only changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import logging
2+
from collections.abc import Callable, Sequence
3+
from contextvars import ContextVar
4+
from functools import wraps
5+
from typing import Any
6+
7+
from mangum.types import LambdaContext, LambdaEvent
8+
from pythonjsonlogger.json import JsonFormatter
9+
10+
from eligibility_signposting_api.config.config import LOG_LEVEL
11+
12+
request_id_context_var: ContextVar[str | None] = ContextVar("request_id", default=None)
13+
14+
LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s %(module)s.py:%(funcName)s():%(lineno)d %(message)s"
15+
16+
17+
def add_request_id_to_logs() -> Callable:
18+
def decorator(func: Callable) -> Callable:
19+
@wraps(func)
20+
def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None:
21+
aws_request_id = request_id_context_var.set(context.aws_request_id)
22+
try:
23+
return func(event, context)
24+
finally:
25+
request_id_context_var.reset(aws_request_id)
26+
27+
return wrapper
28+
29+
return decorator
30+
31+
32+
class EnrichedJsonFormatter(JsonFormatter):
33+
def add_fields(self, log_record: dict[str, Any], record: logging.LogRecord, message_dict: dict[str, Any]) -> None:
34+
log_record["request_id"] = request_id_context_var.get() or "-"
35+
super().add_fields(log_record, record, message_dict)
36+
37+
38+
def init_logging(quieten: Sequence[str] = ("asyncio", "botocore", "boto3", "mangum", "urllib3")) -> None:
39+
formatter = EnrichedJsonFormatter(LOG_FORMAT)
40+
handler = logging.StreamHandler()
41+
handler.setFormatter(formatter)
42+
43+
logging.root.handlers = [] # Remove default handlers
44+
logging.root.setLevel(LOG_LEVEL)
45+
logging.root.addHandler(handler)
46+
47+
for q in quieten:
48+
logging.getLogger(q).setLevel(logging.WARNING)

tests/unit/logging/__init__.py

Whitespace-only changes.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import io
2+
import json
3+
import logging
4+
import threading
5+
from http import HTTPStatus
6+
from unittest.mock import MagicMock, Mock
7+
8+
import pytest
9+
from mangum.types import LambdaContext
10+
11+
from eligibility_signposting_api.logging.logs_manager import (
12+
LOG_FORMAT,
13+
EnrichedJsonFormatter,
14+
add_request_id_to_logs,
15+
request_id_context_var,
16+
)
17+
18+
19+
def test_decorator_sets_request_id_in_context():
20+
test_request_id = "test-id-12345"
21+
mock_context = MagicMock()
22+
mock_context.aws_request_id = test_request_id
23+
24+
@add_request_id_to_logs()
25+
def decorated_handler(event, context): # noqa : ARG001
26+
return request_id_context_var.get()
27+
28+
result = decorated_handler({}, mock_context)
29+
30+
assert result == test_request_id
31+
32+
33+
def test_decorator_preserves_function_return_value():
34+
expected_result = {"statusCode": 200, "body": "Success"}
35+
mock_context = MagicMock()
36+
mock_context.aws_request_id = "any-id"
37+
38+
@add_request_id_to_logs()
39+
def decorated_handler(event, context): # noqa : ARG001
40+
return expected_result
41+
42+
result = decorated_handler({}, mock_context)
43+
44+
assert result == expected_result
45+
46+
47+
def test_request_id_context_is_properly_isolated():
48+
results = {}
49+
50+
@add_request_id_to_logs()
51+
def decorated_handler(event, context): # noqa : ARG001
52+
rid = request_id_context_var.get()
53+
results[threading.current_thread().name] = rid
54+
return rid
55+
56+
def thread_func(name, rid): # noqa : ARG001
57+
mock_context = MagicMock(aws_request_id=rid)
58+
decorated_handler({}, mock_context)
59+
60+
threads = [
61+
threading.Thread(target=thread_func, name="Thread-A", args=("Thread-A", "id-A")),
62+
threading.Thread(target=thread_func, name="Thread-B", args=("Thread-B", "id-B")),
63+
threading.Thread(target=thread_func, name="Thread-C", args=("Thread-C", "id-C")),
64+
]
65+
66+
for t in threads:
67+
t.start()
68+
for t in threads:
69+
t.join()
70+
71+
assert results["Thread-A"] == "id-A"
72+
assert request_id_context_var.get() is None
73+
74+
assert results["Thread-B"] == "id-B"
75+
assert request_id_context_var.get() is None
76+
77+
assert results["Thread-C"] == "id-C"
78+
assert request_id_context_var.get() is None
79+
80+
81+
@pytest.fixture
82+
def lambda_context():
83+
context = Mock(spec=LambdaContext)
84+
context.aws_request_id = "test-request-id"
85+
return context
86+
87+
88+
def test_enriched_json_formatter_adds_all_fields(lambda_context):
89+
@add_request_id_to_logs()
90+
def test_handler(event, context): # noqa : ARG001
91+
logger = logging.getLogger("test_logger")
92+
logger.info("Test log inside handler")
93+
return HTTPStatus.OK
94+
95+
log_stream = io.StringIO()
96+
handler = logging.StreamHandler(log_stream)
97+
handler.setFormatter(EnrichedJsonFormatter(LOG_FORMAT))
98+
99+
test_logger = logging.getLogger("test_logger")
100+
test_logger.handlers = []
101+
test_logger.addHandler(handler)
102+
test_logger.setLevel(logging.INFO)
103+
104+
result = test_handler({}, lambda_context)
105+
log_output = log_stream.getvalue()
106+
107+
test_logger.removeHandler(handler)
108+
109+
assert result == HTTPStatus.OK
110+
logged_json = json.loads(log_output)
111+
112+
assert logged_json["request_id"] == lambda_context.aws_request_id
113+
assert "asctime" in logged_json
114+
assert logged_json["levelname"] == "INFO"
115+
assert logged_json["name"] == "test_logger"
116+
assert logged_json["module"] == "test_logs_manager"
117+
assert logged_json["funcName"] == "test_handler"
118+
assert "lineno" in logged_json
119+
assert logged_json["message"] == "Test log inside handler"
120+
assert request_id_context_var.get() is None

0 commit comments

Comments
 (0)