Migrating from prometheus_client to OpenTelemetry

Many Python services started with prometheus_client and a /metrics endpoint, and many teams now want their metrics to travel with traces and logs through OpenTelemetry. The migration itself is small — a few dozen instruments in most services — and the risk is entirely in what depends on them: dashboards, alerts and SLO rules keyed on exact metric names, label names and bucket boundaries. This article describes a migration in stages that keeps every query working at each step. It belongs to OpenTelemetry vs Prometheus for Python metrics in the Python metrics and instrumentation section; for keeping prometheus_client metrics and forwarding them through OpenTelemetry instead, see bridging Prometheus metrics into OpenTelemetry.

Four stages, queries unchanged throughout Four stages side by side. Stage one, today: prometheus_client instruments served on /metrics and scraped by Prometheus; dashboards and alerts query the existing names. Stage two, canary: one replica runs the migrated code, with OpenTelemetry instruments served by the OpenTelemetry Prometheus exporter on the usual endpoint under a distinct target label, and its ratios compared with the stable replicas. Stage three, roll out: every replica runs the migrated code, with names that translate to the old ones, so queries see no change. Stage four, optional: the Prometheus reader replaced by an OTLP exporter to a collector, which writes to the same backend by remote write. A bar beneath all four says dashboards and alerts are unchanged in every stage. 1 · today prometheus_client/metrics, scrapedthe baseline 2 · canary one replica migrateddistinct target labelcompare ratios 3 · roll out every replica migratedOTel on /metricssame translated names 4 · OTLP (optional) OTLP exportercollector → remote writesame backend dashboards, alerts and SLO rules unchanged in every stage the migration is invisible to consumers if names, labels and buckets are preserved
Each stage is deployable and reversible on its own. Consumers of the metrics should not be able to tell which stage the service is in.

Prerequisites

pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
            "opentelemetry-exporter-prometheus>=0.48b0,<1.0.0" \
            "opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0"

Implementation steps

Step 1 — Inventory metrics and consumers. Every metric the service exposes, with its type, labels and buckets; and every query that refers to it. The second list is the one that matters, and Prometheus can help: searching the rule files and dashboard JSON for each metric name finds most consumers. Metrics with no consumers are candidates for deletion rather than migration.

curl -s localhost:8000/metrics | awk '/^# TYPE/ {print $3, $4}' | sort

Step 2 — Map each metric. The mapping is mostly direct.

prometheus_client OpenTelemetry Notes
Counter create_counter _total added by the exporter
Histogram create_histogram + a view with the same boundaries defaults differ
Gauge set from code create_gauge (synchronous) or an observable gauge
Gauge inc/dec create_up_down_counter additive
Gauge with set_function create_observable_gauge callback
Summary create_histogram quantile queries change
Info resource attributes or a gauge of 1 build info

Buckets need explicit attention. OpenTelemetry's default histogram boundaries differ from prometheus_client's, and any SLO query on le="0.3" breaks if 0.3 is not a boundary. A view pins them:

from opentelemetry.sdk.metrics.view import View, ExplicitBucketHistogramAggregation

latency_view = View(
    instrument_name="http.server.request.duration",
    aggregation=ExplicitBucketHistogramAggregation(
        boundaries=(0.01, 0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, 2.5, 5.0)),
)

Step 3 — Choose names that translate to the existing ones. The Prometheus exporter turns dots into underscores, appends the unit, and adds _total to counters. The old http_request_duration_seconds is produced by an instrument named http.request.duration with unit s; the old orders_placed_total by a counter named orders.placed with an annotation unit such as {order}, which adds no suffix. Label names carry over as attribute keys. Getting this right means no query changes at all.

Step 4 — Run the migrated code as a canary. Running both instrument sets in one process does not work cleanly: the OpenTelemetry Prometheus reader registers on prometheus_client's global registry, so identical names would appear twice in one exposition. A canary avoids the problem. One replica runs the migrated code, serving the OpenTelemetry instruments on the usual port; its scrape target carries a distinct label, such as track="canary", set by a pod label and a relabel rule.

from prometheus_client import start_http_server
from opentelemetry import metrics
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.sdk.metrics import MeterProvider

reader = PrometheusMetricReader()                   # registers on the default registry
provider = MeterProvider(metric_readers=[reader], views=[latency_view])
metrics.set_meter_provider(provider)
start_http_server(9464)                             # the same port as before

The canary handles a fraction of the traffic, so raw rates differ from the stable replicas. Compare what does not depend on traffic share: the availability and latency ratios, the per-instance request rate, and the set of series and label values present. Ratios within a few hundredths of a percent and identical series sets are the signal to proceed.

What the canary comparison catches A table of discrepancies seen when comparing the canary with the stable replicas. The canary's per-instance rate is lower than the stable replicas': a call site records to the old instrument only. Series present in old but not new: a label name differs, such as status versus http_status. Latency ratios differ at the SLO threshold: bucket boundaries differ, and the new histogram interpolates. Values identical but a different metric name: the unit is missing or doubled in translation. Canary values jump between scrapes under Gunicorn: each worker exports separately with no aggregation. The note says a few days of canary data, compared on the SLO queries themselves, is the test that matters. discrepancyusual cause canary per-instance rate lowera call site still records only the old instrument series missing in newa label name differs: status vs http_status SLO ratio differs at thresholdbucket boundaries differ — interpolation same values, different nameunit missing or doubled in translation canary values jump between scrapesper-worker registries, no aggregation compare on the SLO queries themselves, over a few days — that is the test that matters
Each discrepancy in the comparison maps to one specific migration mistake. A few days of canary data finds all of them before the rollout.

Step 5 — Roll out. Deploy the migrated code to every replica and remove the canary label. With names preserved, queries see nothing change. Keep the previous release ready to redeploy for a few days, since rolling back is the fastest fix for anything the canary missed.

Step 6 — Optionally move to OTLP push. Replace the Prometheus reader with a periodic exporting reader and an OTLP exporter, and have the collector write to the same backend through its Prometheus remote-write exporter. The collector's exporter performs the same name translation, so series keep their names. Target labels that Prometheus used to attach at scrape time — job, instance, Kubernetes pod labels — now come from resource attributes, and need mapping so that queries filtering on them still match.

exporters:
  prometheusremotewrite:
    endpoint: https://prometheus.internal/api/v1/write
    resource_to_telemetry_conversion: {enabled: true}

Multi-process servers change the picture

A service using prometheus_client multiprocess mode under Gunicorn has no direct equivalent in OpenTelemetry. The SDK's Prometheus reader serves only its own process's values, so the canary stage under prefork shows exactly the unaggregated behaviour described in metrics in multi-process Python servers.

The practical path for such services is to skip the scrape-based cutover and go straight to OTLP push: each worker, initialised after fork, exports its own metrics with a worker identity, and the collector or backend aggregates. Queries that previously summed over one aggregated series now sum over one series per worker, which sum by handles without change, as long as the queries already aggregated rather than selecting a single series. Checking that every dashboard query uses sum or another aggregation, before the cutover, avoids panels that suddenly show one line per worker.

What changes for the people using the metrics

A migration done this way is invisible to dashboards, and still changes a few things for the engineers who work with the service's metrics day to day.

Same queries, different code Two columns. What stays the same: metric names in the backend, label names and values, histogram bucket boundaries, every dashboard, alert and recording rule, and the scrape endpoint until an optional move to OTLP. What changes: instrument creation in code uses a meter rather than module-level metric objects; histogram buckets are configured in views at provider setup rather than per metric; gauges are often callbacks rather than set calls; exemplars linking metrics to traces become available through the SDK; and, after an OTLP move, identity labels come from resource attributes rather than scrape targets. The note says the second column is what to cover in a short internal note when the migration lands. stays the same metric names in the backend label names and values bucket boundaries dashboards, alerts, recording rules the scrape endpoint (until OTLP) changes instruments created from a meter buckets set in views, at setup gauges often become callbacks exemplars link metrics to traces identity from resource attributes the right-hand column is what a short internal note should cover when the migration lands
Consumers of the metrics see no difference. Authors of new instruments see a different API and a few new capabilities.

The most visible change for authors is where configuration lives. In prometheus_client, a histogram's buckets are declared with the histogram; in OpenTelemetry, they are declared in a view registered with the meter provider, often in a different module from the instrument. A new histogram added without a matching view gets the SDK's default boundaries, which silently breaks any SLO threshold that is not among them. A short convention — every histogram gets a view, in one module — prevents that, and a test that lists instruments without views catches it in CI.

The most useful new capability is exemplars: histogram points that carry the trace ID of a request that fell in a bucket, so a latency spike on a dashboard links straight to a trace of a slow request. Linking metrics to traces with exemplars covers enabling them, and they are often the feature that justifies the migration to the people who did not ask for it.

Configuration options

Concern Setting Why
Histogram buckets a view with the old boundaries SLO queries depend on them
Names chosen to translate to old names no query changes
Units in the unit field exporter builds the suffix
Labels same attribute keys selectors keep matching
Canary one replica, distinct target label comparison without duplicate names
Prefork servers OTLP push, post-fork init no shared-file aggregation
Target labels resource attribute conversion job and instance selectors
Summaries histograms aggregatable

Verification

At each stage, run the service's SLO and alert queries against the new data and compare with the old: the availability ratio, the latency ratio at each threshold, and the burn-rate expressions from burn-rate alerts and error budgets. Agreement on those is the test that matters; agreement on raw counts alone can hide a bucket or label difference.

promtool query instant http://prometheus:9090 \
  'sum(rate(http_request_duration_seconds_bucket{le="0.3",track="canary"}[1h])) / sum(rate(http_request_duration_seconds_count{track="canary"}[1h]))'

Expected Output: a value within a few hundredths of a percent of the same query with track!="canary".

Common mistakes

Default OpenTelemetry buckets. Error signature: latency SLI shifting at the cutover. Root cause: the threshold no longer a boundary. Remediation: a view with the old boundaries.

Unit in the name. Error signature: http_request_duration_seconds_seconds in the backend. Root cause: the unit given twice. Remediation: the unit field only.

Renamed labels. Error signature: panels empty after cutover for one breakdown. Root cause: an attribute key that differs from the old label. Remediation: identical keys, or a relabel rule during transition.

Cutting over under Gunicorn with the Prometheus reader. Error signature: counters jumping between scrapes. Root cause: no multiprocess aggregation in the SDK. Remediation: OTLP push with per-worker export.

Queries that select a single series. Error signature: panels showing one line per worker after moving to OTLP. Root cause: queries that relied on pre-aggregated series. Remediation: aggregate with sum by in every panel before the cutover.

Both instrument sets in one process. Error signature: duplicate metric families in one exposition, or a registration error at startup. Root cause: the OpenTelemetry reader shares prometheus_client's global registry. Remediation: compare through a canary rather than inside one process.

A new histogram without a view. Error signature: an SLO query returning nothing for a recently added route group. Root cause: default boundaries that omit the threshold. Remediation: a view for every histogram, checked by a test.

No rollback plan. Error signature: a long evening restoring dashboards after a subtle mismatch. Root cause: the previous release no longer deployable. Remediation: keep it ready for a few days after rollout.

Frequently Asked Questions

Can I keep my existing metric names?

Mostly. The OpenTelemetry Prometheus exporter translates names by replacing dots with underscores, appending the unit and adding _total to counters. Choosing the instrument name and unit so the translation produces the old name keeps queries working unchanged.

What replaces a prometheus_client Gauge that I set from code?

A synchronous Gauge instrument in recent SDK versions for values set at a point in code, an UpDownCounter for values adjusted with inc and dec, or an observable gauge for state that can be read on demand. The observable form is usually the better fit.

Do I need to move to OTLP push?

No. The OpenTelemetry SDK can serve a Prometheus scrape endpoint, and many services stay on scraping. Moving to OTLP is worthwhile when traces and logs already flow through a collector and a single pipeline is the goal.

What about multiprocess mode?

OpenTelemetry has no equivalent of prometheus_client's shared-file aggregation. Under prefork servers, each worker exports its own metrics with a distinct identity and aggregation happens in the collector or backend, which is simplest with OTLP push.

How do I handle summaries?

OpenTelemetry has no summary instrument. Replace summaries with histograms, which are aggregatable, and update queries from quantile series to histogram_quantile over buckets.