Files
LedgerLine/services/fraud/app.py
T
Movses AslanyanandClaude Opus 5.5 bc11638a15 Rewrite every log call as an event name with typed fields
Prose f-strings become past-tense events with consistent field names
across services (account_id, transaction_id, amount_minor, risk_score,
status_code, consumer_group, message_id, chaos_flag). The orchestrator
and both consumers move from logging.getLogger to structlog so they can
pass fields.

New events cover what was silent before: the gateway's UpstreamFailure
path (transaction_failed, with the underlying cause), fraud's score and
sanctions' screening outcomes, and each successfully processed stream
message. holder_name is no longer logged at all.

"lookup" and "healthz" still appear in exactly one event each, so the
pathology tests' log-line counts are unchanged until they move to LogQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:09:55 +03:30

117 lines
4.1 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("healthz_probe_received", probe="chatty-health")
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("readyz_check_failed")
return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"}
@app.post("/internal/score")
async def score_transaction(body: ScoreRequest):
logger.info(
"score_requested",
account_id=body.account_id,
amount_minor=body.amount_minor,
category_id=body.category_id,
)
await redis_client.incr("fraud:calls_total")
if await chaos.blocking_event_loop_enabled():
logger.info("chaos_engaged", chaos_flag="blocking_event_loop", account_id=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("sanctions_check_timed_out", account_id=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("sanctions_check_failed", account_id=body.account_id)
return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"})
if resp.status_code >= 300:
logger.warning(
"sanctions_check_failed", account_id=body.account_id, status_code=resp.status_code
)
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)
logger.info(
"transaction_scored", account_id=body.account_id, risk_score=risk_score, matched=matched
)
return {"risk_score": risk_score, "matched": matched}