Skip to content
ExponaBLOG
PROMETHEUS, GRAFANA & MONITORING · DAY 007 / 100

Naming metrics well: Monitoring best practices explained

Consistent names make queries easy.

Expona Admin6 min read

After a week of setting up Prometheus scrapers and dashboards, you're staring at a query that looks like this: rate(app_http_req_duration_ms[5m]). It works, but you realize your teammate wrote something called service_response_time elsewhere, and you're not sure if they're the same thing. Naming metrics well: monitoring best practices explained is the difference between a monitoring system that scales across teams and one that becomes a source of confusion. We'll walk through the patterns that let you query with confidence and onboard new engineers without diving into code.

Why naming conventions matter in practice

Before we talk about the rules, let's acknowledge why they exist. Prometheus doesn't enforce naming conventions—it will happily scrape a metric called x or FooBar_mega_requests. But when you're on-call at 3 AM and need to find "the metric for request latency," inconsistent names cost you minutes. When you're building dashboards across three microservices owned by different teams, naming drift means duplicate metrics tracking the same thing. When a junior engineer joins and needs to write their first Prometheus query, clear conventions mean they can guess the right name.

We've seen teams waste weeks retrofitting metrics after realizing their naming broke down halfway through a migration. The fix is simple: agree on a pattern upfront, document it, and stick to it.

Snake_case with a unit suffix

Start here. All metric names should use lowercase letters, digits, and underscores—no camelCase, no dots, no hyphens. This is actually Prometheus convention, not just opinion.

The second part is critical: include the unit in the name itself. Not as a label, not as a comment—in the name. This tells everyone instantly what they're looking at.

If you measure request latency in milliseconds, call it http_request_duration_ms. If you measure it in seconds, call it http_request_duration_seconds. If you measure database pool size as a count of connections, call it db_pool_size_connections. If you measure memory as bytes, call it process_memory_bytes.

yaml
// Good
http_request_duration_ms
database_query_time_seconds
cache_size_bytes
active_connections_count

// Avoid
request_duration
query_time
cache_size
active_connections

Why? Because when you write a Prometheus expression like rate(http_request_duration_ms[5m]), anyone reading it knows they're getting milliseconds per second, not seconds per second. When you're building an alert threshold and need to decide if http_request_duration_ms > 500 is reasonable, the name tells you what 500 means. And when you're converting units in a Grafana panel, you won't accidentally apply the wrong transformation.

This matters especially across teams. A backend team measures latency in milliseconds; a platform team measures infrastructure metrics in seconds. If everyone includes the unit, there's no ambiguity.

_total for counters

Counters only go up (or reset). A typical counter measures cumulative events: HTTP requests received, errors encountered, bytes processed. Prometheus has specific expectations for counter naming, and following them unlocks tools and conventions everyone on your team will recognize.

Always suffix a counter with _total. This tells any reader immediately: "this is cumulative, use rate() or increase() on it to get something meaningful."

yaml
// Good
http_requests_total
errors_total
messages_processed_total
cache_hits_total
cache_misses_total

// Avoid
http_requests
total_errors
messages_processed
cache_hits
cache_misses

In practice, this prevents confusion. Without _total, you might think http_requests is the current request rate (a gauge). With _total, it's unmistakably cumulative. When someone writes http_requests_total in a query, your Prometheus UI and editor already know it's a counter—and Prometheus can auto-suggest the right aggregation functions.

Counters often pair with labels. For example:

yaml
http_requests_total{method="GET", status="200"}
http_requests_total{method="POST", status="500"}
errors_total{service="order-api", error_type="validation"}
errors_total{service="order-api", error_type="timeout"}

The labels tell you the dimensions; the _total suffix tells you the type. Together, they make queries unambiguous.

_seconds and _bytes for standard units

Some units are so common in monitoring that Prometheus has de facto standards. If you measure time, use seconds. If you measure size, use bytes. This is practical because Prometheus's official exporters and client libraries follow this convention, and you want your metrics to sit comfortably alongside those.

When you use seconds and bytes consistently, your team develops intuition. Everyone learns the scale. Queries become muscle memory. Dashboards become portable: if every latency metric is in seconds, you can build a template dashboard and reuse it across services.

yaml
// Time in seconds
http_request_duration_seconds
database_query_time_seconds
gc_pause_time_seconds
cache_eviction_latency_seconds

// Size in bytes
memory_usage_bytes
disk_space_free_bytes
cache_size_bytes
request_payload_size_bytes
response_payload_size_bytes

If your underlying instrumentation uses a different unit—say, nanoseconds or kilobytes—convert it before you export. Your monitoring layer should present a consistent face to the organization, not leak implementation details.

For other units (temperature, requests per second, percentage), be explicit but consistent:

yaml
cpu_temperature_celsius
request_rate_rps
disk_usage_percent
cache_hit_ratio

Prefix with your app or library name

If you're running multiple services, or if multiple teams push metrics to the same Prometheus instance, you need a namespace. This is where the prefix comes in.

Every metric should start with your application or service name, followed by an underscore. This serves multiple purposes:

  • It prevents collisions. Two teams can both have a metric called requests_total, but only if one exposes order_service_requests_total and the other exposes payment_service_requests_total.
  • It makes dashboards and alerts portable. You can instantly tell which service a metric belongs to.
  • It simplifies PromQL queries: you can use order_service_* to match all metrics for a single service.
yaml
// Good
order_service_http_requests_total
order_service_database_query_time_seconds
payment_api_request_duration_ms
payment_api_cache_hits_total
user_auth_service_login_attempts_total
user_auth_service_token_expiry_seconds

// Avoid (unclear scope)
http_requests_total
database_query_time_seconds
requests_total
cache_hits_total

If you're building a library or exporter, the prefix is the library name. For example, the official PostgreSQL exporter prefixes everything with pg_.

The full metric name becomes: [service_name]_[metric_description]_[unit].

yaml
order_service_http_request_duration_seconds
order_service_http_requests_total
payment_api_cache_size_bytes
payment_api_database_connections_active

This pattern scales. When you query the dashboard for your order service, you filter by {job="order_service"} or order_service_*. When you write an alert for payment latency, you use metrics starting with payment_api_. New team members see the namespace immediately and don't have to guess which metrics belong to which system.

Common mistakes

We've seen teams stumble in a few predictable ways:

  • Mixing units inconsistently: One service measures latency in milliseconds, another in seconds. Queries become fragile. Someone writes an alert threshold and forgets to convert. Stick to seconds for time, bytes for size.
  • Forgetting _total on counters: A year in, someone writes a dashboard assuming requests is a gauge. It breaks production alerts because they used the wrong aggregation. Use _total every time.
  • No namespace prefix: Services proliferate, metrics collide, and you can't tell which database query time belongs to which service. Always namespace by service or library.
  • Changing names mid-stream: You realize http_latency_ms should be http_request_duration_ms. By then, dashboards and alerts are built on the old name. Decide early and stick with it. Renaming costs.
  • Plural vs. singular: Is it http_requests_total or http_request_total? Pick one. We prefer plural (more natural: "total requests"). Consistency matters more than the choice.
  • Units in labels instead of names: Storing the unit as a label (e.g., latency{unit="ms"}) seems flexible. It's not. It breaks queries, makes aggregation harder, and defeats the purpose of having conventions.

Key takeaways

  • Use snake_case and include the unit suffix in every metric name—milliseconds, bytes, connections, percent, or whatever your metric measures.
  • Always add _total to counters. This instantly signals the metric is cumulative and should be used with rate() or increase().
  • Prefer seconds for time and bytes for size. These are Prometheus conventions, and building around them makes your metrics portable and intuitive.
  • Namespace all metrics with your application or library name as a prefix. This prevents collisions and clarifies ownership.
  • Decide on naming conventions early and document them. Renaming metrics after they're in production is expensive.
  • Consistency compounds: good naming becomes invisible and natural. Bad naming becomes pain, every query, every day.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles