77
88import static io .prometheus .metrics .model .snapshots .PrometheusNaming .prometheusName ;
99import static io .prometheus .metrics .model .snapshots .PrometheusNaming .sanitizeLabelName ;
10- import static io .prometheus .metrics .model .snapshots .PrometheusNaming .sanitizeMetricName ;
1110import static java .util .Objects .requireNonNull ;
1211
1312import io .opentelemetry .api .common .AttributeKey ;
@@ -82,8 +81,13 @@ final class Otel2PrometheusConverter {
8281 private static final long NANOS_PER_MILLISECOND = TimeUnit .MILLISECONDS .toNanos (1 );
8382 static final int MAX_CACHE_SIZE = 10 ;
8483
84+ private static final String [] RESERVED_METRIC_SUFFIXES = {
85+ "_total" , "_created" , "_bucket" , "_info"
86+ };
87+
8588 private final boolean otelScopeLabelsEnabled ;
8689 private final boolean targetInfoMetricEnabled ;
90+ private final TranslationStrategy translationStrategy ;
8791 @ Nullable private final Predicate <String > allowedResourceAttributesFilter ;
8892
8993 /**
@@ -92,21 +96,14 @@ final class Otel2PrometheusConverter {
9296 */
9397 private final Map <Attributes , List <AttributeKey <?>>> resourceAttributesToAllowedKeysCache ;
9498
95- /**
96- * Constructor with feature flag parameters.
97- *
98- * @param otelScopeLabelsEnabled whether to add OpenTelemetry scope labels to exported metrics
99- * @param targetInfoMetricEnabled whether to export the target_info metric with resource
100- * attributes
101- * @param allowedResourceAttributesFilter if not {@code null}, resource attributes with keys
102- * matching this predicate will be added as labels on each exported metric
103- */
10499 Otel2PrometheusConverter (
105100 boolean otelScopeLabelsEnabled ,
106101 boolean targetInfoMetricEnabled ,
102+ TranslationStrategy translationStrategy ,
107103 @ Nullable Predicate <String > allowedResourceAttributesFilter ) {
108104 this .otelScopeLabelsEnabled = otelScopeLabelsEnabled ;
109105 this .targetInfoMetricEnabled = targetInfoMetricEnabled ;
106+ this .translationStrategy = translationStrategy ;
110107 this .allowedResourceAttributesFilter = allowedResourceAttributesFilter ;
111108 this .resourceAttributesToAllowedKeysCache =
112109 allowedResourceAttributesFilter != null
@@ -122,6 +119,10 @@ boolean isTargetInfoMetricEnabled() {
122119 return targetInfoMetricEnabled ;
123120 }
124121
122+ TranslationStrategy getTranslationStrategy () {
123+ return translationStrategy ;
124+ }
125+
125126 @ Nullable
126127 Predicate <String > getAllowedResourceAttributesFilter () {
127128 return allowedResourceAttributesFilter ;
@@ -155,7 +156,8 @@ private MetricSnapshot convert(MetricData metricData) {
155156 // Note that AggregationTemporality.DELTA should never happen
156157 // because PrometheusMetricReader#getAggregationTemporality returns CUMULATIVE.
157158
158- MetricMetadata metadata = convertMetadata (metricData );
159+ boolean isCounter = isMonotonicSum (metricData );
160+ MetricMetadata metadata = convertMetadata (metricData , isCounter );
159161 InstrumentationScopeInfo scope = metricData .getInstrumentationScopeInfo ();
160162 switch (metricData .getType ()) {
161163 case LONG_GAUGE :
@@ -210,6 +212,17 @@ private MetricSnapshot convert(MetricData metricData) {
210212 return null ;
211213 }
212214
215+ private static boolean isMonotonicSum (MetricData metricData ) {
216+ switch (metricData .getType ()) {
217+ case LONG_SUM :
218+ return metricData .getLongSumData ().isMonotonic ();
219+ case DOUBLE_SUM :
220+ return metricData .getDoubleSumData ().isMonotonic ();
221+ default :
222+ return false ;
223+ }
224+ }
225+
213226 private GaugeSnapshot convertLongGauge (
214227 MetricMetadata metadata ,
215228 InstrumentationScopeInfo scope ,
@@ -545,34 +558,116 @@ private List<AttributeKey<?>> filterAllowedResourceAttributeKeys(@Nullable Resou
545558 return allowedAttributeKeys ;
546559 }
547560
548- /**
549- * Convert an attribute key to a legacy Prometheus label name. {@code prometheusName} converts
550- * non-standard characters (dots, dashes, etc.) to underscores, and {@code sanitizeLabelName}
551- * strips invalid leading prefixes.
552- */
553- private static String convertLabelName (String key ) {
554- return sanitizeLabelName (prometheusName (key ));
561+ private String convertLabelName (String key ) {
562+ if (translationStrategy .shouldEscape ()) {
563+ return sanitizeLabelName (prometheusName (key ));
564+ }
565+ return key ;
566+ }
567+
568+ private MetricMetadata convertMetadata (MetricData metricData , boolean isCounter ) {
569+ switch (translationStrategy ) {
570+ case UNDERSCORE_ESCAPING_WITH_SUFFIXES :
571+ return convertMetadataEscapedWithSuffixes (metricData );
572+ case UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES :
573+ return convertMetadataEscapedWithoutSuffixes (metricData );
574+ case NO_UTF8_ESCAPING_WITH_SUFFIXES :
575+ return convertMetadataUtf8WithSuffixes (metricData , isCounter );
576+ case NO_TRANSLATION :
577+ return convertMetadataNoTranslation (metricData );
578+ }
579+ throw new IllegalStateException ("Unknown strategy: " + translationStrategy );
555580 }
556581
557- private static MetricMetadata convertMetadata (MetricData metricData ) {
558- String name = sanitizeMetricName (prometheusName (metricData .getName ()));
582+ /**
583+ * Default strategy: escape names, append unit and type suffixes, collapse repeated __. Uses 3-arg
584+ * MetricMetadata constructor so the format writer handles type suffixes (_total for counters)
585+ * automatically — preserving backward-compatible output format.
586+ */
587+ private static MetricMetadata convertMetadataEscapedWithSuffixes (MetricData metricData ) {
588+ String name = prometheusName (metricData .getName ());
559589 String help = metricData .getDescription ();
560590 Unit unit = PrometheusUnitsHelper .convertUnit (metricData .getUnit ());
591+
592+ // Strip reserved suffixes (_total, _info, etc.) BEFORE appending unit.
593+ // This replicates the old sanitizeMetricName behavior which ran before unit append.
594+ name = stripReservedMetricSuffixes (name );
595+
596+ // Append unit suffix
561597 if (unit != null && !name .endsWith (unit .toString ())) {
562598 name = name + "_" + unit ;
563599 }
564- // Repeated __ are discouraged according to spec, although this is allowed in prometheus, see
565- // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1
600+
601+ // Collapse repeated __ introduced by escaping
566602 while (name .contains ("__" )) {
567603 name = name .replace ("__" , "_" );
568604 }
569605
570606 return new MetricMetadata (name , help , unit );
571607 }
572608
573- private static void putOrMerge (
574- Map <String , MetricSnapshot > snapshotsByName , MetricSnapshot snapshot ) {
575- String name = snapshot .getMetadata ().getPrometheusName ();
609+ /** Escape names but don't add any suffixes. */
610+ private static MetricMetadata convertMetadataEscapedWithoutSuffixes (MetricData metricData ) {
611+ String name = prometheusName (metricData .getName ());
612+
613+ // Collapse repeated __ introduced by escaping
614+ while (name .contains ("__" )) {
615+ name = name .replace ("__" , "_" );
616+ }
617+
618+ return new MetricMetadata (name , name , metricData .getDescription (), null );
619+ }
620+
621+ /** Passthrough names (UTF-8 preserved), but add unit and type suffixes. */
622+ private static MetricMetadata convertMetadataUtf8WithSuffixes (
623+ MetricData metricData , boolean isCounter ) {
624+ String name = metricData .getName ();
625+ String help = metricData .getDescription ();
626+ Unit unit = PrometheusUnitsHelper .convertUnit (metricData .getUnit ());
627+
628+ if (unit != null && !name .endsWith (unit .toString ())) {
629+ name = name + "_" + unit ;
630+ }
631+
632+ String expositionBaseName = name ;
633+ if (isCounter && !name .endsWith ("_total" )) {
634+ expositionBaseName = name + "_total" ;
635+ }
636+
637+ return new MetricMetadata (name , expositionBaseName , help , unit );
638+ }
639+
640+ /** Full passthrough: no escaping, no suffixes, no unit. */
641+ private static MetricMetadata convertMetadataNoTranslation (MetricData metricData ) {
642+ String name = metricData .getName ();
643+ return new MetricMetadata (name , name , name , metricData .getDescription (), null );
644+ }
645+
646+ /**
647+ * Strip reserved Prometheus metric name suffixes (_total, _info, _created, _bucket). This
648+ * replicates the behavior of the old {@code PrometheusNaming.sanitizeMetricName} which was
649+ * changed to a no-op in prometheus client_java 1.6.0.
650+ */
651+ private static String stripReservedMetricSuffixes (String name ) {
652+ boolean modified = true ;
653+ while (modified ) {
654+ modified = false ;
655+ for (String suffix : RESERVED_METRIC_SUFFIXES ) {
656+ if (name .equals (suffix )) {
657+ // Corner case: name is exactly "_total" → return "total"
658+ return name .substring (1 );
659+ }
660+ if (name .endsWith (suffix )) {
661+ name = name .substring (0 , name .length () - suffix .length ());
662+ modified = true ;
663+ }
664+ }
665+ }
666+ return name ;
667+ }
668+
669+ private void putOrMerge (Map <String , MetricSnapshot > snapshotsByName , MetricSnapshot snapshot ) {
670+ String name = getMergeKey (snapshot .getMetadata ());
576671 if (snapshotsByName .containsKey (name )) {
577672 MetricSnapshot merged = merge (snapshotsByName .get (name ), snapshot );
578673 if (merged != null ) {
@@ -583,6 +678,13 @@ private static void putOrMerge(
583678 }
584679 }
585680
681+ private String getMergeKey (MetricMetadata metadata ) {
682+ if (translationStrategy .shouldEscape ()) {
683+ return metadata .getPrometheusName ();
684+ }
685+ return metadata .getName ();
686+ }
687+
586688 /**
587689 * OpenTelemetry may use the same metric name multiple times but in different instrumentation
588690 * scopes. In that case, we try to merge the metrics. They will have different {@code
0 commit comments