SkyWalking GraalVM Distro - Distribution Policy
Goal
Build and package Apache SkyWalking OAP server as a GraalVM native image on JDK 25.
Architecture Constraints
- Submodule:
skywalking/is a git submodule ofapache/skywalking.git. All SkyWalking source changes go through upstream PRs. Minimize upstream changes. - This repo: Maven + Makefile to orchestrate building on top of the submodule. Pre-compilation, GraalVM config, native-image wiring, and the fixed module manager live here.
- JDK 25: Already compiles and runs. Not an issue.
Module Selection (Fixed at Build Time)
| Category | Module | Provider |
|---|---|---|
| Core | CoreModule | default |
| Storage | StorageModule | BanyanDB |
| Cluster | ClusterModule | Standalone, Kubernetes |
| Configuration | ConfigurationModule | Kubernetes |
| Receivers | SharingServerModule, TraceModule, JVMModule, MeterReceiverModule, LogModule, RegisterModule, ProfileModule, BrowserModule, EventModule, OtelMetricReceiverModule, MeshReceiverModule, EnvoyMetricReceiverModule, ZipkinReceiverModule, ZabbixReceiverModule, TelegrafReceiverModule, AWSFirehoseReceiverModule, CiliumFetcherModule, EBPFReceiverModule, AsyncProfilerModule, PprofModule, CLRModule, ConfigurationDiscoveryModule, KafkaFetcherModule | default providers |
| Analyzers | AnalyzerModule, LogAnalyzerModule, EventAnalyzerModule | default providers |
| Query | QueryModule (GraphQL), PromQLModule, LogQLModule, ZipkinQueryModule, StatusQueryModule | default providers |
| Alarm | AlarmModule | default |
| Telemetry | TelemetryModule | Prometheus |
| Other | ExporterModule, HealthCheckerModule, AIPipelineModule | default providers |
Full feature set. Work around issues as they arise.
Core Strategy
-
Build-Time Class Export: All runtime code generation (OAL via Javassist, MAL/LAL via Groovy) runs at build time. Export
.classfiles and package into native-image classpath. Classpath scanning also runs here as a verification gate. -
Fixed Module Wiring: Module/provider selection is hardcoded in this distro (no SPI discovery). Simplified config file for selected providers only.
-
Separation: SkyWalking upstream changes tracked separately, go through upstream PRs.
OAL Runtime Class Generation (Javassist)
What Happens
OAL V2 generates metrics/builder/dispatcher classes at startup via Javassist (ClassPool.makeClass() → CtClass.toClass()). Already has writeGeneratedFile() for debug export.
Approach (this repo)
All .oal scripts are known. Run OAL engine at build time, export .class files, load them directly at runtime from manifests.
Details: oal-immigration.md
What Was Built
OALClassExporterprocesses all 9 OAL defines, exports ~620 metrics classes, ~620 builder classes, ~45 dispatchers- 3 manifest files:
oal-metrics-classes.txt,oal-dispatcher-classes.txt,oal-disabled-sources.txt - Same-FQCN replacement
OALEngineLoaderServiceloads pre-compiled classes from manifests instead of running Javassist
Upstream Changes Needed
- None. Build-time class export works via existing debug API (
setOpenEngineDebug(true)+setGeneratedFilePath())
MAL and LAL (Groovy + Javassist)
What Happens
- MAL uses
GroovyShell+DelegatingScriptfor meter rule expressions (~1250 rules across 71 YAML files). Also,MeterSystem.create()uses Javassist to dynamically generate one meter subclass per metric rule. - LAL uses
GroovyShell+@CompileStatic+LALPrecompiledExtensionfor log analysis scripts (10 rules).
Approach (this repo)
Run full MAL/LAL initialization at build time via build-tools/precompiler (unified tool). Export Javassist-generated .class files. Transpile all Groovy expressions to pure Java at build time — zero Groovy at runtime.
Details: mal-immigration.md | lal-immigration.md
What Was Built
- Unified precompiler (
build-tools/precompiler): Replaced separateoal-exporterandmal-compilermodules. Compiles all 71 MAL YAML rule files (meter-analyzer-config, otel-rules, log-mal-rules, envoy-metrics-rules, telegraf-rules, zabbix-rules) producing 1209 meter classes. - MAL-to-Java transpiler: 1250+ MAL expressions transpiled from Groovy AST to pure Java
MalExpressionimplementations. 29 filter expressions transpiled toMalFilterimplementations. Zero Groovy at runtime. - LAL-to-Java transpiler: 10 LAL scripts (6 unique) transpiled to pure Java
LalExpressionimplementations. Spec classes enhanced withConsumeroverloads for transpiled code. - Groovy stubs module: Minimal
groovy.lang.*types (Binding, Closure, etc.) for class loading. Noorg.codehaus.groovy.*— prevents GraalVMGroovyIndyInterfaceFeaturefrom activating. - Manifests:
META-INF/mal-expressions.txt(transpiled Java classes),META-INF/mal-groovy-expression-hashes.txt(SHA-256 for combination pattern resolution),META-INF/mal-meter-classes.txt(Javassist-generated classes),META-INF/lal-expressions.txt(transpiled LAL classes),META-INF/annotation-scan/MeterFunction.txt(16 function classes). - Combination pattern: Multiple YAML files from different data sources (otel, telegraf, zabbix) may define metrics with the same name. Deterministic suffixes (
_1,_2) with expression hash tracking enable unambiguous resolution. - Same-FQCN replacements:
DSL.java(MAL),DSL.java(LAL),FilterExpression.java,MeterSystem.java,Expression.java,SampleFamily.java— all use pure Java, no Groovy. - Comparison test suite: 73 MAL test classes (1281 assertions) + 5 LAL test classes (19 assertions) covering all 79 YAML files. Tests require data flow through full pipeline (no vacuous agreements). Dual-path: fresh Groovy compilation (Path A) vs transpiled Java (Path B).
Groovy Elimination
- MAL:
MalExpressioninterface replacesDelegatingScript.SampleFamilyuses Java functional interfaces (TagFunction,SampleFilter,ForEachFunction,DecorateFunction,PropertiesExtractor) instead ofgroovy.lang.Closure. - LAL:
LalExpressioninterface replacesDelegatingScript. Spec classes haveConsumeroverloads. - No
groovy.lang.Closurein any production source code. Groovy is test-only dependency.
Classpath Scanning (Guava ClassPath)
What Happens
ClassPath.from() used in SourceReceiverImpl.scan(), AnnotationScan, MeterSystem, DefaultMetricsFunctionRegistry, FilterMatchers, MetricsHolder.
What Was Solved
AnnotationScanandSourceReceiverImplreplaced with same-FQCN classes that read from build-time manifests. 6 annotation/interface manifests underMETA-INF/annotation-scan/:ScopeDeclaration,Stream,Disable,MultipleDisable,SourceDispatcher,ISourceDecorator.DefaultMetricsFunctionRegistry,FilterMatchers,MetricsHolder— these only run inside the OAL engine at build time, not at runtime. Automatically solved.MeterSystemreplaced with same-FQCN class that reads fromMETA-INF/annotation-scan/MeterFunction.txtmanifest (16 meter function classes). Solved as part of MAL immigration.
Module System & Configuration
Current Behavior
ModuleManager uses ServiceLoader (SPI). application.yml selects providers. Config loaded via reflection (Field.setAccessible + field.set in YamlConfigLoaderUtils.copyProperties).
Approach (this repo)
- New module manager: Directly constructs chosen
ModuleDefine/ModuleProvider— no SPI - Simplified config file: Only knobs for selected providers
- Config loading: No reflection. Build-time tool scans all
ModuleConfigsubclass fields → generates same-FQCN replacement ofYamlConfigLoaderUtilsthat uses Lombok setters and VarHandle to set config fields directly. EliminatesField.setAccessible/field.setand the need forreflect-config.jsonfor config classes.
Details: config-init-immigration.md
What Was Built
FixedModuleManager— direct module/provider construction viaModuleDefine.prepare()overload, no SPIGraalVMOAPServerStartUp— entry point withconfiguration.has()guards for 6 optional modulesapplication.yml— simplified config for selected providersConfigInitializerGenerator— build-time tool that scans config classes and generatesYamlConfigLoaderUtilsreplacementYamlConfigLoaderUtils— same-FQCN replacement using type-dispatch + setter/VarHandle instead of reflectionModuleDefine— same-FQCN replacement (library-module-for-graalvm) addingprepare(ModuleManager, ModuleProvider, ...)overload for direct provider wiring without ServiceLoader
Same-FQCN Packaging (Repackaged Modules)
Problem
Same-FQCN replacement classes need to shadow upstream originals. Classpath ordering tricks confuse developers and AI tools.
Solution: Per-JAR Repackaged Modules (oap-libs-for-graalvm)
Each upstream JAR that has replacement classes gets a corresponding *-for-graalvm module under oap-libs-for-graalvm/. The module uses maven-shade-plugin to:
- Include only the upstream JAR in the shade
- Exclude the specific
.classfiles being replaced - Produce a JAR containing: all upstream classes MINUS replaced ones PLUS our replacements
oap-graalvm-server depends on *-for-graalvm JARs instead of originals. Original upstream JARs are forced to provided scope via <dependencyManagement> to prevent transitive leakage.
23 Same-FQCN Replacement Classes Across 13 Modules
Non-trivial replacements (load pre-compiled assets from manifests):
| Module | Replacement Classes | Purpose |
|---|---|---|
library-module-for-graalvm |
ModuleDefine |
Add prepare() overload for direct provider wiring (bypasses ServiceLoader) |
server-core-for-graalvm |
OALEngineLoaderService, AnnotationScan, SourceReceiverImpl, MeterSystem, CoreModuleConfig, HierarchyDefinitionService |
Load from manifests instead of Javassist/ClassPath; config with @Setter; Java-backed closures instead of GroovyShell |
library-util-for-graalvm |
YamlConfigLoaderUtils |
Set config fields via setter instead of reflection |
meter-analyzer-for-graalvm |
DSL, FilterExpression, Rules |
Load pre-compiled MAL Groovy scripts from manifest; load rule data from JSON config-data manifests |
log-analyzer-for-graalvm |
DSL, LogAnalyzerModuleConfig, LALConfigs |
Load pre-compiled LAL scripts; config with @Setter; load LAL config data from JSON config-data manifests |
agent-analyzer-for-graalvm |
AnalyzerModuleConfig, MeterConfigs |
Config with @Setter; load meter config data from JSON config-data manifests |
Config-only replacements (add @Setter for reflection-free config):
| Module | Replacement Class |
|---|---|
envoy-metrics-receiver-for-graalvm |
EnvoyMetricReceiverConfig |
otel-receiver-for-graalvm |
OtelMetricReceiverConfig |
ebpf-receiver-for-graalvm |
EBPFReceiverModuleConfig |
aws-firehose-receiver-for-graalvm |
AWSFirehoseReceiverModuleConfig |
cilium-fetcher-for-graalvm |
CiliumFetcherConfig |
status-query-for-graalvm |
StatusQueryConfig |
health-checker-for-graalvm |
HealthCheckerConfig |
No Classpath Ordering Required
No duplicate FQCNs on the classpath. The startup script (oapService.sh) uses a simple flat classpath. The oap-graalvm-native uber JAR also has no FQCN conflicts.
Adding New Replacements
To add a new same-FQCN replacement:
- Create a new
*-for-graalvmmodule underoap-libs-for-graalvm/(or add to existing one) - Add the replacement
.javafile with the same FQCN - Configure shade plugin to exclude the original
.classfrom the upstream JAR - Add the
-for-graalvmartifact to rootpom.xml<dependencyManagement> - In
oap-graalvm-server/pom.xml: add the original JAR to<dependencyManagement>asprovided, add-for-graalvmto<dependencies> - Add the original JAR to
distribution.xml<excludes>
Additional GraalVM Risks
| Risk | Status | Mitigation |
|---|---|---|
| Reflection (annotations, OAL enricher, HTTP handlers, GraphQL types) | SOLVED | Auto-generated by precompiler from manifests; log4j2-reflect-config.json for Log4j2 plugins |
| gRPC / Netty / Armeria | SOLVED | GraalVM reachability metadata repo handles these automatically |
Resource loading (ResourceUtils, config files) |
SOLVED | resource-config.json via tracing agent |
| Log4j2 | SOLVED | Console-only log4j2.xml avoids RollingFile reflection chain; Log4j2 plugin classes in log4j2-reflect-config.json |
| Kafka client (for Kafka fetcher) | Untested | Known GraalVM support, may need config |
| Kubernetes client 6.7.1 (for cluster + config) | Untested | Has GraalVM support, may need config at runtime |
Distro Resource Files
Upstream server-starter/src/main/resources/ contains 236 files. They fall into
two categories: files included directly in the distro config/ directory (loaded
at runtime via file I/O), and files consumed by the precompiler at build time
(not needed at runtime — their logic is baked into pre-compiled .class files).
Directly Included in Distro (config/)
These files are loaded at runtime via ResourceUtils.read(), Files.walk(), or
YAML parsing. No reflection involved — safe for GraalVM native image as-is.
| File / Directory | Count | Loaded By | Purpose |
|---|---|---|---|
application.yml |
1 | Custom (distro’s own, not upstream) | Module/provider config |
bydb.yml |
1 | BanyanDBConfigLoader |
BanyanDB storage base config |
bydb-topn.yml |
1 | BanyanDBConfigLoader |
BanyanDB TopN aggregation config |
log4j2.xml |
1 | Log4j2 framework | Logging configuration |
alarm-settings.yml |
1 | AlarmModuleProvider via ResourceUtils.read() |
Alarm rules |
component-libraries.yml |
1 | ComponentLibraryCatalogService via ResourceUtils.read() |
Component ID mapping |
endpoint-name-grouping.yml |
1 | EndpointNameGroupingRuleWatcher via ResourceUtils.read() |
Endpoint grouping rules |
gateways.yml |
1 | UninstrumentedGatewaysConfig via ResourceUtils.read() |
Gateway definitions |
hierarchy-definition.yml |
1 | HierarchyDefinitionService via ResourceUtils.read() |
Layer hierarchy |
metadata-service-mapping.yaml |
1 | ResourceUtils.read() |
Metadata service mapping |
service-apdex-threshold.yml |
1 | ApdexThresholdConfig via ResourceUtils.read() |
APDEX thresholds |
trace-sampling-policy-settings.yml |
1 | TraceSamplingPolicyWatcher via ResourceUtils.read() |
Trace sampling |
ui-initialized-templates/** |
131 | UITemplateInitializer via Files.walk() |
UI dashboard JSON templates |
cilium-rules/** |
2 | CiliumFetcherProvider via ResourceUtils.getPathFiles() |
Cilium flow rules |
openapi-definitions/** |
1 | EndpointNameGrouping via ResourceUtils.getPathFiles() |
OpenAPI grouping definitions |
Total: 146 files included in the distro config/ directory.
Pre-compiled at Build Time (NOT in distro)
These files are consumed by build-tools/precompiler during the build. Their
expressions, scripts, and metric definitions are compiled into .class files
packaged in JARs. The YAML source files are not needed at runtime.
| Category | Count | Pre-compiled Into | Tool |
|---|---|---|---|
oal/*.oal |
9 | ~620 metrics classes + ~620 builders + ~45 dispatchers (Javassist) | OALClassExporter |
meter-analyzer-config/*.yaml |
11 | 147 Groovy scripts + Javassist meter classes | MALPrecompiler |
otel-rules/**/*.yaml |
55 | 1044 Groovy scripts + Javassist meter classes | MALPrecompiler |
log-mal-rules/*.yaml |
2 | 2 Groovy scripts | MALPrecompiler |
envoy-metrics-rules/*.yaml |
2 | 26 Groovy scripts + Javassist meter classes | MALPrecompiler |
telegraf-rules/*.yaml |
1 | 20 Groovy scripts + Javassist meter classes | MALPrecompiler |
zabbix-rules/*.yaml |
1 | 15 Groovy scripts + Javassist meter classes | MALPrecompiler |
lal/*.yaml |
8 | 6 unique @CompileStatic Groovy classes |
LALPrecompiler |
Total: 89 files consumed at build time, producing ~1285 pre-compiled classes and ~1254 Groovy scripts stored in JARs.
Additionally, the precompiler serializes parsed config POJOs as JSON manifests in
META-INF/config-data/ (7 JSON files for meter-analyzer-config, otel-rules,
envoy-metrics-rules, log-mal-rules, telegraf-rules, zabbix-rules, and lal). These
provide the runtime “wiring” data (metric prefixes, rule names, expression lookup
keys) that replacement loader classes use instead of filesystem YAML access.
Not Included (upstream-only)
| File | Reason |
|---|---|
application.yml (upstream) |
Replaced by distro’s own simplified application.yml |
Build Workflow
Build System
- Maven + Makefile orchestrates building on top of the skywalking submodule
- GraalVM JDK 25 in CI (
.github/workflows/ci.yml) - JVM-mode starter with fixed module wiring (
FixedModuleManager+GraalVMOAPServerStartUp) - Simplified config file for selected modules (
application.yml)
Build-Time Pre-Compilation
OAL: OAL engine exports .class files (9 defines, ~620 metrics, ~620 builders, ~45 dispatchers). 7 annotation/interface manifests. 3 same-FQCN replacement classes (OALEngineLoaderService, AnnotationScan, SourceReceiverImpl).
MAL: Unified precompiler (build-tools/precompiler) processes 71 YAML files → 1250 expressions transpiled to pure Java MalExpression + 1209 Javassist meter classes. Combination pattern with deterministic suffixes + expression hash tracking. Same-FQCN replacements: DSL.java, FilterExpression.java, MeterSystem.java, Expression.java, SampleFamily.java. 73 comparison test classes, 1281 assertions (100% YAML coverage).
LAL: 8 YAML files → 10 rules → 6 unique transpiled Java LalExpression classes. Same-FQCN DSL.java loads via SHA-256 hash lookup. 5 comparison test classes, 19 assertions (100% branch coverage).
Config initialization: ConfigInitializerGenerator generates same-FQCN YamlConfigLoaderUtils using Lombok setters — zero Field.setAccessible at runtime.
Config data serialization: Precompiler serializes parsed config POJOs to META-INF/config-data/*.json (7 JSON files). 3 same-FQCN replacement loaders (MeterConfigs, Rules, LALConfigs) deserialize from JSON instead of filesystem YAML.
Module system: ModuleDefine replacement with direct prepare() overload (bypasses ServiceLoader). GraalVMOAPServerStartUp with configuration.has() guards for 6 optional modules.
Distro resource packaging: 146 runtime files → distro config/, 89 pre-compiled files → JARs. Assembly descriptor (distribution.xml) packages runtime config files from upstream.
Groovy Elimination
- MAL-to-Java transpiler: 1250+ expressions → pure Java
MalExpression(no Groovy MOP/ExpandoMetaClass) - LAL-to-Java transpiler: 10 scripts → pure Java
LalExpression(no DelegatingScript) SampleFamilyClosure parameters → Java functional interfaces (zerogroovy.lang.Closurein production)- Groovy stubs module for class loading (no
org.codehaus.groovy.*) - HierarchyDefinitionService: same-FQCN replacement with Java-backed closures
- Real Groovy (
groovy-5.0.3.jar) is test-only;groovy-stubs-1.0.0-SNAPSHOT.jaron runtime classpath - 1303 tests require actual data flow (no vacuous empty-result agreements)
Native Image Build
native-maven-plugin(GraalVM buildtools 0.10.4) inoap-graalvm-nativewith-Pnativeprofilereflect-config.jsonauto-generated by precompiler from manifests (OAL, MAL, LAL, meter, HTTP handlers, GraphQL types)log4j2-reflect-config.jsonfor Log4j2 plugin classes; console-onlylog4j2.xmlwithSW_LOG_LEVELenv var- gRPC/Netty/Protobuf/Armeria via GraalVM reachability metadata repository
- Auto-scanned reflection metadata: Armeria HTTP handlers (~19), GraphQL resolvers (~32), GraphQL types (~182), config POJOs (8)
- Native binary: ~203MB, boots to full module init with all HTTP endpoints functional
Native Distro Packaging
- Assembly descriptor (
native-distribution.xml) packages native binary + config files Dockerfile.nativepackages native distro intodebian:bookworm-slimdocker-compose.ymlwith BanyanDB + OAP native services- CI pipeline: multi-arch native build (amd64 + arm64) with Docker manifest push to GHCR
Remaining Verification
- Verify all receiver plugins work (gRPC + HTTP endpoints)
- Verify all query APIs work (GraphQL, PromQL, LogQL, Zipkin)
- Verify cluster mode (K8s)
- Verify alarm module
- Performance benchmarking vs JVM
Upstream Changes Tracker
No upstream changes needed. All GraalVM incompatibilities are resolved in this distro via same-FQCN replacement and build-time pre-compilation:
- OAL: build-time class export works via existing debug API
- MAL: transpiled to pure Java, bypasses Groovy entirely
- LAL: transpiled to pure Java, bypasses Groovy entirely
- Dynamic Groovy MOP: transpiled to pure Java, no ExpandoMetaClass/MOP at runtime