Files
LedgerLine/services/fraud/app.py
T
Movses AslanyanandClaude Opus 5.5 7ddb614ded Replace X-Request-Id with the trace ID and retire the guard suite
Step 6: downstream_client() no longer sends X-Request-Id, and request_id
is gone from the internal request bodies, models, function signatures
and log lines; correlation now travels as traceparent. The gateway's
public request_id stays (the contract tests require it) but is now the
trace ID, 32 hex digits and still a valid UUID, so the value the
frontend shows can be pasted into Tempo.

Step 7: delete tests/test_no_observability.py. It guarded the Phase 0
"no observability" state, which Phase 3 ends by design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:48:27 +03:30

107 lines
3.9 KiB
Python

import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from opentelemetry import trace
from opentelemetry.trace import StatusCode
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from common.http import downstream_client
from common.logging_config import configure_logging
from common.redis_client import redis_client
from common.settings import settings
from common.telemetry import setup_telemetry
from . import chaos
from .scoring import score
SERVICE_NAME = "fraud"
logger = configure_logging(SERVICE_NAME)
tracer = trace.get_tracer(__name__)
setup_telemetry(service_name=SERVICE_NAME)
app = FastAPI(title=SERVICE_NAME)
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/healthz",
exclude_spans=["receive", "send"],
)
class ScoreRequest(BaseModel):
account_id: str
holder_name: str
category_id: int | None = None
amount_minor: int
@app.get("/")
async def root():
return {"service": SERVICE_NAME, "version": "0.1.0"}
@app.get("/healthz")
async def healthz(request: Request):
if request.headers.get("X-Ledgerline-Probe") == "chatty-health":
logger.info("received chatty-health healthz probe from gateway")
return {"status": "ok", "service": SERVICE_NAME}
@app.get("/readyz")
async def readyz():
try:
await redis_client.ping()
except Exception as exc:
span = trace.get_current_span()
span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"readyz check failed: {exc}")
return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"}
@app.post("/internal/score")
async def score_transaction(body: ScoreRequest):
logger.info(f"scoring account {body.account_id}")
await redis_client.incr("fraud:calls_total")
if await chaos.blocking_event_loop_enabled():
logger.info(f"blocking_event_loop chaos engaged for account {body.account_id}")
chaos.synchronous_cpu_work()
with tracer.start_as_current_span(
"fraud.score_transaction",
attributes={
"ledgerline.account.id": body.account_id,
"ledgerline.tx.amount_minor": body.amount_minor,
},
) as span:
if body.category_id is not None:
span.set_attribute("ledgerline.merchant.category_id", body.category_id)
async with downstream_client() as client:
try:
resp = await client.post(
f"{settings.sanctions_url}/internal/lookup",
json={"holder_name": body.holder_name},
)
except httpx.TimeoutException as exc:
span.record_exception(exc)
span.set_status(StatusCode.ERROR, "sanctions lookup timed out")
logger.exception(f"sanctions lookup timed out for account {body.account_id}")
return JSONResponse(status_code=504, content={"error": "sanctions lookup timed out"})
except httpx.RequestError as exc:
span.record_exception(exc)
span.set_status(StatusCode.ERROR, "sanctions lookup failed")
logger.exception(f"sanctions lookup failed for account {body.account_id}: {exc}")
return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"})
if resp.status_code >= 300:
logger.info(f"sanctions returned {resp.status_code} for account {body.account_id}")
return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"})
matched = resp.json().get("matched", False)
risk_score = score(body.category_id, matched)
span.set_attribute("ledgerline.sanctions.matched", matched)
span.set_attribute("ledgerline.risk.score", risk_score)
return {"risk_score": risk_score, "matched": matched}