OpenMetrics 2.0: option to keep _total and unit suffixes, so switching from OM1 doesn't rename series #2518
Description
With io.prometheus.openmetrics2.enabled, the OM2 writer exposes names exactly as passed to the builder. It never appends _total or the unit suffix:
Lines 174 to 179 in 9e9deb6
| private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingScheme scheme) | |
| throws IOException { | |
| MetricMetadata metadata = snapshot.getMetadata(); | |
| // OM2: use the original name, no _total or unit suffix appending. | |
| String counterName = getOriginalMetadataName(metadata, scheme); | |
| writeMetadataWithName(writer, counterName, "counter", metadata); |
This is documented in
client_java/docs/content/exporters/openmetrics2.md
Lines 57 to 77 in 9e9deb6
| ## Naming Behavior | |
| OpenMetrics 2.0 removes OM1 suffix rewriting. | |
| - Counters do not get `_total` appended automatically. | |
| - Units do not get appended automatically. | |
| - Info metrics still end in `_info` because that is required by the spec. | |
| Examples: | |
| | Metric builder input | OM1 output | OM2 output | | |
| | ---------------------------------- | ----------------- | -------------- | | |
| | `Counter("events")` | `events_total` | `events` | | |
| | `Counter("events_total")` | `events_total` | `events_total` | | |
| | `Counter("req").unit(BYTES)` | `req_bytes_total` | `req` | | |
| | `Counter("req_bytes").unit(BYTES)` | `req_bytes_total` | `req_bytes` | | |
| | `Info("target")` | `target_info` | `target_info` | | |
| This means OpenMetrics 2.0 does not apply OM1 suffix behavior such as appending `_total` or unit | |
| suffixes, while the legacy OpenMetrics 1.0 and Prometheus text formats keep that existing suffix | |
| behavior. |
Counter("req").unit(BYTES) is req_bytes_total in OM1 but req in OM2.
The problem is that switching a target from OM1 to OM2 renames every counter, and every metric with a unit, that was instrumented the way client_java has always recommended (Counter.builder().name("events")). Prometheus then stores them as new series, so existing queries, dashboards and alerts silently stop matching.
Note
I hit this with a demo that scrapes the same client_java app with OM1 and OM2 and diffs the stored series: the JVM metrics match (they already have the suffixes in their names), but http_requests_total (OM1) became http_requests (OM2), and http_request_size_bytes_total became http_request_size. Demo: https://35-204-166-191.sslip.io/, code: bwplotka/prometheus#7.
The OM2 spec relaxed _total and the unit suffix from MUST to SHOULD, mainly for OpenTelemetry compatibility (https://github.com/prometheus/docs/blob/605cf81fefc2e8e91f8ba89bb1555ae52a43a318/docs/guides/open_metrics_2_0_migration.md?plain=1#L140). Both are still recommended though (https://github.com/prometheus/docs/blob/605cf81fefc2e8e91f8ba89bb1555ae52a43a318/docs/specs/om/open_metrics_spec_2_0.md?plain=1#L220 and https://github.com/prometheus/docs/blob/605cf81fefc2e8e91f8ba89bb1555ae52a43a318/docs/specs/om/open_metrics_spec_2_0.md?plain=1#L664). Today there is no way to get them in OM2 other than renaming every metric in code; OpenMetrics2Properties has no option for it.
Proposal:
- add a property, e.g.
io.prometheus.openmetrics2.suffixes(or a naming mode), that keeps the OM1 suffix behaviour in the OM2 writer: append_totalto counters and the unit suffix where missing, asexpositionBaseNamealready does for OM1. - I'd argue it should default to on, so that negotiating OM2 doesn't change any series names, and users who want names exactly as written (e.g. for OTel-style names) can opt out.
For comparison, client_golang keeps the names users register (which by convention include _total and the unit), so there OM1 and OM2 produce the same series.
Activity
zeitlinger commentedon Oct 7, 2026
For that reason I would argue that whenever options we add - if any - they should make migration easier for existing applications.
The migration story I planned for would be that everyone who switches to OM2 would also adopt the new convention that metric names are not modified.
Would that work?
arnabnandy7 commentedon Oct 7, 2026
Keeping application-provided names unchanged as the long-term OM2 behavior makes sense.
The migration concern raised here still seems relevant, especially with content negotiation enabled. The same endpoint can serve OM1 and OM2 at the same time, so an existing
Counter("events")could appear asevents_totalto one scraper andeventsto another. Updating applications, dashboards, alerts, and recording rules may not always be possible in one step.Would an opt-in migration flag be a reasonable compromise? OM2 could preserve registered names by default, while
io.prometheus.openmetrics2.suffixes=truetemporarily retains the existing exposition names. Users could enable it while introducing OM2, migrate metric registrations and downstream queries, and then disable it.The current PR defaults this option to
true, but I can change it tofalseand document it specifically as a migration aid if that better matches the intended design.zeitlinger commentedon Oct 9, 2026
I'm spelling it out in more detail to make it easier to discuss in the upcoming sig meeting.
@arnabnandy7 please double check I'm getting the use case correctly.
I want to clarify the intended default: OM2 should preserve metric names exactly as registered, like the Go client. Suffixing should not be on by default.
The migration question is whether an opt-in compatibility setting, such as
io.prometheus.openmetrics2.suffixes=true, would help existing Java users move over gradually:suffixes=false)suffixes=true)Counter("events")events_totalCounter("events")events_totaleventsCounter("events")events_totalevents_totaleventsandevents_totalevents_totalevents_totalCounter("events_total")events_totalevents_totalWith the default, switching to OM2 changes the exposed name for producers still registering
events. With compatibility mode enabled, OM2 can retain the OM1 name while producer registrations are updated; consumers can keep queryingevents_total. Once registrations use canonical names, compatibility mode can be turned off without changing the exposed name.arnabnandy7 commentedon Oct 9, 2026
@zeitlinger yes, this captures the use case correctly.
One small clarification the mix of
eventsandevents_totalduring a gradual migration would normally be across application instances or deployment versions. Registering both in the same registry would cause a collision because they resolve to the same exposition name in compatibility mode.The same migration flow should also apply to unit suffixes. For example, compatibility mode would keep
Counter("req").unit(BYTES)exposed asreq_bytes_totalwhile producers gradually move to registering the complete name explicitly.With those clarifications,
suffixes=falseby default andsuffixes=trueas an opt-in migration aid matches the intended behavior.bwplotka commentedon Oct 12, 2026
Totally makes sense.
Sounds like you have a solid migration plan with the
suffixes=trueand asking users to add manually the suffixes over time 👍🏽bwplotka commentedon Oct 13, 2026
I double checked -- I didn't know this but apparently with the existing API
eventscounter automatically adding_totalsuffix is a norm for ALL exposition formats, right?0.0.4MetricFamily)main)Counter("events")# TYPE events_total counterevents_totalname: "events_total"type: COUNTER# TYPE events counterevents_total# TYPE events countereventsCounter("events_total")# TYPE events_total counterevents_totalname: "events_total"type: COUNTER# TYPE events counterevents_total# TYPE events_total counterevents_totalCounter("req").unit(BYTES)# TYPE req_bytes_total counterreq_bytes_totalname: "req_bytes_total"type: COUNTER# TYPE req_bytes counter# UNIT req_bytes bytesreq_bytes_total# TYPE req counter# UNIT req bytesreqGauge("mem").unit(BYTES)# TYPE mem_bytes gaugemem_bytesname: "mem_bytes"type: GAUGE# TYPE mem_bytes gauge# UNIT mem_bytes bytesmem_bytes# TYPE mem gauge# UNIT mem bytesmemHistogram("lat").unit(SECONDS)# TYPE lat_seconds histogramlat_seconds_bucket/_count/_sumname: "lat_seconds"type: HISTOGRAM# TYPE lat_seconds histogram# UNIT lat_seconds secondslat_seconds_bucket/_count/_sum# TYPE lat histogram# UNIT lat secondslat_bucket/_count/_sum(or compositelat {...})Info("target")# TYPE target_info gaugetarget_infoname: "target_info"type: GAUGE# TYPE target infotarget_info# TYPE target_info infotarget_infoIn this case, in your case I would propose sticking to suffix=true by default.
It's not exposition protocol that is glueing those suffixes. It's your API so this should not change IMO, until the next API version. Instead I would add some API option for preserving name which allows your intend.
Have you considered that?
zeitlinger commentedon Oct 13, 2026
all but 2.0 - yes
not true - OM2 doesn't add the suffixes
bwplotka commentedon Oct 13, 2026
I love this goal, but the question is -- do you need to break implementers to get there so fast. I don't see how you can do this "cleanly" with existing API doing opposite.
Is it realistic for asking people to rewrite their code in order to adopt OM2? Wouldn't it be more productive to add OM2 by default (!) with existing glue-ing behaviour and add opt-outs and v2 of API one day?
Is it? Going forward we envisioned Proto and OM 2(+) to be the main exposition formats we support and maintain, so having different behavior across those for the same API code seems odd?
I'd argue it's true. There's nothing in OM2 that tells anyone to stop adding suffixes, similarly there's nothing in OM1 that asked to add automatically add them if users missed them (there was just ask to have them). That's a choice of a client and it just happened that in client_golang we didn't make this automatic append.
TL;DR -- I am just exploring options, after all it's your decision. I think asking users to change their metric names and only then allow OM2 is a lot of work. This is not the worst choice, just might delay OM2 adoption and potentially bring some surprises. Perhaps acceptable 💪🏽
Up to you and thank you for adopting OM2 so quickly so we can learn!**
arnabnandy7 commentedon Oct 13, 2026
I took another look at how naming works internally, and it seems suffix normalization is already part of the client_java API model rather than something specific to the OM1 writer.
For example,
Counter.Builder.name()strips_total,MetricMetadata.Builder.name()treats its input as a base name without the type suffix, and setting a unit automatically incorporates that unit into the exposition name. The registry also uses these normalized exposition names when checking for collisions.So the current OM2 behavior is a little surprising because it goes back to the original, pre-normalized input, while every other format uses the normalized name. That means the same builder call has different naming semantics depending on which format is negotiated.
Given that, I think keeping
suffixes=trueby default for the current API would be the safer and more consistent behavior, while still allowing users to opt out when they explicitly want exact-name OM2 semantics. Making exact-name behavior the default could then be considered for a future API version where the naming contract can be changed consistently across all formats.