Skip to content

    Back to articles

    6 min read

    Dual-exporting .NET metrics without making it permanent

    Updated

    Two paths can bridge a real boundary

    A .NET service does not have to switch its metrics destination in one cutover. It can expose a Prometheus scrape endpoint for the monitoring system that already runs the service while also sending metrics over OTLP to a new backend. That is the bridge described in the OpenTelemetry .NET dual-exporting guide: configure both exporters in the application, and expose the Prometheus endpoint for scraping.

    I would use that arrangement when it reduces a specific migration risk. Perhaps the service team needs to keep its existing dashboards and alerts working while an observability team checks whether the new OTLP route receives the same useful signals. The old and new paths then serve different operational purposes during a transition: one keeps the current service boundary observable; the other proves the replacement path in parallel.

    That is different from enabling two exporters because more destinations must be better. Each route adds configuration, ownership questions, and a chance for monitoring to disagree with itself. Before I dual-export, I want to name the two systems, the people responsible for each, and the decision the temporary second route is meant to enable. If I cannot state that decision, I would keep one path.

    Decide where each copy goes

    First draw the actual data path. In the common transition shape, Prometheus scrapes the application’s endpoint while an OTLP exporter sends to a collector or an OTLP-compatible backend. These are not two labels for the same mechanism: one is a pull from a scrape target; the other is an application-initiated export. The .NET guide shows this combined approach, and its summary describes using both systems in parallel before committing to an OpenTelemetry-compatible backend (guide).

    Be precise about what “both” means in your topology. If OTLP goes to a collector and the collector forwards metrics to another backend, that is distinct from also pushing OTLP directly into Prometheus. Prometheus supports an OTLP receiver, but its documentation says to use OTLP ingestion cautiously for specific low-volume cases and not as a replacement for scraping (Prometheus OTLP receiver documentation). I would not make that receiver part of a migration by accident just because the application already has an OTLP exporter.

    The boundary matters because duplicate data is a routing problem as much as an instrumentation problem. If both paths land in one store, or if a collector forwards data into a system that also scrapes the service, one logical measurement may arrive twice. Even when the two copies live in separate systems, teams can mistake them for interchangeable sources and count both in a dashboard or alert. I would record the expected destination for each route and test whether any downstream component copies, scrapes, or forwards the same series again.

    A useful smoke test is to emit a recognizable measurement from a non-production instance, then inspect it in each destination. Confirm that it appears once where expected, with the intended service identity and dimensions. Also check what happens when one destination is unavailable: does the other path continue, and can its failure be noticed without flooding logs or causing the service to fail? Those are operational questions to answer in your own environment, not guarantees supplied by enabling two exporters.

    Compare meaning, not just names

    The same instrument can look different after crossing the two export formats. Names may be translated, units represented differently, and attributes become labels or resource context according to the exporter and backend. The OpenTelemetry Prometheus compatibility specification describes how resource attributes are associated with scraped metrics and how attributes such as service identity relate to Prometheus labels. The practical point is to compare what your queries and alerts actually see, not assume that identical .NET instrumentation creates identical query surfaces.

    I would check several representative instruments: a request counter, a duration histogram, and a service-level gauge if the application has one. For each, compare the exported name, unit, type, description, and dimensions in both destinations. Confirm that one service instance can be distinguished from another, and that deployment or environment context has not been lost or turned into an unexpected label. If an alert depends on a particular label or histogram bucket, test that query against the new route before relying on it.

    Labels deserve particular scrutiny. A dimension that seems convenient in application code can create many distinct series when its values vary widely. During a dual-export period, that cost and its operational effect can be harder to see if the two systems have different conventions or controls. I would start with a small, intentional set of dimensions, inspect the resulting series, and avoid adding user-specific or request-specific values as labels. That is a design rule I apply because the cardinality risk grows with the combinations of values, not because two exporters somehow make a label safe.

    Make the exit part of the design

    Running two paths means maintaining two configurations and two ways for data to fail. The Prometheus scrape target can be missing while OTLP continues, or OTLP can be rejected while the scrape still looks healthy. The team needs to know which system is authoritative for an operational question during the transition, where to look when signals diverge, and who owns exporter and collector changes. The OpenTelemetry guide characterizes the additional runtime overhead as minimal (guide); I would still treat “minimal” as a reason to measure rather than as a reason to ignore the extra path. Operational overhead includes configuration, access control, troubleshooting, dashboards, and alert ownership—not only CPU use.

    Before enabling both routes, write down the condition for removing one. It might be that the new backend has the required service coverage, the key dashboards and alerts have been checked against its data, and the people on call know where to investigate. The condition should be observable and agreed by the teams that run the service and monitoring systems. “We have been dual-exporting for a while” is not an exit criterion.

    Then give the temporary route an owner and review point. At that point, either retire it or record a concrete unresolved dependency and choose a new review point. Keeping both indefinitely leaves future maintainers guessing whether the duplication is intentional, whether both feeds are needed, and which one they should trust.

    Dual-exporting is useful when it protects a real service boundary while a replacement is tested. It is not a default architecture. I would instrument once, verify the meaning and delivery of each copy, and make the second route earn its place by helping the team reach a named migration decision.

    Drafted with AI assistance, reviewed and edited by Ali.