Naming Metrics and Choosing Units
A metric's name is its interface. Queries, dashboards, alerts and recording rules all refer to it, often across many services, and every inconsistency — milliseconds in one service and seconds in another, requests here and reqs there — becomes a bug in every query that crosses them. A small set of conventions prevents most of it. This article covers base units, the naming rules for prometheus_client and OpenTelemetry, how OpenTelemetry names are translated for Prometheus backends, and the mistakes that cause real query errors. It belongs to metric types and cardinality in the Python metrics and instrumentation section.
Prerequisites
Familiarity with the metric types in choosing between counter, gauge, histogram and summary.
Implementation steps
Step 1 — Use base units. Durations in seconds, sizes in bytes, ratios as fractions between zero and one, temperatures in Celsius. Base units make every metric of a kind comparable and let dashboards scale for display. A Python service measuring with time.perf_counter() already has seconds; converting to milliseconds before recording adds a step, loses precision in integer histograms, and creates a unit mismatch with every other service.
t0 = time.perf_counter()
...
LATENCY.observe(time.perf_counter() - t0) # seconds, as measured
Step 2 — Follow the path's naming convention. For prometheus_client: snake_case, a unit suffix in its plural base form (_seconds, _bytes), and _total on counters, which the library adds automatically. For OpenTelemetry: lowercase dotted names, the unit in the instrument's unit field rather than the name, and no _total.
# prometheus_client
Histogram("http_server_request_duration_seconds", "...")
Counter("orders_placed", "...") # exposed as orders_placed_total
# OpenTelemetry
meter.create_histogram("http.server.request.duration", unit="s")
meter.create_counter("orders.placed", unit="{order}")
Step 3 — Use semantic-convention names where they exist. OpenTelemetry defines names and attributes for HTTP servers and clients, database clients, messaging, and runtime metrics. Using them means dashboards, alerts and vendor integrations built for the convention work unchanged, and different services' metrics line up. Automatic instrumentation already emits them; hand-written instruments should match when they measure the same thing. The same idea applies to spans, as in naming spans and semantic conventions.
Step 4 — Put dimensions in labels, not names. http_requests_orders_total and http_requests_payments_total are one metric split into two names; a query for all requests must now list every name. http_requests_total{route="/orders"} is one metric, and sum(rate(http_requests_total[5m])) covers everything.
Step 5 — Check translated names before writing queries. OpenTelemetry names are translated when exported to Prometheus: dots become underscores, the unit is appended, counters gain _total. A curl of the exporter's endpoint, or a look at the backend's metric list, shows the real name.
Names for business and domain metrics
Semantic conventions cover infrastructure. Domain metrics — orders, payments, searches, jobs — need names the team chooses, and a few habits keep them consistent.
A namespace per domain, not per service. orders.placed and orders.cancelled share a prefix that groups them in metric browsers and autocomplete. If three services all record order events, they use the same names and are distinguished by the service resource attribute; a query for all orders placed then works across the fleet.
Past-tense verbs for event counters. orders.placed, payments.failed, emails.sent read as counts of things that happened. Present-tense or noun-only names — order, payment_status — leave readers guessing the type.
Nouns for states. queue.length, cache.entries, pool.connections read as current values, which is what gauges and up-down counters report.
Outcomes as labels. payments.processed{outcome="failed"} rather than a separate payments.failed lets one query compute a failure ratio. Either is defensible; the labelled form makes ratios and breakdowns easier, and the separate form makes the most important signal findable by name. Consistency within a service matters more than which one is chosen.
A short naming guide in the repository — a page listing the namespaces in use, the unit rules and a few examples — saves more time than any linting, because most naming inconsistency comes from someone not knowing a name already exists.
Label names deserve the same care
Everything above applies to label names too, and label names are where inconsistency does the most damage, because joins and aggregations match on them exactly. A service labelling status as status and another as code cannot be summed in one expression without relabelling. A route labelled route in one service and path in another, or handler in a third, makes every fleet-wide dashboard a union of special cases.
The semantic conventions again give a shared vocabulary: http.request.method, http.response.status_code, http.route, server.address, db.system. In a Prometheus backend these become http_request_method, http_response_status_code and so on. Services using prometheus_client directly can adopt the same names in snake_case, which lets their metrics sit beside OpenTelemetry-instrumented services without translation.
Label values matter as well. Status codes as strings of digits ("200"), not classes in one service and codes in another. Methods in upper case. Booleans as "true" and "false", not a mix of those with "1" and "yes". A value vocabulary written down once, beside the names, keeps queries that filter on values from silently missing half the fleet.
Renaming a metric without losing history
Sooner or later a name turns out to be wrong — the wrong unit, a service name embedded in it, a convention adopted after the fact. Renaming is straightforward in code and disruptive everywhere else: every dashboard, alert and recording rule refers to the old name, and the backend's history stays under it.
The ordering in phase two matters. A dashboard on a name that stops being emitted goes blank, which someone notices. An alert on it goes silent, which nobody notices until the incident it should have caught. Moving alerts before anything else, and adding an absent-data rule for the new name as described in writing alert rules for Python services, makes the rename safe to finish.
Help text and descriptions
Every metric carries a description — the help string in prometheus_client, the description argument in OpenTelemetry — and it is the one piece of documentation guaranteed to travel with the metric into every backend and metric browser. A good description says what is counted or measured, where, and anything surprising about it: "Requests handled by the ASGI app, excluding /metrics and /health; latency measured to the last body byte." That sentence answers the questions a person writing a query at two in the morning actually has, and it costs nothing at runtime. Keeping descriptions accurate when behaviour changes — a new exclusion, a different measurement point — matters as much as writing them, since a wrong description is worse than a missing one.
Descriptions also help catch naming mistakes in review. A description that has to explain the name away — "latency in milliseconds, despite the suffix" — is a signal the name should change now, before anything depends on it.
Configuration options
| Rule | prometheus_client | OpenTelemetry |
|---|---|---|
| Case and separator | snake_case |
lower.dotted |
| Unit | suffix: _seconds, _bytes |
unit field: s, By |
| Counter suffix | _total (automatic) |
none; exporter adds |
| Counts of things | _total on the counter |
{thing} annotation |
| Ratios | _ratio, 0–1 |
unit 1 |
| Service identity | target labels | resource attributes |
| Dimensions | labels | attributes |
Verification
List the names a service actually exposes and check them against the rules:
curl -s localhost:9464/metrics | grep -v '^#' | sed 's/[{ ].*//' | sort -u
Expected Output: base-unit suffixes, _total only on counters, no service names or route names inside metric names.
http_server_request_duration_seconds_bucket
http_server_request_duration_seconds_count
http_server_request_duration_seconds_sum
orders_placed_total
process_memory_usage_bytes
A name ending in _ms, _milliseconds_seconds or containing a route or service is a candidate for renaming — before dashboards depend on it, because a rename later means changing every query and losing continuity in history.
Common mistakes
Milliseconds in one service, seconds in another. Error signature: a cross-service latency panel where one line is a thousand times the others. Root cause: non-base units. Remediation: seconds everywhere; convert in the dashboard.
Unit in the OpenTelemetry name. Error signature: request_duration_ms_milliseconds in Prometheus. Root cause: the unit in both name and field. Remediation: the unit field only.
_total on a gauge. Error signature: rate() applied to a value that goes down, producing nonsense. Root cause: the name implies a counter. Remediation: _total only on counters.
Dimensions in names. Error signature: dashboards listing twenty metric names for one quantity. Root cause: routes or targets encoded in names. Remediation: one name, labels for dimensions.
Renaming without a transition. Error signature: dashboards going blank after a deploy. Root cause: queries still using the old name. Remediation: emit both names for a period, or use recording rules to bridge.
Inconsistent label names across services. Error signature: fleet-wide queries that silently cover only some services. Root cause: status in one service, code in another. Remediation: semantic-convention label names everywhere.
Empty or generic help strings. Error signature: metric browsers full of names nobody can interpret. Root cause: descriptions left as the name repeated. Remediation: one sentence on what, where and any exclusions.
Frequently Asked Questions
Should latency be in seconds or milliseconds?
Seconds, as a floating-point value. It is the Prometheus convention and the OpenTelemetry semantic convention for durations, and mixing units across services makes every cross-service query a unit-conversion exercise. Dashboards display milliseconds when that reads better.
Why does my OpenTelemetry metric have a different name in Prometheus?
The Prometheus exporters translate names: dots become underscores, the unit is appended as a suffix in its Prometheus spelling, and counters gain _total. An instrument named http.server.request.duration with unit s becomes http_server_request_duration_seconds.
What should the _total suffix be used for?
Counters only. prometheus_client adds it automatically to Counter names, and OpenTelemetry's Prometheus exporters add it to monotonic sums. Using it on a gauge misleads anyone reading the name about how to query it.
Should the service name be in the metric name?
No. The service is a label or resource attribute added by the scrape target or the SDK resource. Metric names describe what is measured; putting the service in the name prevents one query from covering every service.
How are units written in OpenTelemetry?
As UCUM strings in the instrument's unit field: s for seconds, By for bytes, 1 for dimensionless ratios, and curly-brace annotations such as {request} or {connection} for counts of things.