Redis Streams have no headers, so the gateway injects traceparent into the message fields inside a PRODUCER span. The notifier and audit consumers extract it and open one CONSUMER span per message, so their DB and XACK spans join the transaction's trace. The idle XREADGROUP poll runs under suppress_instrumentation (it was a ~2s root trace every loop), and audit strips the trace fields so its stored payload is unchanged. The gateway also stops tracing its frontend's /api/health/ polling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
5.0 KiB
Python
161 lines
5.0 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 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"
|
|
|
|
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}
|