OpenTelemetry Metrics: the Convergence with Prometheus¶
What was run¶
A real app, otel_metrics_app.py (port 9504), creating metrics through the OpenTelemetry Metrics SDK instead of
prometheus_client directly — then rendered to real Prometheus exposition format using the exact same
generate_latest() function Level 1 used for native metrics,
proving the two approaches genuinely converge on the wire.
A real environment limitation, found and worked around¶
The OpenTelemetry Prometheus exporter's documented approach uses its own start_http_server() to run a
standalone metrics endpoint. On this machine, that specific server reproducibly accepted no real connections —
curl timed out against it every time, on multiple ports, in both foreground and backgrounded runs, while every
other server in this entire course (built on uvicorn) worked reliably. The real, working fix: the
PrometheusMetricReader registers itself as a collector on prometheus_client's own global REGISTRY — so
calling generate_latest(REGISTRY) from inside a real, already-proven-reliable FastAPI/uvicorn endpoint renders
the identical real output start_http_server would have, without needing that specific server at all.
@app.get("/metrics")
def metrics_endpoint():
return Response(generate_latest(REGISTRY), media_type="text/plain")
A real OTel metric, in real Prometheus exposition format¶
otel_counter = meter.create_counter("otel_requests_total", description="Requests via the OTel SDK")
otel_counter.add(1, {"endpoint": "/work"})
# HELP otel_requests_total Requests via the OTel SDK
# TYPE otel_requests_total counter
otel_requests_total{endpoint="/work",otel_scope_name="otel-metrics-app",otel_scope_schema_url="",otel_scope_version=""} 2.0
A real counter, created through OpenTelemetry's meter.create_counter() API (not prometheus_client.Counter),
rendered in the exact same text format Prometheus always scrapes — with two real, additional labels,
otel_scope_name and otel_scope_version, automatically attached by the exporter to record which OTel
instrumentation scope produced the metric, something native prometheus_client metrics don't carry.
target_info{service_instance_id="b9fe3d23-...", service_name="otel-metrics-app", telemetry_sdk_language="python", ...} 1.0
A real, separate target_info gauge, generated automatically from the OTel Resource this app declared
(Resource.create({"service.name": "otel-metrics-app"})) — this is how OpenTelemetry's richer, resource-based
identity model (a service can have many attributes beyond just a scrape target's instance/job labels) gets
represented in Prometheus's flatter label model.
How It Actually Works¶
OpenTelemetry and Prometheus converge at the data model level more than they diverge: both ultimately represent a
metric as a name, a set of key-value labels, and a numeric value over time — which is exactly why rendering an
OTel-created counter through generate_latest() produces a real, valid Prometheus series with no translation
logic needed beyond what the official exporter already provides. The real, visible differences — otel_scope_*
labels, the separate target_info gauge — come from OpenTelemetry's resource and instrumentation scope
concepts, which are richer than Prometheus's native job/instance model and get flattened into extra labels
and a synthetic info metric to fit.
The practical implication: a team doesn't have to choose between "instrument with OpenTelemetry" and "keep using Prometheus" — OpenTelemetry's metrics API can be the single instrumentation surface for an application (unifying it with the trace instrumentation from Lessons 1–2), while Prometheus remains the real scraping and storage backend underneath, exactly as this lesson's app demonstrates end to end. This matters most for an organization already standardized on OpenTelemetry for tracing, who want metrics instrumented the same consistent way rather than maintaining two separate, differently-shaped instrumentation APIs side by side in the same codebase.
Common mistakes¶
- Assuming
start_http_server()'s specific implementation will work in every real environment without testing — this lesson's own real, reproducible failure on this machine is exactly the kind of environment-specific gotcha worth verifying directly rather than assuming from documentation. - Expecting OTel-exported metrics and native
prometheus_clientmetrics to look byte-for-byte identical — the realotel_scope_*labels andtarget_infogauge shown above are genuine, visible differences a consumer (a dashboard, an alert rule) may need to account for, even though the underlying data model converges. - Mixing OTel-created and
prometheus_client-created metrics for the same logical measurement across different parts of a codebase — both can coexist in one process (as this lesson's REGISTRY-sharing trick proves), but having two different instrumentation paths for what should be one consistent metric invites exactly the kind of divergence Level 1, Lesson 10's cross-check was designed to catch.
Exercise¶
Add a Prometheus scrape target for otel_metrics_app.py (port 9504) to the real prometheus.yml from Level 1,
confirm Prometheus successfully scrapes it (checked against /api/v1/targets, the same real verification method
from Level 1, Lesson 4), and run a real PromQL rate() query
against otel_requests_total — confirming it works identically to any native Prometheus metric from earlier in
this course, with no special query syntax needed just because it originated from OpenTelemetry.