opentelemetrysamplingazurepythontypescriptobservabilitycost-optimization

OpenTelemetry al 10%: cómo reduje el costo de Azure Monitor sin perder visibilidad

Azure Monitor cobra por volumen de datos ingestados. Sin sampling, un servicio con 1,000 requests por minuto genera 1,000 trazas por minuto. Con el costo por GB de Application Insights, eso puede ser $200-500 al mes solo en observabilidad para un sistema moderado.

El sampling resuelve el problema: no instrumentas el 100% del tráfico, solo una fracción estadísticamente representativa. Los errores siempre se capturan. El tráfico normal se samplea.

Este post es la implementación de head-based sampling en Python y TypeScript, y cuándo ese enfoque no es suficiente.

Head-based vs tail-based sampling

Head-based: la decisión de samplear se toma al inicio de un trace, antes de procesar el request. Simple de implementar, bajo overhead. No puede tomar decisiones basadas en el resultado (no sabe si el request va a fallar).

Tail-based: la decisión se toma al final, cuando el trace está completo. Puede samplear el 100% de los errores y un porcentaje de los éxitos. Requiere un collector que buffer el trace completo antes de decidir — más complejo operacionalmente.

Para la mayoría de sistemas, head-based al 10-20% con priorización de errores es suficiente. El tail-based se justifica cuando necesitas garantizar captura del 100% de traces de errores en sistemas de alta criticidad.

Head-based en Python: TraceIdRatioBased

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.sampling import (
    TraceIdRatioBased,
    ParentBased,
    ALWAYS_ON,
    Decision,
)
from opentelemetry.sdk.resources import Resource

def setup_tracing(
    service_name: str,
    connection_string: str,
    sample_rate: float = 0.1,  # 10% por defecto
) -> TracerProvider:

    # TraceIdRatioBased: samplea X% de los traces según el trace_id
    # Determinístico — el mismo trace_id siempre produce la misma decisión
    ratio_sampler = TraceIdRatioBased(sample_rate)

    # ParentBasedSampler: si hay un span padre, usa su decisión de sampling
    # Si no hay padre (trace raíz), usa el ratio_sampler
    # Esto garantiza que un trace es completo o no existe — no fragmentos
    sampler = ParentBased(root=ratio_sampler)

    provider = TracerProvider(
        sampler=sampler,
        resource=Resource.create({"service.name": service_name}),
    )

    # Configurar exporter...
    trace.set_tracer_provider(provider)
    return provider

ParentBased es crítico cuando tienes múltiples servicios. Sin él, cada servicio toma su propia decisión de sampling — puedes tener el span raíz de un trace sampled pero los spans de los servicios downstream no, resultando en traces incompletos.

Con ParentBased, si el servicio A decide samplear un trace, el servicio B (que recibe el request downstream) hereda esa decisión y también samplea. El trace queda completo.

Siempre capturar errores (sin importar el rate)

El problema con 10% de sampling: el 90% de los requests no se capturan, incluyendo los que fallan. Para sistemas donde los errores son raros, puedes perderte el 90% de los errores.

La solución: un sampler custom que siempre captura errores:

from opentelemetry.sdk.trace.sampling import Sampler, SamplingResult, Decision
from opentelemetry.trace import SpanKind, StatusCode
from opentelemetry.trace.span import Span
from opentelemetry.context import Context
from opentelemetry.util.types import Attributes

class AlwaysSampleErrorsSampler(Sampler):
    """Samplea X% del tráfico normal + 100% de los spans con error."""

    def __init__(self, base_rate: float = 0.1):
        self._ratio_sampler = TraceIdRatioBased(base_rate)

    def should_sample(
        self,
        parent_context: Context,
        trace_id: int,
        name: str,
        kind: SpanKind = SpanKind.INTERNAL,
        attributes: Attributes = None,
        links=None,
        trace_state=None,
    ) -> SamplingResult:
        # Verificar si hay un span padre activo con error
        from opentelemetry import trace
        parent_span = trace.get_current_span(parent_context)

        if parent_span and parent_span.status.status_code == StatusCode.ERROR:
            # Siempre samplear si hay error en el padre
            return SamplingResult(Decision.RECORD_AND_SAMPLE)

        # Para el resto, usar el ratio normal
        return self._ratio_sampler.should_sample(
            parent_context, trace_id, name, kind, attributes, links, trace_state
        )

    def get_description(self) -> str:
        return f"AlwaysSampleErrors(base_rate={self._ratio_sampler.rate})"

Este sampler siempre captura spans que tienen un padre con error, independientemente del rate base.

Head-based en TypeScript

import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { TraceIdRatioBasedSampler, ParentBasedSampler } from '@opentelemetry/sdk-trace-base';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

function setupTracing(serviceName: string, sampleRate: number = 0.1) {
    const sampler = new ParentBasedSampler({
        root: new TraceIdRatioBasedSampler(sampleRate),
    });

    const provider = new NodeTracerProvider({
        sampler,
        resource: new Resource({
            [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
        }),
    });

    // Configurar exporter...
    provider.register();
    return provider;
}

La API es análoga a Python. ParentBasedSampler en TypeScript hace lo mismo que ParentBased en Python — hereda la decisión del span padre.

Head-based en C#

using OpenTelemetry;
using OpenTelemetry.Trace;

public static IServiceCollection AddObservability(
    this IServiceCollection services,
    string serviceName,
    string connectionString,
    double sampleRate = 0.1)
{
    services.AddOpenTelemetry()
        .WithTracing(builder => builder
            .SetResourceBuilder(ResourceBuilder.CreateDefault()
                .AddService(serviceName))
            .SetSampler(new ParentBasedSampler(
                new TraceIdRatioBasedSampler(sampleRate)))
            .AddAzureMonitorTraceExporter(o =>
                o.ConnectionString = connectionString));

    return services;
}

Cuándo el 10% no es suficiente

El head-based al 10% tiene limitaciones:

Problema 1: tráfico muy bajo. Si el servicio procesa 10 requests por minuto, el 10% son 1 request por minuto — estadísticamente insignificante. Para servicios de bajo tráfico, 50-100% es más apropiado.

Problema 2: errores raros y críticos. Si el error rate es 0.1% y el sampling rate es 10%, en la práctica capturas ~0.01% de los errores. Para sistemas donde perder el trace de un error es inaceptable, necesitas tail-based sampling o el sampler custom de siempre-capturar-errores.

Problema 3: debugging de issues específicos. Cuando necesitas diagnosticar un problema, aumentar temporalmente el sampling rate al 100% para el servicio afectado es la solución más rápida. Esto requiere que el rate sea configurable en runtime:

import os

# Rate configurable por variable de entorno
sample_rate = float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "0.1"))
setup_tracing(service_name="mi-servicio", sample_rate=sample_rate)

Cambiar OTEL_TRACES_SAMPLER_ARG=1.0 y redeployar el contenedor activa 100% de sampling temporalmente.

Calcular el saving de costos

Para estimar el ahorro antes de implementar:

# Cálculo aproximado
requests_per_minute = 500
avg_trace_size_kb = 2  # ~2KB por trace con sus spans
minutes_per_month = 30 * 24 * 60  # 43,200 minutos

# Sin sampling
monthly_gb_without = (requests_per_minute * avg_trace_size_kb * minutes_per_month) / 1_000_000
# = 500 * 2 * 43200 / 1M = 43.2 GB/mes

# Con 10% sampling
monthly_gb_with = monthly_gb_without * 0.1
# = 4.32 GB/mes

# Azure Monitor: ~$2.30 por GB (precio aproximado, verificar precios actuales)
saving_per_month = (monthly_gb_without - monthly_gb_with) * 2.30
# = ~$90/mes de ahorro

Para servicios de mayor tráfico, el ahorro puede ser de $300-500 al mes. Justifica implementar sampling desde el primer deployment.

Lo que aprendí

El ParentBasedSampler es obligatorio en sistemas distribuidos. Sin él, los traces quedan fragmentados — el span raíz existe pero los spans downstream no, o viceversa. Un trace fragmentado es tan útil como no tener trace.

El rate 10% es un buen punto de partida, no un valor universal. Servicios de bajo tráfico necesitan rates más altos. Servicios críticos donde los errores son raros necesitan el sampler que siempre captura errores. El 10% es el default razonable para un servicio de tráfico medio.

La configurabilidad del rate es casi tan importante como el rate mismo. Cuando hay un bug en producción y necesitas ver todos los traces, no puedes esperar a un deploy con el rate cambiado en código. El rate como variable de entorno + redeploy rápido (Container Apps tarda ~30 segundos) es la solución práctica.

Sampling no reemplaza las métricas. Las trazas son para debugging. Las métricas (Azure Monitor metrics, custom metrics) son para monitoring general — error rate, latencia P95, throughput. El sampling afecta las trazas, no las métricas. Los dashboards de monitoreo deben basarse en métricas, no en conteos de trazas.

Volver al blog