This is an automated email from the ASF dual-hosted git repository.

zaynt4606 pushed a commit to branch branch-0.7
in repository https://gitbox.apache.org/repos/asf/celeborn.git


The following commit(s) were added to refs/heads/branch-0.7 by this push:
     new 9b392d8a02 [CELEBORN-2359] Deduplicate # HELP/# TYPE lines in 
PrometheusServlet output
9b392d8a02 is described below

commit 9b392d8a02c372b0fb9d90f59f26642c62048834
Author: afterincomparableyum 
<[email protected]>
AuthorDate: Mon Oct 12 19:02:05 2026 +0800

    [CELEBORN-2359] Deduplicate # HELP/# TYPE lines in PrometheusServlet output
    
    ### What changes were proposed in this pull request?
    
    AbstractSource emits a `# HELP` and `# TYPE` line for every metric sample. 
When a metric family has multiple label sets — like ActiveConnectionCount (per 
application), RequestSlotsFailedCount (per status code), per-user/app resource 
consumption, or FlushWorkingQueueSize (per mountpoint) — the same family is 
exported with repeated `# HELP` and `# TYPE` lines. This violates the 
Prometheus exposition format.
    
    The fix is to override PrometheusServlet.getMetricsSnapshot to keep only 
the first `# HELP` and `# TYPE` line per metric family while preserving all 
sample lines, producing valid Prometheus output.
    
    I also added a PrometheusServletSuite covering a multi-label-set counter 
and a distinct gauge.
    
    ### Why are the changes needed?
    
    To dedup the help and type lines to no longer violate prometheus exposition 
format.
    
    ### Does this PR resolve a correctness bug?
    
    - [x] Yes
    
    ### Does this PR introduce _any_ user-facing change?
    
    - [ ] Yes
    
    ### How was this patch tested?
    
    CI/CD
    
    Closes #3852 from afterincomparableyum/CELEBORN-2359.
    
    Authored-by: afterincomparableyum 
<[email protected]>
    Signed-off-by: zhengtao <[email protected]>
    Co-Authored-By: Codex <[email protected]>
    (cherry picked from commit 63381a8593b6402965ffbd9dde7058bfc04e027b)
    Signed-off-by: zhengtao <[email protected]>
    AI-Model: gpt-6
    AI-Contributed/Feature: 0/46
    AI-Contributed/UT: 0/106
---
 .../common/metrics/sink/PrometheusServlet.scala    |  46 +++++++++
 .../metrics/sink/PrometheusServletSuite.scala      | 106 +++++++++++++++++++++
 2 files changed, 152 insertions(+)

diff --git 
a/service/src/main/scala/org/apache/celeborn/common/metrics/sink/PrometheusServlet.scala
 
b/service/src/main/scala/org/apache/celeborn/common/metrics/sink/PrometheusServlet.scala
index 27797c8fc9..5ce54db97d 100644
--- 
a/service/src/main/scala/org/apache/celeborn/common/metrics/sink/PrometheusServlet.scala
+++ 
b/service/src/main/scala/org/apache/celeborn/common/metrics/sink/PrometheusServlet.scala
@@ -19,9 +19,12 @@ package org.apache.celeborn.common.metrics.sink
 
 import java.util.Properties
 
+import scala.collection.mutable
+
 import com.codahale.metrics.MetricRegistry
 import org.eclipse.jetty.servlet.ServletContextHandler
 
+import org.apache.celeborn.common.metrics.sink.PrometheusServlet.{HeaderLine, 
MetricFamily}
 import org.apache.celeborn.common.metrics.source.Source
 import org.apache.celeborn.server.common.http.HttpUtils
 import org.apache.celeborn.server.common.http.HttpUtils.ServletParams
@@ -37,4 +40,47 @@ class PrometheusServlet(
       servletPath,
       new ServletParams(_ => getMetricsSnapshot, "text/plain"))
   }
+
+  /**
+   * Sources emit a # HELP / # TYPE pair before every sample and iterate 
metrics in hash order,
+   * so samples of one metric family can be repeated and interleaved with 
other families. The
+   * Prometheus exposition format requires a single # HELP / # TYPE per family 
with all of its
+   * samples grouped together, so regroup the output by metric family.
+   */
+  override def getMetricsSnapshot: String = {
+    val raw = super.getMetricsSnapshot
+    val families = mutable.LinkedHashMap[String, MetricFamily]()
+
+    raw.linesIterator.foreach {
+      case line @ HeaderLine(kind, metric) =>
+        val family = families.getOrElseUpdate(metric, new MetricFamily)
+        if (kind == "HELP" && family.help == null) {
+          family.help = line
+        } else if (kind == "TYPE" && family.metricType == null) {
+          family.metricType = line
+        }
+      case line if line.nonEmpty =>
+        val metric = line.takeWhile(c => c != '{' && !c.isWhitespace)
+        families.getOrElseUpdate(metric, new MetricFamily).samples += line
+      case _ =>
+    }
+
+    val out = new StringBuilder(raw.length)
+    families.valuesIterator.foreach { family =>
+      if (family.help != null) out.append(family.help).append('\n')
+      if (family.metricType != null) out.append(family.metricType).append('\n')
+      family.samples.foreach(out.append(_).append('\n'))
+    }
+    out.result()
+  }
+}
+
+object PrometheusServlet {
+  private val HeaderLine = "^#\\s+(TYPE|HELP)\\s+(\\S+)\\b.*$".r
+
+  private class MetricFamily {
+    var help: String = _
+    var metricType: String = _
+    val samples = mutable.ArrayBuffer[String]()
+  }
 }
diff --git 
a/service/src/test/scala/org/apache/celeborn/common/metrics/sink/PrometheusServletSuite.scala
 
b/service/src/test/scala/org/apache/celeborn/common/metrics/sink/PrometheusServletSuite.scala
new file mode 100644
index 0000000000..dec14b2108
--- /dev/null
+++ 
b/service/src/test/scala/org/apache/celeborn/common/metrics/sink/PrometheusServletSuite.scala
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.celeborn.common.metrics.sink
+
+import java.util.Properties
+
+import com.codahale.metrics.MetricRegistry
+
+import org.apache.celeborn.CelebornFunSuite
+import org.apache.celeborn.common.CelebornConf
+import org.apache.celeborn.common.metrics.source.{AbstractSource, Role}
+
+class PrometheusServletSuite extends CelebornFunSuite {
+
+  private def newServlet(sources: AbstractSource*): PrometheusServlet = {
+    new PrometheusServlet(
+      new Properties(),
+      new MetricRegistry(),
+      sources,
+      "/metrics/prometheus")
+  }
+
+  private def newSource(conf: CelebornConf): AbstractSource = {
+    new AbstractSource(conf, Role.WORKER) {
+      override def sourceName: String = "mockSource"
+    }
+  }
+
+  test("getMetricsSnapshot deduplicates # HELP and # TYPE lines per metric 
family") {
+    val conf = new CelebornConf()
+    val source = newSource(conf)
+
+    val user1 = Map("user" -> "user1")
+    val user2 = Map("user" -> "user2")
+    source.addCounter("Counter", user1)
+    source.addCounter("Counter", user2)
+    source.incCounter("Counter", 1, user1)
+    source.incCounter("Counter", 2, user2)
+
+    source.addGauge("Gauge1") { () => 1000 }
+
+    val snapshot = newServlet(source).getMetricsSnapshot
+    val lines = snapshot.linesIterator.toList
+
+    val counterName = "metrics_Counter_Count"
+    val gaugeName = "metrics_Gauge1_Value"
+
+    assert(lines.count(_ == s"# HELP $counterName") == 1)
+    assert(lines.count(_ == s"# TYPE $counterName counter") == 1)
+    assert(lines.count(_.startsWith(s"$counterName{")) == 2)
+    assert(snapshot.contains("""user="user1""""))
+    assert(snapshot.contains("""user="user2""""))
+    assert(lines.count(_ == s"# HELP $gaugeName") == 1)
+    assert(lines.count(_ == s"# TYPE $gaugeName gauge") == 1)
+    val gaugeSamples = lines.filter(_.startsWith(gaugeName))
+    assert(gaugeSamples.size == 1)
+    assert(gaugeSamples.head.split(" ")(1) == "1000")
+  }
+
+  test("getMetricsSnapshot groups samples of a metric family that are 
interleaved") {
+    val conf = new CelebornConf()
+    val user1 = Map("user" -> "user1")
+    val user2 = Map("user" -> "user2")
+    // Sources emit gauges before counters, so the raw snapshot is
+    // Counter{user1}, Gauge1, Counter{user2}.
+    val source1 = newSource(conf)
+    source1.addCounter("Counter", user1)
+    val source2 = newSource(conf)
+    source2.addGauge("Gauge1") { () => 1000 }
+    source2.addCounter("Counter", user2)
+
+    val servlet = newServlet(source1, source2)
+    val counterName = "metrics_Counter_Count"
+    val gaugeName = "metrics_Gauge1_Value"
+    val rawLines = 
servlet.sources.map(_.getMetrics).mkString.linesIterator.toList
+    assert(rawLines.indexWhere(_.startsWith(gaugeName)) <
+      rawLines.lastIndexWhere(_.startsWith(s"$counterName{")))
+
+    val lines = servlet.getMetricsSnapshot.linesIterator.toList
+    val counterStart = lines.indexOf(s"# HELP $counterName")
+    assert(lines(counterStart + 1) == s"# TYPE $counterName counter")
+    val counterSamples = lines.slice(counterStart + 2, counterStart + 4)
+    assert(counterSamples.forall(_.startsWith(s"$counterName{")))
+    assert(counterSamples.exists(_.contains("user=\"user1\"")))
+    assert(counterSamples.exists(_.contains("user=\"user2\"")))
+    assert(lines.count(_.startsWith(s"$counterName{")) == 2)
+    val gaugeStart = lines.indexOf(s"# HELP $gaugeName")
+    assert(lines(gaugeStart + 1) == s"# TYPE $gaugeName gauge")
+    assert(lines(gaugeStart + 2).startsWith(s"$gaugeName{"))
+  }
+}

Reply via email to