Files
LedgerLine/services/gateway/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

165 lines
5.2 KiB
Python

import asyncio
import uuid
from pathlib import Path
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.utils import suppress_instrumentation
from common.chaos_flags import ALL_FLAGS, get_flags, set_flag, set_profile
from common.logging_config import configure_logging
from common.settings import settings
from common.telemetry import setup_telemetry
from . import orchestrator
from .orchestrator import UpstreamFailure
SERVICE_NAME = "gateway"
logger = configure_logging(SERVICE_NAME)
setup_telemetry(service_name=SERVICE_NAME)
app = FastAPI(title=SERVICE_NAME)
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/healthz,/api/health/",
exclude_spans=["receive", "send"],
)
STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# A shared, connection-pooling client for the simple read-only proxy routes
# below -- opening a fresh httpx.AsyncClient (and TCP connection) per request
# is real, needless overhead under concurrent load. orchestrator.py's
# downstream_client() stays per-request since it carries per-transaction
# headers (X-Request-Id).
_proxy_client = httpx.AsyncClient(
timeout=settings.downstream_timeout_seconds,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=50),
)
DOWNSTREAM_URLS = {
"ledger": settings.ledger_url,
"fraud": settings.fraud_url,
"sanctions": settings.sanctions_url,
"insights": settings.insights_url,
"notifier": settings.notifier_url,
}
class TransactionRequest(BaseModel):
account_id: str
kind: str
amount_minor: int
currency: str
merchant_name: str
category_id: int | None = None
@field_validator("kind")
@classmethod
def kind_must_be_known(cls, v):
if v not in ("deposit", "withdrawal", "purchase"):
raise ValueError("kind must be one of deposit, withdrawal, purchase")
return v
@field_validator("amount_minor")
@classmethod
def deposit_amount_must_be_non_negative(cls, v, info):
if info.data.get("kind") == "deposit" and v < 0:
raise ValueError("amount_minor must be non-negative for a deposit")
return v
class ChaosFlagRequest(BaseModel):
flag: str
enabled: bool
class ChaosProfileRequest(BaseModel):
profile: str
@app.get("/", include_in_schema=False)
async def index():
"""Serves the frontend (phase-0-base-project-spec.md §10). This overrides
§4's generic `GET / -> {"service", "version"}` info route, which the
other five HTTP services still expose as-is."""
return FileResponse(STATIC_DIR / "index.html")
@app.get("/healthz")
async def healthz():
return {"status": "ok", "service": SERVICE_NAME}
@app.get("/readyz")
async def readyz():
return {"status": "ok"}
@app.get("/api/accounts")
async def list_accounts():
resp = await _proxy_client.get(f"{settings.ledger_url}/internal/accounts")
return JSONResponse(status_code=resp.status_code, content=resp.json())
@app.get("/api/accounts/{account_id}/insights")
async def account_insights(account_id: str):
resp = await _proxy_client.get(f"{settings.insights_url}/internal/accounts/{account_id}/summary")
return JSONResponse(status_code=resp.status_code, content=resp.json())
@app.post("/api/transactions", status_code=201)
async def submit_transaction(body: TransactionRequest):
request_id = str(uuid.uuid4())
try:
result = await orchestrator.create_transaction(body.model_dump(), request_id)
except UpstreamFailure as exc:
return JSONResponse(
status_code=exc.status_code, content={"error": exc.message, "request_id": request_id}
)
return JSONResponse(status_code=201, content=result)
@app.get("/api/health/all")
async def health_all():
async def check(name, url):
try:
resp = await _proxy_client.get(f"{url}/healthz", timeout=2.0)
return name, "ok" if resp.status_code == 200 else "down"
except httpx.RequestError:
return name, "down"
# The route itself is in excluded_urls; without this its five downstream
# /healthz calls would each still be a parentless root trace.
with suppress_instrumentation():
results = await asyncio.gather(*[check(name, url) for name, url in DOWNSTREAM_URLS.items()])
services = {"gateway": "ok", **dict(results)}
return {"services": services}
@app.get("/api/admin/chaos")
async def get_chaos():
return {"flags": await get_flags()}
@app.post("/api/admin/chaos")
async def post_chaos(body: ChaosFlagRequest):
if body.flag not in ALL_FLAGS:
return JSONResponse(status_code=400, content={"error": f"unknown flag {body.flag!r}"})
await set_flag(body.flag, body.enabled)
return {"flags": await get_flags()}
@app.post("/api/admin/chaos/profile")
async def post_chaos_profile(body: ChaosProfileRequest):
try:
flags = await set_profile(body.profile)
except ValueError as exc:
return JSONResponse(status_code=400, content={"error": str(exc)})
return {"flags": flags}