This is an automated email from the ASF dual-hosted git repository. wusheng pushed a commit to branch feature/lal-def-variables in repository https://gitbox.apache.org/repos/asf/skywalking.git
commit f4c1462df844cbae1aa284f1b36b134eddd4a397 Author: Wu Sheng <[email protected]> AuthorDate: Tue Mar 10 22:04:54 2026 +0800 LAL: add def local variable support with toJson/toJsonArray and type cast Add `def` keyword to LAL grammar for declaring local variables in extractor and filter blocks. Type is inferred from the initializer expression at compile time, with optional `as` cast for type narrowing (supports built-in types and fully qualified class names). Split monolithic LALClassGeneratorTest into 5 scoped test classes plus a shared base. Add EnvoyAlsLalTest in envoy receiver module for ALS-specific proto chain tests with generated .class output. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- docs/en/changes/changes.md | 2 + docs/en/concepts-and-designs/lal.md | 88 ++- oap-server/analyzer/log-analyzer/CLAUDE.md | 71 ++- oap-server/analyzer/log-analyzer/pom.xml | 5 + .../apache/skywalking/lal/rt/grammar/LALLexer.g4 | 3 + .../apache/skywalking/lal/rt/grammar/LALParser.g4 | 15 +- .../log/analyzer/v2/compiler/LALBlockCodegen.java | 327 ++++++++++- .../analyzer/v2/compiler/LALClassGenerator.java | 45 +- .../log/analyzer/v2/compiler/LALScriptModel.java | 17 + .../log/analyzer/v2/compiler/LALScriptParser.java | 21 + .../analyzer/v2/compiler/rt/LalRuntimeHelper.java | 117 ++++ .../v2/compiler/LALClassGeneratorBasicTest.java | 109 ++++ .../compiler/LALClassGeneratorConditionTest.java | 186 ++++++ .../v2/compiler/LALClassGeneratorDefTest.java | 220 +++++++ .../compiler/LALClassGeneratorExtractorTest.java | 269 +++++++++ .../v2/compiler/LALClassGeneratorSinkTest.java | 113 ++++ .../v2/compiler/LALClassGeneratorTest.java | 641 --------------------- .../v2/compiler/LALClassGeneratorTestBase.java | 68 +++ .../v2/compiler/LALExpressionExecutionTest.java | 44 +- .../analyzer/v2/compiler/LALScriptParserTest.java | 117 ++++ .../envoy/persistence/EnvoyAlsLalTest.java | 368 ++++++++++++ .../feature-cases/execution-def.input.data | 68 +++ .../lal/test-lal/feature-cases/execution-def.yaml | 99 ++++ 23 files changed, 2351 insertions(+), 662 deletions(-) diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md index 9c7aeb6223..9bcbd42cd2 100644 --- a/docs/en/changes/changes.md +++ b/docs/en/changes/changes.md @@ -26,6 +26,8 @@ - All bundled LAL scripts (`mysql-slowsql.yaml`, `pgsql-slowsql.yaml`, `redis-slowsql.yaml`, `envoy-als.yaml`, `k8s-service.yaml`, `mesh-dp.yaml`) have been updated. - Users with custom LAL scripts using `slowSql {}` or `sampledTrace {}` must migrate to the new syntax. See [LAL documentation](../concepts-and-designs/lal.md#output-type). - Rename `ExtractorSpec` to `MetricExtractor` — now only handles LAL `metrics {}` blocks. Standard field setters (service, layer, timestamp, etc.) are compiled as direct setter calls on the output builder. + - Add `def` local variable support in LAL extractor (and filter level). Supports `toJson()` and `toJsonArray()` built-in functions for converting strings, Maps, and protobuf `Struct` to Gson JSON objects. Variables support null-safe navigation (`?.`), method chaining with compile-time type inference, and explicit type cast via `as` (built-in types or fully qualified class names, e.g., `def resp = parsed?.response as io.envoyproxy.envoy.data.accesslog.v3.HTTPResponseProperties`). + - **Breaking Change** — `LALOutputBuilder.init()` signature changed from `init(LogData, NamingControl)` to `init(LogData, Optional<Object> extraLog, NamingControl)`. The `extraLog` parameter carries the typed input object (e.g., `HTTPAccessLogEntry` for envoy access logs) so that output builders can access protocol-specific fields. Custom `LALOutputBuilder` implementations must update their `init()` method signature. * Fix E2E test metrics verify: make it failure if the metric values all null. * Support building, testing, and publishing with Java 25. * Add `CLAUDE.md` as AI assistant guide for the project. diff --git a/docs/en/concepts-and-designs/lal.md b/docs/en/concepts-and-designs/lal.md index 626b66f17d..6f5b07e01c 100644 --- a/docs/en/concepts-and-designs/lal.md +++ b/docs/en/concepts-and-designs/lal.md @@ -145,6 +145,89 @@ filter { Extractors aim to extract metadata from the logs. The metadata can be a service name, a service instance name, an endpoint name, or even a trace ID, all of which can be associated with the existing traces and metrics. +#### Local variables (`def`) + +You can use `def` to declare local variables in the extractor (or at the filter level). This is useful when an +expression is reused multiple times, or when you want to break a long chain into readable steps. + +The syntax is: + +``` +def variableName = expression +def variableName = expression as TypeName +``` + +The variable type is inferred from the initializer expression at compile time. `def` is not limited to JSON — it works +with any value access expression whose type is resolvable on the classpath, including protobuf getter chains, +`log.*` fields, and Gson JSON method chains. Subsequent method calls on the variable are validated at compile time +against the inferred type. + +You can optionally add an explicit `as` type cast to narrow the variable type. The cast type can be a built-in +type (`String`, `Long`, `Integer`, `Boolean`) or a fully qualified class name: + +``` +def value = someExpression as com.example.MyType +``` + +This is useful when the compiler infers a type that is too general (e.g., `Object` from a generic API return) +and you know the concrete runtime type. The cast tells the compiler which type to use for subsequent method chain +validation. Note that `as` performs a Java cast — it does **not** convert between types. For JSON conversion, use +`toJson()` or `toJsonArray()` instead. + +The FQCN must be resolvable on the classpath at compile time. If the class is not found, the OAP server will fail +to start. + +Two built-in conversion functions are provided for JSON interoperability: + +- `toJson(expr)` — converts a value to a Gson `JsonObject`. Works with JSON strings, `Map`, and protobuf `Struct`. +- `toJsonArray(expr)` — converts a value to a Gson `JsonArray`. Works with JSON array strings. + +After declaration, the variable can be used in subsequent expressions with full null-safe navigation support (`?.`). + +Example — extracting fields from a protobuf input type (no JSON conversion needed): + +``` +filter { + extractor { + def resp = parsed?.response + tag 'status.code': resp?.responseCode?.value + tag 'resp.flags': resp?.responseCodeDetails + } + sink {} +} +``` + +Example — extracting JWT claims from envoy access log filter metadata via `toJson()`: + +``` +filter { + extractor { + def jwt = toJson(parsed?.commonProperties?.metadata + ?.filterMetadataMap?.get("envoy.filters.http.jwt_authn")) + def payload = jwt?.getAsJsonObject("payload") + if (payload != null) { + tag 'email': payload?.get("email")?.getAsString() + tag 'group': payload?.get("group")?.getAsString() + } + } + sink {} +} +``` + +Example — parsing a JSON log body field into a structured object: + +``` +filter { + json {} + extractor { + def config = toJson(parsed.metadata) + tag 'env': config?.get("env")?.getAsString() + tag 'region': config?.getAsJsonObject("location")?.get("region")?.getAsString() + } + sink {} +} +``` + #### Standard fields - `service` @@ -473,7 +556,7 @@ LAL supports two kinds of output types: | Output path | Base type | How it works | |---|---|---| | **Log path** | Subclass of `AbstractLog` | The sink populates standard log fields (service, instance, endpoint, tags, body, etc.) from `LogData` and persists via `SourceReceiver` | -| **Builder path** | Implements `LALOutputBuilder` | The sink creates the builder, calls `init(LogData, NamingControl)` to pre-populate standard fields, applies output field values via setters, then calls `complete(SourceReceiver)` to validate and dispatch | +| **Builder path** | Implements `LALOutputBuilder` | The sink creates the builder, calls `init(LogData, Optional<Object> extraLog, NamingControl)` to pre-populate standard fields, applies output field values via setters, then calls `complete(SourceReceiver)` to validate and dispatch | The builder path is used when the output type implements the `LALOutputBuilder` interface. This is how SkyWalking's built-in slow SQL and sampled trace features work. @@ -537,8 +620,9 @@ public class MyCustomBuilder implements LALOutputBuilder { public String name() { return NAME; } @Override - public void init(LogData logData, NamingControl namingControl) { + public void init(LogData logData, Optional<Object> extraLog, NamingControl namingControl) { // Pre-populate from LogData (service, timestamp, traceId, etc.) + // extraLog contains the typed input object (e.g., a protobuf message) if available } @Override diff --git a/oap-server/analyzer/log-analyzer/CLAUDE.md b/oap-server/analyzer/log-analyzer/CLAUDE.md index 6e75231e03..eb3c134c65 100644 --- a/oap-server/analyzer/log-analyzer/CLAUDE.md +++ b/oap-server/analyzer/log-analyzer/CLAUDE.md @@ -50,8 +50,13 @@ oap-server/analyzer/log-analyzer/ src/test/java/.../compiler/ LALScriptParserTest.java — 19 parser tests - LALClassGeneratorTest.java — 35 generator tests - LALExpressionExecutionTest.java — 25 data-driven execution tests (from YAML + .data.yaml) + LALClassGeneratorTestBase.java — shared base: fresh generator per test, .class output, naming + LALClassGeneratorBasicTest.java — 10 tests: minimal compile, parsers, source gen, errors + LALClassGeneratorConditionTest.java — 10 tests: tag(), safe-nav, if-blocks, else-if + LALClassGeneratorExtractorTest.java — 10 tests: ProcessRegistry, metrics, inputType, outputType + LALClassGeneratorDefTest.java — 7 tests: def variables, toJson/toJsonArray + LALClassGeneratorSinkTest.java — 5 tests: sampler, rateLimit, interpolated IDs + LALExpressionExecutionTest.java — 25+ data-driven execution tests (from YAML + .data.yaml) ``` ## Package & Class Naming @@ -105,6 +110,68 @@ All generated methods include a `LocalVariableTable` attribute for debugger/deco LVT entries are added via `PrivateMethod` inner class which carries both source code and variable descriptors. +## Local Variables (`def`) + +The `def` keyword declares local variables in the extractor (or filter level). The grammar rule: + +``` +defStatement: DEF IDENTIFIER ASSIGN valueAccess typeCast? ; +``` + +The optional `typeCast` supports built-in types (`String`, `Long`, `Integer`, `Boolean`) and +fully qualified class names (`as com.example.MyType`). The FQCN is resolved via `Class.forName()` +at compile time. If not found, compilation fails with `IllegalArgumentException`. + +### Type inference + +The variable type is inferred from the initializer expression: + +| Initializer | Inferred type | Generated code | +|---|---|---| +| `toJson(expr)` | `JsonObject` | `h.toJsonObject(expr)` | +| `toJsonArray(expr)` | `JsonArray` | `h.toJsonArray(expr)` | +| General value access | Last resolved type via reflection | Standard value access codegen | + +Built-in functions are registered in `BUILTIN_FUNCTIONS` map in `LALBlockCodegen`. + +### Chained def variables + +A `def` variable can be initialized from another `def` variable's method chain: + +``` +def jwt = toJson(parsed?.commonProperties?.metadata?.filterMetadataMap?.get("envoy.filters.http.jwt_authn")) +def payload = jwt?.getAsJsonObject("payload") +tag 'email': payload?.get("email")?.getAsString() +``` + +The general value access path in `generateDefStatement()` recognizes `jwt` as a def variable, +delegates to `generateDefVarChain()` which uses reflection to resolve the chain, and +`genCtx.lastResolvedType` captures the resolved type (`JsonObject` in this case). + +### Runtime helpers + +`LalRuntimeHelper` provides `toJsonObject()` and `toJsonArray()` overloads: + +| Method | Input type | Conversion | +|---|---|---| +| `toJsonObject(String)` | JSON string | `JsonParser.parseString().getAsJsonObject()` | +| `toJsonObject(Map)` | Map (from JSON/YAML parser) | Recursive Gson conversion | +| `toJsonObject(Struct)` | Protobuf `Struct` | Recursive field conversion preserving nested structures | +| `toJsonObject(Object)` | Any (fallback) | Delegates to above based on runtime type | +| `toJsonArray(String)` | JSON array string | `JsonParser.parseString().getAsJsonArray()` | +| `toJsonArray(Object)` | Any (fallback) | String fallback | + +All methods return `null` for `null` input (null-safe). + +### Code generation + +Def variables are stored in `genCtx.localVars` map (name → `LocalVarInfo` with Java variable name +and resolved type). Variable declarations are emitted at method top via `genCtx.localVarDecls`; +assignments are emitted at the point where `def` appears in the DSL. + +Java variable names follow the pattern `_d0`, `_d1`, etc. LVT entries are added for debugger +visibility. + ## Compile-Time Data Source Analysis The generator detects the parser type from the AST at compile time and generates typed value access: diff --git a/oap-server/analyzer/log-analyzer/pom.xml b/oap-server/analyzer/log-analyzer/pom.xml index 180a8435df..b0733f1592 100644 --- a/oap-server/analyzer/log-analyzer/pom.xml +++ b/oap-server/analyzer/log-analyzer/pom.xml @@ -47,6 +47,11 @@ <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> + <dependency> + <groupId>com.google.code.gson</groupId> + <artifactId>gson</artifactId> + <version>${gson.version}</version> + </dependency> <dependency> <groupId>org.antlr</groupId> <artifactId>antlr4-runtime</artifactId> diff --git a/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALLexer.g4 b/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALLexer.g4 index c6edd0ad60..adca49a52f 100644 --- a/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALLexer.g4 +++ b/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALLexer.g4 @@ -54,6 +54,9 @@ RPM: 'rpm'; ENFORCER: 'enforcer'; DROPPER: 'dropper'; +// Keywords - local variables +DEF: 'def'; + // Keywords - control flow IF: 'if'; ELSE: 'else'; diff --git a/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALParser.g4 b/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALParser.g4 index fa2f615e16..cf8956da0b 100644 --- a/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALParser.g4 +++ b/oap-server/analyzer/log-analyzer/src/main/antlr4/org/apache/skywalking/lal/rt/grammar/LALParser.g4 @@ -54,6 +54,7 @@ filterStatement | sinkBlock | ifStatement | abortBlock + | defStatement ; // ==================== Parser blocks ==================== @@ -116,7 +117,8 @@ extractorContent ; extractorStatement - : serviceStatement + : defStatement + | serviceStatement | instanceStatement | endpointStatement | layerStatement @@ -130,6 +132,10 @@ extractorStatement | outputFieldStatement ; +defStatement + : DEF IDENTIFIER ASSIGN valueAccess typeCast? + ; + serviceStatement : SERVICE valueAccess typeCast? ; @@ -368,7 +374,11 @@ functionArg // ==================== Type cast ==================== typeCast - : AS (STRING_TYPE | LONG_TYPE | INTEGER_TYPE | BOOLEAN_TYPE) + : AS (STRING_TYPE | LONG_TYPE | INTEGER_TYPE | BOOLEAN_TYPE | qualifiedName) + ; + +qualifiedName + : IDENTIFIER (DOT IDENTIFIER)* ; // ==================== Common ==================== @@ -384,6 +394,7 @@ anyIdentifier | NAME | VALUE | LABELS | SAMPLER | RATE_LIMIT | RPM | ENFORCER | DROPPER | TEXT | JSON | YAML | FILTER | EXTRACTOR | SINK | ABORT + | DEF ; boolValue diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java index 3d10117f00..7e4e4df7f6 100644 --- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java +++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALBlockCodegen.java @@ -17,7 +17,10 @@ package org.apache.skywalking.oap.log.analyzer.v2.compiler; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.apache.skywalking.apm.network.logging.v3.LogData; @@ -41,6 +44,15 @@ final class LALBlockCodegen { private static final String PROCESS_REGISTRY = "org.apache.skywalking.oap.meter.analyzer.v2.dsl.registry.ProcessRegistry"; + // Built-in function registry for def variable type inference. + // Maps DSL function name → [runtime helper method, return type]. + static final Map<String, Object[]> BUILTIN_FUNCTIONS = new HashMap<>(); + + static { + BUILTIN_FUNCTIONS.put("toJson", new Object[]{"h.toJsonObject", JsonObject.class}); + BUILTIN_FUNCTIONS.put("toJsonArray", new Object[]{"h.toJsonArray", JsonArray.class}); + } + private LALBlockCodegen() { // utility class } @@ -92,6 +104,12 @@ final class LALBlockCodegen { "L" + outTypeName.replace('.', '/') + ";"}); } + // Add local var declarations from def statements + if (genCtx.localVarDecls.length() > 0) { + body.append(genCtx.localVarDecls); + lvtVars.addAll(genCtx.localVarLvtVars); + } + // Add LVT entry for _metrics if any metrics block exists if (hasMetricsBlock(block.getStatements())) { lvtVars.add(new String[]{"_metrics", @@ -128,6 +146,9 @@ final class LALBlockCodegen { } else if (stmt instanceof LALScriptModel.OutputFieldAssignment) { generateOutputFieldAssignment( sb, (LALScriptModel.OutputFieldAssignment) stmt, genCtx); + } else if (stmt instanceof LALScriptModel.DefStatement) { + generateDefStatement( + sb, (LALScriptModel.DefStatement) stmt, genCtx); } } } @@ -861,6 +882,17 @@ final class LALBlockCodegen { return; } + // Check for def variable reference + if (!value.getSegments().isEmpty()) { + final String primaryName = value.getSegments().get(0); + final LALClassGenerator.LocalVarInfo localVar = + genCtx.localVars.get(primaryName); + if (localVar != null) { + generateDefVarChain(sb, localVar, chain, genCtx); + return; + } + } + // Fallback for unknown primary if (chain.isEmpty()) { sb.append("null"); @@ -1140,6 +1172,283 @@ final class LALBlockCodegen { return prevVar; } + // ==================== Def statement codegen ==================== + + static void generateDefStatement(final StringBuilder sb, + final LALScriptModel.DefStatement def, + final LALClassGenerator.GenCtx genCtx) { + final LALScriptModel.ValueAccess init = def.getInitializer(); + final String varName = def.getVarName(); + final String javaVar = "_d" + genCtx.localVarCounter++; + + // Determine type and generate initializer expression + Class<?> resolvedType; + final StringBuilder initExpr = new StringBuilder(); + + if (init.getFunctionCallName() != null + && BUILTIN_FUNCTIONS.containsKey(init.getFunctionCallName())) { + // Built-in function: toJson(...), toJsonArray(...) + final Object[] info = BUILTIN_FUNCTIONS.get(init.getFunctionCallName()); + final String helperMethod = (String) info[0]; + resolvedType = (Class<?>) info[1]; + + initExpr.append(helperMethod).append("("); + if (!init.getFunctionCallArgs().isEmpty()) { + generateValueAccess(initExpr, + init.getFunctionCallArgs().get(0).getValue(), genCtx); + } else { + initExpr.append("null"); + } + initExpr.append(")"); + } else { + // General value access — type inferred from lastResolvedType + generateValueAccess(initExpr, init, genCtx); + resolvedType = genCtx.lastResolvedType != null + ? genCtx.lastResolvedType : Object.class; + // Box primitive types for local variable declarations + if (resolvedType.isPrimitive()) { + final String boxName = LALCodegenHelper.boxTypeName(resolvedType); + if (boxName != null) { + try { + resolvedType = Class.forName("java.lang." + boxName); + } catch (ClassNotFoundException ignored) { + // keep primitive + } + } + } + } + + // Apply explicit type cast if specified (e.g., "as com.example.MyType") + final String castType = def.getCastType(); + if (castType != null && !castType.isEmpty()) { + // Resolve the cast type — primitive wrapper names are handled, + // anything else is treated as a FQCN + final Class<?> castClass = resolveDefCastType(castType); + if (castClass != null) { + resolvedType = castClass; + } + } + + // Register in local vars for later reference + genCtx.localVars.put(varName, + new LALClassGenerator.LocalVarInfo(javaVar, resolvedType)); + + // Emit declaration (placed at method top via localVarDecls) + genCtx.localVarDecls.append(" ").append(resolvedType.getName()) + .append(" ").append(javaVar).append(";\n"); + genCtx.localVarLvtVars.add(new String[]{ + javaVar, "L" + resolvedType.getName().replace('.', '/') + ";" + }); + + // Emit assignment in body (at the point where def appears) + sb.append(" ").append(javaVar).append(" = "); + if (castType != null && !castType.isEmpty()) { + sb.append("(").append(resolvedType.getName()).append(") "); + } + sb.append(initExpr).append(";\n"); + } + + /** + * Resolves a cast type string to a {@link Class}. + * Handles the four built-in type names ({@code String}, {@code Long}, + * {@code Integer}, {@code Boolean}) and fully qualified class names. + */ + private static Class<?> resolveDefCastType(final String castType) { + switch (castType) { + case "String": + return String.class; + case "Long": + return Long.class; + case "Integer": + return Integer.class; + case "Boolean": + return Boolean.class; + default: + try { + return Class.forName(castType); + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException( + "def cast type not found on classpath: " + castType, e); + } + } + } + + // ==================== Def variable chain codegen ==================== + + /** + * Generates typed method-chain access on a def variable. + * Uses reflection to resolve each method/field call and track types. + * + * @param sb output buffer + * @param localVar the def variable info (java var name + resolved type) + * @param chain the chain segments after the variable name + * @param genCtx codegen context + */ + static void generateDefVarChain( + final StringBuilder sb, + final LALClassGenerator.LocalVarInfo localVar, + final List<LALScriptModel.ValueAccessSegment> chain, + final LALClassGenerator.GenCtx genCtx) { + if (chain.isEmpty()) { + sb.append(localVar.javaVarName); + genCtx.lastResolvedType = localVar.resolvedType; + return; + } + + String prevExpr = localVar.javaVarName; + Class<?> currentType = localVar.resolvedType; + boolean canBeNull = true; + + for (int i = 0; i < chain.size(); i++) { + final LALScriptModel.ValueAccessSegment seg = chain.get(i); + final boolean isLast = i == chain.size() - 1; + + if (seg instanceof LALScriptModel.MethodSegment) { + final LALScriptModel.MethodSegment ms = + (LALScriptModel.MethodSegment) seg; + final String methodName = ms.getName(); + + // Resolve method on currentType via reflection + final java.lang.reflect.Method method = + resolveMethod(currentType, methodName, ms.getArguments()); + if (method == null) { + throw new IllegalArgumentException( + "Cannot resolve method " + currentType.getSimpleName() + + "." + methodName + "() in def variable chain"); + } + final Class<?> returnType = method.getReturnType(); + final String args = generateMethodArgs(ms.getArguments()); + + if (ms.isSafeNav() && canBeNull) { + if (isLast && returnType.isPrimitive()) { + // Primitive return with null guard + final String boxName = + LALCodegenHelper.boxTypeName(returnType); + prevExpr = "(" + prevExpr + " == null ? null : " + + boxName + ".valueOf(" + prevExpr + "." + + methodName + "(" + args + ")))"; + currentType = returnType; + } else { + prevExpr = "(" + prevExpr + " == null ? null : " + + prevExpr + "." + methodName + "(" + args + "))"; + currentType = returnType; + canBeNull = true; + } + } else { + prevExpr = prevExpr + "." + methodName + "(" + args + ")"; + currentType = returnType; + canBeNull = !returnType.isPrimitive(); + } + } else if (seg instanceof LALScriptModel.FieldSegment) { + final LALScriptModel.FieldSegment fs = + (LALScriptModel.FieldSegment) seg; + final String fieldName = fs.getName(); + // Try getter first + final String getterName = "get" + + Character.toUpperCase(fieldName.charAt(0)) + + fieldName.substring(1); + java.lang.reflect.Method getter = null; + try { + getter = currentType.getMethod(getterName); + } catch (NoSuchMethodException e) { + // Try direct field access name + try { + getter = currentType.getMethod(fieldName); + } catch (NoSuchMethodException e2) { + throw new IllegalArgumentException( + "Cannot resolve field/getter " + + currentType.getSimpleName() + + "." + fieldName + " in def variable chain"); + } + } + final Class<?> returnType = getter.getReturnType(); + + if (fs.isSafeNav() && canBeNull) { + if (isLast && returnType.isPrimitive()) { + final String boxName = + LALCodegenHelper.boxTypeName(returnType); + prevExpr = "(" + prevExpr + " == null ? null : " + + boxName + ".valueOf(" + prevExpr + "." + + getter.getName() + "()))"; + currentType = returnType; + } else { + prevExpr = "(" + prevExpr + " == null ? null : " + + prevExpr + "." + getter.getName() + "())"; + currentType = returnType; + canBeNull = true; + } + } else { + prevExpr = prevExpr + "." + getter.getName() + "()"; + currentType = returnType; + canBeNull = !returnType.isPrimitive(); + } + } else if (seg instanceof LALScriptModel.IndexSegment) { + final int index = ((LALScriptModel.IndexSegment) seg).getIndex(); + // Try get(int) method (e.g., JsonArray.get(int)) + java.lang.reflect.Method getMethod = null; + try { + getMethod = currentType.getMethod("get", int.class); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException( + "Cannot resolve index access on " + + currentType.getSimpleName() + + " in def variable chain"); + } + final Class<?> returnType = getMethod.getReturnType(); + if (canBeNull) { + prevExpr = "(" + prevExpr + " == null ? null : " + + prevExpr + ".get(" + index + "))"; + } else { + prevExpr = prevExpr + ".get(" + index + ")"; + } + currentType = returnType; + canBeNull = true; + } + } + + genCtx.lastResolvedType = currentType; + sb.append(prevExpr); + } + + /** + * Resolves a method on the given type by name, matching argument count. + * For methods with String arguments (like JsonObject.get(String)), + * prioritizes exact match by parameter types. + */ + private static java.lang.reflect.Method resolveMethod( + final Class<?> type, final String name, + final List<LALScriptModel.FunctionArg> args) { + final int argCount = args != null ? args.size() : 0; + // Try exact match with common parameter types + if (argCount == 1) { + try { + return type.getMethod(name, String.class); + } catch (NoSuchMethodException ignored) { + // fall through + } + try { + return type.getMethod(name, int.class); + } catch (NoSuchMethodException ignored) { + // fall through + } + } + if (argCount == 0) { + try { + return type.getMethod(name); + } catch (NoSuchMethodException ignored) { + // fall through + } + } + // Fallback: find by name and arg count + for (final java.lang.reflect.Method m : type.getMethods()) { + if (m.getName().equals(name) + && m.getParameterCount() == argCount) { + return m; + } + } + return null; + } + // ==================== ProcessRegistry ==================== static void generateProcessRegistryCall( @@ -1175,23 +1484,21 @@ final class LALBlockCodegen { static String appendMethodSegment(final String current, final LALScriptModel.MethodSegment ms) { + final String mn = ms.getName(); + final String args = ms.getArguments().isEmpty() + ? "" : generateMethodArgs(ms.getArguments()); if (ms.isSafeNav()) { - final String mn = ms.getName(); + // Special-cased helpers for common safe-nav methods on Object if ("toString".equals(mn)) { return "h.toString(" + current + ")"; } else if ("trim".equals(mn)) { return "h.trim(" + current + ")"; - } else { - throw new IllegalArgumentException( - "Unsupported safe-nav method: ?." + mn + "()"); } + // General safe-nav: null guard with ternary + return "(" + current + " == null ? null : " + + current + "." + mn + "(" + args + "))"; } else { - if (ms.getArguments().isEmpty()) { - return current + "." + ms.getName() + "()"; - } else { - return current + "." + ms.getName() + "(" - + generateMethodArgs(ms.getArguments()) + ")"; - } + return current + "." + mn + "(" + args + ")"; } } diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java index 78f20f844a..62aa58de5a 100644 --- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java +++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGenerator.java @@ -85,6 +85,18 @@ public final class LALClassGenerator { } } + static class LocalVarInfo { + final String javaVarName; + final Class<?> resolvedType; + final String descriptor; + + LocalVarInfo(final String javaVarName, final Class<?> resolvedType) { + this.javaVarName = javaVarName; + this.resolvedType = resolvedType; + this.descriptor = "L" + resolvedType.getName().replace('.', '/') + ";"; + } + } + static class GenCtx { final ParserType parserType; final Class<?> inputType; @@ -107,6 +119,13 @@ public final class LALClassGenerator { int protoVarCounter; boolean usedProtoAccess; + // Local variables from def statements. + // Maps user-chosen name (e.g., "metadata") to type info. + final Map<String, LocalVarInfo> localVars = new HashMap<>(); + int localVarCounter; + final StringBuilder localVarDecls = new StringBuilder(); + final List<String[]> localVarLvtVars = new ArrayList<>(); + GenCtx(final ParserType parserType, final Class<?> inputType, final Class<?> outputType) { this.parserType = parserType; @@ -131,6 +150,10 @@ public final class LALClassGenerator { protoVarDecls.setLength(0); protoVarCounter = 0; usedProtoAccess = false; + localVars.clear(); + localVarCounter = 0; + localVarDecls.setLength(0); + localVarLvtVars.clear(); } Object[] saveProtoVarState() { @@ -139,7 +162,11 @@ public final class LALClassGenerator { new ArrayList<>(protoLvtVars), protoVarDecls.toString(), protoVarCounter, - usedProtoAccess + usedProtoAccess, + new HashMap<>(localVars), + localVarCounter, + localVarDecls.toString(), + new ArrayList<>(localVarLvtVars) }; } @@ -153,6 +180,13 @@ public final class LALClassGenerator { protoVarDecls.append((String) state[2]); protoVarCounter = (Integer) state[3]; usedProtoAccess = (Boolean) state[4]; + localVars.clear(); + localVars.putAll((Map<String, LocalVarInfo>) state[5]); + localVarCounter = (Integer) state[6]; + localVarDecls.setLength(0); + localVarDecls.append((String) state[7]); + localVarLvtVars.clear(); + localVarLvtVars.addAll((List<String[]>) state[8]); } } @@ -455,6 +489,7 @@ public final class LALClassGenerator { } execLvt.addAll(genCtx.protoLvtVars); } + execLvt.addAll(genCtx.localVarLvtVars); addLocalVariableTable(execMethod, className, execLvt.toArray(new String[0][])); addLineNumberTable(execMethod, 3); // slot 0=this, 1=filterSpec, 2=ctx @@ -517,6 +552,11 @@ public final class LALClassGenerator { sb.append(genCtx.protoVarDecls); } + // Insert local var declarations from def statements at execute level + if (genCtx.localVarDecls.length() > 0) { + sb.append(genCtx.localVarDecls); + } + sb.append(bodyContent); sb.append("}\n"); return sb.toString(); @@ -552,6 +592,9 @@ public final class LALClassGenerator { } } else if (stmt instanceof LALScriptModel.IfBlock) { generateTopLevelIfBlock(sb, (LALScriptModel.IfBlock) stmt, genCtx); + } else if (stmt instanceof LALScriptModel.DefStatement) { + LALBlockCodegen.generateDefStatement( + sb, (LALScriptModel.DefStatement) stmt, genCtx); } } diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptModel.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptModel.java index e20232e56b..9f590225ae 100644 --- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptModel.java +++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptModel.java @@ -82,6 +82,23 @@ public final class LALScriptModel { public static final class AbortStatement implements FilterStatement { } + // ==================== Local variable declaration ==================== + + @Getter + public static final class DefStatement implements FilterStatement, ExtractorStatement { + private final String varName; + private final ValueAccess initializer; + private final String castType; + + public DefStatement(final String varName, + final ValueAccess initializer, + final String castType) { + this.varName = varName; + this.initializer = initializer; + this.castType = castType; + } + } + // ==================== Extractor block ==================== @Getter diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParser.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParser.java index fd91feb3c5..6a526e282e 100644 --- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParser.java +++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParser.java @@ -29,6 +29,7 @@ import org.antlr.v4.runtime.Recognizer; import org.apache.skywalking.lal.rt.grammar.LALLexer; import org.apache.skywalking.lal.rt.grammar.LALParser; import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALScriptModel.AbortStatement; +import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALScriptModel.DefStatement; import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALScriptModel.InterpolationPart; import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALScriptModel.CompareOp; import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALScriptModel.ComparisonCondition; @@ -134,6 +135,9 @@ public final class LALScriptParser { if (ctx.abortBlock() != null) { return new AbortStatement(); } + if (ctx.defStatement() != null) { + return visitDefStatement(ctx.defStatement()); + } // ifStatement return visitIfStatement(ctx.ifStatement()); } @@ -190,6 +194,9 @@ public final class LALScriptParser { private static ExtractorStatement visitExtractorStatement( final LALParser.ExtractorStatementContext ctx) { + if (ctx.defStatement() != null) { + return (ExtractorStatement) visitDefStatement(ctx.defStatement()); + } if (ctx.serviceStatement() != null) { return visitFieldAssignment(FieldType.SERVICE, ctx.serviceStatement().valueAccess(), ctx.serviceStatement().typeCast()); @@ -250,6 +257,17 @@ public final class LALScriptParser { return new LALScriptModel.OutputFieldAssignment(fieldName, value, castType); } + // ==================== Def statement ==================== + + private static DefStatement visitDefStatement( + final LALParser.DefStatementContext ctx) { + final String varName = ctx.IDENTIFIER().getText(); + final ValueAccess initializer = visitValueAccess(ctx.valueAccess()); + final String castType = ctx.typeCast() != null + ? extractCastType(ctx.typeCast()) : null; + return new DefStatement(varName, initializer, castType); + } + // ==================== Tag statement ==================== private static TagAssignment visitTagStatement(final LALParser.TagStatementContext ctx) { @@ -741,6 +759,9 @@ public final class LALScriptParser { if (ctx.BOOLEAN_TYPE() != null) { return "Boolean"; } + if (ctx.qualifiedName() != null) { + return ctx.qualifiedName().getText(); + } return null; } diff --git a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/rt/LalRuntimeHelper.java b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/rt/LalRuntimeHelper.java index 0c2a11b3ad..83c6494699 100644 --- a/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/rt/LalRuntimeHelper.java +++ b/oap-server/analyzer/log-analyzer/src/main/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/rt/LalRuntimeHelper.java @@ -17,6 +17,14 @@ package org.apache.skywalking.oap.log.analyzer.v2.compiler.rt; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.protobuf.ListValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.List; @@ -180,6 +188,115 @@ public final class LalRuntimeHelper { return ""; } + // ==================== JSON conversion (for def variables) ==================== + // + // toJsonObject: converts to JsonObject. Overloaded for Struct, String, Object. + // toJsonArray: converts to JsonArray. Overloaded for String, Object. + // + // Primary use case: extract JWT claims from envoy ALS filter_metadata: + // def jwt = toJson(parsed?.commonProperties?.metadata + // ?.filterMetadataMap?.get("envoy.filters.http.jwt_authn")) + // tag 'email': jwt?.getAsJsonObject("payload")?.get("email")?.getAsString() + + /** + * Converts a protobuf {@link Struct} to a Gson {@link JsonObject}. + * Recursively converts nested Struct/Value/ListValue to Gson types. + */ + public JsonObject toJsonObject(final Struct struct) { + if (struct == null) { + return null; + } + return structToJsonObject(struct); + } + + /** + * Parses a JSON string into a {@link JsonObject}. + */ + public JsonObject toJsonObject(final String s) { + if (s == null || s.isEmpty()) { + return null; + } + final JsonElement el = JsonParser.parseString(s); + return el.isJsonObject() ? el.getAsJsonObject() : null; + } + + /** + * Fallback for {@code Map.get()} erasure — dispatches to typed overloads. + */ + public JsonObject toJsonObject(final Object obj) { + if (obj == null) { + return null; + } + if (obj instanceof Struct) { + return toJsonObject((Struct) obj); + } + if (obj instanceof String) { + return toJsonObject((String) obj); + } + return null; + } + + /** + * Parses a JSON string into a {@link JsonArray}. + */ + public JsonArray toJsonArray(final String s) { + if (s == null || s.isEmpty()) { + return null; + } + final JsonElement el = JsonParser.parseString(s); + return el.isJsonArray() ? el.getAsJsonArray() : null; + } + + /** + * Fallback for {@code Map.get()} erasure. + */ + public JsonArray toJsonArray(final Object obj) { + if (obj == null) { + return null; + } + if (obj instanceof String) { + return toJsonArray((String) obj); + } + return null; + } + + // ==================== Struct → Gson recursive conversion ==================== + + private static JsonObject structToJsonObject(final Struct struct) { + final JsonObject obj = new JsonObject(); + for (final Map.Entry<String, Value> entry : struct.getFieldsMap().entrySet()) { + obj.add(entry.getKey(), valueToJsonElement(entry.getValue())); + } + return obj; + } + + private static JsonElement valueToJsonElement(final Value value) { + switch (value.getKindCase()) { + case STRING_VALUE: + return new JsonPrimitive(value.getStringValue()); + case NUMBER_VALUE: + return new JsonPrimitive(value.getNumberValue()); + case BOOL_VALUE: + return new JsonPrimitive(value.getBoolValue()); + case STRUCT_VALUE: + return structToJsonObject(value.getStructValue()); + case LIST_VALUE: + return listValueToJsonArray(value.getListValue()); + case NULL_VALUE: + case KIND_NOT_SET: + default: + return com.google.gson.JsonNull.INSTANCE; + } + } + + private static JsonArray listValueToJsonArray(final ListValue listValue) { + final JsonArray arr = new JsonArray(); + for (final Value v : listValue.getValuesList()) { + arr.add(valueToJsonElement(v)); + } + return arr; + } + // ==================== Timestamp parsing ==================== public long parseTimestamp(final String timestamp, final String formatPattern) { diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorBasicTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorBasicTest.java new file mode 100644 index 0000000000..29f4103319 --- /dev/null +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorBasicTest.java @@ -0,0 +1,109 @@ +/* + * 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.skywalking.oap.log.analyzer.v2.compiler; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Basic compilation and error handling tests for LAL class generator. + */ +class LALClassGeneratorBasicTest extends LALClassGeneratorTestBase { + + // ==================== Minimal compile tests ==================== + + @Test + void compileMinimalFilter() throws Exception { + compileAndAssert("filter { sink {} }"); + } + + @Test + void compileJsonParserFilter() throws Exception { + compileAndAssert("filter { json {} sink {} }"); + } + + @Test + void compileJsonWithExtractor() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " service parsed.service as String\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileTextWithRegexp() throws Exception { + compileAndAssert( + "filter {\n" + + " text {\n" + + " regexp '(?<timestamp>\\\\d+) (?<level>\\\\w+) (?<msg>.*)'\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileSinkWithEnforcer() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " sink {\n" + + " enforcer {}\n" + + " }\n" + + "}"); + } + + @Test + void generateSourceReturnsJavaCode() { + final String source = generator.generateSource( + "filter { json {} sink {} }"); + assertNotNull(source); + assertTrue(source.contains("filterSpec.json(ctx)")); + assertTrue(source.contains("filterSpec.sink(ctx)")); + } + + // ==================== Error handling ==================== + + @Test + void emptyScriptThrows() { + assertThrows(Exception.class, () -> generator.compile("")); + } + + @Test + void missingFilterKeywordThrows() { + assertThrows(Exception.class, () -> generator.compile("json {}")); + } + + @Test + void unclosedBraceThrows() { + assertThrows(Exception.class, + () -> generator.compile("filter { json {")); + } + + @Test + void invalidStatementInFilterThrows() { + assertThrows(Exception.class, + () -> generator.compile("filter { invalid {} }")); + } +} diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorConditionTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorConditionTest.java new file mode 100644 index 0000000000..b537a3e940 --- /dev/null +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorConditionTest.java @@ -0,0 +1,186 @@ +/* + * 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.skywalking.oap.log.analyzer.v2.compiler; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Condition, safe navigation, tag function, if-block, and else-if chain tests. + */ +class LALClassGeneratorConditionTest extends LALClassGeneratorTestBase { + + // ==================== tag() function in conditions ==================== + + @Test + void compileTagFunctionInCondition() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " if (tag(\"LOG_KIND\") == \"SLOW_SQL\") {\n" + + " sink {}\n" + + " }\n" + + "}"); + } + + @Test + void generateSourceTagFunctionEmitsTagValue() { + final String source = generator.generateSource( + "filter {\n" + + " if (tag(\"LOG_KIND\") == \"SLOW_SQL\") {\n" + + " sink {}\n" + + " }\n" + + "}"); + assertTrue(source.contains("h.tagValue(\"LOG_KIND\")"), + "Expected tagValue call but got: " + source); + assertTrue(source.contains("SLOW_SQL")); + } + + @Test + void compileTagFunctionNestedInExtractor() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " if (tag(\"LOG_KIND\") == \"NET_PROFILING_SAMPLED_TRACE\") {\n" + + " service parsed.service as String\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + // ==================== Safe navigation ==================== + + @Test + void compileSafeNavigationFieldAccess() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " service parsed?.response?.service as String\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileSafeNavigationMethodCalls() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " service parsed?.flags?.toString()?.trim() as String\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void generateSourceSafeNavMethodEmitsSpecificHelper() { + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " if (parsed?.flags?.toString()) {\n" + + " sink {}\n" + + " }\n" + + "}"); + assertTrue(source.contains("h.toString("), + "Expected toString helper for safe nav method but got: " + source); + assertTrue(source.contains("h.isNotEmpty("), + "Expected isNotEmpty for ExprCondition but got: " + source); + } + + // ==================== If blocks in extractor/sink ==================== + + @Test + void compileIfInsideExtractor() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " if (parsed?.status) {\n" + + " tag 'http.status_code': parsed.status\n" + + " }\n" + + " tag 'response.flag': parsed.flags\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileIfInsideExtractorWithTagCondition() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " if (tag(\"LOG_KIND\") == \"NET_PROFILING\") {\n" + + " service parsed.service as String\n" + + " layer parsed.layer as String\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + // ==================== Else-if chain ==================== + + @Test + void compileElseIfChain() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " if (parsed.a) {\n" + + " sink {}\n" + + " } else if (parsed.b) {\n" + + " sink {}\n" + + " } else if (parsed.c) {\n" + + " sink {}\n" + + " } else {\n" + + " sink {}\n" + + " }\n" + + "}"); + } + + @Test + void generateSourceElseIfEmitsNestedBranches() { + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " if (parsed.a) {\n" + + " sink {}\n" + + " } else if (parsed.b) {\n" + + " sink {}\n" + + " } else {\n" + + " sink {}\n" + + " }\n" + + "}"); + assertTrue(source.contains("else"), + "Expected else branch but got: " + source); + int ifCount = 0; + for (int i = 0; i < source.length() - 2; i++) { + if (source.substring(i, i + 3).equals("if ")) { + ifCount++; + } + } + assertTrue(ifCount >= 2, + "Expected at least 2 if-conditions for else-if chain but got " + + ifCount + " in: " + source); + } +} diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorDefTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorDefTest.java new file mode 100644 index 0000000000..d9cdafb097 --- /dev/null +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorDefTest.java @@ -0,0 +1,220 @@ +/* + * 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.skywalking.oap.log.analyzer.v2.compiler; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@code def} local variable support with + * {@code toJson()}/{@code toJsonArray()}. + * + * <p>Focuses on JSON body parsing → def var → typed method chain. + * ALS-specific proto chain tests belong in the ALS receiver module. + */ +class LALClassGeneratorDefTest extends LALClassGeneratorTestBase { + + // ==================== Source generation ==================== + + @Test + void generateSourceDefWithToJson() { + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def config = toJson(parsed.metadata)\n" + + " tag 'env': config?.get(\"env\")?.getAsString()\n" + + " }\n" + + " sink {}\n" + + "}"); + assertTrue(source.contains("com.google.gson.JsonObject _d0"), + "Expected JsonObject declaration but got:\n" + source); + assertTrue(source.contains("h.toJsonObject("), + "Expected h.toJsonObject() call but got:\n" + source); + assertTrue(source.contains(".get(\"env\")"), + "Expected .get(\"env\") call but got:\n" + source); + assertTrue(source.contains(".getAsString()"), + "Expected .getAsString() call but got:\n" + source); + } + + @Test + void generateSourceDefWithToJsonArray() { + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def items = toJsonArray(parsed.tags)\n" + + " tag 'first': items?.get(0)?.getAsString()\n" + + " }\n" + + " sink {}\n" + + "}"); + assertTrue(source.contains("com.google.gson.JsonArray _d0"), + "Expected JsonArray declaration but got:\n" + source); + assertTrue(source.contains("h.toJsonArray("), + "Expected h.toJsonArray() call but got:\n" + source); + assertTrue(source.contains(".get(0)"), + "Expected .get(0) call but got:\n" + source); + } + + @Test + void generateSourceDefWithCondition() { + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def config = toJson(parsed.metadata)\n" + + " if (config?.has(\"env\")) {\n" + + " tag 'env': config?.get(\"env\")?.getAsString()\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"); + assertTrue(source.contains("_d0 == null") || source.contains("_d0 != null"), + "Expected null check on _d0 but got:\n" + source); + assertTrue(source.contains(".has(\"env\")"), + "Expected .has(\"env\") call but got:\n" + source); + } + + // ==================== Compilation with class output ==================== + + @Test + void compileDefWithNestedJsonAccess() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def config = toJson(parsed.metadata)\n" + + " tag 'env': config?.get(\"env\")?.getAsString()\n" + + " tag 'region': config?.getAsJsonObject(\"location\")" + + "?.get(\"region\")?.getAsString()\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileDefWithToJsonArrayAndIndex() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def items = toJsonArray(parsed.tags)\n" + + " if (items?.size() > 0) {\n" + + " tag 'first': items?.get(0)?.getAsString()\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileMultipleDefs() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def config = toJson(parsed.metadata)\n" + + " def roles = toJsonArray(parsed.roles)\n" + + " tag 'env': config?.get(\"env\")?.getAsString()\n" + + " if (roles?.size() > 0) {\n" + + " tag 'role': roles?.get(0)?.getAsString()\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileDefWithConditionGuard() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def config = toJson(parsed.metadata)\n" + + " if (config?.has(\"payload\")) {\n" + + " tag 'name': config?.getAsJsonObject(\"payload\")" + + "?.get(\"name\")?.getAsString()\n" + + " tag 'iss': config?.getAsJsonObject(\"payload\")" + + "?.get(\"iss\")?.getAsString()\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + // ==================== Type cast on def ==================== + + @Test + void generateSourceDefWithStringCast() { + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " def svc = parsed.service as String\n" + + " tag 'svc': svc\n" + + " }\n" + + " sink {}\n" + + "}"); + assertTrue(source.contains("java.lang.String _d0"), + "Expected String declaration but got:\n" + source); + assertTrue(source.contains("(java.lang.String)"), + "Expected String cast but got:\n" + source); + } + + @Test + void compileDefWithQualifiedNameCast() throws Exception { + // With inputType, parsed?.commonProperties returns AccessLogCommon. + // The cast narrows parsed?.metadata to Metadata (which is already the + // correct inferred type here, but demonstrates FQCN cast syntax). + // A more realistic use: narrowing an Object-typed return to a known + // subclass when the compiler can't infer the concrete type. + generator.setInputType( + io.envoyproxy.envoy.data.accesslog.v3.HTTPAccessLogEntry.class); + compileAndAssert( + "filter {\n" + + " extractor {\n" + + " def common = parsed?.commonProperties" + + " as io.envoyproxy.envoy.data.accesslog.v3.AccessLogCommon\n" + + " tag 'cluster': common?.upstreamCluster\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void generateSourceDefWithQualifiedNameCast() { + generator.setInputType( + io.envoyproxy.envoy.data.accesslog.v3.HTTPAccessLogEntry.class); + final String source = generator.generateSource( + "filter {\n" + + " extractor {\n" + + " def common = parsed?.commonProperties" + + " as io.envoyproxy.envoy.data.accesslog.v3.AccessLogCommon\n" + + " tag 'cluster': common?.upstreamCluster\n" + + " }\n" + + " sink {}\n" + + "}"); + assertTrue(source.contains( + "io.envoyproxy.envoy.data.accesslog.v3.AccessLogCommon _d0"), + "Expected AccessLogCommon declaration but got:\n" + source); + assertTrue(source.contains( + "(io.envoyproxy.envoy.data.accesslog.v3.AccessLogCommon)"), + "Expected AccessLogCommon cast but got:\n" + source); + } +} diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorExtractorTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorExtractorTest.java new file mode 100644 index 0000000000..8fa30a6adc --- /dev/null +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorExtractorTest.java @@ -0,0 +1,269 @@ +/* + * 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.skywalking.oap.log.analyzer.v2.compiler; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Extractor feature tests: ProcessRegistry, metrics, inputType, outputType, + * output field assignment, and LogData fallback. + */ +class LALClassGeneratorExtractorTest extends LALClassGeneratorTestBase { + + // ==================== ProcessRegistry static calls ==================== + + @Test + void compileProcessRegistryCall() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " service ProcessRegistry.generateVirtualLocalProcess(" + + "parsed.service as String, parsed.serviceInstance as String" + + ") as String\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + @Test + void compileProcessRegistryWithThreeArgs() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " service ProcessRegistry.generateVirtualRemoteProcess(" + + "parsed.service as String, parsed.serviceInstance as String, " + + "parsed.address as String) as String\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + // ==================== Metrics block ==================== + + @Test + void compileMetricsBlock() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " metrics {\n" + + " timestamp log.timestamp as Long\n" + + " labels level: parsed.level, service: log.service\n" + + " name \"nginx_error_log_count\"\n" + + " value 1\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"); + } + + // ==================== Complex production-like rules ==================== + + @Test + void compileNginxAccessLogRule() throws Exception { + compileAndAssert( + "filter {\n" + + " if (tag(\"LOG_KIND\") == \"NGINX_ACCESS_LOG\") {\n" + + " text {\n" + + " regexp '.+\"(?<request>.+)\"(?<status>\\\\d{3}).+'\n" + + " }\n" + + " extractor {\n" + + " if (parsed.status) {\n" + + " tag 'http.status_code': parsed.status\n" + + " }\n" + + " }\n" + + " sink {}\n" + + " }\n" + + "}"); + } + + @Test + void compileEnvoyAlsAbortRuleFailsWithoutInputType() { + assertThrows(IllegalArgumentException.class, () -> generator.compile( + "filter {\n" + + " if (parsed?.response?.responseCode?.value as Integer < 400" + + " && !parsed?.commonProperties?.responseFlags?.toString()?.trim()) {\n" + + " abort {}\n" + + " }\n" + + " extractor {\n" + + " if (parsed?.response?.responseCode) {\n" + + " tag 'status.code': parsed?.response?.responseCode?.value\n" + + " }\n" + + " tag 'response.flag': parsed?.commonProperties?.responseFlags\n" + + " }\n" + + " sink {}\n" + + "}")); + } + + @Test + void compileNoParserFallsBackToLogDataProto() throws Exception { + final String dsl = + "filter {\n" + + " extractor {\n" + + " service parsed.service as String\n" + + " instance parsed.serviceInstance as String\n" + + " }\n" + + " sink {}\n" + + "}"; + final String source = generator.generateSource(dsl); + assertTrue(source.contains("h.ctx().log().getService()"), + "Expected h.ctx().log().getService() but got: " + source); + assertTrue(source.contains("h.ctx().log().getServiceInstance()"), + "Expected h.ctx().log().getServiceInstance() but got: " + source); + assertFalse(source.contains("_p"), + "Should NOT have _p variable for LogData fallback but got: " + source); + compileAndAssert(dsl); + } + + @Test + void compileInputTypeGeneratesDirectGetterCalls() throws Exception { + generator.setInputType( + io.envoyproxy.envoy.data.accesslog.v3.HTTPAccessLogEntry.class); + final String dsl = + "filter {\n" + + " if (parsed?.response?.responseCode?.value as Integer < 400" + + " && !parsed?.commonProperties?.responseFlags?.toString()?.trim()) {\n" + + " abort {}\n" + + " }\n" + + " extractor {\n" + + " if (parsed?.response?.responseCode) {\n" + + " tag 'status.code': parsed?.response?.responseCode?.value\n" + + " }\n" + + " tag 'response.flag': parsed?.commonProperties?.responseFlags\n" + + " }\n" + + " sink {}\n" + + "}"; + final String source = generator.generateSource(dsl); + final String fqcn = + "io.envoyproxy.envoy.data.accesslog.v3.HTTPAccessLogEntry"; + assertTrue(source.contains( + fqcn + " _p = (" + fqcn + ") h.ctx().extraLog()"), + "Expected _p local variable for inputType cast but got: " + source); + assertTrue(source.contains("_p.getResponse()"), + "Expected _p.getResponse() via cached variable but got: " + source); + assertTrue(source.contains("_p.getCommonProperties()"), + "Expected _p.getCommonProperties() via cached variable but got: " + + source); + assertFalse(source.contains("getAt"), + "Should NOT contain getAt calls but got: " + source); + assertTrue(source.contains("_p == null ? null :"), + "Expected null checks for ?. safe navigation but got: " + source); + assertTrue(source.contains("_t0") && source.contains("_t1"), + "Expected _tN local variables for chain dedup but got: " + source); + assertTrue(source.contains(".getValue() < 400"), + "Expected direct primitive comparison without boxing but got: " + + source); + assertFalse(source.contains("h.toLong"), + "Should NOT use h.toLong for primitive int comparison but got: " + + source); + assertTrue(source.contains("_o.addTag(\"status.code\""), + "Expected _o.addTag(key, value) but got: " + source); + assertFalse(source.contains("singletonMap"), + "Should NOT use singletonMap for single tags but got: " + source); + compileAndAssert(dsl); + } + + // ==================== Output field assignment ==================== + + @Test + void compileOutputFieldAssignment() { + generator.setOutputType(TestOutputType.class); + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " statement parsed.statement as String\n" + + " latency parsed.latency as Long\n" + + " }\n" + + " sink {}\n" + + "}"); + assertTrue(source.contains(".setStatement("), + "Expected direct setStatement() call but got: " + source); + assertTrue(source.contains(".setLatency("), + "Expected direct setLatency() call but got: " + source); + assertTrue(source.contains("h.toStr("), + "Expected toStr() cast for String but got: " + source); + assertTrue(source.contains("h.toLong("), + "Expected toLong() cast for Long but got: " + source); + assertTrue(source.contains("h.ctx().output()"), + "Expected ctx.output() access but got: " + source); + assertTrue(source.contains("h.ctx().setOutput(new"), + "Expected output object creation but got: " + source); + } + + @Test + void compileOutputFieldWithOutputTypeValidation() { + generator.setOutputType(TestOutputType.class); + final String source = generator.generateSource( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " statement parsed.stmt as String\n" + + " }\n" + + " sink {}\n" + + "}"); + assertTrue(source.contains(".setStatement("), + "Expected direct setStatement() call but got: " + source); + } + + @Test + void compileOutputFieldWithInvalidSetter() { + generator.setOutputType(TestOutputType.class); + final Exception ex = assertThrows(RuntimeException.class, () -> + generator.generateSource( + "filter {\n" + + " json {}\n" + + " extractor {\n" + + " nonExistentField parsed.x as String\n" + + " }\n" + + " sink {}\n" + + "}")); + assertTrue(ex.getMessage().contains("setNonExistentField"), + "Expected error about missing setter but got: " + ex.getMessage()); + } + + /** + * Test output type with custom fields (statement, latency). + */ + public static class TestOutputType { + private String statement; + private long latency; + + public void setStatement(final String statement) { + this.statement = statement; + } + + public String getStatement() { + return statement; + } + + public void setLatency(final long latency) { + this.latency = latency; + } + + public long getLatency() { + return latency; + } + } +} diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorSinkTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorSinkTest.java new file mode 100644 index 0000000000..15e8aff13e --- /dev/null +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorSinkTest.java @@ -0,0 +1,113 @@ +/* + * 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.skywalking.oap.log.analyzer.v2.compiler; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Sampler, rateLimit, and interpolated ID tests. + */ +class LALClassGeneratorSinkTest extends LALClassGeneratorTestBase { + + @Test + void compileSamplerWithRateLimit() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " sink {\n" + + " sampler {\n" + + " rateLimit('service:error') {\n" + + " rpm 6000\n" + + " }\n" + + " }\n" + + " }\n" + + "}"); + } + + @Test + void compileSamplerWithInterpolatedId() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " sink {\n" + + " sampler {\n" + + " rateLimit(\"${log.service}:${parsed.code}\") {\n" + + " rpm 6000\n" + + " }\n" + + " }\n" + + " }\n" + + "}"); + } + + @Test + void parseInterpolatedIdParts() { + final java.util.List<LALScriptModel.InterpolationPart> parts = + LALScriptParser.parseInterpolation( + "${log.service}:${parsed.code}"); + assertNotNull(parts); + assertEquals(3, parts.size()); + assertFalse(parts.get(0).isLiteral()); + assertTrue(parts.get(0).getExpression().isLogRef()); + assertTrue(parts.get(1).isLiteral()); + assertEquals(":", parts.get(1).getLiteral()); + assertFalse(parts.get(2).isLiteral()); + assertTrue(parts.get(2).getExpression().isParsedRef()); + } + + @Test + void compileSamplerWithSafeNavInterpolatedId() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " sink {\n" + + " sampler {\n" + + " rateLimit(\"${log.service}:${parsed?.commonProperties" + + "?.responseFlags?.toString()}\") {\n" + + " rpm 6000\n" + + " }\n" + + " }\n" + + " }\n" + + "}"); + } + + @Test + void compileSamplerWithIfAndRateLimit() throws Exception { + compileAndAssert( + "filter {\n" + + " json {}\n" + + " sink {\n" + + " sampler {\n" + + " if (parsed?.error) {\n" + + " rateLimit('svc:err') {\n" + + " rpm 6000\n" + + " }\n" + + " } else {\n" + + " rateLimit('svc:ok') {\n" + + " rpm 3000\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}"); + } +} diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorTest.java deleted file mode 100644 index 3bb9bbc325..0000000000 --- a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorTest.java +++ /dev/null @@ -1,641 +0,0 @@ -/* - * 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.skywalking.oap.log.analyzer.v2.compiler; - -import javassist.ClassPool; -import org.apache.skywalking.oap.log.analyzer.v2.dsl.LalExpression; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class LALClassGeneratorTest { - - private LALClassGenerator generator; - - @BeforeEach - void setUp() { - generator = new LALClassGenerator(new ClassPool(true)); - } - - @Test - void compileMinimalFilter() throws Exception { - final LalExpression expr = generator.compile( - "filter { sink {} }"); - assertNotNull(expr); - } - - @Test - void compileJsonParserFilter() throws Exception { - final LalExpression expr = generator.compile( - "filter { json {} sink {} }"); - assertNotNull(expr); - } - - @Test - void compileJsonWithExtractor() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " service parsed.service as String\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileTextWithRegexp() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " text {\n" - + " regexp '(?<timestamp>\\\\d+) (?<level>\\\\w+) (?<msg>.*)'\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileSinkWithEnforcer() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " sink {\n" - + " enforcer {}\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - @Test - void generateSourceReturnsJavaCode() { - final String source = generator.generateSource( - "filter { json {} sink {} }"); - assertNotNull(source); - org.junit.jupiter.api.Assertions.assertTrue( - source.contains("filterSpec.json(ctx)")); - org.junit.jupiter.api.Assertions.assertTrue( - source.contains("filterSpec.sink(ctx)")); - } - - // ==================== Error handling tests ==================== - - @Test - void emptyScriptThrows() { - // Demo error: LAL script parsing failed: 1:0 mismatched input '<EOF>' - // expecting 'filter' - assertThrows(Exception.class, () -> generator.compile("")); - } - - @Test - void missingFilterKeywordThrows() { - // Demo error: LAL script parsing failed: 1:0 extraneous input 'json' - // expecting 'filter' - assertThrows(Exception.class, () -> generator.compile("json {}")); - } - - @Test - void unclosedBraceThrows() { - // Demo error: LAL script parsing failed: 1:15 mismatched input '<EOF>' - // expecting '}' - assertThrows(Exception.class, - () -> generator.compile("filter { json {")); - } - - @Test - void invalidStatementInFilterThrows() { - // Demo error: LAL script parsing failed: 1:9 extraneous input 'invalid' - // expecting {'text', 'json', 'yaml', 'extractor', 'sink', 'abort', 'if', '}'} - assertThrows(Exception.class, - () -> generator.compile("filter { invalid {} }")); - } - - // ==================== tag() function in conditions ==================== - - @Test - void compileTagFunctionInCondition() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " if (tag(\"LOG_KIND\") == \"SLOW_SQL\") {\n" - + " sink {}\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - @Test - void generateSourceTagFunctionEmitsTagValue() { - final String source = generator.generateSource( - "filter {\n" - + " if (tag(\"LOG_KIND\") == \"SLOW_SQL\") {\n" - + " sink {}\n" - + " }\n" - + "}"); - // Should use tagValue helper, not emit null - assertTrue(source.contains("h.tagValue(\"LOG_KIND\")"), - "Expected tagValue call but got: " + source); - assertTrue(source.contains("SLOW_SQL")); - } - - @Test - void compileTagFunctionNestedInExtractor() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " if (tag(\"LOG_KIND\") == \"NET_PROFILING_SAMPLED_TRACE\") {\n" - + " service parsed.service as String\n" - + " }\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - // ==================== Safe navigation ==================== - - @Test - void compileSafeNavigationFieldAccess() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " service parsed?.response?.service as String\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileSafeNavigationMethodCalls() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " service parsed?.flags?.toString()?.trim() as String\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - @Test - void generateSourceSafeNavMethodEmitsSpecificHelper() { - final String source = generator.generateSource( - "filter {\n" - + " json {}\n" - + " if (parsed?.flags?.toString()) {\n" - + " sink {}\n" - + " }\n" - + "}"); - // Safe method calls should emit specific helpers, not generic safeCall - assertTrue(source.contains("h.toString("), - "Expected toString helper for safe nav method but got: " + source); - assertTrue(source.contains("h.isNotEmpty("), - "Expected isNotEmpty for ExprCondition but got: " + source); - } - - // ==================== ProcessRegistry static calls ==================== - - @Test - void compileProcessRegistryCall() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " service ProcessRegistry.generateVirtualLocalProcess(" - + "parsed.service as String, parsed.serviceInstance as String" - + ") as String\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileProcessRegistryWithThreeArgs() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " service ProcessRegistry.generateVirtualRemoteProcess(" - + "parsed.service as String, parsed.serviceInstance as String, " - + "parsed.address as String) as String\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - // ==================== Metrics block ==================== - - @Test - void compileMetricsBlock() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " metrics {\n" - + " timestamp log.timestamp as Long\n" - + " labels level: parsed.level, service: log.service\n" - + " name \"nginx_error_log_count\"\n" - + " value 1\n" - + " }\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - // ==================== Sampler / rateLimit ==================== - - @Test - void compileSamplerWithRateLimit() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " sink {\n" - + " sampler {\n" - + " rateLimit('service:error') {\n" - + " rpm 6000\n" - + " }\n" - + " }\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileSamplerWithInterpolatedId() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " sink {\n" - + " sampler {\n" - + " rateLimit(\"${log.service}:${parsed.code}\") {\n" - + " rpm 6000\n" - + " }\n" - + " }\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - @Test - void parseInterpolatedIdParts() { - // Verify the parser correctly splits interpolated strings - final java.util.List<LALScriptModel.InterpolationPart> parts = - LALScriptParser.parseInterpolation( - "${log.service}:${parsed.code}"); - assertNotNull(parts); - // expr, literal ":", expr - assertEquals(3, parts.size()); - assertFalse(parts.get(0).isLiteral()); - assertTrue(parts.get(0).getExpression().isLogRef()); - assertTrue(parts.get(1).isLiteral()); - assertEquals(":", parts.get(1).getLiteral()); - assertFalse(parts.get(2).isLiteral()); - assertTrue(parts.get(2).getExpression().isParsedRef()); - } - - @Test - void compileSamplerWithSafeNavInterpolatedId() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " sink {\n" - + " sampler {\n" - + " rateLimit(\"${log.service}:${parsed?.commonProperties?.responseFlags?.toString()}\") {\n" - + " rpm 6000\n" - + " }\n" - + " }\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileSamplerWithIfAndRateLimit() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " sink {\n" - + " sampler {\n" - + " if (parsed?.error) {\n" - + " rateLimit('svc:err') {\n" - + " rpm 6000\n" - + " }\n" - + " } else {\n" - + " rateLimit('svc:ok') {\n" - + " rpm 3000\n" - + " }\n" - + " }\n" - + " }\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - // ==================== If blocks in extractor/sink ==================== - - @Test - void compileIfInsideExtractor() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " if (parsed?.status) {\n" - + " tag 'http.status_code': parsed.status\n" - + " }\n" - + " tag 'response.flag': parsed.flags\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileIfInsideExtractorWithTagCondition() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " if (tag(\"LOG_KIND\") == \"NET_PROFILING\") {\n" - + " service parsed.service as String\n" - + " layer parsed.layer as String\n" - + " }\n" - + " }\n" - + " sink {}\n" - + "}"); - assertNotNull(expr); - } - - // ==================== Complex production-like rules ==================== - - @Test - void compileNginxAccessLogRule() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " if (tag(\"LOG_KIND\") == \"NGINX_ACCESS_LOG\") {\n" - + " text {\n" - + " regexp '.+\"(?<request>.+)\"(?<status>\\\\d{3}).+'\n" - + " }\n" - + " extractor {\n" - + " if (parsed.status) {\n" - + " tag 'http.status_code': parsed.status\n" - + " }\n" - + " }\n" - + " sink {}\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - @Test - void compileEnvoyAlsAbortRuleFailsWithoutInputType() { - // envoy-als pattern has no parser (json/yaml/text) — falls back to LogData - // but LogData.Builder doesn't have getResponse(), so compile fails - assertThrows(IllegalArgumentException.class, () -> generator.compile( - "filter {\n" - + " if (parsed?.response?.responseCode?.value as Integer < 400" - + " && !parsed?.commonProperties?.responseFlags?.toString()?.trim()) {\n" - + " abort {}\n" - + " }\n" - + " extractor {\n" - + " if (parsed?.response?.responseCode) {\n" - + " tag 'status.code': parsed?.response?.responseCode?.value\n" - + " }\n" - + " tag 'response.flag': parsed?.commonProperties?.responseFlags\n" - + " }\n" - + " sink {}\n" - + "}")); - } - - @Test - void compileNoParserFallsBackToLogDataProto() throws Exception { - // No parser (json/yaml/text) and no inputType — should use LogData.Builder - final String dsl = - "filter {\n" - + " extractor {\n" - + " service parsed.service as String\n" - + " instance parsed.serviceInstance as String\n" - + " }\n" - + " sink {}\n" - + "}"; - final String source = generator.generateSource(dsl); - // Should generate getter chains on h.ctx().log() - assertTrue(source.contains("h.ctx().log().getService()"), - "Expected h.ctx().log().getService() but got: " + source); - assertTrue(source.contains("h.ctx().log().getServiceInstance()"), - "Expected h.ctx().log().getServiceInstance() but got: " + source); - // No _p variable (LogData doesn't need it) - assertFalse(source.contains("_p"), - "Should NOT have _p variable for LogData fallback but got: " + source); - // Verify it compiles - final LalExpression expr = generator.compile(dsl); - assertNotNull(expr); - } - - @Test - void compileInputTypeGeneratesDirectGetterCalls() throws Exception { - generator.setInputType( - io.envoyproxy.envoy.data.accesslog.v3.HTTPAccessLogEntry.class); - final String dsl = - "filter {\n" - + " if (parsed?.response?.responseCode?.value as Integer < 400" - + " && !parsed?.commonProperties?.responseFlags?.toString()?.trim()) {\n" - + " abort {}\n" - + " }\n" - + " extractor {\n" - + " if (parsed?.response?.responseCode) {\n" - + " tag 'status.code': parsed?.response?.responseCode?.value\n" - + " }\n" - + " tag 'response.flag': parsed?.commonProperties?.responseFlags\n" - + " }\n" - + " sink {}\n" - + "}"; - final String source = generator.generateSource(dsl); - final String fqcn = - "io.envoyproxy.envoy.data.accesslog.v3.HTTPAccessLogEntry"; - // Proto field access uses _p local variable, not inline cast - assertTrue(source.contains( - fqcn + " _p = (" + fqcn + ") h.ctx().extraLog()"), - "Expected _p local variable for inputType cast but got: " + source); - // Intermediate fields cached in _tN local variables - assertTrue(source.contains("_p.getResponse()"), - "Expected _p.getResponse() via cached variable but got: " + source); - assertTrue(source.contains("_p.getCommonProperties()"), - "Expected _p.getCommonProperties() via cached variable but got: " + source); - assertFalse(source.contains("getAt"), - "Should NOT contain getAt calls but got: " + source); - // Safe navigation: null checks with == null on local variables - assertTrue(source.contains("_p == null ? null :"), - "Expected null checks for ?. safe navigation but got: " + source); - // Dedup: _tN variables declared once and reused - assertTrue(source.contains("_t0") && source.contains("_t1"), - "Expected _tN local variables for chain dedup but got: " + source); - // Numeric comparison: direct primitive via _tN variable, no h.toLong() - assertTrue(source.contains(".getValue() < 400"), - "Expected direct primitive comparison without boxing but got: " + source); - assertFalse(source.contains("h.toLong"), - "Should NOT use h.toLong for primitive int comparison but got: " + source); - // Single-tag: uses addTag on output builder - assertTrue(source.contains("_o.addTag(\"status.code\""), - "Expected _o.addTag(key, value) but got: " + source); - assertFalse(source.contains("singletonMap"), - "Should NOT use singletonMap for single tags but got: " + source); - - // Verify it compiles - final LalExpression expr = generator.compile(dsl); - assertNotNull(expr); - } - - // ==================== Else-if chain ==================== - - @Test - void compileElseIfChain() throws Exception { - final LalExpression expr = generator.compile( - "filter {\n" - + " json {}\n" - + " if (parsed.a) {\n" - + " sink {}\n" - + " } else if (parsed.b) {\n" - + " sink {}\n" - + " } else if (parsed.c) {\n" - + " sink {}\n" - + " } else {\n" - + " sink {}\n" - + " }\n" - + "}"); - assertNotNull(expr); - } - - @Test - void generateSourceElseIfEmitsNestedBranches() { - final String source = generator.generateSource( - "filter {\n" - + " json {}\n" - + " if (parsed.a) {\n" - + " sink {}\n" - + " } else if (parsed.b) {\n" - + " sink {}\n" - + " } else {\n" - + " sink {}\n" - + " }\n" - + "}"); - // The else-if should produce a nested if inside else - assertTrue(source.contains("else"), - "Expected else branch but got: " + source); - // Both condition branches should appear - int ifCount = 0; - for (int i = 0; i < source.length() - 2; i++) { - if (source.substring(i, i + 3).equals("if ")) { - ifCount++; - } - } - assertTrue(ifCount >= 2, - "Expected at least 2 if-conditions for else-if chain but got " - + ifCount + " in: " + source); - } - - @Test - void compileOutputFieldAssignment() { - generator.setOutputType(TestOutputType.class); - final String source = generator.generateSource( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " statement parsed.statement as String\n" - + " latency parsed.latency as Long\n" - + " }\n" - + " sink {}\n" - + "}"); - assertTrue(source.contains(".setStatement("), - "Expected direct setStatement() call but got: " + source); - assertTrue(source.contains(".setLatency("), - "Expected direct setLatency() call but got: " + source); - assertTrue(source.contains("h.toStr("), - "Expected toStr() cast for String but got: " + source); - assertTrue(source.contains("h.toLong("), - "Expected toLong() cast for Long but got: " + source); - assertTrue(source.contains("h.ctx().output()"), - "Expected ctx.output() access but got: " + source); - assertTrue(source.contains("h.ctx().setOutput(new"), - "Expected output object creation but got: " + source); - } - - @Test - void compileOutputFieldWithOutputTypeValidation() { - generator.setOutputType(TestOutputType.class); - final String source = generator.generateSource( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " statement parsed.stmt as String\n" - + " }\n" - + " sink {}\n" - + "}"); - assertTrue(source.contains(".setStatement("), - "Expected direct setStatement() call but got: " + source); - } - - @Test - void compileOutputFieldWithInvalidSetter() { - generator.setOutputType(TestOutputType.class); - final Exception ex = assertThrows(RuntimeException.class, () -> - generator.generateSource( - "filter {\n" - + " json {}\n" - + " extractor {\n" - + " nonExistentField parsed.x as String\n" - + " }\n" - + " sink {}\n" - + "}")); - assertTrue(ex.getMessage().contains("setNonExistentField"), - "Expected error about missing setter but got: " + ex.getMessage()); - } - - public static class TestOutputType { - private String statement; - private long latency; - - public void setStatement(final String statement) { - this.statement = statement; - } - - public String getStatement() { - return statement; - } - - public void setLatency(final long latency) { - this.latency = latency; - } - - public long getLatency() { - return latency; - } - } -} diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorTestBase.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorTestBase.java new file mode 100644 index 0000000000..dbb0357418 --- /dev/null +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALClassGeneratorTestBase.java @@ -0,0 +1,68 @@ +/* + * 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.skywalking.oap.log.analyzer.v2.compiler; + +import java.io.File; +import javassist.ClassPool; +import org.apache.skywalking.oap.log.analyzer.v2.dsl.LalExpression; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Shared base for LAL class generator tests. + * + * <p>Provides a fresh {@link LALClassGenerator} per test and dumps generated + * {@code .class} files to {@code target/lal-generated-classes/} for + * inspection with tools like {@code javap -c}. + * + * <p>Generated class names follow the pattern + * {@code {TestClassName}_{testMethodName}} for easy identification, + * e.g. {@code BasicTest_compileMinimalFilter}. + */ +abstract class LALClassGeneratorTestBase { + + private static final File CLASS_OUTPUT_DIR = + new File("target/lal-generated-classes"); + + protected LALClassGenerator generator; + + @BeforeEach + void setUp(final TestInfo testInfo) { + generator = new LALClassGenerator(new ClassPool(true)); + generator.setClassOutputDir(CLASS_OUTPUT_DIR); + // Build hint from test class + method for readable .class file names + final String className = getClass().getSimpleName() + .replace("LALClassGenerator", ""); + final String methodName = testInfo.getTestMethod() + .map(m -> m.getName()).orElse("unknown"); + generator.setClassNameHint(className + "_" + methodName); + } + + /** + * Compiles a LAL DSL string and asserts it produces a non-null expression. + * The generated {@code .class} file is written to + * {@code target/lal-generated-classes/} for inspection. + */ + protected LalExpression compileAndAssert(final String dsl) throws Exception { + final LalExpression expr = generator.compile(dsl); + assertNotNull(expr); + return expr; + } +} diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALExpressionExecutionTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALExpressionExecutionTest.java index 4a93674ef5..302727c37b 100644 --- a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALExpressionExecutionTest.java +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALExpressionExecutionTest.java @@ -254,6 +254,11 @@ class LALExpressionExecutionTest { expr.execute(filterSpec, ctx); + // The output builder (LogBuilder) holds extracted fields. + // For standard fields (service, instance, endpoint, layer), + // check the output builder first, falling back to ctx.log() proto. + final Object outputObj = ctx.output(); + // Assert expected values @SuppressWarnings("unchecked") final Map<String, Object> expect = @@ -268,20 +273,27 @@ class LALExpressionExecutionTest { switch (key) { case "service": - assertEquals(expected, ctx.log().getService(), + assertEquals(expected, + getOutputOrProto(outputObj, "service", + ctx.log().getService()), ruleName + ": service mismatch"); break; case "instance": assertEquals(expected, - ctx.log().getServiceInstance(), + getOutputOrProto(outputObj, "serviceInstance", + ctx.log().getServiceInstance()), ruleName + ": serviceInstance mismatch"); break; case "endpoint": - assertEquals(expected, ctx.log().getEndpoint(), + assertEquals(expected, + getOutputOrProto(outputObj, "endpoint", + ctx.log().getEndpoint()), ruleName + ": endpoint mismatch"); break; case "layer": - assertEquals(expected, ctx.log().getLayer(), + assertEquals(expected, + getOutputOrProto(outputObj, "layer", + ctx.log().getLayer()), ruleName + ": layer mismatch"); break; case "save": @@ -318,6 +330,30 @@ class LALExpressionExecutionTest { } } + /** + * Reads a field from the output builder if present, otherwise falls back + * to the proto value. The output builder (e.g. LogBuilder) stores + * extracted fields that haven't been flushed to LogData proto yet. + */ + private static String getOutputOrProto(final Object outputObj, + final String fieldName, + final String protoValue) { + if (outputObj == null) { + return protoValue; + } + try { + final Field f = outputObj.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + final Object val = f.get(outputObj); + if (val != null) { + return val.toString(); + } + } catch (NoSuchFieldException | IllegalAccessException ignored) { + // Field not on this output type — fall back to proto + } + return protoValue; + } + // ==================== LogData builder ==================== @SuppressWarnings("unchecked") diff --git a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParserTest.java b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParserTest.java index bc39b443a3..afead4c9e9 100644 --- a/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParserTest.java +++ b/oap-server/analyzer/log-analyzer/src/test/java/org/apache/skywalking/oap/log/analyzer/v2/compiler/LALScriptParserTest.java @@ -497,4 +497,121 @@ class LALScriptParserTest { assertEquals("latency", latency.getFieldName()); assertEquals("Long", latency.getCastType()); } + + // ==================== Def statement parsing ==================== + + @Test + void parseDefWithToJson() { + final LALScriptModel model = LALScriptParser.parse( + "filter {\n" + + " extractor {\n" + + " def metadata = toJson(parsed?.request?.requestHeaders?.get(\"x-metadata\"))\n" + + " }\n" + + " sink {}\n" + + "}"); + + final LALScriptModel.ExtractorBlock extractor = + (LALScriptModel.ExtractorBlock) model.getStatements().get(0); + assertEquals(1, extractor.getStatements().size()); + final LALScriptModel.DefStatement def = + assertInstanceOf(LALScriptModel.DefStatement.class, + extractor.getStatements().get(0)); + assertEquals("metadata", def.getVarName()); + assertNotNull(def.getInitializer()); + assertEquals("toJson", def.getInitializer().getFunctionCallName()); + } + + @Test + void parseDefWithToJsonArray() { + final LALScriptModel model = LALScriptParser.parse( + "filter {\n" + + " extractor {\n" + + " def items = toJsonArray(parsed?.response?.responseTrailers?.get(\"x-items\"))\n" + + " }\n" + + " sink {}\n" + + "}"); + + final LALScriptModel.ExtractorBlock extractor = + (LALScriptModel.ExtractorBlock) model.getStatements().get(0); + final LALScriptModel.DefStatement def = + assertInstanceOf(LALScriptModel.DefStatement.class, + extractor.getStatements().get(0)); + assertEquals("items", def.getVarName()); + assertEquals("toJsonArray", def.getInitializer().getFunctionCallName()); + } + + @Test + void parseDefWithSimpleValueAccess() { + final LALScriptModel model = LALScriptParser.parse( + "filter {\n" + + " extractor {\n" + + " def code = parsed?.response?.responseCode\n" + + " }\n" + + " sink {}\n" + + "}"); + + final LALScriptModel.ExtractorBlock extractor = + (LALScriptModel.ExtractorBlock) model.getStatements().get(0); + final LALScriptModel.DefStatement def = + assertInstanceOf(LALScriptModel.DefStatement.class, + extractor.getStatements().get(0)); + assertEquals("code", def.getVarName()); + assertTrue(def.getInitializer().isParsedRef()); + } + + @Test + void parseDefAtFilterLevel() { + final LALScriptModel model = LALScriptParser.parse( + "filter {\n" + + " json {}\n" + + " def metadata = toJson(parsed.headers)\n" + + " sink {}\n" + + "}"); + + assertEquals(3, model.getStatements().size()); + final LALScriptModel.DefStatement def = + assertInstanceOf(LALScriptModel.DefStatement.class, + model.getStatements().get(1)); + assertEquals("metadata", def.getVarName()); + } + + @Test + void parseDefWithPrimitiveCast() { + final LALScriptModel model = LALScriptParser.parse( + "filter {\n" + + " extractor {\n" + + " def svc = parsed?.service as String\n" + + " }\n" + + " sink {}\n" + + "}"); + + final LALScriptModel.ExtractorBlock extractor = + (LALScriptModel.ExtractorBlock) model.getStatements().get(0); + final LALScriptModel.DefStatement def = + assertInstanceOf(LALScriptModel.DefStatement.class, + extractor.getStatements().get(0)); + assertEquals("svc", def.getVarName()); + assertEquals("String", def.getCastType()); + } + + @Test + void parseDefWithQualifiedNameCast() { + final LALScriptModel model = LALScriptParser.parse( + "filter {\n" + + " extractor {\n" + + " def resp = parsed?.response" + + " as io.envoyproxy.envoy.data.accesslog.v3.HTTPResponseProperties\n" + + " }\n" + + " sink {}\n" + + "}"); + + final LALScriptModel.ExtractorBlock extractor = + (LALScriptModel.ExtractorBlock) model.getStatements().get(0); + final LALScriptModel.DefStatement def = + assertInstanceOf(LALScriptModel.DefStatement.class, + extractor.getStatements().get(0)); + assertEquals("resp", def.getVarName()); + assertEquals("io.envoyproxy.envoy.data.accesslog.v3.HTTPResponseProperties", + def.getCastType()); + } } diff --git a/oap-server/server-receiver-plugin/envoy-metrics-receiver-plugin/src/test/java/org/apache/skywalking/oap/server/receiver/envoy/persistence/EnvoyAlsLalTest.java b/oap-server/server-receiver-plugin/envoy-metrics-receiver-plugin/src/test/java/org/apache/skywalking/oap/server/receiver/envoy/persistence/EnvoyAlsLalTest.java new file mode 100644 index 0000000000..efe20e4f14 --- /dev/null +++ b/oap-server/server-receiver-plugin/envoy-metrics-receiver-plugin/src/test/java/org/apache/skywalking/oap/server/receiver/envoy/persistence/EnvoyAlsLalTest.java @@ -0,0 +1,368 @@ +/* + * 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.skywalking.oap.server.receiver.envoy.persistence; + +import com.google.protobuf.Struct; +import com.google.protobuf.UInt32Value; +import com.google.protobuf.Value; +import io.envoyproxy.envoy.config.core.v3.Metadata; +import io.envoyproxy.envoy.data.accesslog.v3.AccessLogCommon; +import io.envoyproxy.envoy.data.accesslog.v3.HTTPAccessLogEntry; +import io.envoyproxy.envoy.data.accesslog.v3.HTTPResponseProperties; +import java.io.File; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.Optional; +import javassist.ClassPool; +import org.apache.skywalking.apm.network.logging.v3.LogData; +import org.apache.skywalking.oap.log.analyzer.v2.compiler.LALClassGenerator; +import org.apache.skywalking.oap.log.analyzer.v2.dsl.ExecutionContext; +import org.apache.skywalking.oap.log.analyzer.v2.dsl.LalExpression; +import org.apache.skywalking.oap.log.analyzer.v2.dsl.spec.filter.FilterSpec; +import org.apache.skywalking.oap.log.analyzer.v2.module.LogAnalyzerModule; +import org.apache.skywalking.oap.log.analyzer.v2.provider.LogAnalyzerModuleConfig; +import org.apache.skywalking.oap.log.analyzer.v2.provider.LogAnalyzerModuleProvider; +import org.apache.skywalking.oap.server.core.CoreModule; +import org.apache.skywalking.oap.server.core.config.ConfigService; +import org.apache.skywalking.oap.server.core.config.NamingControl; +import org.apache.skywalking.oap.server.core.config.group.EndpointNameGrouping; +import org.apache.skywalking.oap.server.core.source.Log; +import org.apache.skywalking.oap.server.core.source.SourceReceiver; +import org.apache.skywalking.oap.server.library.module.ModuleManager; +import org.apache.skywalking.oap.server.library.module.ModuleProviderHolder; +import org.apache.skywalking.oap.server.library.module.ModuleServiceHolder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * LAL compilation and execution tests for envoy ALS proto chains. + * + * <p>Tests {@code def} local variables with {@code toJson()} to extract + * JWT claims from envoy {@code filter_metadata} Struct, and verifies + * runtime tag extraction against real proto objects. + * + * <p>Generated {@code .class} files are dumped to + * {@code target/lal-generated-classes/} with names like + * {@code EnvoyAlsLalTest_defJwtFromFilterMetadata.class}. + */ +class EnvoyAlsLalTest { + + private static final File CLASS_OUTPUT_DIR = + new File("target/lal-generated-classes"); + + private LALClassGenerator generator; + private NamingControl namingControl; + + @BeforeEach + void setUp(final TestInfo testInfo) { + generator = new LALClassGenerator(new ClassPool(true)); + generator.setInputType(HTTPAccessLogEntry.class); + generator.setOutputType(EnvoyAccessLogBuilder.class); + generator.setClassOutputDir(CLASS_OUTPUT_DIR); + final String methodName = testInfo.getTestMethod() + .map(m -> m.getName()).orElse("unknown"); + generator.setClassNameHint("EnvoyAlsLalTest_" + methodName); + namingControl = new NamingControl( + 512, 512, 512, new EndpointNameGrouping()); + } + + // ==================== def + toJson: JWT from filter_metadata =========== + + /** + * LAL script under test: + * <pre>{@code + * filter { + * extractor { + * def jwt = toJson(parsed?.commonProperties?.metadata + * ?.filterMetadataMap?.get("envoy.filters.http.jwt_authn")) + * def payload = jwt?.getAsJsonObject("payload") + * if (payload != null) { + * tag 'email': payload?.get("email")?.getAsString() + * tag 'group': payload?.get("group")?.getAsString() + * } + * tag 'status.code': parsed?.response?.responseCode?.value + * } + * sink {} + * } + * }</pre> + */ + @Test + void defJwtFromFilterMetadata() throws Exception { + final String dsl = + "filter {\n" + + " extractor {\n" + + " def jwt = toJson(parsed?.commonProperties?.metadata" + + "?.filterMetadataMap?.get(\"envoy.filters.http.jwt_authn\"))\n" + + " def payload = jwt?.getAsJsonObject(\"payload\")\n" + + " if (payload != null) {\n" + + " tag 'email': payload?.get(\"email\")?.getAsString()\n" + + " tag 'group': payload?.get(\"group\")?.getAsString()\n" + + " }\n" + + " tag 'status.code': parsed?.response?.responseCode?.value\n" + + " }\n" + + " sink {}\n" + + "}"; + + final LalExpression expr = generator.compile(dsl); + + final Struct jwtStruct = Struct.newBuilder() + .putFields("payload", Value.newBuilder() + .setStructValue(Struct.newBuilder() + .putFields("email", Value.newBuilder() + .setStringValue("[email protected]").build()) + .putFields("group", Value.newBuilder() + .setStringValue("admin").build())) + .build()) + .build(); + + final HTTPAccessLogEntry entry = HTTPAccessLogEntry.newBuilder() + .setResponse(HTTPResponseProperties.newBuilder() + .setResponseCode(UInt32Value.of(200))) + .setCommonProperties(AccessLogCommon.newBuilder() + .setMetadata(Metadata.newBuilder() + .putFilterMetadata( + "envoy.filters.http.jwt_authn", jwtStruct))) + .build(); + + final Log log = executeAndBuildLog(expr, entry, + "email", "group", "status.code"); + + assertTag(log, "email", "[email protected]"); + assertTag(log, "group", "admin"); + assertTag(log, "status.code", "200"); + } + + // ==================== def + toJson: missing metadata =================== + + /** + * Same LAL script as {@link #defJwtFromFilterMetadata()}, but the + * input proto has no filter_metadata. Verifies null-safe navigation + * handles the missing Struct gracefully (no crash, no tags extracted). + * <pre>{@code + * filter { + * extractor { + * def jwt = toJson(parsed?.commonProperties?.metadata + * ?.filterMetadataMap?.get("envoy.filters.http.jwt_authn")) + * def payload = jwt?.getAsJsonObject("payload") + * if (payload != null) { + * tag 'email': payload?.get("email")?.getAsString() + * } + * } + * sink {} + * } + * }</pre> + */ + @Test + void defJwtMissingMetadata() throws Exception { + final String dsl = + "filter {\n" + + " extractor {\n" + + " def jwt = toJson(parsed?.commonProperties?.metadata" + + "?.filterMetadataMap?.get(\"envoy.filters.http.jwt_authn\"))\n" + + " def payload = jwt?.getAsJsonObject(\"payload\")\n" + + " if (payload != null) {\n" + + " tag 'email': payload?.get(\"email\")?.getAsString()\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"; + + final LalExpression expr = generator.compile(dsl); + + // Entry with NO filter_metadata — jwt should be null, no crash + final HTTPAccessLogEntry entry = HTTPAccessLogEntry.newBuilder() + .setResponse(HTTPResponseProperties.newBuilder() + .setResponseCode(UInt32Value.of(404))) + .build(); + + final ExecutionContext ctx = execute(expr, entry); + assertNotNull(ctx.output()); + } + + // ==================== def: multiple filter_metadata keys =============== + + /** + * Two {@code def} variables extracting from different filter_metadata + * keys (jwt_authn for JWT claims, rbac for authorization result). + * Uses {@code def payload} to avoid duplicate + * {@code getAsJsonObject("payload")} calls. + * <pre>{@code + * filter { + * extractor { + * def jwt = toJson(parsed?.commonProperties?.metadata + * ?.filterMetadataMap?.get("envoy.filters.http.jwt_authn")) + * def rbac = toJson(parsed?.commonProperties?.metadata + * ?.filterMetadataMap?.get("envoy.filters.http.rbac")) + * def payload = jwt?.getAsJsonObject("payload") + * if (payload != null) { + * tag 'email': payload?.get("email")?.getAsString() + * } + * if (rbac?.has("shadow_engine_result")) { + * tag 'rbac': rbac?.get("shadow_engine_result")?.getAsString() + * } + * } + * sink {} + * } + * }</pre> + */ + @Test + void defMultipleFilterMetadataKeys() throws Exception { + final String dsl = + "filter {\n" + + " extractor {\n" + + " def jwt = toJson(parsed?.commonProperties?.metadata" + + "?.filterMetadataMap?.get(\"envoy.filters.http.jwt_authn\"))\n" + + " def rbac = toJson(parsed?.commonProperties?.metadata" + + "?.filterMetadataMap?.get(\"envoy.filters.http.rbac\"))\n" + + " def payload = jwt?.getAsJsonObject(\"payload\")\n" + + " if (payload != null) {\n" + + " tag 'email': payload?.get(\"email\")?.getAsString()\n" + + " }\n" + + " if (rbac?.has(\"shadow_engine_result\")) {\n" + + " tag 'rbac': rbac?.get(\"shadow_engine_result\")" + + "?.getAsString()\n" + + " }\n" + + " }\n" + + " sink {}\n" + + "}"; + + final LalExpression expr = generator.compile(dsl); + + final Struct jwtStruct = Struct.newBuilder() + .putFields("payload", Value.newBuilder() + .setStructValue(Struct.newBuilder() + .putFields("email", Value.newBuilder() + .setStringValue("[email protected]").build())) + .build()) + .build(); + final Struct rbacStruct = Struct.newBuilder() + .putFields("shadow_engine_result", Value.newBuilder() + .setStringValue("DENY").build()) + .build(); + + final HTTPAccessLogEntry entry = HTTPAccessLogEntry.newBuilder() + .setResponse(HTTPResponseProperties.newBuilder() + .setResponseCode(UInt32Value.of(403))) + .setCommonProperties(AccessLogCommon.newBuilder() + .setMetadata(Metadata.newBuilder() + .putFilterMetadata( + "envoy.filters.http.jwt_authn", jwtStruct) + .putFilterMetadata( + "envoy.filters.http.rbac", rbacStruct))) + .build(); + + final Log log = executeAndBuildLog(expr, entry, "email", "rbac"); + + assertTag(log, "email", "[email protected]"); + assertTag(log, "rbac", "DENY"); + } + + // ==================== Helpers ========================================== + + private ExecutionContext execute( + final LalExpression expr, + final HTTPAccessLogEntry entry) throws Exception { + final LogData.Builder logData = LogData.newBuilder() + .setService("als-test-svc") + .setTimestamp(1609459200000L) + .setLayer("MESH"); + + final FilterSpec filterSpec = buildFilterSpec(); + final ExecutionContext ctx = new ExecutionContext(); + ctx.log(logData); + ctx.extraLog(entry); + expr.execute(filterSpec, ctx); + return ctx; + } + + private Log executeAndBuildLog( + final LalExpression expr, + final HTTPAccessLogEntry entry, + final String... searchableTagKeys) throws Exception { + final ExecutionContext ctx = execute(expr, entry); + + assertNotNull(ctx.output()); + final EnvoyAccessLogBuilder output = + (EnvoyAccessLogBuilder) ctx.output(); + output.setSearchableTagKeys(java.util.Arrays.asList(searchableTagKeys)); + output.init(ctx.log().build(), Optional.of(entry), namingControl); + return output.toLog(); + } + + private static void assertTag(final Log log, + final String key, + final String expectedValue) { + assertTrue(log.getTags().stream().anyMatch( + t -> key.equals(t.getKey()) + && expectedValue.equals(t.getValue())), + "Expected tag " + key + "=" + expectedValue + + ", got: " + log.getTags()); + } + + private FilterSpec buildFilterSpec() throws Exception { + final ModuleManager manager = mock(ModuleManager.class); + final Field f = + ModuleManager.class.getDeclaredField("isInPrepareStage"); + f.setAccessible(true); + f.set(manager, false); + + when(manager.find(anyString())) + .thenReturn(mock(ModuleProviderHolder.class)); + + final ModuleProviderHolder logHolder = + mock(ModuleProviderHolder.class); + final LogAnalyzerModuleProvider logProvider = + mock(LogAnalyzerModuleProvider.class); + when(logProvider.getMetricConverts()) + .thenReturn(Collections.emptyList()); + when(logHolder.provider()).thenReturn(logProvider); + when(manager.find(LogAnalyzerModule.NAME)).thenReturn(logHolder); + + final ModuleProviderHolder coreHolder = + mock(ModuleProviderHolder.class); + final ModuleServiceHolder coreServices = + mock(ModuleServiceHolder.class); + when(coreHolder.provider()).thenReturn(coreServices); + when(manager.find(CoreModule.NAME)).thenReturn(coreHolder); + + when(coreServices.getService(SourceReceiver.class)) + .thenReturn(mock(SourceReceiver.class)); + when(coreServices.getService(NamingControl.class)) + .thenReturn(namingControl); + final ConfigService configService = mock(ConfigService.class); + when(configService.getSearchableLogsTags()).thenReturn(""); + when(coreServices.getService(ConfigService.class)) + .thenReturn(configService); + + final FilterSpec filterSpec = + new FilterSpec(manager, new LogAnalyzerModuleConfig()); + final Field slf = + FilterSpec.class.getDeclaredField("sinkListenerFactories"); + slf.setAccessible(true); + slf.set(filterSpec, Collections.emptyList()); + + return filterSpec; + } +} diff --git a/test/script-cases/scripts/lal/test-lal/feature-cases/execution-def.input.data b/test/script-cases/scripts/lal/test-lal/feature-cases/execution-def.input.data new file mode 100644 index 0000000000..d6b28f28fc --- /dev/null +++ b/test/script-cases/scripts/lal/test-lal/feature-cases/execution-def.input.data @@ -0,0 +1,68 @@ +# 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. + +# Mock input data for execution-def.yaml rules. +# Tests def local variable with toJson()/toJsonArray() on JSON body. +# metadata field is a JSON string that toJson() parses into JsonObject. + +def-json-extract-service: + body-type: json + body: '{"metadata":"{\"service\":\"my-svc\"}"}' + expect: + save: true + abort: false + service: my-svc + +def-json-nested-access: + body-type: json + body: '{"metadata":"{\"app\":{\"name\":\"frontend\",\"endpoint\":\"/api/v2\"}}"}' + expect: + save: true + abort: false + service: frontend + endpoint: /api/v2 + +def-json-with-condition: + body-type: json + body: '{"metadata":"{\"app\":{\"name\":\"backend\"}}"}' + expect: + save: true + abort: false + service: backend + +def-json-missing-field: + body-type: json + body: '{"other":"value"}' + expect: + save: true + abort: false + service: "" + +def-multiple-vars: + body-type: json + body: '{"metadata":"{\"service\":\"order-svc\"}","extra":"{\"endpoint\":\"/checkout\"}"}' + expect: + save: true + abort: false + service: order-svc + endpoint: /checkout + +def-null-var-safe-nav: + body-type: json + body: '{"other":"value"}' + expect: + save: true + abort: false + service: "" diff --git a/test/script-cases/scripts/lal/test-lal/feature-cases/execution-def.yaml b/test/script-cases/scripts/lal/test-lal/feature-cases/execution-def.yaml new file mode 100644 index 0000000000..2aee126062 --- /dev/null +++ b/test/script-cases/scripts/lal/test-lal/feature-cases/execution-def.yaml @@ -0,0 +1,99 @@ +# 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. + +# Execution tests for LAL 'def' local variable feature. +# Tests toJson()/toJsonArray() on JSON body parsed values. +# Uses standard field assignment (service/endpoint) for assertions, +# since tag assertions require full sink pipeline. +# Paired with execution-def.input.data for mock input and expected output. +rules: + - name: def-json-extract-service + layer: GENERAL + dsl: | + filter { + json {} + extractor { + def config = toJson(parsed.metadata) + service config?.get("service")?.getAsString() + } + sink {} + } + + - name: def-json-nested-access + layer: GENERAL + dsl: | + filter { + json {} + extractor { + def config = toJson(parsed.metadata) + service config?.getAsJsonObject("app")?.get("name")?.getAsString() + endpoint config?.getAsJsonObject("app")?.get("endpoint")?.getAsString() + } + sink {} + } + + - name: def-json-with-condition + layer: GENERAL + dsl: | + filter { + json {} + extractor { + def config = toJson(parsed.metadata) + if (config?.has("app")) { + service config?.getAsJsonObject("app")?.get("name")?.getAsString() + } + } + sink {} + } + + - name: def-json-missing-field + layer: GENERAL + dsl: | + filter { + json {} + extractor { + def config = toJson(parsed.nonexistent) + if (config?.has("key")) { + service config?.get("key")?.getAsString() + } + } + sink {} + } + + - name: def-multiple-vars + layer: GENERAL + dsl: | + filter { + json {} + extractor { + def config = toJson(parsed.metadata) + def info = toJson(parsed.extra) + service config?.get("service")?.getAsString() + endpoint info?.get("endpoint")?.getAsString() + } + sink {} + } + + - name: def-null-var-safe-nav + layer: GENERAL + dsl: | + filter { + json {} + extractor { + def config = toJson(parsed.nonexistent) + service config?.get("service")?.getAsString() + } + sink {} + }
