Files
LedgerLine/services/fraud/app.py
T
Movses AslanyanandClaude Opus 5.5 d52a19965a Add business spans and silence the frontend health poll
Manual INTERNAL spans for the business steps the auto-instrumentation
can't name: fraud.score_transaction, sanctions.lookup_watchlist,
ledger.settle and insights.aggregate_120d, with ledgerline.* attributes
(account, amount, risk score, match outcome). holder_name stays off the
spans. Each span starts after its handler's chaos flag reads and
injection points, and no chaos-derived values become attributes.

The gateway's /api/health/all fan-out now runs under
suppress_instrumentation: with the route excluded, its five downstream
/healthz calls were each a parentless root trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:11:38 +03:30

100 lines
3.6 KiB
Python

import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from opentelemetry import trace
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
request_id: str = "unknown"
@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:
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} request_id={body.request_id}")
await redis_client.incr("fraud:calls_total")
if await chaos.blocking_event_loop_enabled():
logger.info(f"blocking_event_loop chaos engaged for request_id={body.request_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(body.request_id) as client:
try:
resp = await client.post(
f"{settings.sanctions_url}/internal/lookup",
json={"holder_name": body.holder_name, "request_id": body.request_id},
)
except httpx.TimeoutException:
logger.exception(f"sanctions lookup timed out for request_id={body.request_id}")
return JSONResponse(status_code=504, content={"error": "sanctions lookup timed out"})
except httpx.RequestError as exc:
logger.exception(f"sanctions lookup failed for request_id={body.request_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 request_id={body.request_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}