Skip to content

Commit 4401bc0

Browse files
authored
Add rid to logs with asgi-correlation-id (#782)
* Add `asgi_correlation_id` package * Replace our RID middleware with `asgi_correlation_id`'s middleware * Provide logging outside uvicorn We now call `logging.dictConfig` in app.py, rather that passing the config dict to the `log_config` param of uvicorn. This is so that the configuration is picked up both in tests and when running the app. * Avoid using TestClient as context manager I don't know _why_ this was happening, but trying to use a breakpoint in test_request_summary_is_logged caused the test to hang. I found that using the fixture directly (i.e. not as a context manager) made the test work as expected
1 parent f5f7355 commit 4401bc0

File tree

11 files changed

+88
-72
lines changed

11 files changed

+88
-72
lines changed

asgi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
host=settings.host,
1414
port=settings.port,
1515
reload=settings.app_reload,
16-
log_config=LOGGING_CONFIG,
16+
log_config=None,
1717
)
1818
)
1919
server.run()

jbi/app.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,24 @@
88
from typing import Any, Awaitable, Callable
99

1010
import sentry_sdk
11+
from asgi_correlation_id import CorrelationIdMiddleware
1112
from fastapi import FastAPI, Request, Response, status
1213
from fastapi.encoders import jsonable_encoder
1314
from fastapi.exceptions import RequestValidationError
1415
from fastapi.responses import JSONResponse
1516
from fastapi.staticfiles import StaticFiles
1617

1718
from jbi.environment import get_settings, get_version
18-
from jbi.log import format_request_summary_fields
19+
from jbi.log import CONFIG, format_request_summary_fields
1920
from jbi.router import router
2021

2122
SRC_DIR = Path(__file__).parent
2223

2324
settings = get_settings()
2425
version_info = get_version()
2526

27+
logging.config.dictConfig(CONFIG)
28+
2629
logger = logging.getLogger(__name__)
2730

2831

@@ -54,16 +57,6 @@ def traces_sampler(sampling_context: dict[str, Any]) -> float:
5457
app.mount("/static", StaticFiles(directory=SRC_DIR / "static"), name="static")
5558

5659

57-
@app.middleware("http")
58-
async def request_id(
59-
request: Request, call_next: Callable[[Request], Awaitable[Response]]
60-
) -> Response:
61-
"""Read the request id from headers. This is set by NGinx."""
62-
request.state.rid = request.headers.get("X-Request-Id", token_hex(16))
63-
response = await call_next(request)
64-
return response
65-
66-
6760
@app.middleware("http")
6861
async def request_summary(
6962
request: Request, call_next: Callable[[Request], Awaitable[Response]]
@@ -106,3 +99,11 @@ async def validation_exception_handler(
10699
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
107100
content={"detail": jsonable_encoder(exc.errors())},
108101
)
102+
103+
104+
app.add_middleware(
105+
CorrelationIdMiddleware,
106+
header_name="X-Request-Id",
107+
generator=lambda: token_hex(16),
108+
validator=None,
109+
)

jbi/log.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,57 @@
88
from datetime import datetime
99
from typing import Optional
1010

11+
from asgi_correlation_id import CorrelationIdFilter
1112
from fastapi import Request
1213
from pydantic import BaseModel
1314

1415
from jbi.environment import get_settings
1516

1617
settings = get_settings()
1718

19+
20+
class RequestIdFilter(CorrelationIdFilter):
21+
"""Renames `correlation_id` log field to `rid`"""
22+
23+
def __init__(self, *args, **kwargs):
24+
super().__init__(*args, **kwargs)
25+
26+
def filter(self, record) -> bool:
27+
result = super().filter(record) # Apply the existing filter
28+
29+
# Rename the field from 'correlation_id' to 'rid'
30+
if hasattr(record, "correlation_id"):
31+
setattr(record, "rid", getattr(record, "correlation_id"))
32+
delattr(record, "correlation_id")
33+
34+
return result
35+
36+
1837
CONFIG = {
1938
"version": 1,
2039
"disable_existing_loggers": False,
40+
"filters": {
41+
"request_id": {
42+
"()": RequestIdFilter,
43+
"uuid_length": 32,
44+
"default_value": "-",
45+
},
46+
},
2147
"formatters": {
2248
"mozlog_json": {
2349
"()": "dockerflow.logging.JsonLogFormatter",
2450
"logger_name": "jbi",
2551
},
2652
"text": {
27-
"format": "%(asctime)s %(levelname)-8s %(name)-15s %(message)s",
53+
"format": "%(asctime)s %(levelname)-8s [%(rid)s] %(name)-15s %(message)s",
2854
"datefmt": "%Y-%m-%d %H:%M:%S",
2955
},
3056
},
3157
"handlers": {
3258
"console": {
3359
"level": settings.log_level.upper(),
3460
"class": "logging.StreamHandler",
61+
"filters": ["request_id"],
3562
"formatter": "text"
3663
if settings.log_format.lower() == "text"
3764
else "mozlog_json",
@@ -63,7 +90,6 @@ class RequestSummary(BaseModel):
6390
t: int
6491
time: str
6592
status_code: int
66-
rid: str
6793

6894

6995
def format_request_summary_fields(
@@ -82,5 +108,4 @@ def format_request_summary_fields(
82108
t=int((current_time - request_time) * 1000.0),
83109
time=datetime.fromtimestamp(current_time).isoformat(),
84110
status_code=status_code,
85-
rid=request.state.rid,
86111
).model_dump()

jbi/models.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,6 @@ def lookup_action(self, actions: Actions) -> Action:
286286
class BugzillaWebhookRequest(BaseModel):
287287
"""Bugzilla Webhook Request Object"""
288288

289-
rid: str = "" # This field has a default since it's not parsed from body.
290289
webhook_id: int
291290
webhook_name: str
292291
event: BugzillaWebhookEvent
@@ -362,7 +361,6 @@ class JiraContext(Context):
362361
class RunnerContext(Context, extra="forbid"):
363362
"""Logging context from runner"""
364363

365-
rid: str
366364
operation: Operation
367365
event: BugzillaWebhookEvent
368366
action: Optional[Action] = None
@@ -373,7 +371,6 @@ class ActionContext(Context, extra="forbid"):
373371
"""Logging context from actions"""
374372

375373
action: Action
376-
rid: str
377374
operation: Operation
378375
current_step: Optional[str] = None
379376
event: BugzillaWebhookEvent

jbi/router.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ def bugzilla_webhook(
7979
webhook_request: BugzillaWebhookRequest = Body(..., embed=False),
8080
):
8181
"""API endpoint that Bugzilla Webhook Events request"""
82-
webhook_request.rid = request.state.rid
8382
try:
8483
result = execute_action(webhook_request, actions)
8584
return result

jbi/runner.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ def execute_action(
152152
"""
153153
bug, event = request.bug, request.event
154154
runner_context = RunnerContext(
155-
rid=request.rid,
156155
bug=bug,
157156
event=event,
158157
operation=Operation.HANDLE,
@@ -190,7 +189,6 @@ def execute_action(
190189

191190
action_context = ActionContext(
192191
action=action,
193-
rid=request.rid,
194192
bug=bug,
195193
event=event,
196194
operation=Operation.IGNORE,

poetry.lock

Lines changed: 21 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ backoff = "^2.2.1"
1919
statsd = "^4.0.1"
2020
requests = "^2.31.0"
2121
pydantic-settings = "^2.1.0"
22+
asgi-correlation-id = "^4.2.0"
2223

2324
[tool.poetry.group.dev.dependencies]
2425
pre-commit = "^3.5.0"

tests/fixtures/factories.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ class Meta:
139139
model = models.ActionContext
140140

141141
action = factory.SubFactory(ActionFactory)
142-
rid = factory.LazyFunction(lambda: token_hex(16))
143142
operation = Operation.IGNORE
144143
bug = factory.SubFactory(BugFactory)
145144
event = factory.SubFactory(WebhookEventFactory)

tests/unit/services/test_jira.py

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ def test_get_issue(mocked_responses, action_context_factory, capturelogs):
161161

162162
assert response == mock_response_data
163163
for record in capturelogs.records:
164-
assert record.rid == context.rid
165164
assert record.action["whiteboard_tag"] == context.action.whiteboard_tag
166165

167166
before, after = capturelogs.messages
@@ -179,9 +178,6 @@ def test_get_issue_handles_404(mocked_responses, action_context_factory, capture
179178

180179
assert return_val is None
181180

182-
for record in capturelogs.records:
183-
assert record.rid == context.rid
184-
185181
before, after = capturelogs.records
186182
assert before.levelno == logging.DEBUG
187183
assert before.message == "Getting issue JBI-234"
@@ -202,7 +198,6 @@ def test_get_issue_reraises_other_erroring_status_codes(
202198
jira.get_service().get_issue(context=context, issue_key="JBI-234")
203199

204200
[record] = capturelogs.records
205-
assert record.rid == context.rid
206201
assert record.levelno == logging.DEBUG
207202
assert record.message == "Getting issue JBI-234"
208203

@@ -223,10 +218,6 @@ def test_update_issue_resolution(mocked_responses, action_context_factory, captu
223218
context=context, jira_resolution="DONEZO"
224219
)
225220

226-
for record in capturelogs.records:
227-
assert record.rid == context.rid
228-
assert record.levelno == logging.DEBUG
229-
230221
before, after = capturelogs.messages
231222
assert before == "Updating resolution of Jira issue JBI-234 to DONEZO"
232223
assert after == "Updated resolution of Jira issue JBI-234 to DONEZO"
@@ -252,10 +243,8 @@ def test_update_issue_resolution_raises(
252243
context=context, jira_resolution="DONEZO"
253244
)
254245

255-
[record] = capturelogs.records
256-
assert record.rid == context.rid
257-
assert record.levelno == logging.DEBUG
258-
assert record.message == "Updating resolution of Jira issue JBI-234 to DONEZO"
246+
[message] = capturelogs.messages
247+
assert message == "Updating resolution of Jira issue JBI-234 to DONEZO"
259248

260249

261250
def test_create_jira_issue(mocked_responses, action_context_factory, capturelogs):
@@ -287,10 +276,6 @@ def test_create_jira_issue(mocked_responses, action_context_factory, capturelogs
287276

288277
assert response == mocked_response_data
289278

290-
for record in capturelogs.records:
291-
assert record.rid == context.rid
292-
assert record.levelno == logging.DEBUG
293-
294279
before, after = capturelogs.records
295280
assert before.message == f"Creating new Jira issue for Bug {context.bug.id}"
296281
assert before.fields == issue_fields
@@ -333,12 +318,10 @@ def test_create_jira_issue_when_list_is_returned(
333318
before, after = capturelogs.records
334319
assert before.message == f"Creating new Jira issue for Bug {context.bug.id}"
335320
assert before.levelno == logging.DEBUG
336-
assert before.rid == context.rid
337321
assert before.fields == issue_fields
338322

339323
assert after.message == f"Jira issue JBI-234 created for Bug {context.bug.id}"
340324
assert after.levelno == logging.DEBUG
341-
assert after.rid == context.rid
342325
assert after.response == [mocked_issue_data]
343326

344327

@@ -369,11 +352,9 @@ def test_create_jira_issue_returns_errors(
369352

370353
before, after = capturelogs.records
371354
assert before.message == f"Creating new Jira issue for Bug {context.bug.id}"
372-
assert before.rid == context.rid
373355
assert before.levelno == logging.DEBUG
374356
assert before.fields == issue_fields
375357

376358
assert after.message == f"Failed to create issue for Bug {context.bug.id}"
377-
assert after.rid == context.rid
378359
assert after.levelno == logging.ERROR
379360
assert after.response == fake_error_data

tests/unit/test_app.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,32 @@
99
from jbi.models import BugzillaWebhookRequest
1010

1111

12-
def test_request_summary_is_logged(caplog):
12+
def test_request_summary_is_logged(caplog, anon_client):
1313
with caplog.at_level(logging.INFO):
14-
with TestClient(app) as anon_client:
15-
# https://fastapi.tiangolo.com/advanced/testing-events/
16-
anon_client.get(
17-
"/",
18-
headers={
19-
"X-Request-Id": "foo-bar",
20-
},
21-
)
14+
# https://fastapi.tiangolo.com/advanced/testing-events/
15+
anon_client.get(
16+
"/",
17+
headers={
18+
"X-Request-Id": "foo-bar",
19+
},
20+
)
21+
22+
summary = [r for r in caplog.records if r.name == "request.summary"][0]
23+
24+
assert summary.rid == "foo-bar"
25+
assert summary.method == "GET"
26+
assert summary.path == "/"
27+
assert summary.querystring == "{}"
28+
2229

23-
summary = [r for r in caplog.records if r.name == "request.summary"][0]
30+
def test_request_summary_defaults_user_agent_to_empty_string(caplog, anon_client):
31+
with caplog.at_level(logging.INFO):
32+
del anon_client.headers["User-Agent"]
33+
anon_client.get("/")
34+
35+
summary = [r for r in caplog.records if r.name == "request.summary"][0]
2436

25-
assert summary.rid == "foo-bar"
26-
assert summary.method == "GET"
27-
assert summary.path == "/"
28-
assert summary.querystring == "{}"
37+
assert summary.agent == ""
2938

3039

3140
def test_422_errors_are_logged(webhook_create_example, caplog):
@@ -43,17 +52,6 @@ def test_422_errors_are_logged(webhook_create_example, caplog):
4352
)
4453

4554

46-
def test_request_summary_defaults_user_agent_to_empty_string(caplog):
47-
with caplog.at_level(logging.INFO):
48-
with TestClient(app) as anon_client:
49-
del anon_client.headers["User-Agent"]
50-
anon_client.get("/")
51-
52-
summary = [r for r in caplog.records if r.name == "request.summary"][0]
53-
54-
assert summary.agent == ""
55-
56-
5755
@pytest.mark.parametrize(
5856
"sampling_context,expected",
5957
[

0 commit comments

Comments
 (0)