DSL Build-Time Pre-Compilation (OAL/MAL/LAL/TAL/Hierarchy)
Context
SkyWalking OAP uses five DSL compilers that generate Java bytecode at startup via Javassist:
| DSL | Purpose | Compiled Interface | Generated Classes |
|---|---|---|---|
| OAL | Metrics aggregation rules | Metrics, Builder, Dispatcher classes | ~620 metrics + ~620 builders + ~45 dispatchers |
| MAL | Meter expression rules | MalExpression |
~1250 expression classes |
| LAL | Log analysis rules | LalExpression |
~10 expression classes (6 unique) |
| TAL | Trace (OTLP span) analysis rules | TalExpression |
2 expression classes |
| Hierarchy | Service hierarchy matching | BiFunction<Service, Service, Boolean> |
~4 rule classes |
All five share the same pipeline (since upstream PR #13723; TAL since 11.1):
DSL text → ANTLR4 parse → Immutable AST → Javassist bytecode → .class
GraalVM native image cannot run Javassist at runtime. Solution: run all five compilers at build time via their exposed APIs (setClassOutputDir(), setClassNameHint()), capture .class files into the precompiler output JAR. At runtime, load pre-compiled classes instead of compiling.
Build-Time Compilation
Module: build-tools/precompiler
The precompiler invokes each v2 compiler at build time, capturing generated .class files to the output directory. All classes are packaged into precompiler-*-generated.jar, which is on the native-image build classpath.
OAL
Uses OALEngineV2.start() with setOpenEngineDebug(true) and setGeneratedFilePath() to export classes. Processes all 9 OAL defines:
| Define | Config File |
|---|---|
DisableOALDefine |
oal/disable.oal |
CoreOALDefine |
oal/core.oal |
JVMOALDefine |
oal/java-agent.oal |
CLROALDefine |
oal/dotnet-agent.oal |
BrowserOALDefine |
oal/browser.oal |
MeshOALDefine |
oal/mesh.oal |
EBPFOALDefine |
oal/ebpf.oal |
TCPOALDefine |
oal/tcp.oal |
CiliumOALDefine |
oal/cilium.oal |
Generated class packages:
- Metrics:
org.apache.skywalking.oap.server.core.source.oal.rt.metrics.* - Builders:
org.apache.skywalking.oap.server.core.source.oal.rt.metrics.builder.* - Dispatchers:
org.apache.skywalking.oap.server.core.source.oal.rt.dispatcher.*
MAL
Uses MALClassGenerator with setClassOutputDir() and setClassNameHint() for deterministic class naming.
For each MAL YAML rule file (71 files, ~1250 rules):
1. Parse YAML → List<MetricsRule>
2. For each rule:
a. generator.setClassNameHint(sanitize(yamlSourceId + "_" + metricName))
b. generator.setYamlSource(yamlSourceId)
c. MalExpression malExpr = generator.compile(metricName, expression)
d. .class file auto-written to classOutputDir
e. Record mapping: sha256(expression)|metricName → FQCN
3. Compile filter expressions the same way
4. Write manifest: META-INF/mal-v2-expression-map.properties
Rule file sources:
| Source | Path | Files | Rules |
|---|---|---|---|
| Agent meter | meter-analyzer-config/ |
11 | ~147 |
| OTel metrics | otel-rules/ |
55 | ~1039 |
| Log MAL | log-mal-rules/ |
2 | ~2 |
| Envoy metrics | envoy-metrics-rules/ |
2 | ~26 |
| Telegraf | telegraf-rules/ |
1 | ~20 |
| Zabbix | zabbix-rules/ |
1 | ~15 |
Generated class package: org.apache.skywalking.oap.meter.analyzer.v2.compiler.rt.*
LAL
Uses LALClassGenerator with setClassOutputDir() and setClassNameHint().
For each LAL YAML rule file (8 files, 10 rules):
1. Parse YAML → List<LALConfig>
2. For each rule:
a. generator.setClassNameHint(sanitize(yamlSourceId + "_" + ruleName))
b. generator.setYamlSource(yamlSourceId)
c. generator.setExtraLogType(extraLogType) // if needed
d. LalExpression expr = generator.compile(dsl)
e. .class file auto-written to classOutputDir
| File | Rules |
|---|---|
default.yaml |
1 |
nginx.yaml |
2 |
mysql-slowsql.yaml |
1 |
pgsql-slowsql.yaml |
1 |
redis-slowsql.yaml |
1 |
envoy-als.yaml |
2 |
mesh-dp.yaml |
1 |
k8s-service.yaml |
1 |
Generated class package: org.apache.skywalking.oap.log.analyzer.v2.compiler.rt.*
TAL
Uses one TALClassGenerator per rule with setClassOutputDir(), setSourceRef() and setClassNameHint(),
mirroring upstream SpanFilterListener.Factory.compile + TalDSL.of:
For each tal/*.yaml file (TALConfigs.load stamps sourcePath, line and the file's metricPrefix):
For each rule:
a. input type = the TALSourceTypeProvider claiming rule.inputType (`otlp` -> OTLP Span)
output type = the TALOutputBuilder named by rule.outputType (`SlowSQL` -> DatabaseSlowStatementBuilder)
Layer.valueOf(rule.layer)
b. TalExpression expr = generator.compile(dsl) // .class + .java written to classOutputDir
c. TALMetricShapes.claim(sourceRef + "#" + rule, generator.getMetricDeclarations())
d. generator.createMetrics(meterSystem) // the build-time MeterSystem MAL uses
| File | Rules | Metrics |
|---|---|---|
otlp-general.yaml |
2 (otlp-general, otlp-slow-sql) |
28 otlp_* |
Every file is loaded before any rule compiles, as upstream’s loadStaticRules does, so a layer one file declares
under layerDefinitions serves the rules of every file. A rule that does not compile, or that names an unknown
layer, input type or output type, fails the build with upstream’s boot-time message. Upstream’s TAL compiler emits
no debug probes, so TAL rules are not live-debuggable.
Nothing in the pass is specific to the shipped file: input providers and output builders come from the SPIs
(recorded in tal-io-classes.txt for reflection, with the input types and any builder a rule names by class), every declaration shape is carried (buckets, ranks,
labelLimit), the input types join the protobuf reflection roots as LAL’s do, and tal.json is keyed by each file’s
own name (every file, as upstream takes any file talFiles names by its stem; two files sharing a stem fail the
build). The manifest is written with Properties#store’s escapes in UTF-8 and read back the same way, so any rule or
file name round-trips, and records every input a class was compiled from (coordinates, name, DSL digest, input and
output type, metric prefix): two rules can share a line in flow-style YAML, and the runtime TalDSL loads only the
class compiled from exactly the inputs it is given. The files are installed in upstream’s order (by stem through the
HashMap RuleSetMerger.merge returns), which decides which rule runs first on a span. TALPrecompileTest compiles operator-style fixtures (every metric function, labeled metrics with a label
limit, percentile buckets and ranks, alias and FQCN input/output types, a .yml file, a rules-less file declaring a
layer another file uses) and the build failures; TALDocScriptsTest extracts the scripts of upstream’s
tal.md and of the practice pages (otlp-trace-practices/gateway-{overview,canary,nginx}.md, database-and-cache.md,
message-queue.md), splices or substitutes each practice into a copy of the shipped file where the page says, compiles them through the same pass and runs the classes the manifest names against spans each practice is
about.
Generated class package: org.apache.skywalking.oap.trace.analyzer.v2.compiler.rt.*
Hierarchy
Uses HierarchyRuleClassGenerator with setClassOutputDir() and setClassNameHint().
1. Load hierarchy-definition.yml
2. For each rule expression:
a. generator.setClassNameHint(sanitize(ruleName))
b. BiFunction result = generator.compile(ruleName, expression)
c. .class file auto-written to classOutputDir
4 rule types: name, short-name, lower-short-name-remove-namespace, lower-short-name-with-fqdn.
Generated class package: org.apache.skywalking.oap.server.core.config.v2.compiler.hierarchy.rule.rt.*
DSL Live Debugger (SWIP-13)
Upstream’s debugger is a codegen-time decision: when DSLDebugCodegenSwitch.isInjectionEnabled()
is true, the OAL, MAL and LAL generators emit a GateHolder field per rule (per metric for OAL
dispatchers), a debugHolder() accessor and if (holder.isGateOn()) probe call sites. The
upstream OAP flips the switch in DSLDebuggingModuleProvider.prepare(); this distro flips it in
the precompiler (DSLDebugCodegenSwitch.enableInjection() before every generator pass) and sets
setContent(...) on the MAL/LAL generators, so the pre-compiled classes carry the same probes
and rule text. Nothing is compiled at runtime: the wired-in upstream dsl-debugging module only
adds and removes recorders on the holders. Registration works unchanged — MAL holders are
published by upstream’s receivers through MalStaticBindingHook, LAL holders by
LogFilterListener through LalStaticBindingHook (the log DSL replacement stamps the same
ruleName/inputClass/outputClass metadata upstream does), and OAL holders are found on the
loaded dispatchers via DebugHolderProvider. Idle cost is one volatile read per probe site.
Two details the distro supplies for the module: OALEngineLoaderService.getLoadedDefines() on
the replacement loader, and the raw oal/*.oal scripts at their classpath paths in the
precompiler jar, which GET /runtime/oal/files/{name} reads (the file name must be
path-escaped, oal%2Fcore.oal, as swctl and Horizon send it).
Runtime Loading via Same-FQCN Replacements
At runtime (native image or JVM distro), same-FQCN replacement classes load pre-compiled classes instead of running Javassist.
OAL: OALEngineLoaderService
Upstream: server-core/.../oal/rt/OALEngineLoaderService.java
Replacement: oap-libs-for-graalvm/server-core-for-graalvm/
On first load() call, reads manifests and registers all OAL classes:
META-INF/oal-metrics-classes.txt→StreamAnnotationListener.notify()META-INF/oal-dispatcher-classes.txt→DispatcherDetectorListener.addIfAsSourceDispatcher()META-INF/oal-disabled-sources.txt→DisableRegister
MAL: DSL (v2)
Upstream: analyzer/meter-analyzer/.../v2/dsl/DSL.java
Replacement: oap-libs-for-graalvm/meter-analyzer-for-graalvm/
parse(metricName, expression) loads per-file configs from META-INF/mal-v2/, looks up the pre-compiled class by expression text, and wires closure fields:
// At startup: load META-INF/mal-v2.manifest → per-file configs → expression→FQCN map
String className = expressionMap.get(expression);
MalExpression malExpr = (MalExpression) Class.forName(className)
.getDeclaredConstructor().newInstance();
wireClosures(malExpr.getClass(), malExpr); // LambdaMetafactory wiring
return new Expression(metricName, expression, malExpr);
Per-file manifests: META-INF/mal-v2/ mirrors the original YAML directory structure. Each config file (e.g., otel-rules/vm.yaml) contains rule names, full expressions, filter info, and compiled class FQCNs. META-INF/mal-v2.manifest lists all config files. Runtime builds an in-memory expression → FQCN map from all per-file configs. Since expressions are globally unique (different YAML files produce different expressions even for the same metric name), no hash or disambiguation is needed.
Closure wiring: Pre-compiled MalExpression classes may have public fields typed as functional interfaces (TagFunction, ForEachFunction, PropertiesExtractor, DecorateFunction). At build time, MALClassGenerator wires these via LambdaMetafactory after class loading. At runtime, the replacement DSL.java replicates this wiring by scanning public fields and invoking LambdaMetafactory.metafactory() to create the functional interface wrapper from the corresponding method (e.g., field _tag → method _tag_apply).
LAL: DSL (v2)
Upstream: analyzer/log-analyzer/.../v2/dsl/DSL.java
Replacement: oap-libs-for-graalvm/log-analyzer-for-graalvm/
of(moduleManager, config, dsl, inputType, outputType, ruleName, sourceRef) looks the rule up by its DslSourceRef coordinates (lal/<file>.yaml:<line>) in META-INF/lal-v2-rules.txt and loads via Class.forName(). The manifest also carries the effective input type the compiler resolved, which upstream LogFilterListener uses to route Envoy HTTP vs TCP access logs to the right rule.
TAL: TalDSL + TALConfigs
Upstream: analyzer/trace-analyzer/.../v2/dsl/TalDSL.java, .../v2/provider/TALConfigs.java
Replacement: oap-libs-for-graalvm/trace-analyzer-for-graalvm/
TALConfigs.load reads META-INF/config-data/tal.json (keyed by file name, with extension) and re-stamps sourceName/sourcePath/metricPrefix and inline layers as upstream does, so the upstream SpanFilterListener.Factory builds the same DslSourceRef. TalDSL.of(...) looks the rule up by those coordinates (tal/<file>.yaml:<line>, never by name alone) in META-INF/tal-v2-rules.txt, fails if the rule name or the input type differs from the one the class was compiled for, instantiates the class, then claims and creates the metric declarations recorded in the manifest (meterSystem.create + limitLabels), exactly the calls upstream’s createMetrics makes. TALRecordedTraceComparisonTest runs the shipped rules over upstream’s recorded OTel Demo trace through a fresh compile and through the pre-compiled classes and requires identical metrics, traffic, alias rows and slow-statement records.
Hierarchy: HierarchyDefinitionService
Upstream: server-core/.../core/config/HierarchyDefinitionService.java
Replacement: oap-libs-for-graalvm/server-core-for-graalvm/
Skips the upstream HierarchyRuleProvider SPI and loads pre-compiled BiFunction classes by rule name from META-INF/hierarchy-v2-rules.txt.
Classpath Scanning Replacements
Guava ClassPath.from() is used in several places. All replaced with manifest-based loading:
| Upstream Class | Manifest | Purpose |
|---|---|---|
AnnotationScan |
META-INF/annotation-scan/{Annotation}.txt |
6 annotation manifests |
SourceReceiverImpl |
META-INF/annotation-scan/SourceDispatcher.txt |
Dispatcher/decorator discovery |
MeterSystem |
META-INF/annotation-scan/MeterFunction.txt |
18 meter function classes |
MeterSystem Javassist (Unchanged)
MeterSystem.create() generates one dynamic meter subclass per metric rule (~1530 for MAL, 28 for TAL) via Javassist. This is separate from MAL DSL compilation and is handled the same as before: run at build time, export .class files, load from manifests at runtime.
Manifest Files
All manifests are in META-INF/ within the precompiler output JAR:
| Manifest | Format | Purpose |
|---|---|---|
oal-metrics-classes.txt |
FQCN per line | OAL metrics class registration |
oal-dispatcher-classes.txt |
FQCN per line | OAL dispatcher class registration |
oal-disabled-sources.txt |
Source name per line | OAL disabled sources |
mal-v2.manifest |
File path per line | Lists all per-file MAL configs |
mal-v2/{path}/{name}.yaml |
Properties | Per-file config: rule names, expressions, filter, class FQCNs |
mal-v2-classes.txt |
FQCN per line | Reflection config for MAL expression classes |
lal-v2-classes.txt |
FQCN per line | Reflection config for LAL expression classes |
lal-v2-rules.txt |
Properties (rule.N.source/line/name/class/inputType) |
Runtime LAL lookup by source coordinates + effective input type |
tal-v2-classes.txt |
FQCN per line | Reflection config for TAL expression classes |
tal-v2-rules.txt |
Escaped UTF-8 properties (rule.N.source/line/name/class/inputType/outputType/metricPrefix/dslSha256, rule.N.metric.M.fullName/function/scopeType/declaredName/buckets/ranks/labelLimit) |
Runtime TAL lookup by the inputs each class was compiled from + the metrics each rule creates |
tal-meter-classes.txt |
name=FQCN |
Meter classes generated for TAL metrics |
tal-io-classes.txt |
FQCN per line | TAL input types, TALSourceTypeProvider / TALOutputBuilder implementations and class-named builders, for reflection |
hierarchy-v2-classes.txt |
FQCN per line | Reflection config for hierarchy rule classes |
hierarchy-v2-rules.txt |
ruleName=FQCN |
Runtime hierarchy rule lookup |
mal-meter-classes.txt |
name|scopeId|func|type|FQCN |
MeterSystem pre-generated classes |
annotation-scan/*.txt |
FQCN per line or key=FQCN |
Annotation/interface scan replacements |
config-data/*.json |
JSON | Serialized rule configs for runtime loaders |
rule-source/** |
raw MAL/LAL YAML + index.txt |
Read-only /runtime/rule catalog served by BundledRuleCatalogHandler |
oal/*.oal (jar root) |
raw OAL scripts | GET /runtime/oal/files/{name} (upstream dsl-debugging handler) |
Reflection Config Generation
The precompiler auto-generates reflect-config.json from manifests:
- OAL metrics/dispatchers: constructor-only access
- MAL/LAL/TAL/Hierarchy expression classes and MAL/TAL meter classes: constructor-only access
- TAL
PairingFactandSpanMetadata: full access (PairingFactcrosses OAP nodes as stream data andRemoteHandleWorkerrebuilds it withnewInstance(); both are what a rule’spairing/metahand totoJson(), which serializes objects through Gson’s reflective adapters) - The protobuf runtime classes Gson walks when
toJson()is given a protobuf input (ByteStringand its subclasses,AbstractMessage(Lite),GeneratedMessage,UnknownFieldSet, the primitive and lazy lists,MapField), sotoJson(span)returns natively what it returns on the JVM - Annotation-scanned classes: full method/field access
- Armeria HTTP handlers, GraphQL resolvers/types: full access
- LAL input types (Envoy
HTTPAccessLogEntry/TCPAccessLogEntry) and every message, Builder and enum type reachable through their proto descriptors: full access. Upstream JSON-prints these entries with protobuf’sJsonFormat(theEnvoyAccessLogcontent and the DSL debug captures), whose accessor table finds the generated getters by reflection.LalInputTypeReflectionTestwalks the descriptors and fails on any gap.
MAL v2 closures (tag/forEach/decorate functions) are compiled into companion classes next to the expression class, so no runtime lambda wiring is needed; the static analysis sees them as ordinary classes.
Same-FQCN Replacement Summary
| Upstream Class | Module | What Changed |
|---|---|---|
OALEngineLoaderService |
server-core-for-graalvm |
Load OAL classes from manifests |
AnnotationScan |
server-core-for-graalvm |
Read annotation manifests instead of Guava scan |
SourceReceiverImpl |
server-core-for-graalvm |
Read dispatcher manifests instead of Guava scan |
MeterSystem |
server-core-for-graalvm |
Read MeterFunction manifest + load pre-generated meter classes |
HierarchyDefinitionService |
server-core-for-graalvm |
Load pre-compiled hierarchy rule classes by name |
DSL (MAL v2) |
meter-analyzer-for-graalvm |
Load pre-compiled MalExpression classes |
DSL (LAL v2) |
log-analyzer-for-graalvm |
Load pre-compiled LalExpression classes |
Rules |
meter-analyzer-for-graalvm |
Load rule configs from JSON manifests |
LALConfigs |
log-analyzer-for-graalvm |
Load LAL configs from JSON manifests |
TalDSL |
trace-analyzer-for-graalvm |
Load pre-compiled TalExpression classes + create their metrics from the manifest |
TALConfigs |
trace-analyzer-for-graalvm |
Load TAL configs from JSON manifests |
Verification
# 1. Build precompiler and verify class generation
mvn -pl build-tools/precompiler install -DskipTests
# 2. Check generated v2 classes exist in output JAR
jar tf build-tools/precompiler/target/precompiler-*-generated.jar | grep "v2/compiler/rt"
# 3. Run tests
mvn -pl oap-graalvm-server test
# 4. Full distro build
make build-distro
# 5. Native image
make native-image