Cap 3: Traces y Spans

Por: Artiko
opentelemetrytracesspansdistributed-tracing

Anatomía de un Trace

Un Trace es un árbol de Spans que representa el trabajo completo para una operación lógica.

flowchart TD
    ROOT["[ROOT SPAN]\nPOST /checkout\ntrace_id: abc123\nspan_id: 001\nduration: 58ms"] --> AUTH
    ROOT --> ORDER
    ORDER --> DB["[SPAN]\ndb.query SELECT orders\nspan_id: 003\nduration: 7ms"]
    ORDER --> PAY["[SPAN]\ncall PaymentService\nspan_id: 004\nduration: 30ms"]
    PAY --> PAY2["[SPAN]\nverify-card\nspan_id: 005\nduration: 25ms"]
    PAY --> PAY3["[SPAN]\ncreate-transaction\nspan_id: 006\nduration: 5ms"]
    ROOT --> PUB["[SPAN]\npublish order.created\nspan_id: 007\nduration: 3ms"]

    AUTH["[SPAN]\nauthenticate-token\nspan_id: 002\nduration: 5ms"]
    ORDER["[SPAN]\nprocess-order\nspan_id: 003\nduration: 45ms"]

Anatomía de un Span

from opentelemetry import trace
from opentelemetry.trace import SpanKind, StatusCode
from opentelemetry.semconv.trace import SpanAttributes

tracer = trace.get_tracer("com.mycompany.order-service")

with tracer.start_as_current_span(
    "process-order",
    kind=SpanKind.SERVER,
) as span:
    # Atributos: metadatos del span
    span.set_attribute("order.id", order_id)
    span.set_attribute("order.total_usd", total)
    span.set_attribute(SpanAttributes.HTTP_METHOD, "POST")

    # Evento: punto en el tiempo dentro del span
    span.add_event("validación completada", {"items.count": 3})
    span.add_event("pago procesado")

    try:
        resultado = _procesar(order_id)
        # Status OK (implícito si no se setea error)
        span.set_status(StatusCode.OK)
        return resultado
    except Exception as e:
        # Registrar el error
        span.record_exception(e)
        span.set_status(StatusCode.ERROR, str(e))
        raise

Campos de un Span

Nombre

El nombre del span es lo más visible en el backend. Convenciones:

# HTTP server span
POST /users/:id           ✅  (incluye método y ruta parametrizada)
POST /users/123           ❌  (ruta con valores — alta cardinalidad)

# DB span
SELECT orders             ✅
SELECT * FROM orders...   ❌  (query completa — muy larga)

# Función interna
validate-payment          ✅
ValidatePaymentService.validateCardNumber  ❌ (demasiado largo)

SpanKind

Indica el rol del span en la comunicación:

KindCuándo usarlo
SERVERServidor recibiendo una request remota
CLIENTCliente haciendo una llamada remota
PRODUCERPublicando un mensaje a una cola
CONSUMERProcesando un mensaje de una cola
INTERNALOperación interna (default)
# Span de servidor HTTP
with tracer.start_as_current_span("GET /users", kind=SpanKind.SERVER):
    ...

# Span de llamada a base de datos
with tracer.start_as_current_span("SELECT users", kind=SpanKind.CLIENT):
    ...

Atributos

Pares clave-valor que describen el span. Tipos soportados: string, int, float, bool y arrays de estos.

span.set_attribute("http.response.status_code", 200)
span.set_attribute("db.system", "postgresql")
span.set_attribute("enduser.id", "user-123")
span.set_attribute("items", ["item-1", "item-2"])  # array

Límites: Por defecto el SDK limita a 128 atributos por span y 12KB por valor. Configurable.

Eventos

Momentos discretos dentro del ciclo de vida del span:

# Evento simple
span.add_event("cache miss")

# Evento con atributos
span.add_event("retry attempt", {
    "retry.count": 2,
    "retry.reason": "connection timeout",
})

# Evento de excepción (estándar OTel)
try:
    ...
except Exception as e:
    span.record_exception(e)  # agrega evento con stack trace

Status

El status comunica si el span terminó con éxito o error:

from opentelemetry.trace import StatusCode

span.set_status(StatusCode.OK)
span.set_status(StatusCode.ERROR, "Payment declined: insufficient funds")
# StatusCode.UNSET es el default — no sobreescribas con OK innecesariamente

Regla práctica: solo setea ERROR cuando hay un error real del negocio o del sistema. Un HTTP 404 en un endpoint de búsqueda no es necesariamente un error.

Los Links conectan spans de distintos traces — útil para trabajo asíncrono donde el trace original ya finalizó:

# El trace de la request HTTP terminó, pero el job asíncrono
# debe referenciar ese trace original
from opentelemetry.trace import Link

link = Link(context=contexto_del_trace_original)

with tracer.start_as_current_span(
    "process-async-job",
    links=[link],
) as span:
    ...

Casos de uso: queues, batch jobs, fan-out/fan-in.

Span Processors

Los Span Processors interceptan los spans antes de exportarlos:

flowchart LR
    S[Span] --> SP1[SimpleSpanProcessor\nSíncrono, para debug]
    S --> SP2[BatchSpanProcessor\nAsíncrono, para producción]
    SP1 --> EXP1[ConsoleExporter]
    SP2 --> EXP2[OTLPExporter]
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# Producción: batch asíncrono
processor = BatchSpanProcessor(
    OTLPSpanExporter(endpoint="http://localhost:4317"),
    max_export_batch_size=512,
    schedule_delay_millis=5000,
)

Ejemplo completo: servicio HTTP + DB

from opentelemetry import trace
from opentelemetry.trace import SpanKind, StatusCode
from opentelemetry.semconv.trace import SpanAttributes

tracer = trace.get_tracer("order-service", "1.0.0")

def get_user_orders(user_id: str, db) -> list:
    with tracer.start_as_current_span(
        "get-user-orders",
        kind=SpanKind.SERVER,
    ) as span:
        span.set_attribute("enduser.id", user_id)

        # Sub-span para la query DB
        with tracer.start_as_current_span(
            "db.query",
            kind=SpanKind.CLIENT,
        ) as db_span:
            db_span.set_attribute(SpanAttributes.DB_SYSTEM, "postgresql")
            db_span.set_attribute(SpanAttributes.DB_NAME, "orders")
            db_span.set_attribute(
                SpanAttributes.DB_STATEMENT,
                "SELECT * FROM orders WHERE user_id = ?"
            )
            orders = db.query(
                "SELECT * FROM orders WHERE user_id = ?",
                user_id
            )
            db_span.set_attribute("db.rows_returned", len(orders))

        span.set_attribute("orders.count", len(orders))
        span.add_event("orders retrieved", {"count": len(orders)})
        return orders

El resultado en Jaeger/Tempo:

get-user-orders [45ms]
  └── db.query [12ms]