This document describes the JFR Shell backend plugin architecture, how to write custom backends, and how to validate them with the TCK (Technology Compatibility Kit).
JFR Shell uses a pluggable backend system to parse JFR (Java Flight Recorder) files. Different backends offer various trade-offs between feature support, performance, and memory usage.
| Backend | ID | Priority | Description |
|---|---|---|---|
| Jafar Parser | jafar |
100 | Full-featured reference implementation with all capabilities |
| JDK JFR API | jdk |
50 | Uses standard jdk.jfr.consumer API, limited but widely compatible |
Backends are selected in this order:
JFRSHELL_BACKENDenvironment variablejfr.shell.backendsystem property- Highest priority backend available
# Use specific backend via environment variable
JFRSHELL_BACKEND=jdk java -jar jfr-shell.jar
# Or via system property
java -Djfr.shell.backend=jdk -jar jfr-shell.jar
# Or via CLI option
java -jar jfr-shell.jar --backend jdkEach backend declares a set of capabilities. JFR Shell adapts its features based on what the current backend supports.
| Capability | Description | Jafar | JDK |
|---|---|---|---|
EVENT_STREAMING |
Stream events from recordings | ✓ | ✓ |
METADATA_CLASSES |
Access event types and field information | ✓ | ✓ |
CHUNK_INFO |
Access chunk-level details (headers, offsets) | ✓ | ✗ |
CONSTANT_POOLS |
Direct constant pool access | ✓ | ✗ |
STREAMING_PARSE |
Parse large files without full memory load | ✓ | ✓ |
TYPED_HANDLERS |
Compile-time typed event interfaces | ✓ | ✗ |
UNTYPED_HANDLERS |
Map-based event access | ✓ | ✓ |
CONTEXT_REUSE |
Share parsing context across sessions | ✓ | ✗ |
- Java 21 or later
- Gradle or Maven build system
- Familiarity with Java's ServiceLoader mechanism
Your backend needs jfr-shell as a compile-only dependency (Gradle shown; Maven users add equivalent <scope>provided</scope>):
plugins {
id 'java-library'
}
dependencies {
// Compile-only: your JAR doesn't bundle jfr-shell
compileOnly 'io.btrace:jfr-shell:0.9.0'
}package com.example.backend;
import io.jafar.shell.backend.*;
import java.util.EnumSet;
import java.util.Set;
public final class MyBackend implements JfrBackend {
private static final Set<BackendCapability> CAPABILITIES = EnumSet.of(
BackendCapability.EVENT_STREAMING,
BackendCapability.METADATA_CLASSES,
BackendCapability.UNTYPED_HANDLERS
);
@Override
public String getId() {
return "mybackend"; // Unique identifier
}
@Override
public String getName() {
return "My Custom Backend"; // Display name
}
@Override
public String getVersion() {
return "1.0.0";
}
@Override
public int getPriority() {
return 75; // Higher = preferred (Jafar=100, JDK=50)
}
@Override
public Set<BackendCapability> getCapabilities() {
return CAPABILITIES;
}
@Override
public BackendContext createContext() {
// BackendContext holds shared resources (caches, parsers) across sessions.
// Return a simple empty implementation if you don't need context reuse.
return new MyBackendContext();
}
@Override
public EventSource createEventSource(BackendContext context) {
return new MyEventSource(context);
}
@Override
public MetadataSource createMetadataSource() {
return new MyMetadataSource();
}
@Override
public ChunkSource createChunkSource() throws UnsupportedCapabilityException {
throw new UnsupportedCapabilityException(BackendCapability.CHUNK_INFO, getId());
}
@Override
public ConstantPoolSource createConstantPoolSource() throws UnsupportedCapabilityException {
throw new UnsupportedCapabilityException(BackendCapability.CONSTANT_POOLS, getId());
}
}Streams events from JFR recordings. Call consumer.accept() for each event with the event type name and a Map<String, Object> of field values:
public final class MyEventSource implements EventSource {
@Override
public void streamEvents(Path recording, Consumer<Event> consumer) throws Exception {
// Parse recording and emit events
// Event constructor: Event(String typeName, Map<String, Object> fields)
try (var parser = createParser(recording)) {
parser.forEach(event -> {
Map<String, Object> fields = extractFields(event);
consumer.accept(new Event(event.getTypeName(), fields));
});
}
}
}Provides type and field information:
public final class MyMetadataSource implements MetadataSource {
@Override
public List<Map<String, Object>> loadAllClasses(Path recording) throws Exception {
// Return list of class metadata maps
// Each map should contain: id, name, superType, fields, annotations, etc.
}
@Override
public Map<String, Object> loadClass(Path recording, String typeName) throws Exception {
// Return metadata for a specific class, or null if not found
}
@Override
public Map<String, Object> loadField(Path recording, String typeName, String fieldName)
throws Exception {
// Return metadata for a specific field, or null if not found
}
}Provides chunk-level information:
public final class MyChunkSource implements ChunkSource {
@Override
public List<Map<String, Object>> loadAllChunks(Path recording) throws Exception {
// Return list of chunk metadata with: index, offset, size, startNanos, duration, etc.
}
@Override
public Map<String, Object> loadChunk(Path recording, int chunkIndex) throws Exception {
// Return metadata for specific chunk
}
@Override
public List<Map<String, Object>> loadChunks(Path recording,
Predicate<Map<String, Object>> filter) throws Exception {
// Return filtered chunks
}
@Override
public Map<String, Object> getChunkSummary(Path recording) throws Exception {
// Return summary: totalChunks, totalSize, avgSize, minSize, maxSize
}
}Provides constant pool access:
public final class MyConstantPoolSource implements ConstantPoolSource {
@Override
public List<Map<String, Object>> loadSummary(Path recording) throws Exception {
// Return CP type summary with name and totalSize
}
@Override
public Set<String> getAvailableTypes(Path recording) throws Exception {
// Return set of constant pool type names
}
@Override
public List<Map<String, Object>> loadEntries(Path recording, String typeName)
throws Exception {
// Return all entries for a CP type
}
@Override
public List<Map<String, Object>> loadEntries(Path recording, String typeName,
Predicate<Map<String, Object>> filter) throws Exception {
// Return filtered entries
}
}Create META-INF/services/io.jafar.shell.backend.JfrBackend:
com.example.backend.MyBackend
./gradlew jar
# Output: build/libs/my-backend-1.0.0.jarThe TCK validates that your backend implementation conforms to expected behavior.
./gradlew :jfr-shell-tck:shadowJar
# Output: jfr-shell-tck/build/libs/jfr-shell-tck-*-all.jar# Basic usage with built-in test file (2.3MB)
java -jar jfr-shell-tck-all.jar path/to/my-backend.jar
# With custom test recording
java -jar jfr-shell-tck-all.jar path/to/my-backend.jar path/to/test.jfrThe TCK runs 23 tests across these categories:
| Category | Tests | Description |
|---|---|---|
| Identity | 4 | Valid ID, name, version, priority |
| Capabilities | 2 | Capability set consistency |
| Event Streaming | 4 | Events are properly streamed |
| Metadata | 5 | Classes and fields load correctly |
| Chunks | 4 | Chunk info is accurate (if supported) |
| Constant Pools | 4 | CP access works (if supported) |
=== JFR Shell Backend TCK ===
Backend JAR: my-backend-1.0.0.jar
Test recording: /tmp/tck-test-123.jfr
Loaded backend: My Custom Backend (id=mybackend)
Capabilities: [EVENT_STREAMING, METADATA_CLASSES, UNTYPED_HANDLERS]
=== TCK Results ===
Tests run: 23
Passed: 23
Failed: 0
Skipped: 0
Tests for unsupported capabilities are automatically skipped (e.g., if your backend doesn't support CHUNK_INFO, chunk tests are skipped).
| Failure | Cause | Fix |
|---|---|---|
backendHasValidId |
Empty or null ID | Return non-empty string from getId() |
backendHasReasonablePriority |
Priority outside 0-1000 | Use priority between 0-1000 |
eventSourceStreamsEvents |
No events emitted | Ensure streamEvents() calls consumer |
metadataSourceLoadsAllClasses |
Empty or null result | Return non-empty list from loadAllClasses() |
capabilitiesMatchBehavior |
Declared capability but throws | Either implement the source or remove capability |
The built-in test file is intentionally small (2.3MB) to accommodate backends with higher memory overhead. If your backend can handle larger files, provide your own test recording:
java -jar jfr-shell-tck-all.jar my-backend.jar large-recording.jfrjfr-shell/ # Core shell and SPI
├── src/main/java/io/jafar/shell/backend/
│ ├── JfrBackend.java # Main SPI interface
│ ├── BackendCapability.java # Capability enum
│ ├── BackendContext.java # Resource sharing context
│ ├── EventSource.java # Event streaming interface
│ ├── MetadataSource.java # Metadata query interface
│ ├── ChunkSource.java # Chunk info interface
│ └── ConstantPoolSource.java # Constant pool interface
jfr-shell-jafar/ # Jafar backend plugin
jfr-shell-jdk/ # JDK API backend plugin
jfr-shell-tck/ # Technology Compatibility Kit
For environments without internet access (Docker images, air-gapped systems), plugins can be manually installed from local JAR files:
# Install a backend plugin from a local JAR
java -jar jfr-shell.jar --install-plugin /path/to/jfr-shell-jdk-1.0.0.jar
# The JAR filename must follow the convention: {artifactId}-{version}.jar
# Example: jfr-shell-jdk-1.0.0.jar → pluginId "jdk"- ServiceLoader config: The JAR must contain
META-INF/services/io.jafar.shell.backend.JfrBackend - Filename format:
{artifactId}-{version}.jar(e.g.,jfr-shell-jdk-1.0.0.jar) - Optional pom.properties: If present in
META-INF/maven/, the groupId is extracted; otherwise defaults toio.btrace
# Pre-install backend plugin during image build
FROM eclipse-temurin:21-jre
COPY jfr-shell.jar /app/
COPY jfr-shell-jdk-1.0.0.jar /tmp/
# Install the plugin
RUN java -jar /app/jfr-shell.jar --install-plugin /tmp/jfr-shell-jdk-1.0.0.jar && \
rm /tmp/jfr-shell-jdk-1.0.0.jar
ENTRYPOINT ["java", "-jar", "/app/jfr-shell.jar"]Use --plugin-dir to specify an alternative plugin directory:
java -jar jfr-shell.jar --plugin-dir /custom/plugins --install-plugin backend.jar- Declare only supported capabilities - Don't claim
CHUNK_INFOif you can't provide chunk data - Use streaming parsers - Avoid loading entire recordings into memory
- Reuse context when possible - If your parser supports it, implement
CONTEXT_REUSE - Handle missing data gracefully - Return null or empty collections, don't throw for missing types
- Test with the TCK - Run the TCK before releasing your backend
- Use appropriate priority - Don't use priority > 100 unless your backend should be preferred over Jafar
The backend plugin API is stable and follows semantic versioning. See PluginAPICompatibility.md for details on:
- What changes break compatibility
- Safe evolution patterns
- Version requirements
- Automated enforcement with japicmp
- Backend Quickstart (10 minutes) - Build a working backend fast
- JFR Shell Usage Guide
- JfrPath Query Language
- JFR Shell Tutorial