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>
This commit is contained in:
Movses Aslanyan
2026-10-13 18:11:38 +03:30
co-authored by Claude Opus 5.5
parent d1c158c835
commit d52a19965a
5 changed files with 88 additions and 48 deletions
+31 -18
View File
@@ -2,6 +2,7 @@ import httpx
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from common.http import downstream_client from common.http import downstream_client
@@ -15,6 +16,7 @@ from .scoring import score
SERVICE_NAME = "fraud" SERVICE_NAME = "fraud"
logger = configure_logging(SERVICE_NAME) logger = configure_logging(SERVICE_NAME)
tracer = trace.get_tracer(__name__)
setup_telemetry(service_name=SERVICE_NAME) setup_telemetry(service_name=SERVICE_NAME)
app = FastAPI(title=SERVICE_NAME) app = FastAPI(title=SERVICE_NAME)
@@ -64,23 +66,34 @@ async def score_transaction(body: ScoreRequest):
logger.info(f"blocking_event_loop chaos engaged for request_id={body.request_id}") logger.info(f"blocking_event_loop chaos engaged for request_id={body.request_id}")
chaos.synchronous_cpu_work() chaos.synchronous_cpu_work()
async with downstream_client(body.request_id) as client: with tracer.start_as_current_span(
try: "fraud.score_transaction",
resp = await client.post( attributes={
f"{settings.sanctions_url}/internal/lookup", "ledgerline.account.id": body.account_id,
json={"holder_name": body.holder_name, "request_id": body.request_id}, "ledgerline.tx.amount_minor": body.amount_minor,
) },
except httpx.TimeoutException: ) as span:
logger.exception(f"sanctions lookup timed out for request_id={body.request_id}") if body.category_id is not None:
return JSONResponse(status_code=504, content={"error": "sanctions lookup timed out"}) span.set_attribute("ledgerline.merchant.category_id", body.category_id)
except httpx.RequestError as exc: async with downstream_client(body.request_id) as client:
logger.exception(f"sanctions lookup failed for request_id={body.request_id}: {exc}") 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"}) return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"})
if resp.status_code >= 300: matched = resp.json().get("matched", False)
logger.info(f"sanctions returned {resp.status_code} for request_id={body.request_id}") risk_score = score(body.category_id, matched)
return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"}) span.set_attribute("ledgerline.sanctions.matched", matched)
span.set_attribute("ledgerline.risk.score", risk_score)
matched = resp.json().get("matched", False) return {"risk_score": risk_score, "matched": matched}
risk_score = score(body.category_id, matched)
return {"risk_score": risk_score, "matched": matched}
+5 -1
View File
@@ -8,6 +8,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 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.chaos_flags import ALL_FLAGS, get_flags, set_flag, set_profile
from common.logging_config import configure_logging from common.logging_config import configure_logging
@@ -133,7 +134,10 @@ async def health_all():
except httpx.RequestError: except httpx.RequestError:
return name, "down" return name, "down"
results = await asyncio.gather(*[check(name, url) for name, url in DOWNSTREAM_URLS.items()]) # 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)} services = {"gateway": "ok", **dict(results)}
return {"services": services} return {"services": services}
+29 -21
View File
@@ -1,4 +1,5 @@
import sqlalchemy as sa import sqlalchemy as sa
from opentelemetry import trace
# Deliberately the same shape of query the test suite's own EXPLAIN check runs # Deliberately the same shape of query the test suite's own EXPLAIN check runs
# against the schema directly (tests/test_pathologies.py, 6.4) -- kept here # against the schema directly (tests/test_pathologies.py, 6.4) -- kept here
@@ -6,6 +7,8 @@ import sqlalchemy as sa
_N_PLUS_ONE_ROW_CAP = 1500 _N_PLUS_ONE_ROW_CAP = 1500
tracer = trace.get_tracer(__name__)
async def account_exists(session, account_id: str) -> bool: async def account_exists(session, account_id: str) -> bool:
result = await session.execute(sa.text("SELECT 1 FROM accounts WHERE id = :id"), {"id": account_id}) result = await session.execute(sa.text("SELECT 1 FROM accounts WHERE id = :id"), {"id": account_id})
@@ -13,29 +16,34 @@ async def account_exists(session, account_id: str) -> bool:
async def compute_summary(session, account_id: str, *, n_plus_one: bool) -> dict: async def compute_summary(session, account_id: str, *, n_plus_one: bool) -> dict:
if n_plus_one: with tracer.start_as_current_span(
rows = await _n_plus_one_summary(session, account_id) "insights.aggregate_120d",
else: attributes={"ledgerline.account.id": account_id, "ledgerline.summary.window_days": 120},
rows = await _joined_summary(session, account_id) ) as span:
if n_plus_one:
rows = await _n_plus_one_summary(session, account_id)
else:
rows = await _joined_summary(session, account_id)
total_count = 0 total_count = 0
total_amount = 0 total_amount = 0
by_category: dict = {} by_category: dict = {}
for r in rows: for r in rows:
total_count += 1 total_count += 1
total_amount += r["amount_minor"] total_amount += r["amount_minor"]
label = r["label"] or "uncategorized" label = r["label"] or "uncategorized"
entry = by_category.setdefault(label, {"count": 0, "total_amount_minor": 0}) entry = by_category.setdefault(label, {"count": 0, "total_amount_minor": 0})
entry["count"] += 1 entry["count"] += 1
entry["total_amount_minor"] += r["amount_minor"] entry["total_amount_minor"] += r["amount_minor"]
return { span.set_attribute("ledgerline.summary.transaction_count", total_count)
"account_id": account_id, return {
"window_days": 120, "account_id": account_id,
"transaction_count": total_count, "window_days": 120,
"total_amount_minor": total_amount, "transaction_count": total_count,
"by_category": by_category, "total_amount_minor": total_amount,
} "by_category": by_category,
}
async def _joined_summary(session, account_id: str) -> list[dict]: async def _joined_summary(session, account_id: str) -> list[dict]:
+15 -7
View File
@@ -1,6 +1,7 @@
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
@@ -13,6 +14,7 @@ from . import repository
SERVICE_NAME = "ledger" SERVICE_NAME = "ledger"
logger = configure_logging(SERVICE_NAME) logger = configure_logging(SERVICE_NAME)
tracer = trace.get_tracer(__name__)
setup_telemetry(service_name=SERVICE_NAME) setup_telemetry(service_name=SERVICE_NAME)
app = FastAPI(title=SERVICE_NAME) app = FastAPI(title=SERVICE_NAME)
@@ -133,13 +135,19 @@ async def settle(tx_id: str, body: SettleRequest):
starved = await chaos.pool_starvation_enabled() starved = await chaos.pool_starvation_enabled()
row_lock = await chaos.row_lock_contention_enabled() row_lock = await chaos.row_lock_contention_enabled()
async with session_factory(starved=starved) as session: with tracer.start_as_current_span(
tx = await repository.get_transaction(session, tx_id) "ledger.settle",
if tx is None: attributes={"ledgerline.tx.id": tx_id, "ledgerline.risk.score": body.risk_score},
return JSONResponse(status_code=404, content={"error": "transaction not found"}) ) as span:
account = await repository.get_account(session, tx.account_id) async with session_factory(starved=starved) as session:
use_lock = row_lock and bool(account.is_hot) tx = await repository.get_transaction(session, tx_id)
balance = await repository.settle_transaction(session, tx=tx, risk_score=body.risk_score, use_row_lock=use_lock) if tx is None:
return JSONResponse(status_code=404, content={"error": "transaction not found"})
span.set_attribute("ledgerline.account.id", str(tx.account_id))
span.set_attribute("ledgerline.tx.amount_minor", tx.amount_minor)
account = await repository.get_account(session, tx.account_id)
use_lock = row_lock and bool(account.is_hot)
balance = await repository.settle_transaction(session, tx=tx, risk_score=body.risk_score, use_row_lock=use_lock)
logger.info(f"settled transaction {tx_id} request_id={body.request_id}") logger.info(f"settled transaction {tx_id} request_id={body.request_id}")
return {"status": "settled", "balance_minor": balance} return {"status": "settled", "balance_minor": balance}
+8 -1
View File
@@ -3,6 +3,7 @@ import asyncio
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
@@ -15,6 +16,7 @@ from .repository import is_match
SERVICE_NAME = "sanctions" SERVICE_NAME = "sanctions"
logger = configure_logging(SERVICE_NAME) logger = configure_logging(SERVICE_NAME)
tracer = trace.get_tracer(__name__)
setup_telemetry(service_name=SERVICE_NAME) setup_telemetry(service_name=SERVICE_NAME)
app = FastAPI(title=SERVICE_NAME) app = FastAPI(title=SERVICE_NAME)
@@ -66,7 +68,12 @@ async def lookup(body: LookupRequest):
logger.info(f"slow_sanctions chaos sleeping {delay:.3f}s for request_id={body.request_id}") logger.info(f"slow_sanctions chaos sleeping {delay:.3f}s for request_id={body.request_id}")
await asyncio.sleep(delay) await asyncio.sleep(delay)
match = await is_match(body.holder_name) # holder_name is PII: it stays off the span; only the outcome is recorded.
with tracer.start_as_current_span("sanctions.lookup_watchlist") as span:
match = await is_match(body.holder_name)
span.set_attribute("ledgerline.sanctions.matched", match is not None)
if match:
span.set_attribute("ledgerline.sanctions.list_source", match["list_source"])
if match: if match:
return {"matched": True, **match} return {"matched": True, **match}
return {"matched": False, "country": None, "list_source": None} return {"matched": False, "country": None, "list_source": None}