Skip to content

Commit 35298cc

Browse files
committed
feat: implement structured logging with correlation IDs (#939)
- Add StructuredLogger class to ml-service/main.py with JSON-formatted output that auto-injects the active correlation ID - Add ContextVar-based correlation ID storage for async request isolation - Add ASGI middleware that reads/generates X-Correlation-ID header and propagates it through every log entry in the request lifecycle - Add ml-service/tests/test_structured_logging.py with 20+ pytest cases covering the logger, middleware, and end-to-end header propagation Technical scope: backend/services/shared/logging.ts, ml-service/main.py
1 parent 4a62952 commit 35298cc

2 files changed

Lines changed: 398 additions & 6 deletions

File tree

ml-service/main.py

Lines changed: 153 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,236 @@
1-
from fastapi import FastAPI, HTTPException
1+
import uuid
2+
import time
3+
import logging
4+
import json
5+
from contextlib import asynccontextmanager
6+
from contextvars import ContextVar
7+
8+
from fastapi import FastAPI, HTTPException, Request, Response
29
from pydantic import BaseModel
310
from typing import List, Dict, Optional
411
from models import ChurnPredictionModel, RevenueForecastModel
512
from model_registry import registry
613

14+
# ──────────────────────────────────────────────────────────────────────────────
15+
# Structured logging with correlation IDs (issue #939)
16+
# ──────────────────────────────────────────────────────────────────────────────
17+
18+
# Context var that holds the current correlation ID for the active request.
19+
_correlation_id: ContextVar[str] = ContextVar("correlation_id", default="")
20+
21+
22+
class StructuredLogger:
23+
"""JSON-formatted logger that automatically injects the active correlation ID."""
24+
25+
def __init__(self, service: str = "ml-service") -> None:
26+
self._service = service
27+
self._raw = logging.getLogger(service)
28+
if not self._raw.handlers:
29+
handler = logging.StreamHandler()
30+
handler.setFormatter(logging.Formatter("%(message)s"))
31+
self._raw.addHandler(handler)
32+
self._raw.setLevel(logging.DEBUG)
33+
34+
def _emit(self, level: str, message: str, **extra) -> None:
35+
entry = {
36+
"level": level,
37+
"message": message,
38+
"service": self._service,
39+
"correlation_id": _correlation_id.get() or None,
40+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
41+
**extra,
42+
}
43+
# Strip None values to keep logs clean
44+
entry = {k: v for k, v in entry.items() if v is not None}
45+
getattr(self._raw, level if level != "warning" else "warning")(
46+
json.dumps(entry)
47+
)
48+
49+
def debug(self, message: str, **extra) -> None:
50+
self._emit("debug", message, **extra)
51+
52+
def info(self, message: str, **extra) -> None:
53+
self._emit("info", message, **extra)
54+
55+
def warning(self, message: str, **extra) -> None:
56+
self._emit("warning", message, **extra)
57+
58+
def error(self, message: str, **extra) -> None:
59+
self._emit("error", message, **extra)
60+
61+
62+
logger = StructuredLogger()
63+
64+
# ──────────────────────────────────────────────────────────────────────────────
65+
# Application
66+
# ──────────────────────────────────────────────────────────────────────────────
67+
768
app = FastAPI(title="SubTrackr ML Service", version="1.0.0")
869

70+
71+
# ── Correlation-ID middleware ──────────────────────────────────────────────────
72+
73+
@app.middleware("http")
74+
async def correlation_id_middleware(request: Request, call_next) -> Response:
75+
"""
76+
Reads X-Correlation-ID from the incoming request (or generates a new UUID
77+
if absent), stores it in the context var, injects it into the response, and
78+
records basic request/response telemetry via the structured logger.
79+
"""
80+
correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4())
81+
token = _correlation_id.set(correlation_id)
82+
83+
start = time.monotonic()
84+
logger.info(
85+
"request_started",
86+
method=request.method,
87+
path=request.url.path,
88+
)
89+
90+
try:
91+
response: Response = await call_next(request)
92+
except Exception as exc:
93+
logger.error(
94+
"request_error",
95+
method=request.method,
96+
path=request.url.path,
97+
error=str(exc),
98+
)
99+
raise
100+
finally:
101+
elapsed_ms = round((time.monotonic() - start) * 1000, 2)
102+
logger.info(
103+
"request_completed",
104+
method=request.method,
105+
path=request.url.path,
106+
status_code=getattr(response, "status_code", None),
107+
duration_ms=elapsed_ms,
108+
)
109+
_correlation_id.reset(token)
110+
111+
response.headers["X-Correlation-ID"] = correlation_id
112+
return response
113+
114+
115+
# ──────────────────────────────────────────────────────────────────────────────
116+
# Pydantic models
117+
# ──────────────────────────────────────────────────────────────────────────────
118+
9119
class UserData(BaseModel):
10120
recent_payment_failures: float
11121
baseline_logins_per_month: float
12122
recent_logins: float
13123
open_support_tickets: float
14124
price_sensitivity_index: float
15125

126+
16127
class PredictRequest(BaseModel):
17128
subscriber: str
18129
user_data: UserData
19130

131+
20132
class BatchPredictItem(BaseModel):
21133
subscriber: str
22134
user_data: UserData
23135

136+
24137
class BatchPredictRequest(BaseModel):
25138
items: List[BatchPredictItem]
26139

140+
27141
class Observation(BaseModel):
28142
period: str
29143
revenue: float
30144

145+
31146
class ForecastRequest(BaseModel):
32147
observations: List[Observation]
33148
horizon: int = 3
34149

150+
151+
# ──────────────────────────────────────────────────────────────────────────────
152+
# Model initialisation
153+
# ──────────────────────────────────────────────────────────────────────────────
154+
35155
churn_model = ChurnPredictionModel()
36156
forecast_model = RevenueForecastModel()
37157

38-
# Try to load a customized model from registry if available
39158
custom_weights = registry.load_model("v1.1")
40159
if custom_weights and "feature_weights" in custom_weights:
41160
churn_model.feature_weights = custom_weights["feature_weights"]
161+
logger.info("model_loaded", version="v1.1")
162+
else:
163+
logger.info("model_loaded", version="v1.0")
164+
165+
166+
# ──────────────────────────────────────────────────────────────────────────────
167+
# Endpoints
168+
# ──────────────────────────────────────────────────────────────────────────────
42169

43170
@app.post("/v1/churn/predict")
44171
async def predict_churn(req: PredictRequest):
172+
logger.info("predict_churn", subscriber=req.subscriber)
45173
try:
46174
prediction = churn_model.predict_churn(req.subscriber, req.user_data.model_dump())
47175
prediction["model_version"] = "v1.1" if custom_weights else "v1.0"
48176
return prediction
49177
except Exception as e:
178+
logger.error("predict_churn_failed", subscriber=req.subscriber, error=str(e))
50179
raise HTTPException(status_code=500, detail=str(e))
51180

181+
52182
@app.post("/v1/churn/predict/batch")
53183
async def predict_churn_batch(req: BatchPredictRequest):
184+
logger.info("predict_churn_batch", count=len(req.items))
54185
results = []
55186
for item in req.items:
56187
try:
57188
pred = churn_model.predict_churn(item.subscriber, item.user_data.model_dump())
58189
pred["ok"] = True
59190
results.append(pred)
60191
except Exception as e:
192+
logger.warning(
193+
"predict_churn_item_failed",
194+
subscriber=item.subscriber,
195+
error=str(e),
196+
)
61197
results.append({"subscriber": item.subscriber, "ok": False, "error": str(e)})
62-
198+
63199
return {
64200
"model_version": "v1.1" if custom_weights else "v1.0",
65-
"results": results
201+
"results": results,
66202
}
67203

204+
68205
@app.post("/v1/churn/forecast")
69206
async def forecast_revenue(req: ForecastRequest):
207+
logger.info("forecast_revenue", horizon=req.horizon, observations=len(req.observations))
70208
try:
71209
observations = [obs.model_dump() for obs in req.observations]
72210
forecast = forecast_model.forecast(observations, req.horizon)
73211
return forecast
74212
except Exception as e:
213+
logger.error("forecast_revenue_failed", error=str(e))
75214
raise HTTPException(status_code=500, detail=str(e))
76215

216+
77217
@app.post("/v1/models/retrain")
78218
async def retrain_model():
79-
"""Trigger the retraining pipeline"""
219+
"""Trigger the retraining pipeline."""
220+
logger.info("model_retrain_triggered")
80221
new_version = registry.retrain_model([])
81-
# Hot reload the weights
82222
new_weights = registry.load_model(new_version)
83223
if new_weights:
84224
churn_model.feature_weights = new_weights["feature_weights"]
225+
logger.info("model_weights_reloaded", version=new_version)
85226
return {"status": "success", "new_version": new_version}
86227

228+
229+
@app.get("/healthz")
230+
async def health():
231+
return {"status": "ok", "service": "ml-service"}
232+
233+
87234
if __name__ == "__main__":
88235
import uvicorn
89236
uvicorn.run(app, host="0.0.0.0", port=8000)

0 commit comments

Comments
 (0)