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

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 599616cb0de [fix](metrics) Escape label values in the Prometheus 
output of FE (#69837)
599616cb0de is described below

commit 599616cb0de60049b72df3e8fcfd7f6b55883d1c
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Nov 2 09:40:12 2026 +0000

    [fix](metrics) Escape label values in the Prometheus output of FE (#69837)
    
    ### What problem does this PR solve?
    
    Issue Number: #69907
    
    Related PR: None
    
    Problem Summary:
    
    **In short:** the FE writes metric label values into its Prometheus
    output without escaping them. A table name may contain a backslash, a
    double quote or a line feed, and in cloud mode the Master FE labels four
    per-table series with the table name, so a single such table leaves the
    Master's whole `/metrics` response outside the text format, and
    Prometheus keeps no sample of a scrape it cannot parse. This PR escapes
    every label value that is not a literal through one function, as the
    format requires. Ordinary names are written exactly as before.
    
    #### Background
    
    In the Prometheus text format a label value is written between double
    quotes, and a backslash, a double quote or a line feed inside it must be
    written as `\\`, `\"` and `\n`; no other character is escaped. A scrape
    whose response does not parse fails as a whole.
    
    With `enable_unicode_name_support`, which is on by default, a table name
    may contain all three characters:
    `FeNameFormat.UNICODE_TABLE_NAME_REGEX` only requires the last character
    not to be white space. A local cluster with default settings accepts
    tables named ``` `a"b` ```, `` `a\b` `` and one with a line feed in the
    middle. The names of databases, users, workload groups, jobs and
    catalogs keep their restricted patterns.
    
    #### The problem, and what it cost
    
    - `PrometheusMetricVisitor` concatenates label values as they are, in
    every place that writes one: the labels of registered metrics (`visit`),
    both kinds of histograms, the cloud per-table series
    (`visitCloudTableStats`), the workload group series and the JVM GC
    series.
    - In cloud mode the Master FE exports `doris_fe_table_data_size`,
    `doris_fe_table_rowset_count`, `doris_fe_table_segment_count` and
    `doris_fe_table_row_count` for up to `prom_output_table_metrics_limit`
    (10000) tables, labeled with `db_name` and `table_name`. One table whose
    name holds one of the three characters makes the response invalid, and
    every metric of the Master FE is lost while that table is exported.
    - The coming monitoring of table streams and IVM materialized views
    labels series with the names of materialized views and streams, which
    would run into the same problem in both modes.
    
    #### How this PR fixes it
    
    - `escapeLabelValue` writes a backslash, a double quote and a line feed
    as `\\`, `\"` and `\n` and leaves every other character alone, which is
    the full set the format requires.
    - Every label value that is not a literal goes through it: the two
    histogram paths and `visit`, the database and table names of the
    per-table series (escaped once per table, not once per series), the
    workload group name and the GC name. The JVM GC and workload group names
    cannot hold these characters today; they go through the same function so
    that no label value in the class is written raw.
    - A name without these characters comes back as the same string, so the
    output of ordinary names does not change, byte for byte, and costs no
    extra allocation (`String.replace` returns the string itself when
    nothing matches).
    - A null label value is written as `null`, as the concatenation without
    escaping wrote it. Label values can be null: a table warm-up job has no
    source cluster, so the series of its start and finish times carry a null
    `src_cluster_name`.
    
    #### Results
    
    What the cloud per-table series is written as for each table name, and
    what `promtool check metrics` (Prometheus v3.2.0) says about it:
    
    | Table name | Before this PR | promtool | After this PR | promtool |
    | --- | --- | --- | --- | --- |
    | `t1` | `table_name="t1"` | OK | `table_name="t1"` | OK |
    | `a"b` | `table_name="a"b"` | `unexpected end of label value "a"` |
    `table_name="a\"b"` | OK |
    | `a\b` | `table_name="a\b"` | `invalid escape sequence '\b'` |
    `table_name="a\\b"` | OK |
    | `a\` | `table_name="a\"` | `label value "a\"} 100" contains unescaped
    new-line` | `table_name="a\\"` | OK |
    | `a`, line feed, `b` | the sample split over two lines | `label value
    "a" contains unescaped new-line` | `table_name="a\nb"` | OK |
    
    A parser that follows the format turns each escaped value back into the
    table name itself.
    
    #### Classes, and how they call each other
    
    - `PrometheusMetricVisitor`: the new private `escapeLabelValue`, called
    wherever a label value is written.
    - `MetricsTest`: `testPrometheusVisitorEscapesLabelValues` (a gauge with
    an escaped and a plain label, and both kinds of histograms),
    `testCloudTableStatsEscapeTableName` (the four per-table series, with a
    mocked `CloudTabletStatMgr`) and
    `testPrometheusVisitorWritesNullLabelValue` (a gauge and a labeled
    histogram with a null label value).
    
    ```
    MetricsAction (/metrics) -> MetricRepo.getMetric(PrometheusMetricVisitor)
      |-- visitJvm                  GC name            -> escapeLabelValue
      |-- DORIS_METRIC_REGISTER.accept -> visit         labels -> 
escapeLabelValue
      |-- visitHistograms -> visitHistogram (both)      labels -> 
escapeLabelValue
      |-- visitCloudTableStats      db / table name    -> escapeLabelValue
      `-- visitWorkloadGroup        group name         -> escapeLabelValue
    ```
    
    Not changed: the JSON output (`type=json`), which builds its own strings
    and would need JSON escaping instead; the core output (`type=core`),
    which writes no labels; the metric names and the `# HELP` texts, which
    are literals.
---
 .../doris/metric/PrometheusMetricVisitor.java      | 41 ++++++----
 .../java/org/apache/doris/metric/MetricsTest.java  | 88 ++++++++++++++++++++++
 2 files changed, 113 insertions(+), 16 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/metric/PrometheusMetricVisitor.java 
b/fe/fe-core/src/main/java/org/apache/doris/metric/PrometheusMetricVisitor.java
index e32097f54df..5bd299ad3ba 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/metric/PrometheusMetricVisitor.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/metric/PrometheusMetricVisitor.java
@@ -118,11 +118,11 @@ public class PrometheusMetricVisitor extends 
MetricVisitor {
         sb.append(Joiner.on(" ").join(TYPE, JVM_GC, "gauge\n"));
         for (GarbageCollector gc : jvmStats.getGc()) {
             sb.append(JVM_GC).append("{");
-            sb.append("name=\"").append(gc.getName()).append(" 
Count").append("\", ").append("type=\"count\"} ")
-                    .append(gc.getCollectionCount()).append("\n");
+            
sb.append("name=\"").append(escapeLabelValue(gc.getName())).append(" 
Count").append("\", ")
+                    .append("type=\"count\"} 
").append(gc.getCollectionCount()).append("\n");
             sb.append(JVM_GC).append("{");
-            sb.append("name=\"").append(gc.getName()).append(" 
Time").append("\", ").append("type=\"time\"} ")
-                    .append(gc.getCollectionTime().getMillis()).append("\n");
+            
sb.append("name=\"").append(escapeLabelValue(gc.getName())).append(" 
Time").append("\", ")
+                    .append("type=\"time\"} 
").append(gc.getCollectionTime().getMillis()).append("\n");
         }
 
         // threads
@@ -164,7 +164,7 @@ public class PrometheusMetricVisitor extends MetricVisitor {
         List<MetricLabel> labels = metric.getLabels();
         if (!labels.isEmpty()) {
             sb.append("{");
-            List<String> labelStrs = labels.stream().map(l -> l.getKey() + 
"=\"" + l.getValue()
+            List<String> labelStrs = labels.stream().map(l -> l.getKey() + 
"=\"" + escapeLabelValue(l.getValue())
                     + "\"").collect(Collectors.toList());
             sb.append(Joiner.on(", ").join(labelStrs));
             sb.append("}");
@@ -184,7 +184,7 @@ public class PrometheusMetricVisitor extends MetricVisitor {
             if (kv.length == 1) {
                 names.add(kv[0]);
             } else if (kv.length == 2) {
-                tags.add(String.format("%s=\"%s\"", kv[0], kv[1]));
+                tags.add(String.format("%s=\"%s\"", kv[0], 
escapeLabelValue(kv[1])));
             }
         }
         final String fullName = prefix + String.join("_", names);
@@ -195,7 +195,7 @@ public class PrometheusMetricVisitor extends MetricVisitor {
     public void visitHistogram(String prefix, String name, Histogram 
histogram, List<MetricLabel> labels) {
         final String fullName = prefix + name.replace('.', '_');
         List<String> tags = labels.stream()
-                .map(l -> l.getKey() + "=\"" + l.getValue() + "\"")
+                .map(l -> l.getKey() + "=\"" + escapeLabelValue(l.getValue()) 
+ "\"")
                 .collect(Collectors.toList());
         appendHistogram(fullName, histogram, tags);
     }
@@ -264,34 +264,36 @@ public class PrometheusMetricVisitor extends 
MetricVisitor {
         Collection<OlapTable.Statistics> values = 
tabletStatMgr.getCloudTableStats();
         long totalTableSize = tabletStatMgr.getTotalTableSize();
         for (OlapTable.Statistics stats : values) {
+            String dbName = escapeLabelValue(stats.getDbName());
+            String tableName = escapeLabelValue(stats.getTableName());
             dataSizeBuilder.append("doris_fe_table_data_size{db_name=\"");
-            dataSizeBuilder.append(stats.getDbName());
+            dataSizeBuilder.append(dbName);
             dataSizeBuilder.append("\", table_name=\"");
-            dataSizeBuilder.append(stats.getTableName());
+            dataSizeBuilder.append(tableName);
             dataSizeBuilder.append("\"} ");
             dataSizeBuilder.append(stats.getDataSize());
             dataSizeBuilder.append("\n");
 
             
rowsetCountBuilder.append("doris_fe_table_rowset_count{db_name=\"");
-            rowsetCountBuilder.append(stats.getDbName());
+            rowsetCountBuilder.append(dbName);
             rowsetCountBuilder.append("\", table_name=\"");
-            rowsetCountBuilder.append(stats.getTableName());
+            rowsetCountBuilder.append(tableName);
             rowsetCountBuilder.append("\"} ");
             rowsetCountBuilder.append(stats.getRowsetCount());
             rowsetCountBuilder.append("\n");
 
             
segmentCountBuilder.append("doris_fe_table_segment_count{db_name=\"");
-            segmentCountBuilder.append(stats.getDbName());
+            segmentCountBuilder.append(dbName);
             segmentCountBuilder.append("\", table_name=\"");
-            segmentCountBuilder.append(stats.getTableName());
+            segmentCountBuilder.append(tableName);
             segmentCountBuilder.append("\"} ");
             segmentCountBuilder.append(stats.getSegmentCount());
             segmentCountBuilder.append("\n");
 
             tableRowCountBuilder.append("doris_fe_table_row_count{db_name=\"");
-            tableRowCountBuilder.append(stats.getDbName());
+            tableRowCountBuilder.append(dbName);
             tableRowCountBuilder.append("\", table_name=\"");
-            tableRowCountBuilder.append(stats.getTableName());
+            tableRowCountBuilder.append(tableName);
             tableRowCountBuilder.append("\"} ");
             tableRowCountBuilder.append(stats.getRowCount());
             tableRowCountBuilder.append("\n");
@@ -351,7 +353,7 @@ public class PrometheusMetricVisitor extends MetricVisitor {
             Map<String, List<String>> workloadGroupMap = 
Env.getCurrentEnv().getWorkloadGroupMgr()
                     .getWorkloadGroupQueryDetail();
             for (Map.Entry<String, List<String>> entry : 
workloadGroupMap.entrySet()) {
-                String name = entry.getKey();
+                String name = escapeLabelValue(entry.getKey());
                 List<String> valList = entry.getValue();
                 tmpSb.append(String.format("%s{name=\"%s\", type=\"%s\"} 
%s\n", counterTitle, name, "running_query_num",
                         valList.get(0)));
@@ -363,4 +365,11 @@ public class PrometheusMetricVisitor extends MetricVisitor 
{
             logger.warn("error happends when get workload group query detail 
", e);
         }
     }
+
+    // The text format requires a backslash, a double quote and a line feed in 
a label value to be escaped,
+    // and names such as a table name may contain any of them. A label value 
may be null (a table warm-up job
+    // has no source cluster), which is written as null, as the concatenation 
without escaping wrote it.
+    private static String escapeLabelValue(String value) {
+        return String.valueOf(value).replace("\\", "\\\\").replace("\"", 
"\\\"").replace("\n", "\\n");
+    }
 }
diff --git a/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
index 33fc70c2f90..10c729db9f7 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/metric/MetricsTest.java
@@ -18,7 +18,9 @@
 package org.apache.doris.metric;
 
 import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.CloudTabletStatMgr;
 import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.cloud.CloudWarmUpJob;
 import org.apache.doris.cloud.JobWarmUpStats;
 import org.apache.doris.common.Config;
@@ -342,6 +344,92 @@ public class MetricsTest {
         
Assertions.assertFalse(prometheusResult.contains("doris_fe_disabled_latency_ms"));
     }
 
+    @Test
+    public void testPrometheusVisitorEscapesLabelValues() {
+        // A table name may contain a backslash, a double quote and a line 
feed, which the text format only
+        // accepts escaped in a label value; the plain label beside it is 
written as is.
+        String name = "a\\b\"c\nd";
+        String escapedName = "a\\\\b\\\"c\\nd";
+        GaugeMetricImpl<Long> gauge = new GaugeMetricImpl<>("escape_test", 
MetricUnit.NOUNIT, "escape test", 1L);
+        gauge.addLabel(new MetricLabel("table_name", name)).addLabel(new 
MetricLabel("db_name", "db1"));
+        MetricVisitor visitor = new PrometheusMetricVisitor();
+        visitor.visit(MetricVisitor.FE_PREFIX, gauge);
+        Assertions.assertEquals("# HELP doris_fe_escape_test escape test\n"
+                + "# TYPE doris_fe_escape_test gauge\n"
+                + "doris_fe_escape_test{table_name=\"" + escapedName + "\", 
db_name=\"db1\"} 1\n", visitor.finish());
+
+        HistogramMetric histogram = new HistogramMetric("escape.latency.ms",
+                Lists.newArrayList(new MetricLabel("table_name", name)));
+        histogram.update(10L);
+        visitor = new PrometheusMetricVisitor();
+        visitor.visitHistogram(MetricVisitor.FE_PREFIX, histogram.getName(), 
histogram.getHistogram(),
+                histogram.getLabels());
+        // a histogram of the codahale registry carries its labels in the 
name, as part.part.key=value
+        visitor.visitHistogram(MetricVisitor.FE_PREFIX, 
"escape.name.ms.table_name=" + name,
+                histogram.getHistogram());
+        String result = visitor.finish();
+        
Assertions.assertTrue(result.contains("doris_fe_escape_latency_ms{quantile=\"0.999\",table_name=\""
+                + escapedName + "\"} 10.0\n"), result);
+        
Assertions.assertTrue(result.contains("doris_fe_escape_latency_ms_count{table_name=\""
+                + escapedName + "\"} 1\n"), result);
+        
Assertions.assertTrue(result.contains("doris_fe_escape_name_ms{quantile=\"0.999\",table_name=\""
+                + escapedName + "\"} 10.0\n"), result);
+        
Assertions.assertTrue(result.contains("doris_fe_escape_name_ms_count{table_name=\""
+                + escapedName + "\"} 1\n"), result);
+    }
+
+    @Test
+    public void testPrometheusVisitorWritesNullLabelValue() {
+        // A table warm-up job has no source cluster, so the series of its 
start and finish times carry a null
+        // src_cluster_name; it is written as null, as before label values 
were escaped.
+        GaugeMetricImpl<Long> gauge = new GaugeMetricImpl<>("null_label_test", 
MetricUnit.NOUNIT, "null label test",
+                1L);
+        gauge.addLabel(new MetricLabel("src_cluster_name", null)).addLabel(new 
MetricLabel("dst_cluster_name", "cg1"));
+        MetricVisitor visitor = new PrometheusMetricVisitor();
+        visitor.visit(MetricVisitor.FE_PREFIX, gauge);
+        Assertions.assertEquals("# HELP doris_fe_null_label_test null label 
test\n"
+                + "# TYPE doris_fe_null_label_test gauge\n"
+                + "doris_fe_null_label_test{src_cluster_name=\"null\", 
dst_cluster_name=\"cg1\"} 1\n",
+                visitor.finish());
+
+        HistogramMetric histogram = new 
HistogramMetric("null_label.latency.ms",
+                Lists.newArrayList(new MetricLabel("src_cluster_name", null)));
+        histogram.update(10L);
+        visitor = new PrometheusMetricVisitor();
+        visitor.visitHistogram(MetricVisitor.FE_PREFIX, histogram.getName(), 
histogram.getHistogram(),
+                histogram.getLabels());
+        String result = visitor.finish();
+        
Assertions.assertTrue(result.contains("doris_fe_null_label_latency_ms_count{src_cluster_name=\"null\"}
 1\n"),
+                result);
+    }
+
+    @Test
+    public void testCloudTableStatsEscapeTableName() {
+        String originCloudUniqueId = Config.cloud_unique_id;
+        Env env = Env.getCurrentEnv();
+        Object originTabletStatMgr = Deencapsulation.getField(env, 
"tabletStatMgr");
+        CloudTabletStatMgr tabletStatMgr = 
Mockito.mock(CloudTabletStatMgr.class);
+        
Mockito.when(tabletStatMgr.getCloudTableStats()).thenReturn(Collections.singletonList(
+                new OlapTable.Statistics("db1", "a\\b\"c\nd", 100L, 300L, 0L, 
3L, 10L, 2L, 4L,
+                        0L, 0L, 0L, 0L, 0L, 0L)));
+        Mockito.when(tabletStatMgr.getTotalTableSize()).thenReturn(100L);
+        try {
+            Config.cloud_unique_id = "test_cloud_unique_id";
+            Deencapsulation.setField(env, "tabletStatMgr", tabletStatMgr);
+            MetricVisitor visitor = new PrometheusMetricVisitor();
+            visitor.visitCloudTableStats();
+            String result = visitor.finish();
+            String labels = "{db_name=\"db1\", table_name=\"a\\\\b\\\"c\\nd\"} 
";
+            Assertions.assertTrue(result.contains("\ndoris_fe_table_data_size" 
+ labels + "100\n"), result);
+            
Assertions.assertTrue(result.contains("\ndoris_fe_table_rowset_count" + labels 
+ "2\n"), result);
+            
Assertions.assertTrue(result.contains("\ndoris_fe_table_segment_count" + labels 
+ "4\n"), result);
+            Assertions.assertTrue(result.contains("\ndoris_fe_table_row_count" 
+ labels + "10\n"), result);
+        } finally {
+            Deencapsulation.setField(env, "tabletStatMgr", 
originTabletStatMgr);
+            Config.cloud_unique_id = originCloudUniqueId;
+        }
+    }
+
     @Test
     public void testVirtualComputeGroupSwitchMetricRename() {
         String originCloudUniqueId = Config.cloud_unique_id;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to