Carry trace context across the Redis Stream by hand

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>
This commit is contained in:
Movses Aslanyan
2026-10-13 17:46:51 +03:30
co-authored by Claude Opus 5.5
parent 14938ab36b
commit d1c158c835
4 changed files with 92 additions and 36 deletions
+23 -1
View File
@@ -3,12 +3,16 @@ import json
import logging import logging
import sqlalchemy as sa import sqlalchemy as sa
from opentelemetry import propagate, trace
from opentelemetry.instrumentation.utils import suppress_instrumentation
from opentelemetry.trace import SpanKind
from common.db import Session 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 = logging.getLogger("audit")
tracer = trace.get_tracer(__name__)
GROUP = "audit" GROUP = "audit"
CONSUMER = "audit-1" CONSUMER = "audit-1"
@@ -23,13 +27,16 @@ async def ensure_group():
async def _handle(fields: dict): async def _handle(fields: dict):
# Store the business fields only, not the propagated trace context.
trace_fields = propagate.get_global_textmap().fields
payload = {k: v for k, v in fields.items() if k not in trace_fields}
async with Session() as session: async with Session() as session:
await session.execute( await session.execute(
sa.text( sa.text(
"INSERT INTO audit_events (transaction_id, event_type, payload, recorded_at) " "INSERT INTO audit_events (transaction_id, event_type, payload, recorded_at) "
"VALUES (:tx_id, 'transaction_processed', CAST(:payload AS jsonb), now())" "VALUES (:tx_id, 'transaction_processed', CAST(:payload AS jsonb), now())"
), ),
{"tx_id": fields.get("transaction_id"), "payload": json.dumps(fields)}, {"tx_id": fields.get("transaction_id"), "payload": json.dumps(payload)},
) )
await session.commit() await session.commit()
@@ -42,6 +49,8 @@ async def run():
logger.info("audit consumer started") logger.info("audit consumer started")
while True: while True:
try: try:
# The blocking poll would otherwise be a ~2s root trace every loop.
with suppress_instrumentation():
resp = await redis_client.xreadgroup( resp = await redis_client.xreadgroup(
groupname=GROUP, groupname=GROUP,
consumername=CONSUMER, consumername=CONSUMER,
@@ -61,6 +70,19 @@ async def run():
for _, messages in resp: for _, messages in resp:
for msg_id, fields in messages: for msg_id, fields in messages:
# Parent is the gateway's PRODUCER span, carried in the message.
with tracer.start_as_current_span(
f"process {settings.stream_name}",
context=propagate.extract(fields),
kind=SpanKind.CONSUMER,
attributes={
"messaging.system": "redis",
"messaging.destination.name": settings.stream_name,
"messaging.operation.type": "process",
"messaging.consumer.group.name": GROUP,
"messaging.message.id": msg_id,
},
):
try: try:
await _handle(fields) await _handle(fields)
except Exception: except Exception:
+1 -1
View File
@@ -24,7 +24,7 @@ app = FastAPI(title=SERVICE_NAME)
FastAPIInstrumentor.instrument_app( FastAPIInstrumentor.instrument_app(
app, app,
excluded_urls="/healthz", excluded_urls="/healthz,/api/health/",
exclude_spans=["receive", "send"], exclude_spans=["receive", "send"],
) )
+19 -4
View File
@@ -3,6 +3,8 @@ import time
import uuid import uuid
import httpx import httpx
from opentelemetry import propagate, trace
from opentelemetry.trace import SpanKind
from common.http import downstream_client from common.http import downstream_client
from common.redis_client import redis_client from common.redis_client import redis_client
@@ -11,6 +13,7 @@ from common.settings import settings
from . import chaos from . import chaos
logger = logging.getLogger("gateway") logger = logging.getLogger("gateway")
tracer = trace.get_tracer(__name__)
REJECT_THRESHOLD = 0.8 REJECT_THRESHOLD = 0.8
@@ -129,17 +132,29 @@ async def create_transaction(payload: dict, request_id: str) -> dict:
settled = settle_resp.json() settled = settle_resp.json()
try: try:
await redis_client.xadd( fields = {
settings.stream_name,
{
"transaction_id": tx_id, "transaction_id": tx_id,
"account_id": created["account_id"], "account_id": created["account_id"],
"kind": created["kind"], "kind": created["kind"],
"amount_minor": str(created["amount_minor"]), "amount_minor": str(created["amount_minor"]),
"status": settled["status"], "status": settled["status"],
"risk_score": str(risk_score), "risk_score": str(risk_score),
}
with tracer.start_as_current_span(
f"send {settings.stream_name}",
kind=SpanKind.PRODUCER,
attributes={
"messaging.system": "redis",
"messaging.destination.name": settings.stream_name,
"messaging.operation.type": "send",
}, },
) ) as span:
# Redis Streams carry no trace context, so it travels in the
# message itself. Injected inside the PRODUCER span, so the
# consumers' parent is this span.
propagate.inject(fields)
msg_id = await redis_client.xadd(settings.stream_name, fields)
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(f"failed to publish transaction {tx_id} to the stream")
+19
View File
@@ -4,6 +4,9 @@ import random
import uuid import uuid
import sqlalchemy as sa import sqlalchemy as sa
from opentelemetry import propagate, trace
from opentelemetry.instrumentation.utils import suppress_instrumentation
from opentelemetry.trace import SpanKind
from common.db import Session from common.db import Session
from common.redis_client import redis_client from common.redis_client import redis_client
@@ -12,6 +15,7 @@ from common.settings import settings
from . import chaos from . import chaos
logger = logging.getLogger("notifier") logger = logging.getLogger("notifier")
tracer = trace.get_tracer(__name__)
GROUP = "notifier" GROUP = "notifier"
CONSUMER = "notifier-1" CONSUMER = "notifier-1"
@@ -52,6 +56,8 @@ async def run():
logger.info("notifier consumer started") logger.info("notifier consumer started")
while True: while True:
try: try:
# The blocking poll would otherwise be a ~2s root trace every loop.
with suppress_instrumentation():
resp = await redis_client.xreadgroup( resp = await redis_client.xreadgroup(
groupname=GROUP, groupname=GROUP,
consumername=CONSUMER, consumername=CONSUMER,
@@ -71,6 +77,19 @@ async def run():
for _, messages in resp: for _, messages in resp:
for msg_id, fields in messages: for msg_id, fields in messages:
# Parent is the gateway's PRODUCER span, carried in the message.
with tracer.start_as_current_span(
f"process {settings.stream_name}",
context=propagate.extract(fields),
kind=SpanKind.CONSUMER,
attributes={
"messaging.system": "redis",
"messaging.destination.name": settings.stream_name,
"messaging.operation.type": "process",
"messaging.consumer.group.name": GROUP,
"messaging.message.id": msg_id,
},
):
try: try:
await _handle(fields) await _handle(fields)
except Exception: except Exception: