|
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 |
2 | 9 | from pydantic import BaseModel |
3 | 10 | from typing import List, Dict, Optional |
4 | 11 | from models import ChurnPredictionModel, RevenueForecastModel |
5 | 12 | from model_registry import registry |
6 | 13 |
|
| 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 | + |
7 | 68 | app = FastAPI(title="SubTrackr ML Service", version="1.0.0") |
8 | 69 |
|
| 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 | + |
9 | 119 | class UserData(BaseModel): |
10 | 120 | recent_payment_failures: float |
11 | 121 | baseline_logins_per_month: float |
12 | 122 | recent_logins: float |
13 | 123 | open_support_tickets: float |
14 | 124 | price_sensitivity_index: float |
15 | 125 |
|
| 126 | + |
16 | 127 | class PredictRequest(BaseModel): |
17 | 128 | subscriber: str |
18 | 129 | user_data: UserData |
19 | 130 |
|
| 131 | + |
20 | 132 | class BatchPredictItem(BaseModel): |
21 | 133 | subscriber: str |
22 | 134 | user_data: UserData |
23 | 135 |
|
| 136 | + |
24 | 137 | class BatchPredictRequest(BaseModel): |
25 | 138 | items: List[BatchPredictItem] |
26 | 139 |
|
| 140 | + |
27 | 141 | class Observation(BaseModel): |
28 | 142 | period: str |
29 | 143 | revenue: float |
30 | 144 |
|
| 145 | + |
31 | 146 | class ForecastRequest(BaseModel): |
32 | 147 | observations: List[Observation] |
33 | 148 | horizon: int = 3 |
34 | 149 |
|
| 150 | + |
| 151 | +# ────────────────────────────────────────────────────────────────────────────── |
| 152 | +# Model initialisation |
| 153 | +# ────────────────────────────────────────────────────────────────────────────── |
| 154 | + |
35 | 155 | churn_model = ChurnPredictionModel() |
36 | 156 | forecast_model = RevenueForecastModel() |
37 | 157 |
|
38 | | -# Try to load a customized model from registry if available |
39 | 158 | custom_weights = registry.load_model("v1.1") |
40 | 159 | if custom_weights and "feature_weights" in custom_weights: |
41 | 160 | 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 | +# ────────────────────────────────────────────────────────────────────────────── |
42 | 169 |
|
43 | 170 | @app.post("/v1/churn/predict") |
44 | 171 | async def predict_churn(req: PredictRequest): |
| 172 | + logger.info("predict_churn", subscriber=req.subscriber) |
45 | 173 | try: |
46 | 174 | prediction = churn_model.predict_churn(req.subscriber, req.user_data.model_dump()) |
47 | 175 | prediction["model_version"] = "v1.1" if custom_weights else "v1.0" |
48 | 176 | return prediction |
49 | 177 | except Exception as e: |
| 178 | + logger.error("predict_churn_failed", subscriber=req.subscriber, error=str(e)) |
50 | 179 | raise HTTPException(status_code=500, detail=str(e)) |
51 | 180 |
|
| 181 | + |
52 | 182 | @app.post("/v1/churn/predict/batch") |
53 | 183 | async def predict_churn_batch(req: BatchPredictRequest): |
| 184 | + logger.info("predict_churn_batch", count=len(req.items)) |
54 | 185 | results = [] |
55 | 186 | for item in req.items: |
56 | 187 | try: |
57 | 188 | pred = churn_model.predict_churn(item.subscriber, item.user_data.model_dump()) |
58 | 189 | pred["ok"] = True |
59 | 190 | results.append(pred) |
60 | 191 | except Exception as e: |
| 192 | + logger.warning( |
| 193 | + "predict_churn_item_failed", |
| 194 | + subscriber=item.subscriber, |
| 195 | + error=str(e), |
| 196 | + ) |
61 | 197 | results.append({"subscriber": item.subscriber, "ok": False, "error": str(e)}) |
62 | | - |
| 198 | + |
63 | 199 | return { |
64 | 200 | "model_version": "v1.1" if custom_weights else "v1.0", |
65 | | - "results": results |
| 201 | + "results": results, |
66 | 202 | } |
67 | 203 |
|
| 204 | + |
68 | 205 | @app.post("/v1/churn/forecast") |
69 | 206 | async def forecast_revenue(req: ForecastRequest): |
| 207 | + logger.info("forecast_revenue", horizon=req.horizon, observations=len(req.observations)) |
70 | 208 | try: |
71 | 209 | observations = [obs.model_dump() for obs in req.observations] |
72 | 210 | forecast = forecast_model.forecast(observations, req.horizon) |
73 | 211 | return forecast |
74 | 212 | except Exception as e: |
| 213 | + logger.error("forecast_revenue_failed", error=str(e)) |
75 | 214 | raise HTTPException(status_code=500, detail=str(e)) |
76 | 215 |
|
| 216 | + |
77 | 217 | @app.post("/v1/models/retrain") |
78 | 218 | async def retrain_model(): |
79 | | - """Trigger the retraining pipeline""" |
| 219 | + """Trigger the retraining pipeline.""" |
| 220 | + logger.info("model_retrain_triggered") |
80 | 221 | new_version = registry.retrain_model([]) |
81 | | - # Hot reload the weights |
82 | 222 | new_weights = registry.load_model(new_version) |
83 | 223 | if new_weights: |
84 | 224 | churn_model.feature_weights = new_weights["feature_weights"] |
| 225 | + logger.info("model_weights_reloaded", version=new_version) |
85 | 226 | return {"status": "success", "new_version": new_version} |
86 | 227 |
|
| 228 | + |
| 229 | +@app.get("/healthz") |
| 230 | +async def health(): |
| 231 | + return {"status": "ok", "service": "ml-service"} |
| 232 | + |
| 233 | + |
87 | 234 | if __name__ == "__main__": |
88 | 235 | import uvicorn |
89 | 236 | uvicorn.run(app, host="0.0.0.0", port=8000) |
0 commit comments