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

wu-sheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking.git


The following commit(s) were added to refs/heads/master by this push:
     new 137935d364 Fix: MAL `expPrefix` now applies to every metric source in 
`exp`, not just the leading one. (#13875)
137935d364 is described below

commit 137935d364be83d4b4438730540856cc35a2d5c8
Author: Wan Kai <[email protected]>
AuthorDate: Fri May 15 11:03:25 2026 +0800

    Fix: MAL `expPrefix` now applies to every metric source in `exp`, not just 
the leading one. (#13875)
---
 docs/en/changes/changes.md                         |  1 +
 .../oap/meter/analyzer/v2/MetricConvert.java       | 23 +++---
 .../analyzer/v2/compiler/MALScriptParser.java      | 93 ++++++++++++++++++++++
 .../v2/compiler/MALClassGeneratorScopeTest.java    | 61 +++++++-------
 4 files changed, 141 insertions(+), 37 deletions(-)

diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index d73c6077a8..20b1fb9ec5 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -188,6 +188,7 @@
 * Custom `Layer`s can be declared without modifying the OAP source — via an 
operator-managed `layer-extensions.yml`, inline `layerDefinitions:` block in a 
MAL or LAL rule file, or a plugin extension. UI dashboard templates for new 
layers are auto-discovered from the `ui-initialized-templates/` directory. 
Recommended ordinal range for external layers is `>= 1000`; conflicting names 
or ordinals are reported at boot.
 * LAL: support full arithmetic (`+`, `-`, `*`, `/`) on numeric operands and 
fix the original bug where `(tag("x") as Integer) + (tag("y") as Integer)` was 
treated as string concatenation — expressions like `input_tokens + 
output_tokens < 10000` produced the concatenated string `"2589115"` rather than 
the integer sum `2704`, so token-threshold conditions never triggered `abort 
{}`. Operand types are now inferred from explicit casts (`as Integer` / `as 
Long` / `as Float` / `as Double`), ty [...]
 * Fix: `avgHistogramPercentile` / `sumHistogramPercentile` meter functions 
reported the smallest finite bucket boundary (e.g. `10` for OTel 
`gen_ai_server_request_duration` whose `le` is rewritten from `0.01s` → `10ms`) 
for every rank when no samples were observed in any bucket. The percentile 
loop's `count >= roof` check matched on the first sorted bucket because both 
sides were `0`. `calculate()` now short-circuits to `0` for every rank when the 
windowed total is `0`.
+* Fix: MAL `expPrefix` now applies to every metric source in `exp`, not just 
the leading one. Previously the prefix was spliced after the first `.`, so 
secondary metrics inside arguments (e.g. the divisor in 
`a.sum(['s']).safeDiv(b.sum(['s']))`) silently skipped the prefix — a rule like 
envoy-ai-gateway's `request_latency_avg` (`sum / count`) would tag-rewrite only 
the dividend. The injection is now AST-aware: every bare-IDENTIFIER metric 
source is wrapped, while downsampling-type consta [...]
 
 #### UI
 * Add mobile menu icon and i18n labels for the iOS layer.
diff --git 
a/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/MetricConvert.java
 
b/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/MetricConvert.java
index b3539b2975..4353339755 100644
--- 
a/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/MetricConvert.java
+++ 
b/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/MetricConvert.java
@@ -33,7 +33,7 @@ import java.util.stream.IntStream;
 import java.util.stream.Stream;
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
+import org.apache.skywalking.oap.meter.analyzer.v2.compiler.MALScriptParser;
 import org.apache.skywalking.oap.meter.analyzer.v2.dsl.FilterExpression;
 import org.apache.skywalking.oap.meter.analyzer.v2.dsl.SampleFamily;
 import org.apache.skywalking.oap.server.core.analysis.meter.MeterSystem;
@@ -294,15 +294,18 @@ public class MetricConvert {
         return new FilterExpression(filterText, "filter", yamlSource, pool, 
targetClassLoader);
     }
 
-    private String formatExp(final String expPrefix, String expSuffix, String 
exp) {
-        String ret = exp;
-        if (!Strings.isNullOrEmpty(expPrefix)) {
-            ret = String.format("(%s.%s)", StringUtils.substringBefore(exp, 
"."), expPrefix);
-            final String after = StringUtils.substringAfter(exp, ".");
-            if (!Strings.isNullOrEmpty(after)) {
-                ret = String.format("(%s.%s)", ret, after);
-            }
-        }
+    /**
+     * Build the final MAL expression from {@code expPrefix} + {@code exp} + 
{@code expSuffix}.
+     *
+     * <p>The prefix is injected at every metric source via
+     * {@link MALScriptParser#injectExpPrefix(String, String)} so secondary
+     * metric refs inside arguments (e.g. the {@code b} in
+     * {@code a.sum(['s']).safeDiv(b.sum(['s']))}) are not skipped — a naive
+     * splice-after-first-dot would only rewrite {@code a}.
+     */
+    public static String formatExp(final String expPrefix, final String 
expSuffix,
+                                    final String exp) {
+        String ret = MALScriptParser.injectExpPrefix(exp, expPrefix);
         if (!Strings.isNullOrEmpty(expSuffix)) {
             ret = String.format("(%s).%s", ret, expSuffix);
         }
diff --git 
a/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALScriptParser.java
 
b/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALScriptParser.java
index e37f285456..43897451be 100644
--- 
a/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALScriptParser.java
+++ 
b/oap-server/analyzer/meter-analyzer/src/main/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALScriptParser.java
@@ -19,11 +19,13 @@ package 
org.apache.skywalking.oap.meter.analyzer.v2.compiler;
 
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.antlr.v4.runtime.BaseErrorListener;
 import org.antlr.v4.runtime.ParserRuleContext;
+import org.antlr.v4.runtime.tree.ParseTree;
 import org.antlr.v4.runtime.tree.TerminalNode;
 import org.antlr.v4.runtime.CharStreams;
 import org.antlr.v4.runtime.CommonTokenStream;
@@ -86,6 +88,97 @@ public final class MALScriptParser {
     private MALScriptParser() {
     }
 
+    /**
+     * Splice {@code expPrefix} into every metric source in {@code exp}.
+     *
+     * <p>The MAL {@code expPrefix} (e.g. {@code tag({tags -> tags.service = 
...})})
+     * is a method-call snippet that must apply to every metric reference, not
+     * only the first one. A naive string splice at the first dot misses 
metrics
+     * nested in arguments — e.g. the second metric in
+     * {@code a.sum(['s']).safeDiv(b.sum(['s']))}.
+     *
+     * <p>This walker parses {@code exp}, finds every {@code primary} whose 
child
+     * is a bare {@code IDENTIFIER} (i.e. a metric leaf — not a function call 
or
+     * parenthesised group), and replaces it with {@code (<id>.<expPrefix>)}.
+     * Downsampling-type constants ({@code SUM}, {@code AVG}, {@code LATEST},
+     * {@code SUM_PER_MIN}, {@code MAX}, {@code MIN}) look syntactically 
identical
+     * to metric sources but are recognised by name and skipped — wrapping them
+     * with the prefix would turn them into bogus SampleFamily references.
+     *
+     * @param exp the MAL expression to rewrite (must parse successfully)
+     * @param expPrefix the method-call snippet to inject (e.g. {@code 
tag({...})})
+     * @return the rewritten expression, or {@code exp} unchanged when
+     *         {@code expPrefix} is null or empty
+     */
+    public static String injectExpPrefix(final String exp, final String 
expPrefix) {
+        if (expPrefix == null || expPrefix.isEmpty()) {
+            return exp;
+        }
+        final MALLexer lexer = new MALLexer(CharStreams.fromString(exp));
+        final CommonTokenStream tokens = new CommonTokenStream(lexer);
+        final MALParser parser = new MALParser(tokens);
+        final List<String> errors = new ArrayList<>();
+        parser.removeErrorListeners();
+        parser.addErrorListener(new BaseErrorListener() {
+            @Override
+            public void syntaxError(final Recognizer<?, ?> recognizer,
+                                    final Object offendingSymbol,
+                                    final int line,
+                                    final int charPositionInLine,
+                                    final String msg,
+                                    final RecognitionException e) {
+                errors.add(line + ":" + charPositionInLine + " " + msg);
+            }
+        });
+        final MALParser.ExpressionContext tree = parser.expression();
+        if (!errors.isEmpty()) {
+            throw new IllegalArgumentException(
+                "MAL expression parsing failed while injecting expPrefix: "
+                    + String.join("; ", errors) + " in expression: " + exp);
+        }
+        final List<int[]> ranges = new ArrayList<>();
+        collectMetricSourceRanges(tree, ranges);
+        // Splice from right to left so earlier indices remain valid.
+        ranges.sort(Comparator.comparingInt((int[] r) -> r[0]).reversed());
+        final StringBuilder sb = new StringBuilder(exp);
+        for (final int[] range : ranges) {
+            final String name = sb.substring(range[0], range[1] + 1);
+            sb.replace(range[0], range[1] + 1, "(" + name + "." + expPrefix + 
")");
+        }
+        return sb.toString();
+    }
+
+    private static void collectMetricSourceRanges(final ParseTree node,
+                                                  final List<int[]> ranges) {
+        if (node instanceof MALParser.PrimaryContext) {
+            final MALParser.PrimaryContext primary = 
(MALParser.PrimaryContext) node;
+            // Three primary alternatives: bare IDENTIFIER (metric source),
+            // functionCall, and L_PAREN additiveExpression R_PAREN. Only the
+            // first is a metric ref.
+            if (primary.IDENTIFIER() != null
+                    && primary.functionCall() == null
+                    && primary.L_PAREN() == null) {
+                final TerminalNode id = primary.IDENTIFIER();
+                final String text = id.getText();
+                // Bare-identifier downsampling constants (SUM, AVG, LATEST,
+                // SUM_PER_MIN, MAX, MIN) parse as IDENTIFIER primaries but
+                // codegen treats them as enum values via
+                // MALCodegenHelper.isDownsamplingType — skip them so we don't
+                // turn them into bogus SampleFamily refs.
+                if (!MALCodegenHelper.isDownsamplingType(text)) {
+                    ranges.add(new int[]{
+                        id.getSymbol().getStartIndex(),
+                        id.getSymbol().getStopIndex()
+                    });
+                }
+                return;
+            }
+        }
+        for (int i = 0; i < node.getChildCount(); i++) {
+            collectMetricSourceRanges(node.getChild(i), ranges);
+        }
+    }
+
     /**
      * Pre-process expression to convert Groovy regex literals used as method
      * arguments into string literals. E.g., {@code split(/\|/, -1)} becomes
diff --git 
a/oap-server/analyzer/meter-analyzer/src/test/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGeneratorScopeTest.java
 
b/oap-server/analyzer/meter-analyzer/src/test/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGeneratorScopeTest.java
index 2c994920d7..022d4248dd 100644
--- 
a/oap-server/analyzer/meter-analyzer/src/test/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGeneratorScopeTest.java
+++ 
b/oap-server/analyzer/meter-analyzer/src/test/java/org/apache/skywalking/oap/meter/analyzer/v2/compiler/MALClassGeneratorScopeTest.java
@@ -18,16 +18,19 @@
 package org.apache.skywalking.oap.meter.analyzer.v2.compiler;
 
 import javassist.ClassPool;
+import org.apache.skywalking.oap.meter.analyzer.v2.MetricConvert;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * Full-expression compilation tests combining expPrefix + exp + expSuffix via 
formatExp.
- * Covers endpoint, service, instance, serviceRelation scope suffixes,
- * forEach closures with if/else-if/else chains, sum() with >10 label keys,
- * and tag() closure chained with service() suffix.
+ * Full-expression compilation tests combining expPrefix + exp + expSuffix via
+ * {@link MetricConvert#formatExp(String, String, String)}. Covers endpoint, 
service,
+ * instance, serviceRelation scope suffixes, forEach closures with 
if/else-if/else
+ * chains, sum() with &gt;10 label keys, and tag() closure prefix + chained 
service
+ * suffix (envoy-ai-gateway shape).
  */
 class MALClassGeneratorScopeTest {
 
@@ -38,31 +41,9 @@ class MALClassGeneratorScopeTest {
         generator = new MALClassGenerator(new ClassPool(true));
     }
 
-    /**
-     * Simulates MetricConvert.formatExp() to build the full expression
-     * from expPrefix + exp + expSuffix.
-     */
-    private static String formatExp(String expPrefix, String expSuffix, String 
exp) {
-        String ret = exp;
-        if (expPrefix != null && !expPrefix.isEmpty()) {
-            int dotIdx = exp.indexOf('.');
-            if (dotIdx > 0) {
-                ret = String.format("(%s.%s)", exp.substring(0, dotIdx), 
expPrefix);
-                String after = exp.substring(dotIdx + 1);
-                if (!after.isEmpty()) {
-                    ret = String.format("(%s.%s)", ret, after);
-                }
-            }
-        }
-        if (expSuffix != null && !expSuffix.isEmpty()) {
-            ret = String.format("(%s).%s", ret, expSuffix);
-        }
-        return ret;
-    }
-
     private void compileRule(String name, String expPrefix, String expSuffix, 
String exp)
             throws Exception {
-        final String full = formatExp(expPrefix, expSuffix, exp);
+        final String full = MetricConvert.formatExp(expPrefix, expSuffix, exp);
         assertNotNull(generator.compile(name, full));
     }
 
@@ -152,6 +133,32 @@ class MALClassGeneratorScopeTest {
         }
     }
 
+    @Test
+    void tagClosurePrefixWithServiceSuffix() throws Exception {
+        // Mirrors otel-rules/envoy-ai-gateway/gateway-service.yaml shape, 
exercising
+        // the request_latency_avg rule which divides two metrics — every 
metric
+        // source must receive the tag prefix, not just the leading one:
+        //   filter:       { tags -> tags.job_name == 'envoy-ai-gateway' }
+        //   expPrefix:    tag({tags -> tags.service = tags.service_name + '|' 
+ tags.cluster_name})
+        //   expSuffix:    service(['service'], Layer.ENVOY_AI_GATEWAY)
+        //   metricPrefix: meter_envoy_ai_gw
+        //   request_latency_avg = (sum / count over PT1M) * 1000 (ms)
+        final String prefix = "tag({tags -> tags.service = tags.service_name + 
'|' + tags.cluster_name})";
+        final String suffix = "service(['service'], Layer.ENVOY_AI_GATEWAY)";
+        final String exp = 
"gen_ai_server_request_duration_sum.sum(['service_name']).increase('PT1M')"
+            + 
".safeDiv(gen_ai_server_request_duration_count.sum(['service_name']).increase('PT1M'))
 * 1000";
+        final String formatted = MetricConvert.formatExp(prefix, suffix, exp);
+        // Both the _sum and _count metric sources must carry the tag closure.
+        assertTrue(
+            formatted.contains("(gen_ai_server_request_duration_sum." + prefix 
+ ")"),
+            "expected prefix on _sum metric, got: " + formatted);
+        assertTrue(
+            formatted.contains("(gen_ai_server_request_duration_count." + 
prefix + ")"),
+            "expected prefix on _count metric (inside safeDiv), got: " + 
formatted);
+        
assertNotNull(generator.compile("meter_envoy_ai_gw_request_latency_avg", 
formatted));
+        assertNotNull(generator.compileFilter("{ tags -> tags.job_name == 
'envoy-ai-gateway' }"));
+    }
+
     @Test
     void tagClosureChainedWithServiceSuffix() throws Exception {
         final String suffix = "tag({tags -> tags.service = 'satellite::' + 
tags.service}).service(['service'], Layer.SO11Y_SATELLITE)";

Reply via email to