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>
This commit is contained in:
Movses Aslanyan
2026-10-13 21:09:55 +03:30
co-authored by Claude Opus 5.5
parent 560e3afa0e
commit bc11638a15
9 changed files with 100 additions and 38 deletions
+16 -5
View File
@@ -1,8 +1,8 @@
import asyncio import asyncio
import json import json
import logging
import sqlalchemy as sa import sqlalchemy as sa
import structlog
from opentelemetry import propagate, trace from opentelemetry import propagate, trace
from opentelemetry.instrumentation.utils import suppress_instrumentation from opentelemetry.instrumentation.utils import suppress_instrumentation
from opentelemetry.trace import SpanKind, StatusCode from opentelemetry.trace import SpanKind, StatusCode
@@ -11,7 +11,7 @@ from common.db import Session
from common.redis_client import redis_client from common.redis_client import redis_client
from common.settings import settings from common.settings import settings
logger = logging.getLogger("audit") logger = structlog.get_logger("audit")
tracer = trace.get_tracer(__name__) tracer = trace.get_tracer(__name__)
GROUP = "audit" GROUP = "audit"
@@ -46,7 +46,7 @@ async def run():
proves the async fork fans out to genuinely separate consumers, not one proves the async fork fans out to genuinely separate consumers, not one
consumer doing both jobs.""" consumer doing both jobs."""
await ensure_group() await ensure_group()
logger.info("audit consumer started") logger.info("consumer_started", consumer_group=GROUP)
while True: while True:
try: try:
# The blocking poll would otherwise be a ~2s root trace every loop. # The blocking poll would otherwise be a ~2s root trace every loop.
@@ -61,7 +61,7 @@ async def run():
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
logger.exception("audit consumer read failed") logger.exception("stream_read_failed", consumer_group=GROUP)
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
@@ -85,8 +85,19 @@ async def run():
) as span: ) as span:
try: try:
await _handle(fields) await _handle(fields)
logger.info(
"stream_message_processed",
consumer_group=GROUP,
message_id=msg_id,
transaction_id=fields.get("transaction_id"),
)
except Exception as exc: except Exception as exc:
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc)) span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"audit failed to process message {msg_id}") logger.exception(
"stream_message_failed",
consumer_group=GROUP,
message_id=msg_id,
transaction_id=fields.get("transaction_id"),
)
await redis_client.xack(settings.stream_name, GROUP, msg_id) await redis_client.xack(settings.stream_name, GROUP, msg_id)
+17 -7
View File
@@ -43,7 +43,7 @@ async def root():
@app.get("/healthz") @app.get("/healthz")
async def healthz(request: Request): async def healthz(request: Request):
if request.headers.get("X-Ledgerline-Probe") == "chatty-health": if request.headers.get("X-Ledgerline-Probe") == "chatty-health":
logger.info("received chatty-health healthz probe from gateway") logger.info("healthz_probe_received", probe="chatty-health")
return {"status": "ok", "service": SERVICE_NAME} return {"status": "ok", "service": SERVICE_NAME}
@@ -55,18 +55,23 @@ async def readyz():
span = trace.get_current_span() span = trace.get_current_span()
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc)) span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"readyz check failed: {exc}") logger.exception("readyz_check_failed")
return JSONResponse(status_code=503, content={"status": "unavailable"}) return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"} return {"status": "ok"}
@app.post("/internal/score") @app.post("/internal/score")
async def score_transaction(body: ScoreRequest): async def score_transaction(body: ScoreRequest):
logger.info(f"scoring account {body.account_id}") 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") await redis_client.incr("fraud:calls_total")
if await chaos.blocking_event_loop_enabled(): if await chaos.blocking_event_loop_enabled():
logger.info(f"blocking_event_loop chaos engaged for account {body.account_id}") logger.info("chaos_engaged", chaos_flag="blocking_event_loop", account_id=body.account_id)
chaos.synchronous_cpu_work() chaos.synchronous_cpu_work()
with tracer.start_as_current_span( with tracer.start_as_current_span(
@@ -87,20 +92,25 @@ async def score_transaction(body: ScoreRequest):
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, "sanctions lookup timed out") span.set_status(StatusCode.ERROR, "sanctions lookup timed out")
logger.exception(f"sanctions lookup timed out for account {body.account_id}") logger.exception("sanctions_check_timed_out", account_id=body.account_id)
return JSONResponse(status_code=504, content={"error": "sanctions lookup timed out"}) return JSONResponse(status_code=504, content={"error": "sanctions lookup timed out"})
except httpx.RequestError as exc: except httpx.RequestError as exc:
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, "sanctions lookup failed") span.set_status(StatusCode.ERROR, "sanctions lookup failed")
logger.exception(f"sanctions lookup failed for account {body.account_id}: {exc}") logger.exception("sanctions_check_failed", account_id=body.account_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: if resp.status_code >= 300:
logger.info(f"sanctions returned {resp.status_code} for account {body.account_id}") 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"}) return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"})
matched = resp.json().get("matched", False) matched = resp.json().get("matched", False)
risk_score = score(body.category_id, matched) risk_score = score(body.category_id, matched)
span.set_attribute("ledgerline.sanctions.matched", matched) span.set_attribute("ledgerline.sanctions.matched", matched)
span.set_attribute("ledgerline.risk.score", risk_score) 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} return {"risk_score": risk_score, "matched": matched}
+8
View File
@@ -118,6 +118,14 @@ async def submit_transaction(body: TransactionRequest):
try: try:
result = await orchestrator.create_transaction(body.model_dump()) result = await orchestrator.create_transaction(body.model_dump())
except UpstreamFailure as exc: except UpstreamFailure as exc:
log = logger.error if exc.status_code >= 500 else logger.warning
log(
"transaction_failed",
account_id=body.account_id,
status_code=exc.status_code,
reason=exc.message,
cause=repr(exc.__cause__) if exc.__cause__ else None,
)
return JSONResponse( return JSONResponse(
status_code=exc.status_code, content={"error": exc.message, "request_id": request_id} status_code=exc.status_code, content={"error": exc.message, "request_id": request_id}
) )
+17 -6
View File
@@ -1,8 +1,8 @@
import logging
import time import time
import uuid import uuid
import httpx import httpx
import structlog
from opentelemetry import propagate, trace from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind from opentelemetry.trace import SpanKind
@@ -12,7 +12,7 @@ from common.settings import settings
from . import chaos from . import chaos
logger = logging.getLogger("gateway") logger = structlog.get_logger("gateway")
tracer = trace.get_tracer(__name__) tracer = trace.get_tracer(__name__)
REJECT_THRESHOLD = 0.8 REJECT_THRESHOLD = 0.8
@@ -76,7 +76,12 @@ async def _run_chatty_health_probe(client: httpx.AsyncClient) -> None:
async def create_transaction(payload: dict) -> dict: async def create_transaction(payload: dict) -> dict:
start = time.perf_counter() start = time.perf_counter()
logger.info(f"start transaction for account {payload['account_id']}") logger.info(
"transaction_started",
account_id=payload["account_id"],
kind=payload["kind"],
amount_minor=payload["amount_minor"],
)
retry_storm = await chaos.retry_storm_enabled() retry_storm = await chaos.retry_storm_enabled()
no_batching = await chaos.no_batching_enabled() no_batching = await chaos.no_batching_enabled()
@@ -155,7 +160,7 @@ async def create_transaction(payload: dict) -> dict:
msg_id = await redis_client.xadd(settings.stream_name, fields) msg_id = await redis_client.xadd(settings.stream_name, fields)
span.set_attribute("messaging.message.id", msg_id) span.set_attribute("messaging.message.id", msg_id)
except Exception: except Exception:
logger.exception(f"failed to publish transaction {tx_id} to the stream") logger.exception("stream_publish_failed", transaction_id=tx_id)
summary = {} summary = {}
try: try:
@@ -165,10 +170,16 @@ async def create_transaction(payload: dict) -> dict:
if summary_resp.status_code == 200: if summary_resp.status_code == 200:
summary = summary_resp.json() summary = summary_resp.json()
except httpx.RequestError: except httpx.RequestError:
logger.exception(f"insights summary unavailable for transaction {tx_id}") logger.exception("insights_summary_unavailable", transaction_id=tx_id)
elapsed_ms = (time.perf_counter() - start) * 1000 elapsed_ms = (time.perf_counter() - start) * 1000
logger.info(f"finished transaction {tx_id} status={settled['status']}") logger.info(
"transaction_finished",
transaction_id=tx_id,
status=settled["status"],
risk_score=risk_score,
duration_ms=round(elapsed_ms, 1),
)
return { return {
"transaction_id": tx_id, "transaction_id": tx_id,
+2 -2
View File
@@ -59,7 +59,7 @@ async def root():
@app.get("/healthz") @app.get("/healthz")
async def healthz(request: Request): async def healthz(request: Request):
if request.headers.get("X-Ledgerline-Probe") == "chatty-health": if request.headers.get("X-Ledgerline-Probe") == "chatty-health":
logger.info("received chatty-health healthz probe from gateway") logger.info("healthz_probe_received", probe="chatty-health")
return {"status": "ok", "service": SERVICE_NAME} return {"status": "ok", "service": SERVICE_NAME}
@@ -72,7 +72,7 @@ async def readyz():
span = trace.get_current_span() span = trace.get_current_span()
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc)) span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"readyz check failed: {exc}") logger.exception("readyz_check_failed")
return JSONResponse(status_code=503, content={"status": "unavailable"}) return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"} return {"status": "ok"}
+11 -6
View File
@@ -52,7 +52,7 @@ class SettleRequest(BaseModel):
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception): async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception(f"unhandled exception on {request.url.path}: {exc}") logger.exception("unhandled_exception", path=request.url.path)
return JSONResponse(status_code=500, content={"error": "internal error"}) return JSONResponse(status_code=500, content={"error": "internal error"})
@@ -64,7 +64,7 @@ async def root():
@app.get("/healthz") @app.get("/healthz")
async def healthz(request: Request): async def healthz(request: Request):
if request.headers.get("X-Ledgerline-Probe") == "chatty-health": if request.headers.get("X-Ledgerline-Probe") == "chatty-health":
logger.info("received chatty-health healthz probe from gateway") logger.info("healthz_probe_received", probe="chatty-health")
return {"status": "ok", "service": SERVICE_NAME} return {"status": "ok", "service": SERVICE_NAME}
@@ -77,7 +77,7 @@ async def readyz():
span = trace.get_current_span() span = trace.get_current_span()
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc)) span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"readyz check failed: {exc}") logger.exception("readyz_check_failed")
return JSONResponse(status_code=503, content={"status": "unavailable"}) return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"} return {"status": "ok"}
@@ -91,7 +91,12 @@ async def list_accounts():
@app.post("/internal/transactions") @app.post("/internal/transactions")
async def create_transaction(body: CreateTransactionRequest): async def create_transaction(body: CreateTransactionRequest):
logger.info(f"processing tx for account {body.account_id}, amount {body.amount_minor}") logger.info(
"transaction_received",
account_id=body.account_id,
kind=body.kind,
amount_minor=body.amount_minor,
)
starved = await chaos.pool_starvation_enabled() starved = await chaos.pool_starvation_enabled()
poison_enabled = await chaos.poison_data_enabled() poison_enabled = await chaos.poison_data_enabled()
@@ -151,7 +156,7 @@ async def settle(tx_id: str, body: SettleRequest):
use_lock = row_lock and bool(account.is_hot) 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) 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}") logger.info("transaction_settled", transaction_id=tx_id, risk_score=body.risk_score)
return {"status": "settled", "balance_minor": balance} return {"status": "settled", "balance_minor": balance}
@@ -165,5 +170,5 @@ async def reject(tx_id: str, body: SettleRequest):
return JSONResponse(status_code=404, content={"error": "transaction not found"}) return JSONResponse(status_code=404, content={"error": "transaction not found"})
balance = await repository.reject_transaction(session, tx=tx, risk_score=body.risk_score) balance = await repository.reject_transaction(session, tx=tx, risk_score=body.risk_score)
logger.info(f"rejected transaction {tx_id}") logger.info("transaction_rejected", transaction_id=tx_id, risk_score=body.risk_score)
return {"status": "rejected", "balance_minor": balance} return {"status": "rejected", "balance_minor": balance}
+2 -2
View File
@@ -46,7 +46,7 @@ async def root():
@app.get("/healthz") @app.get("/healthz")
async def healthz(request: Request): async def healthz(request: Request):
if request.headers.get("X-Ledgerline-Probe") == "chatty-health": if request.headers.get("X-Ledgerline-Probe") == "chatty-health":
logger.info("received chatty-health healthz probe from gateway") logger.info("healthz_probe_received", probe="chatty-health")
return {"status": "ok", "service": SERVICE_NAME} return {"status": "ok", "service": SERVICE_NAME}
@@ -60,6 +60,6 @@ async def readyz():
span = trace.get_current_span() span = trace.get_current_span()
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc)) span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"readyz check failed: {exc}") logger.exception("readyz_check_failed")
return JSONResponse(status_code=503, content={"status": "unavailable"}) return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"} return {"status": "ok"}
+16 -5
View File
@@ -1,9 +1,9 @@
import asyncio import asyncio
import logging
import random import random
import uuid import uuid
import sqlalchemy as sa import sqlalchemy as sa
import structlog
from opentelemetry import propagate, trace from opentelemetry import propagate, trace
from opentelemetry.instrumentation.utils import suppress_instrumentation from opentelemetry.instrumentation.utils import suppress_instrumentation
from opentelemetry.trace import SpanKind, StatusCode from opentelemetry.trace import SpanKind, StatusCode
@@ -14,7 +14,7 @@ from common.settings import settings
from . import chaos from . import chaos
logger = logging.getLogger("notifier") logger = structlog.get_logger("notifier")
tracer = trace.get_tracer(__name__) tracer = trace.get_tracer(__name__)
GROUP = "notifier" GROUP = "notifier"
@@ -53,7 +53,7 @@ async def run():
independent group (`audit`) reads the same stream in services/audit/worker.py. independent group (`audit`) reads the same stream in services/audit/worker.py.
Redis Streams carry no automatic context propagation, deliberately.""" Redis Streams carry no automatic context propagation, deliberately."""
await ensure_group() await ensure_group()
logger.info("notifier consumer started") logger.info("consumer_started", consumer_group=GROUP)
while True: while True:
try: try:
# The blocking poll would otherwise be a ~2s root trace every loop. # The blocking poll would otherwise be a ~2s root trace every loop.
@@ -68,7 +68,7 @@ async def run():
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
logger.exception("notifier consumer read failed") logger.exception("stream_read_failed", consumer_group=GROUP)
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
@@ -92,8 +92,19 @@ async def run():
) as span: ) as span:
try: try:
await _handle(fields) await _handle(fields)
logger.info(
"stream_message_processed",
consumer_group=GROUP,
message_id=msg_id,
transaction_id=fields.get("transaction_id"),
)
except Exception as exc: except Exception as exc:
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc)) span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"notifier failed to process message {msg_id}") logger.exception(
"stream_message_failed",
consumer_group=GROUP,
message_id=msg_id,
transaction_id=fields.get("transaction_id"),
)
await redis_client.xack(settings.stream_name, GROUP, msg_id) await redis_client.xack(settings.stream_name, GROUP, msg_id)
+11 -5
View File
@@ -40,7 +40,7 @@ async def root():
@app.get("/healthz") @app.get("/healthz")
async def healthz(request: Request): async def healthz(request: Request):
if request.headers.get("X-Ledgerline-Probe") == "chatty-health": if request.headers.get("X-Ledgerline-Probe") == "chatty-health":
logger.info(f"received chatty-health healthz probe from gateway") logger.info("healthz_probe_received", probe="chatty-health")
return {"status": "ok", "service": SERVICE_NAME} return {"status": "ok", "service": SERVICE_NAME}
@@ -53,22 +53,23 @@ async def readyz():
span = trace.get_current_span() span = trace.get_current_span()
span.record_exception(exc) span.record_exception(exc)
span.set_status(StatusCode.ERROR, str(exc)) span.set_status(StatusCode.ERROR, str(exc))
logger.exception(f"readyz check failed: {exc}") logger.exception("readyz_check_failed")
return JSONResponse(status_code=503, content={"status": "unavailable"}) return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"} return {"status": "ok"}
@app.post("/internal/lookup") @app.post("/internal/lookup")
async def lookup(body: LookupRequest): async def lookup(body: LookupRequest):
logger.info(f"lookup for holder_name={body.holder_name!r}") # holder_name is PII: logged by omission, not by redaction.
logger.info("lookup_received")
if await chaos.sanctions_errors_enabled() and chaos.should_error(): if await chaos.sanctions_errors_enabled() and chaos.should_error():
logger.info("sanctions_errors chaos triggered") logger.info("chaos_engaged", chaos_flag="sanctions_errors")
return JSONResponse(status_code=503, content={"error": "sanctions vendor unavailable"}) return JSONResponse(status_code=503, content={"error": "sanctions vendor unavailable"})
if await chaos.slow_sanctions_enabled(): if await chaos.slow_sanctions_enabled():
delay = chaos.sample_delay_seconds() delay = chaos.sample_delay_seconds()
logger.info(f"slow_sanctions chaos sleeping {delay:.3f}s") logger.info("chaos_engaged", chaos_flag="slow_sanctions", delay_ms=round(delay * 1000, 1))
await asyncio.sleep(delay) await asyncio.sleep(delay)
# holder_name is PII: it stays off the span; only the outcome is recorded. # holder_name is PII: it stays off the span; only the outcome is recorded.
@@ -77,6 +78,11 @@ async def lookup(body: LookupRequest):
span.set_attribute("ledgerline.sanctions.matched", match is not None) span.set_attribute("ledgerline.sanctions.matched", match is not None)
if match: if match:
span.set_attribute("ledgerline.sanctions.list_source", match["list_source"]) span.set_attribute("ledgerline.sanctions.list_source", match["list_source"])
logger.info(
"holder_screened",
matched=match is not None,
list_source=match["list_source"] if match else None,
)
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}