← Todos los artículos

Observabilidad .NET sin corte: métricas duales con Prometheus y OTLP

Guía práctica para migrar observabilidad en ASP.NET Core sin apagar Prometheus: exporta métricas a Prometheus/OTLP y correlaciona logs y trazas.

18 de septiembre de 2026 · · 13 min de lectura
Ilustración abstracta de un servicio .NET que divide sus señales de observabilidad entre logs, métricas y trazas, con una ruta de métricas hacia dos destinos

Migrar la observabilidad suele dar miedo por una razón muy concreta: las métricas actuales no solo alimentan gráficas, también activan alertas y runbooks. Si se cambia el backend de golpe, una consulta que deja de devolver series puede parecer una caída del servicio.

La propuesta publicada el 18 de septiembre de 2026 en el blog oficial de OpenTelemetry ofrece una salida incremental para aplicaciones .NET: instrumentar con Meter y enviar la misma métrica a dos rutas durante una ventana de comparación. Prometheus sigue consultando un endpoint HTTP; el exportador OTLP envía los datos a un Collector o a un backend compatible. Cuando las series y alertas ya están validadas, se retira una de las salidas.

El cambio no obliga a elegir un proveedor de APM ni a reescribir todos los logs. .NET ya aporta ILogger para logs, Meter para métricas y Activity/ActivitySource para trazas; OpenTelemetry conecta esas APIs con instrumentación, contexto y exportadores. La documentación de Microsoft sobre observabilidad con OpenTelemetry describe precisamente esa separación.

TL;DR

  • Mantén Prometheus en modo scrape y añade OTLP para comparar ambos caminos sin un cambio de “todo o nada”.
  • Instrumenta el código con Meter, Counter, Histogram e IMeterFactory, no con APIs específicas de Prometheus.
  • Añade instrumentación ASP.NET Core y HttpClient para trazas; usa ILogger estructurado para logs. Cuando existe Activity.Current, OpenTelemetry rellena automáticamente TraceId, SpanId y TraceFlags en el registro.
  • El ejemplo fija las seis referencias en una combinación compilada: core y OTLP 1.18.0, instrumentación ASP.NET Core/Http 1.18.0 y Prometheus 1.18.0-beta.1.
  • NuGet ya muestra 1.19.0 para OpenTelemetry, OpenTelemetry.Extensions.Hosting y OpenTelemetry.Exporter.OpenTelemetryProtocol; esa actualización no se mezcla aquí sin una compilación de compatibilidad nueva.
  • Antes de retirar scraping, compara series, nombres, unidades, cardinalidad, histogramas, alertas, errores de exportación y el coste de tener dos salidas.

Qué significa “migrar” en este diseño

No se trata de enviar dos veces el mismo dato al mismo almacenamiento. La transición tiene dos destinos y dos mecanismos distintos:

ASP.NET Core
  ├─ ILogger ───────────────► OTLP ─► Collector/backend de logs
  ├─ Activity/ActivitySource ─► OTLP ─► Collector/backend de trazas
  └─ Meter ─► SDK OpenTelemetry
                ├─ Prometheus exporter ─► GET /metrics ─► Prometheus/Grafana
                └─ OTLP exporter ───────► Collector/backend de métricas

El endpoint de Prometheus es pull: el servidor consulta la aplicación a intervalos. OTLP es push: el SDK envía lotes al receptor configurado. Sus tiempos de recolección, agregación y etiquetas pueden diferir. “La misma métrica” significa que nace del mismo instrumento y conserva una semántica acordada; no que ambos backends mostrarán el mismo punto al mismo milisegundo.

La documentación de OpenTelemetry .NET marca trazas, métricas y logs como Stable. Eso no vuelve estable cada exporter: en la consulta del 18 de septiembre de 2026, NuGet muestra OpenTelemetry.Exporter.Prometheus.AspNetCore 1.18.0-beta.1, lo describe como prerelease y advierte que puede tener cambios incompatibles antes de llegar a estable. El paquete recomienda considerar OTLP para entornos de producción. En esa misma comprobación, OpenTelemetry, OpenTelemetry.Extensions.Hosting y OpenTelemetry.Exporter.OpenTelemetryProtocol ya aparecen en 1.19.0, publicados a las 09:01 (America/Mexico_City); OpenTelemetry.Instrumentation.AspNetCore y OpenTelemetry.Instrumentation.Http siguen en 1.18.0.

Paso 1: inventaria lo que no puedes perder

Antes de instalar paquetes, exporta el contrato actual de observabilidad. Para cada métrica, registra:

Elemento Pregunta que hay que responder
Nombre y unidad ¿Qué consulta, dashboard o alerta lo usa? ¿El exporter cambia el nombre o añade _total?
Tipo ¿Es contador, gauge, histograma, summary o histograma nativo?
Etiquetas ¿Son finitas y de baja cardinalidad? ¿Alguna contiene ID de pedido, usuario, URL completa o texto de error?
Frecuencia ¿La alimenta un scrape, un temporizador o un evento? ¿Qué retraso es tolerable?
Retención y alertas ¿Qué rango temporal y qué umbral utiliza el runbook?
Dependencias ¿El dato sale de prometheus-net, de una librería o del runtime?

La doble exportación no resuelve equivalencias que no existen. La guía oficial señala que Meter no tiene un equivalente directo para el tipo Prometheus summary ni para los histogramas nativos. Esas métricas deben migrarse con una decisión explícita: conservar una ruta temporal, rediseñar el instrumento o aceptar una semántica diferente y actualizar la alerta.

La cardinalidad merece su propio control. result=ok|not_found|error es una etiqueta razonable para un ejemplo; order_id=12345, una URL completa o el mensaje de una excepción no lo son. El detalle de una entidad concreta pertenece al log o a la traza, no a una serie que Prometheus tendrá que almacenar para cada valor.

Paso 2: fija paquetes y crea una métrica neutral

El siguiente ejemplo usa una aplicación net8.0 y versiones explícitas. Permanece fijado íntegramente en la combinación 1.18.0/1.18.0-beta.1 para alinear el código con la guía oficial y conservar una reproducción que ya fue compilada. No se recomienda mezclar core/OTLP 1.19.0 con instrumentación 1.18.0 y el exporter Prometheus beta sin ejecutar antes una validación propia; esa matriz no es la que se verifica en este artículo. La existencia de 1.19.0 se documenta como estado actual de NuGet, no como una afirmación de que el ejemplo la use.

dotnet new web -n OTelDualExportDemo --framework net8.0
cd OTelDualExportDemo

dotnet add package OpenTelemetry --version 1.18.0
dotnet add package OpenTelemetry.Extensions.Hosting --version 1.18.0
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol --version 1.18.0
dotnet add package OpenTelemetry.Exporter.Prometheus.AspNetCore --version 1.18.0-beta.1
dotnet add package OpenTelemetry.Instrumentation.AspNetCore --version 1.18.0
dotnet add package OpenTelemetry.Instrumentation.Http --version 1.18.0

En la aplicación, el código de negocio debe conocer Meter, no el formato de exposición de Prometheus. IMeterFactory permite que el Meter pertenezca al contenedor de dependencias y facilita probar el servicio sin un singleton estático global.

using System.Diagnostics.Metrics;
using Microsoft.Extensions.Diagnostics.Metrics;

public sealed class OrderMetrics
{
    public Counter<long> Processed { get; }

    public OrderMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("Lbe.ObservabilityDemo");
        Processed = meter.CreateCounter<long>(
            "lbe.orders.processed",
            unit: "{order}",
            description: "Pedidos procesados por resultado");
    }
}

El nombre, la unidad y las etiquetas son parte del contrato. No los cambies solo porque un backend normalice el nombre al formato de Prometheus; primero documenta la correspondencia y actualiza las consultas de forma controlada.

Paso 3: configura Prometheus y OTLP en el mismo SDK

Este Program.cs registra las tres señales. ASP.NET Core crea spans para las solicitudes HTTP; HttpClient añade spans para dependencias salientes; los logs se envían por OTLP. La métrica propia se conecta a los dos exporters.

using OpenTelemetry.Exporter;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

const string serviceName = "Lbe.ObservabilityDemo";

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<OrderMetrics>();

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName: serviceName, serviceVersion: "1.0.0"))
    .WithMetrics(metrics => metrics
        .AddMeter(serviceName)
        .AddAspNetCoreInstrumentation()
        .AddOtlpExporter()
        .AddPrometheusExporter())
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter())
    .WithLogging(logging => logging
        .AddOtlpExporter());

var app = builder.Build();

// El endpoint se debe publicar solo en la red autorizada para scraping.
app.MapPrometheusScrapingEndpoint();

app.MapGet("/orders/{id:int}",
    (int id, OrderMetrics metrics, ILogger<Program> log) =>
    {
        var result = id % 10 == 0 ? "not_found" : "ok";
        metrics.Processed.Add(
            1,
            new KeyValuePair<string, object?>("result", result));

        log.LogInformation(
            "Consultado pedido {OrderId} con resultado {Result}", id, result);

        return result == "ok"
            ? Results.Ok(new { id })
            : Results.NotFound(new { id });
    });

app.Run();

El endpoint OTLP se configura fuera del código para no incrustar URLs, tokens o certificados:

export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
dotnet run --urls http://localhost:8080

En PowerShell, las variables equivalentes son:

$env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4317"
$env:OTEL_EXPORTER_OTLP_PROTOCOL = "grpc"
dotnet run --urls http://localhost:8080

4317 es un endpoint de desarrollo local; una instalación real debe usar el protocolo, TLS y headers que requiera el Collector o backend. Las credenciales deben venir de secretos del entorno, no de Program.cs ni de un appsettings.json versionado.

Paso 4: valida logs y trazas correlacionados

AddAspNetCoreInstrumentation() crea un span para cada solicitud y añade duración, método, ruta, código de respuesta y atributos de red. AddHttpClientInstrumentation() cubre llamadas salientes si el servicio las realiza. En el contexto de una solicitud ASP.NET Core, Activity.Current está activa y el SDK de OpenTelemetry rellena en el log TraceId, SpanId y TraceFlags automáticamente; así lo explica la guía oficial de correlación de logs.

La correlación no es magia que atraviese cualquier sistema: el Collector y el backend deben conservar esos campos, y el recurso debe mantener un service.name estable. Un log emitido por un worker sin actividad activa no tendrá automáticamente el mismo contexto. Si se necesita una operación interna distinguible, se puede crear un ActivitySource propio y registrarlo:

using System.Diagnostics;

const string ActivitySourceName = "Lbe.ObservabilityDemo";
var source = new ActivitySource(ActivitySourceName);

using var activity = source.StartActivity("orders.validate");
// El ILogger usado aquí hereda Activity.Current si existe.

En ese caso, añade .AddSource(ActivitySourceName) a WithTracing. Para la mayoría de endpoints HTTP, la instrumentación ASP.NET Core ya proporciona el span de servidor. En logs, usa plantillas estructuradas ({OrderId}, {Result}), evita concatenar texto y nunca conviertas tokens, cuerpos completos, datos personales o claves en atributos de telemetría sin una política de redacción y retención.

Paso 5: genera tráfico y compara los dos destinos

Con la aplicación y un Collector de desarrollo escuchando en localhost:4317, genera un tráfico determinista. El siguiente ejemplo produce 100 solicitudes y deja cada décima como 404, lo que crea dos valores controlados para result:

for i in $(seq 1 100); do
  curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:8080/orders/$i"
done

Comprueba primero el endpoint de scraping:

curl -s http://localhost:8080/metrics | grep lbe_orders_processed

El exporter puede normalizar puntos, unidades o contadores al formato Prometheus. No fijes una consulta por intuición: inspecciona la salida real y documenta el nombre final. Cuando corresponda, una consulta de tasa tendrá una forma parecida a:

sum by (result) (rate(lbe_orders_processed_total[5m]))

En el backend OTLP, busca el mismo service.name, nombre lógico de instrumento, unidad y etiquetas. Compara después de una ventana suficiente para el intervalo de scraping y el periodo de exportación por lotes:

Comprobación Prometheus OTLP / Collector
Presencia Serie visible tras el scrape Puntos recibidos y aceptados
Identidad Nombre normalizado y unidad Nombre y unidad del instrumento
Dimensiones result solo con valores esperados Mismos atributos y cardinalidad
Distribución Buckets y consultas de histograma Agregación y temporales del backend
Alertas Reglas existentes y su retraso Regla equivalente, sin doble ingestión
Operación Errores de scrape y respuesta HTTP Errores, reintentos, cola y shutdown
Contexto N/A o exemplars según configuración TraceId, SpanId, service.name en logs/trazas

Apaga temporalmente uno de los receptores y observa qué ocurre. Un exporter puede reintentar, acumular o descartar según su configuración; no afirmes durabilidad si no existe un Collector con cola y persistencia configuradas. Registra el intervalo de pérdida y la conducta al restaurar el receptor.

Prometheus scrape y OTLP no deben duplicarse accidentalmente

Si el destino final es Prometheus y no existe aún un backend OTLP, Prometheus ofrece un receptor OTLP opt-in con --web.enable-otlp-receiver. En ese caso se puede enviar por HTTP/protobuf a /api/v1/otlp/v1/metrics y no hace falta mantener además el exporter de scraping para ese mismo almacenamiento.

La estrategia dual de este artículo es diferente: Prometheus sigue scrapeando /metrics mientras el OTLP exporter envía a otro receptor, preferiblemente un Collector o un backend separado durante la comparación. Si se envían ambas rutas al mismo almacenamiento sin una política de deduplicación, se pueden crear series repetidas o alertas difíciles de explicar.

Lo que puede cambiar durante una migración

Nombres y unidades

Prometheus utiliza convenciones de nombres, sufijos y unidades que pueden no verse idénticas en OTLP. Captura una muestra de ambas rutas y define una tabla de traducción. Actualizar un dashboard no es evidencia de que la métrica sea semánticamente equivalente: revisa la unidad y el tipo.

Summaries e histogramas nativos

Meter cubre contadores, gauges e histogramas, pero no ofrece un reemplazo directo universal para un summary de Prometheus o un histograma nativo. Conserva esas series mientras diseñas una alternativa y valida la nueva consulta con datos de la misma ventana; no automatices una conversión que cambie percentiles o agregación.

Cardinalidad y coste

Dos exporters hacen más trabajo: lecturas, buffers, serialización, conexiones y posiblemente una segunda ruta de red. Mide CPU, memoria, latencia, tamaño de lotes, errores y retraso de exportación con una sola salida y con ambas. La correlación de logs y trazas también tiene coste, en especial si se guardan muchos atributos o se muestrean demasiados spans.

Seguridad del endpoint

La ficha de NuGet advierte que el endpoint de scraping no está protegido por defecto. /metrics puede revelar nombres de servicios, rutas, errores y etiquetas internas. Restríngele el acceso con una red privada, un listener interno, un firewall o autenticación aplicada por el host/proxy. No lo publiques en Internet solo porque el endpoint sea cómodo para probar.

Shutdown y backpressure

Un dotnet run local no representa el apagado de una réplica en Kubernetes o un servicio detrás de un proxy. Configura un periodo de terminación suficiente para vaciar lotes, observa colas y reintentos y prueba qué pasa cuando el Collector está lento. El resultado debe formar parte del runbook, no quedar como una suposición.

Plan de retirada y rollback

La doble salida debe tener fecha y criterio de salida, no convertirse en una duplicación permanente por inercia:

  1. Inventario: congela nombres, unidades, etiquetas, consultas y alertas actuales.
  2. Instrumentación neutral: migra una métrica de bajo riesgo a Meter y conserva la serie anterior si aún es necesaria.
  3. Dual-export: habilita Prometheus y OTLP con versiones fijadas; comprueba que no apuntan al mismo almacenamiento por accidente.
  4. Correlación: verifica una solicitud de prueba en la que log y span compartan TraceId y SpanId, con service.name correcto.
  5. Carga controlada: compara una ventana con tráfico sintético y otra con tráfico representativo; observa coste y pérdida.
  6. Paridad operativa: reescribe queries y alertas en el destino nuevo, ejecuta el runbook y prueba una incidencia controlada.
  7. Canario: retira la ruta antigua solo para una réplica o porcentaje de tráfico, con rollback al artefacto anterior.
  8. Retirada: apaga scraping cuando los dashboards, alertas, retención y permisos del backend OTLP hayan pasado la revisión.

El rollback no debe reconstruir el binario en mitad de una incidencia. Conserva el paquete de la aplicación, las versiones de NuGet, la configuración de exporters y una forma de volver a habilitar Prometheus sin cambiar el código de negocio.

Conclusión

OpenTelemetry permite que el código .NET hable en términos de señales estándar: ILogger, Meter y Activity. Esa separación hace viable una migración por etapas. El mismo contador puede seguir visible en Prometheus mediante scraping y, al mismo tiempo, llegar por OTLP a un Collector; los logs estructurados y las trazas HTTP pueden viajar por la segunda ruta y conservar una correlación consultable.

La parte que exige más cuidado no está en encadenar dos métodos de configuración. Está en demostrar que la métrica conserva su significado, que la cardinalidad es operable, que los tipos especiales tienen un plan y que una beta no se ha convertido accidentalmente en una dependencia invisible. Comparar primero, retirar después: ese orden evita que una modernización de observabilidad se convierta en una pérdida de visibilidad.

Fuentes oficiales

Comentarios

Cargando…