Fast, modern JFR (Java Flight Recorder) parser for the JVM with a small, focused API.
Status: Early public release (0.x) - API may evolve based on feedback. See CHANGELOG.md for details.
JAFAR provides both typed (interface-based) and untyped (Map-based) APIs for parsing JFR recordings with minimal ceremony. It emphasizes performance, low allocation, and ease of use.
./get_resources.sh./gradlew build./gradlew shadowJarDefine a Java interface per JFR type and annotate with @JfrType. Methods correspond to event fields; use @JfrField to map differing names and @JfrIgnore to skip fields.
import io.jafar.parser.api.*;
import java.nio.file.Paths;
@JfrType("custom.MyEvent")
public interface MyEvent { // no base interface required
String myfield();
}
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
HandlerRegistration<MyEvent> reg = p.handle(MyEvent.class, (e, ctl) -> {
System.out.println(e.myfield());
long pos = ctl.stream().position(); // current byte position while in handler
// ctl.abort(); // optionally stop parsing immediately without throwing
});
p.run();
reg.destroy(p); // deregister
}
Notes:
run().ctl.abort() inside a handler to stop parsing early without an exception.Receive events as Map<String, Object> with nested maps/arrays when applicable.
import io.jafar.parser.api.*;
import java.nio.file.Paths;
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/path/to/recording.jfr"))) {
HandlerRegistration<?> reg = p.handle((type, value) -> {
if ("jdk.ExecutionSample".equals(type.getName())) {
// You can retrieve the value by providing 'path' -> "eventThread", "javaThreadId"
Object threadId = Values.get(value, "eventThread", "javaThreadId");
// You can also get the value conveniently typed - for primitive values you need to use the boxed type in the call
long threadIdLong = Values.as(value, Long.class, "eventThread", "javaThreadId");
// use threadId ...
}
});
p.run();
reg.destroy(p);
}
Map<String, Object> or as a wrapper implementing io.jafar.parser.api.ComplexType (e.g., constant-pool backed references). Use getValue() on a ComplexType to obtain the resolved Map<String, Object>.io.jafar.parser.api.ArrayType. Use getType() to inspect the array class (e.g., int[].class, Object[].class) and getArray() to access the underlying Java array.Examples:
import io.jafar.parser.api.*;
import java.util.Map;
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/path/to/recording.jfr"))) {
p.handle((type, value) -> {
// ComplexType: constant-pool backed references (e.g., eventThread)
Map<String, Object> thread = Values.as(value, Map.class, "eventThread").orElse(null);
if (thread != null) {
System.out.println("thread id=" + thread.get("javaThreadId") + ", name=" + thread.get("name"));
}
// ArrayType: arrays of primitives, Strings, maps, or ComplexType elements
Object framesVal = Values.get(value, "stackTrace", "frames");
// You can also reference the array elements directly
Object firstFrame = Values.get(value, "stackTrace", "frames", 0);
if (framesVal instanceof ArrayType at) {
Object arr = at.getArray();
if (arr instanceof Object[] objs) {
for (Object el : objs) {
if (el instanceof ComplexType cpx) {
Map<String, Object> m = cpx.getValue();
// use fields from the resolved element
} else if (el instanceof Map) {
Map<String, Object> m = (Map<String, Object>) el; // inline complex value
} else {
// primitive wrapper or String
}
}
} else if (arr instanceof int[] ints) {
for (int i : ints) { /* ... */ }
} else if (arr instanceof long[] longs) {
for (long l : longs) { /* ... */ }
}
}
});
p.run();
}
go-parser/)A pure Go port of the untyped parser lives in go-parser/, as a
standalone Go module (github.com/btraceio/jafar/go-parser) that is not part of the Gradle build.
It parses recordings and hands events to a callback as map[string]any, with the same value model,
tick normalisation and lazy constant-pool resolution as the Java untyped API. It has no external
dependencies and covers the parser only - no JfrPath, no shell, no CLI.
parser, err := jfr.Open("recording.jfr")
if err != nil {
log.Fatal(err)
}
err = parser.ParseWith(jfr.Options{
TypeFilter: func(t *jfr.ClassType) bool { return t.Name == "jdk.ExecutionSample" },
}, func(e *jfr.Event) error {
thread, _ := jfr.GetString(e.Values, "sampledThread", "javaName")
method, _ := jfr.GetString(e.Values, "stackTrace", "frames", 0, "method", "name", "string")
fmt.Println(thread, method)
return nil
})
The typed API is not ported: it relies on run-time bytecode generation for interfaces discovered at run time, which has no Go equivalent. See go-parser/README.md for the full value model, error handling contract and the differences from the Java parser.
JAFAR now supports build-time handler generation via annotation processor, providing massive performance benefits for production applications.
Benchmark Results:
Compile-time: Annotation processor scans @JfrType interfaces and generates:
Runtime: Parser auto-discovers factories via ServiceLoader, handlers are reused via thread-local cache
Gradle:
dependencies {
implementation 'io.btrace:jafar-parser:0.11.0'
annotationProcessor 'io.btrace:jafar-processor:0.11.0'
}
Maven:
<dependencies>
<dependency>
<groupId>io.btrace</groupId>
<artifactId>jafar-parser</artifactId>
<version>0.11.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.btrace</groupId>
<artifactId>jafar-processor</artifactId>
<version>0.11.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
// Must be top-level interfaces (not nested/inner classes)
@JfrType("jdk.ExecutionSample")
public interface JFRExecutionSample {
@JfrField("startTime")
long startTime();
@JfrField("sampledThread")
JFRThread sampledThread();
}
@JfrType("java.lang.Thread")
public interface JFRThread {
@JfrField("javaThreadId")
long javaThreadId();
@JfrField("javaName")
String javaName();
}
Note: Annotation processor only processes top-level interfaces. Nested/inner classes with @JfrType are skipped and use runtime generation instead.
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
// Factories automatically discovered via ServiceLoader - no registration needed!
// Handle events (uses thread-local cached handlers)
p.handle(JFRExecutionSample.class, (event, ctl) -> {
JFRThread thread = event.sampledThread();
if (thread != null) {
System.out.println("Thread: " + thread.javaName());
}
});
p.run();
}
That's it! The annotation processor generates factories and registers them via ServiceLoader. No manual registration required.
If you don't register factories, JAFAR falls back to runtime bytecode generation (existing behavior):
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
// No factory registration - handlers generated at runtime via ASM
p.handle(JFRExecutionSample.class, (event, ctl) -> {
// Handler generated on first use, cached globally
System.out.println("Event: " + event.startTime());
});
p.run();
}
✅ Use build-time generation when:
✅ Use runtime generation when:
For processing 1 million ExecutionSample events:
| Metric | Runtime Generation | Build-Time Generation | Benefit |
|---|---|---|---|
| Total Allocations | ~223 GB | ~37 GB | -186 GB |
| GC Collections | ~600-800 | ~50-100 | -750 GC pauses |
| GC Pause Time | ~2-3 seconds | ~200-300ms | -2.7 seconds |
| Throughput | ~189k events/sec | ~187k events/sec | Equivalent |
For the architecture of the parser module, see the Parser Architecture.
JafarParser
newTypedParser(Path) / newUntypedParser(Path): start a session.withParserListener(ChunkParserListener): observe low-level parse events (advanced, see below).run(): parse and invoke registered handlers.TypedJafarParser
handle(Class<T>, JFRHandler<T>) -> HandlerRegistration<T>open(String|Path[, ParsingContext]) are also available, but prefer JafarParser.newTypedParser(Path).UntypedJafarParser
handle(UntypedJafarParser.EventHandler) -> HandlerRegistration<?>open(String|Path[, ParsingContext]) also available.ArrayType: wrapper around arrays. getType() returns the array class; getArray() returns the backing Java array.ComplexType: wrapper around complex values. getValue() resolves to a Map<String, Object>. Note that some complex fields may be provided inline as a Map without a wrapper.ParsingContext
create(): build a reusable context.newTypedParser(Path) / newUntypedParser(Path): create parsers bound to the shared context.uptime(): cumulative processing time across sessions using the context.Control
stream().position(): current byte position while a handler executes.abort(): stop parsing immediately (no exception thrown).chunkInfo(): chunk metadata with startTime(), duration(), size(), and convertTicks(long, TimeUnit).
convertTicks(...)? JFR records many time values in chunk-relative ticks. Converting on demand avoids creating Instant/Duration objects for every event, minimizing allocation and GC pressure when a scalar value suffices. Convert only when needed and to the unit you need.MethodHandles.Lookup#defineHiddenClass (fastest, unloadable)MethodHandles.Lookup#defineClass(byte[]) (good)sun.misc.Unsafe#defineAnonymousClass (compatible; slightly heavier)parser artifact is a Multi‑Release JAR:
META-INF/versions/21 and restore faster implementations (e.g., zero‑copy ByteBuffer slicing, Arrays.equals range checks, Files.writeString, etc.).@JfrType("<fq.type>"): declare the JFR type an interface represents.@JfrField("<jfrField>", raw = false): map differing names or request raw representation.@JfrIgnore: exclude a method from mapping.ParsingContext ctx = ParsingContext.create();
try (TypedJafarParser p = ctx.newTypedParser(Paths.get("/path/to.a.jfr"))) {
p.handle(MyEvent.class, (e, ctl) -> {/*...*/});
p.run();
}
try (TypedJafarParser p = ctx.newTypedParser(Paths.get("/path/to.b.jfr"))) {
p.handle(MyEvent.class, (e, ctl) -> {/*...*/});
p.run();
}
System.out.println("uptime(ns)=" + ctx.uptime());
AtomicInteger seen = new AtomicInteger();
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to.jfr"))) {
HandlerRegistration<JFRJdkExecutionSample> reg =
p.handle(JFRJdkExecutionSample.class, (e, ctl) -> {
if (seen.incrementAndGet() >= 1000) {
ctl.abort(); // stop without throwing
}
});
p.run();
reg.destroy(p);
}
// Some fields are expressed in JFR ticks. Convert them only when needed.
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/file.jfr"))) {
p.handle((type, value, ctl) -> {
long ticksObj = value.get("startTime"); // example field holding ticks
long nanos = ctl.chunkInfo().convertTicks(n.longValue(), TimeUnit.NANOSECONDS);
// Use nanos directly, or wrap as Instant only when necessary
Instant startTs = ctl.chunkInfo().startTime().plusNanos(nanos);
// use the startTs instant ...
});
p.run();
}
import io.jafar.parser.internal_api.ChunkParserListener;
import io.jafar.parser.internal_api.metadata.MetadataEvent;
p.withParserListener(new ChunkParserListener() {
@Override public boolean onMetadata(ParserContext c, MetadataEvent md) {
// inspect metadata per chunk
return true; // continue
}
}).run();
Plugin id: io.btrace.jafar-gradle-plugin
Adds task generateJafarTypes and wires it to compileJava. It can generate interfaces for selected JFR types from either the current JVM metadata (default) or a .jfr file.
Generated class naming: The generator includes the full namespace in generated interface names to avoid collisions. For example:
jdk.ExecutionSample → JFRJdkExecutionSampledatadog.ExecutionSample → JFRDatadogExecutionSamplejdk.gc.HeapSummary → JFRJdkGcHeapSummaryThis ensures that events with the same simple name but different namespaces generate distinct interfaces.
plugins {
id 'io.btrace.jafar-gradle-plugin' version '0.11.0'
}
repositories {
mavenCentral()
mavenLocal()
maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
}
generateJafarTypes {
// Optional: use a JFR file to derive metadata; otherwise JVM runtime metadata is used
inputFile = file('/path/to/recording.jfr')
// Optional: where to generate sources (default: build/generated/sources/jafar/src/main)
outputDir = project.file('src/main/java')
// Optional: do not overwrite existing files (default: false)
overwrite = false
// Optional: filter event types by name (closure gets fully-qualified JFR type name)
eventTypeFilter {
it.startsWith('jdk.') && it != 'jdk.SomeExcludedType'
}
// Package for generated interfaces (default: io.jafar.parser.api.types)
targetPackage = 'com.acme.jfr.types'
}
You can also provide the input file via a project property: -Pjafar.input=/path/to/recording.jfr.
.jfrio.jafar.tools.Scrubber can scrub selected string fields in-place while copying to a new file.
Example: scrub the value of jdk.InitialSystemProperty.value when key == 'java.home'.
import static io.jafar.tools.Scrubber.scrubFile;
import io.jafar.tools.Scrubber.ScrubField;
import java.nio.file.Paths;
scrubFile(Paths.get("/in.jfr"), Paths.get("/out-scrubbed.jfr"),
clz -> {
if (clz.equals("jdk.InitialSystemProperty")) {
return new ScrubField("key", "value", (k, v) -> "java.home".equals(k));
}
return null; // no scrubbing for other classes
}
);
Build and run the demo application:
# First you need to publish parser, tools and plugin to local maven
cd demo
./build.sh
java -jar build/libs/jafar-demo-all.jar [jafar|jmc|jfr|jfr-stream] /path/to/recording.jfr
On an M1 and a ~600MiB JFR, the Jafar parser completes in ~1s vs ~7s with JMC (anecdotal). The stock jfr tool may OOM when printing all events.
JAFAR includes jfr-shell, an interactive CLI for exploring and analyzing JFR files with a powerful query language. See jfr-shell/README.md for features and installation.
# Install via JBang (easiest)
jbang app install jfr-shell@btraceio
# Open and analyze a recording
jfr-shell recording.jfr
jfr> events/jdk.ExecutionSample | groupBy(thread/name)
jfr> events/jdk.FileRead | top(10, by=bytes)
jfr> events/jdk.ExecutionSample | flamegraph()
# Event decoration: correlate samples with lock waits
jfr> events/jdk.ExecutionSample | decorateByTime(jdk.JavaMonitorWait, fields=monitorClass)
See Event Decoration and Joining for advanced correlation and joining capabilities.
ask — or ? for short — investigates: it runs a query, reads the result, decides what to look at
next, and concludes.
jfr> ? why is this workload slow
> events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(3, by=count)
3 rows
| count | key |
+-------+----------+
| 8412 | main |
| 210 | worker-1 |
The samples concentrate on one thread, so the next step is that thread's call sites
rather than more parallelism.
Transcript: ~/.jafar/investigations/ask-20260913-202249.jfrs
as-query is the one-shot form — one question, one query, shown and run:
jfr> as-query which threads used the most CPU?
# Groups execution samples by thread name and ranks the ten busiest.
events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(10, by=count)
Every query is printed either way — so a wrong guess is visible, and you learn JfrPath as you go —
and an investigation writes the queries it ran to a re-runnable .jfrs script, so its conclusion
can be checked rather than trusted. The recording itself never leaves your machine: the model
composes the queries, the shell runs them.
Three ways to authenticate, in the order most people want them:
# 1. A key in a file only you can read — no environment variable, no CLI to install
mkdir -p ~/.config/jafar
printf 'llm.api-key = sk-ant-...\n' > ~/.config/jafar/llm.properties
chmod 600 ~/.config/jafar/llm.properties
# 2. Or the provider's environment variable
export ANTHROPIC_API_KEY=sk-ant-...
# 3. Or keylessly, if you have the Anthropic CLI (optional — note the plural 'anthropics')
brew install anthropics/tap/ant && ant auth login
Or none of the above: set llm.backend = ollama runs a local model, and nothing leaves the machine.
ask --dry-run <question> prints exactly what would be sent without sending it, and result data is
redacted by default. See LLM setup,
the tutorial and what leaves your machine.
jafar-perf adds the methodology the tools do not carry: nine skills (triage, cpu, latency,
gc, memory-leak, heap-diff, compare, jfrpath, report) and seven agents that know which
analysis to run on an unfamiliar recording or heap dump, not just how to run one.
/plugin marketplace add btraceio/agent-plugins
/plugin install jafar-perf@btraceio-agent-plugins
The plugin bundles .mcp.json, so installing it also registers the jafar MCP server described
below — no separate claude mcp add is needed. JBang must be on your PATH;
it fetches the server on first use.
It lives in btraceio/agent-plugins, the btraceio
agent-plugin marketplace, not in this repository: adding a marketplace clones its repository, and
there is no reason to pull Jafar's binary test recordings onto a machine that only wants the skills.
(It used to live in btraceio/jafar-perf-box; re-running the installer below moves such an install
over, or switch by hand with /plugin marketplace remove btraceio and the two commands above.)
JAFAR includes an MCP (Model Context Protocol) server that enables AI agents like Claude to analyze JFR recordings. See jfr-mcp/README.md for details.
Installing the plugin above already registers it; the rest of this section is for using the server on its own, or from a client other than Claude Code.
curl -Ls https://raw.githubusercontent.com/btraceio/jafar/main/install.sh | bash
This installs JBang (if needed) and the jfr-mcp command, then registers
the jafar-perf plugin with every supported agent
harness on your PATH. The plugin brings the MCP server plus analysis skills (and, in Claude Code,
specialist subagents), so no separate MCP registration is needed.
| Harness | What the installer does |
|---|---|
Claude Code (claude) | claude plugin marketplace add btraceio/agent-plugins, then claude plugin install jafar-perf@btraceio-agent-plugins |
pi (pi) | pi install npm:pi-mcp-adapter (pi has no built-in MCP support), then pi install git:github.com/btraceio/agent-plugins (which also brings the other btraceio skills). The Claude Code subagents are not available in pi. |
Options go after bash -s --:
curl -Ls https://raw.githubusercontent.com/btraceio/jafar/main/install.sh | bash -s -- --harness claude
| Option | Effect |
|---|---|
--harness <list> | Register only these harnesses (claude, pi, or all) |
--no-harness | Install jfr-mcp only |
--daemon | Also run jfr-mcp as a supervised SSE daemon (doc/mcp/Daemon.md) |
--dev | Install the development snapshot (jfr-mcp-dev); the harness plugins still run the stable release |
Re-running the installer updates everything in place. To install only the server, use
jfr-mcp/install.sh, which takes --daemon and JFR_MCP_DEV=1 the same way.
Without the plugin, register just the server:
claude mcp add jafar -- jbang jfr-mcp@btraceio --stdio
{
"mcpServers": {
"jafar": {
"command": "jbang",
"args": ["jfr-mcp@btraceio", "--stdio"]
}
}
}
JAFAR now supports heap dump (HPROF) analysis using the same interactive shell. Query objects, classes, and GC roots with the HdumpPath query language.
# Open a heap dump
jfr-shell dump.hprof
jfr> objects | count
| count |
|---------|
| 1923456 |
jfr> objects | groupBy(class, agg=count) | top(10, count)
| class | count |
|---------------------------|---------|
| java.util.HashMap$Node | 249,734 |
| java.lang.String | 238,750 |
| java.lang.Object[] | 156,234 |
jfr> objects/java.lang.String[shallow > 1KB] | stats(shallow)
| count | sum | min | max | avg |
|-------|-----------|------|--------|--------|
| 1,234 | 2,456,789 | 1024 | 65,536 | 1,991 |
jfr> gcroots | groupBy(type) | sortBy(count desc)
| type | count |
|-------------|-------|
| JAVA_FRAME | 2,345 |
| THREAD_OBJ | 1,234 |
join(session=id) compares two heap dumps side-by-side1KB, 1MB, 1GB in predicates# Objects by class
objects/java.lang.String | top(10, shallow)
# Include subclasses
objects/instanceof/java.util.Map | groupBy(class)
# Filter with predicates
objects[shallow > 1MB and class ~ "com.myapp.*"]
# Class metadata
classes[instanceCount > 1000] | sortBy(instanceCount desc)
# GC roots
gcroots/THREAD_OBJ | select(type, object, threadSerial)
See doc/hdump-shell-quickstart.md for quick start and doc/hdumppath.md for complete reference.
Releases are split into planes: each plane has its own tag, its own version and publishes only its own artifacts to Maven Central, so a parser-only fix re-ships ~1 MiB and does not re-publish the shells or the LLM plugins.
| Plane | Tag | Publishes |
|---|---|---|
| shell | vX.Y.Z | jafar-shell (the interactive shell), jfr-shell-jdk, jfr-shell-jafar plugins |
| core | core/vX.Y.Z | jafar-parser (+core/codegen), jafar-tools, jafar-gradle-plugin; also tags go-parser/vX.Y.Z |
| mcp | mcp/vX.Y.Z | jfr-mcp |
| llm-anthropic | llm-anthropic/vX.Y.Z | llm-anthropic |
| llm-openai | llm-openai/vX.Y.Z | llm-openai |
To release a plane: update CHANGELOG.md, commit to main, then
scripts/release.sh [--dry-run] [plane] <major|minor|patch>. The script derives the version
from the newest tag of that plane (the version script is the single source of truth: no version
is ever stored in a build file), pushes the tag, and the release workflow publishes that plane's
artifacts, moves that plane's plugin-catalog and JBang-catalog entries, and tags the Go module
on core releases.
Every artifact is self-contained or declares its dependencies: the two fat jars (the shell and
the MCP server) record the exact io.btrace modules and versions they embed in their manifest
(unzip -p jar META-INF/MANIFEST.MF | grep Embedded-Modules), so an app's release notes tell
you which parser it got. Backend and LLM plugins resolve the shell's interfaces at run time, so
they release independently unless the SPI breaks - then every SPI-consuming plane tags the same
day, each with its own number.
Development builds report <newest tag of the plane>-SNAPSHOT; the full process, including the
bootstrap of the plane tags and emergency releases, is in RELEASING.md.
Contributions are welcome! Please read CONTRIBUTING.md for guidelines.
To report security vulnerabilities, see SECURITY.md (do not create public issues).
Apache 2.0 (see LICENSE).
504 followers · starred Feb 2026
248 followers · starred Apr 2025
295 followers · starred Dec 2024
617 followers · starred May 2026
Fast, modern JFR (Java Flight Recorder) parser for the JVM with a small, focused API.
Status: Early public release (0.x) - API may evolve based on feedback. See CHANGELOG.md for details.
JAFAR provides both typed (interface-based) and untyped (Map-based) APIs for parsing JFR recordings with minimal ceremony. It emphasizes performance, low allocation, and ease of use.
./get_resources.sh./gradlew build./gradlew shadowJarDefine a Java interface per JFR type and annotate with @JfrType. Methods correspond to event fields; use @JfrField to map differing names and @JfrIgnore to skip fields.
import io.jafar.parser.api.*;
import java.nio.file.Paths;
@JfrType("custom.MyEvent")
public interface MyEvent { // no base interface required
String myfield();
}
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
HandlerRegistration<MyEvent> reg = p.handle(MyEvent.class, (e, ctl) -> {
System.out.println(e.myfield());
long pos = ctl.stream().position(); // current byte position while in handler
// ctl.abort(); // optionally stop parsing immediately without throwing
});
p.run();
reg.destroy(p); // deregister
}
Notes:
run().ctl.abort() inside a handler to stop parsing early without an exception.Receive events as Map<String, Object> with nested maps/arrays when applicable.
import io.jafar.parser.api.*;
import java.nio.file.Paths;
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/path/to/recording.jfr"))) {
HandlerRegistration<?> reg = p.handle((type, value) -> {
if ("jdk.ExecutionSample".equals(type.getName())) {
// You can retrieve the value by providing 'path' -> "eventThread", "javaThreadId"
Object threadId = Values.get(value, "eventThread", "javaThreadId");
// You can also get the value conveniently typed - for primitive values you need to use the boxed type in the call
long threadIdLong = Values.as(value, Long.class, "eventThread", "javaThreadId");
// use threadId ...
}
});
p.run();
reg.destroy(p);
}
Map<String, Object> or as a wrapper implementing io.jafar.parser.api.ComplexType (e.g., constant-pool backed references). Use getValue() on a ComplexType to obtain the resolved Map<String, Object>.io.jafar.parser.api.ArrayType. Use getType() to inspect the array class (e.g., int[].class, Object[].class) and getArray() to access the underlying Java array.Examples:
import io.jafar.parser.api.*;
import java.util.Map;
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/path/to/recording.jfr"))) {
p.handle((type, value) -> {
// ComplexType: constant-pool backed references (e.g., eventThread)
Map<String, Object> thread = Values.as(value, Map.class, "eventThread").orElse(null);
if (thread != null) {
System.out.println("thread id=" + thread.get("javaThreadId") + ", name=" + thread.get("name"));
}
// ArrayType: arrays of primitives, Strings, maps, or ComplexType elements
Object framesVal = Values.get(value, "stackTrace", "frames");
// You can also reference the array elements directly
Object firstFrame = Values.get(value, "stackTrace", "frames", 0);
if (framesVal instanceof ArrayType at) {
Object arr = at.getArray();
if (arr instanceof Object[] objs) {
for (Object el : objs) {
if (el instanceof ComplexType cpx) {
Map<String, Object> m = cpx.getValue();
// use fields from the resolved element
} else if (el instanceof Map) {
Map<String, Object> m = (Map<String, Object>) el; // inline complex value
} else {
// primitive wrapper or String
}
}
} else if (arr instanceof int[] ints) {
for (int i : ints) { /* ... */ }
} else if (arr instanceof long[] longs) {
for (long l : longs) { /* ... */ }
}
}
});
p.run();
}
go-parser/)A pure Go port of the untyped parser lives in go-parser/, as a
standalone Go module (github.com/btraceio/jafar/go-parser) that is not part of the Gradle build.
It parses recordings and hands events to a callback as map[string]any, with the same value model,
tick normalisation and lazy constant-pool resolution as the Java untyped API. It has no external
dependencies and covers the parser only - no JfrPath, no shell, no CLI.
parser, err := jfr.Open("recording.jfr")
if err != nil {
log.Fatal(err)
}
err = parser.ParseWith(jfr.Options{
TypeFilter: func(t *jfr.ClassType) bool { return t.Name == "jdk.ExecutionSample" },
}, func(e *jfr.Event) error {
thread, _ := jfr.GetString(e.Values, "sampledThread", "javaName")
method, _ := jfr.GetString(e.Values, "stackTrace", "frames", 0, "method", "name", "string")
fmt.Println(thread, method)
return nil
})
The typed API is not ported: it relies on run-time bytecode generation for interfaces discovered at run time, which has no Go equivalent. See go-parser/README.md for the full value model, error handling contract and the differences from the Java parser.
JAFAR now supports build-time handler generation via annotation processor, providing massive performance benefits for production applications.
Benchmark Results:
Compile-time: Annotation processor scans @JfrType interfaces and generates:
Runtime: Parser auto-discovers factories via ServiceLoader, handlers are reused via thread-local cache
Gradle:
dependencies {
implementation 'io.btrace:jafar-parser:0.11.0'
annotationProcessor 'io.btrace:jafar-processor:0.11.0'
}
Maven:
<dependencies>
<dependency>
<groupId>io.btrace</groupId>
<artifactId>jafar-parser</artifactId>
<version>0.11.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.btrace</groupId>
<artifactId>jafar-processor</artifactId>
<version>0.11.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
// Must be top-level interfaces (not nested/inner classes)
@JfrType("jdk.ExecutionSample")
public interface JFRExecutionSample {
@JfrField("startTime")
long startTime();
@JfrField("sampledThread")
JFRThread sampledThread();
}
@JfrType("java.lang.Thread")
public interface JFRThread {
@JfrField("javaThreadId")
long javaThreadId();
@JfrField("javaName")
String javaName();
}
Note: Annotation processor only processes top-level interfaces. Nested/inner classes with @JfrType are skipped and use runtime generation instead.
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
// Factories automatically discovered via ServiceLoader - no registration needed!
// Handle events (uses thread-local cached handlers)
p.handle(JFRExecutionSample.class, (event, ctl) -> {
JFRThread thread = event.sampledThread();
if (thread != null) {
System.out.println("Thread: " + thread.javaName());
}
});
p.run();
}
That's it! The annotation processor generates factories and registers them via ServiceLoader. No manual registration required.
If you don't register factories, JAFAR falls back to runtime bytecode generation (existing behavior):
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
// No factory registration - handlers generated at runtime via ASM
p.handle(JFRExecutionSample.class, (event, ctl) -> {
// Handler generated on first use, cached globally
System.out.println("Event: " + event.startTime());
});
p.run();
}
✅ Use build-time generation when:
✅ Use runtime generation when:
For processing 1 million ExecutionSample events:
| Metric | Runtime Generation | Build-Time Generation | Benefit |
|---|---|---|---|
| Total Allocations | ~223 GB | ~37 GB | -186 GB |
| GC Collections | ~600-800 | ~50-100 | -750 GC pauses |
| GC Pause Time | ~2-3 seconds | ~200-300ms | -2.7 seconds |
| Throughput | ~189k events/sec | ~187k events/sec | Equivalent |
For the architecture of the parser module, see the Parser Architecture.
JafarParser
newTypedParser(Path) / newUntypedParser(Path): start a session.withParserListener(ChunkParserListener): observe low-level parse events (advanced, see below).run(): parse and invoke registered handlers.TypedJafarParser
handle(Class<T>, JFRHandler<T>) -> HandlerRegistration<T>open(String|Path[, ParsingContext]) are also available, but prefer JafarParser.newTypedParser(Path).UntypedJafarParser
handle(UntypedJafarParser.EventHandler) -> HandlerRegistration<?>open(String|Path[, ParsingContext]) also available.ArrayType: wrapper around arrays. getType() returns the array class; getArray() returns the backing Java array.ComplexType: wrapper around complex values. getValue() resolves to a Map<String, Object>. Note that some complex fields may be provided inline as a Map without a wrapper.ParsingContext
create(): build a reusable context.newTypedParser(Path) / newUntypedParser(Path): create parsers bound to the shared context.uptime(): cumulative processing time across sessions using the context.Control
stream().position(): current byte position while a handler executes.abort(): stop parsing immediately (no exception thrown).chunkInfo(): chunk metadata with startTime(), duration(), size(), and convertTicks(long, TimeUnit).
convertTicks(...)? JFR records many time values in chunk-relative ticks. Converting on demand avoids creating Instant/Duration objects for every event, minimizing allocation and GC pressure when a scalar value suffices. Convert only when needed and to the unit you need.MethodHandles.Lookup#defineHiddenClass (fastest, unloadable)MethodHandles.Lookup#defineClass(byte[]) (good)sun.misc.Unsafe#defineAnonymousClass (compatible; slightly heavier)parser artifact is a Multi‑Release JAR:
META-INF/versions/21 and restore faster implementations (e.g., zero‑copy ByteBuffer slicing, Arrays.equals range checks, Files.writeString, etc.).@JfrType("<fq.type>"): declare the JFR type an interface represents.@JfrField("<jfrField>", raw = false): map differing names or request raw representation.@JfrIgnore: exclude a method from mapping.ParsingContext ctx = ParsingContext.create();
try (TypedJafarParser p = ctx.newTypedParser(Paths.get("/path/to.a.jfr"))) {
p.handle(MyEvent.class, (e, ctl) -> {/*...*/});
p.run();
}
try (TypedJafarParser p = ctx.newTypedParser(Paths.get("/path/to.b.jfr"))) {
p.handle(MyEvent.class, (e, ctl) -> {/*...*/});
p.run();
}
System.out.println("uptime(ns)=" + ctx.uptime());
AtomicInteger seen = new AtomicInteger();
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to.jfr"))) {
HandlerRegistration<JFRJdkExecutionSample> reg =
p.handle(JFRJdkExecutionSample.class, (e, ctl) -> {
if (seen.incrementAndGet() >= 1000) {
ctl.abort(); // stop without throwing
}
});
p.run();
reg.destroy(p);
}
// Some fields are expressed in JFR ticks. Convert them only when needed.
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/file.jfr"))) {
p.handle((type, value, ctl) -> {
long ticksObj = value.get("startTime"); // example field holding ticks
long nanos = ctl.chunkInfo().convertTicks(n.longValue(), TimeUnit.NANOSECONDS);
// Use nanos directly, or wrap as Instant only when necessary
Instant startTs = ctl.chunkInfo().startTime().plusNanos(nanos);
// use the startTs instant ...
});
p.run();
}
import io.jafar.parser.internal_api.ChunkParserListener;
import io.jafar.parser.internal_api.metadata.MetadataEvent;
p.withParserListener(new ChunkParserListener() {
@Override public boolean onMetadata(ParserContext c, MetadataEvent md) {
// inspect metadata per chunk
return true; // continue
}
}).run();
Plugin id: io.btrace.jafar-gradle-plugin
Adds task generateJafarTypes and wires it to compileJava. It can generate interfaces for selected JFR types from either the current JVM metadata (default) or a .jfr file.
Generated class naming: The generator includes the full namespace in generated interface names to avoid collisions. For example:
jdk.ExecutionSample → JFRJdkExecutionSampledatadog.ExecutionSample → JFRDatadogExecutionSamplejdk.gc.HeapSummary → JFRJdkGcHeapSummaryThis ensures that events with the same simple name but different namespaces generate distinct interfaces.
plugins {
id 'io.btrace.jafar-gradle-plugin' version '0.11.0'
}
repositories {
mavenCentral()
mavenLocal()
maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
}
generateJafarTypes {
// Optional: use a JFR file to derive metadata; otherwise JVM runtime metadata is used
inputFile = file('/path/to/recording.jfr')
// Optional: where to generate sources (default: build/generated/sources/jafar/src/main)
outputDir = project.file('src/main/java')
// Optional: do not overwrite existing files (default: false)
overwrite = false
// Optional: filter event types by name (closure gets fully-qualified JFR type name)
eventTypeFilter {
it.startsWith('jdk.') && it != 'jdk.SomeExcludedType'
}
// Package for generated interfaces (default: io.jafar.parser.api.types)
targetPackage = 'com.acme.jfr.types'
}
You can also provide the input file via a project property: -Pjafar.input=/path/to/recording.jfr.
.jfrio.jafar.tools.Scrubber can scrub selected string fields in-place while copying to a new file.
Example: scrub the value of jdk.InitialSystemProperty.value when key == 'java.home'.
import static io.jafar.tools.Scrubber.scrubFile;
import io.jafar.tools.Scrubber.ScrubField;
import java.nio.file.Paths;
scrubFile(Paths.get("/in.jfr"), Paths.get("/out-scrubbed.jfr"),
clz -> {
if (clz.equals("jdk.InitialSystemProperty")) {
return new ScrubField("key", "value", (k, v) -> "java.home".equals(k));
}
return null; // no scrubbing for other classes
}
);
Build and run the demo application:
# First you need to publish parser, tools and plugin to local maven
cd demo
./build.sh
java -jar build/libs/jafar-demo-all.jar [jafar|jmc|jfr|jfr-stream] /path/to/recording.jfr
On an M1 and a ~600MiB JFR, the Jafar parser completes in ~1s vs ~7s with JMC (anecdotal). The stock jfr tool may OOM when printing all events.
JAFAR includes jfr-shell, an interactive CLI for exploring and analyzing JFR files with a powerful query language. See jfr-shell/README.md for features and installation.
# Install via JBang (easiest)
jbang app install jfr-shell@btraceio
# Open and analyze a recording
jfr-shell recording.jfr
jfr> events/jdk.ExecutionSample | groupBy(thread/name)
jfr> events/jdk.FileRead | top(10, by=bytes)
jfr> events/jdk.ExecutionSample | flamegraph()
# Event decoration: correlate samples with lock waits
jfr> events/jdk.ExecutionSample | decorateByTime(jdk.JavaMonitorWait, fields=monitorClass)
See Event Decoration and Joining for advanced correlation and joining capabilities.
ask — or ? for short — investigates: it runs a query, reads the result, decides what to look at
next, and concludes.
jfr> ? why is this workload slow
> events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(3, by=count)
3 rows
| count | key |
+-------+----------+
| 8412 | main |
| 210 | worker-1 |
The samples concentrate on one thread, so the next step is that thread's call sites
rather than more parallelism.
Transcript: ~/.jafar/investigations/ask-20260913-202249.jfrs
as-query is the one-shot form — one question, one query, shown and run:
jfr> as-query which threads used the most CPU?
# Groups execution samples by thread name and ranks the ten busiest.
events/jdk.ExecutionSample | groupBy(sampledThread/javaName) | top(10, by=count)
Every query is printed either way — so a wrong guess is visible, and you learn JfrPath as you go —
and an investigation writes the queries it ran to a re-runnable .jfrs script, so its conclusion
can be checked rather than trusted. The recording itself never leaves your machine: the model
composes the queries, the shell runs them.
Three ways to authenticate, in the order most people want them:
# 1. A key in a file only you can read — no environment variable, no CLI to install
mkdir -p ~/.config/jafar
printf 'llm.api-key = sk-ant-...\n' > ~/.config/jafar/llm.properties
chmod 600 ~/.config/jafar/llm.properties
# 2. Or the provider's environment variable
export ANTHROPIC_API_KEY=sk-ant-...
# 3. Or keylessly, if you have the Anthropic CLI (optional — note the plural 'anthropics')
brew install anthropics/tap/ant && ant auth login
Or none of the above: set llm.backend = ollama runs a local model, and nothing leaves the machine.
ask --dry-run <question> prints exactly what would be sent without sending it, and result data is
redacted by default. See LLM setup,
the tutorial and what leaves your machine.
jafar-perf adds the methodology the tools do not carry: nine skills (triage, cpu, latency,
gc, memory-leak, heap-diff, compare, jfrpath, report) and seven agents that know which
analysis to run on an unfamiliar recording or heap dump, not just how to run one.
/plugin marketplace add btraceio/agent-plugins
/plugin install jafar-perf@btraceio-agent-plugins
The plugin bundles .mcp.json, so installing it also registers the jafar MCP server described
below — no separate claude mcp add is needed. JBang must be on your PATH;
it fetches the server on first use.
It lives in btraceio/agent-plugins, the btraceio
agent-plugin marketplace, not in this repository: adding a marketplace clones its repository, and
there is no reason to pull Jafar's binary test recordings onto a machine that only wants the skills.
(It used to live in btraceio/jafar-perf-box; re-running the installer below moves such an install
over, or switch by hand with /plugin marketplace remove btraceio and the two commands above.)
JAFAR includes an MCP (Model Context Protocol) server that enables AI agents like Claude to analyze JFR recordings. See jfr-mcp/README.md for details.
Installing the plugin above already registers it; the rest of this section is for using the server on its own, or from a client other than Claude Code.
curl -Ls https://raw.githubusercontent.com/btraceio/jafar/main/install.sh | bash
This installs JBang (if needed) and the jfr-mcp command, then registers
the jafar-perf plugin with every supported agent
harness on your PATH. The plugin brings the MCP server plus analysis skills (and, in Claude Code,
specialist subagents), so no separate MCP registration is needed.
| Harness | What the installer does |
|---|---|
Claude Code (claude) | claude plugin marketplace add btraceio/agent-plugins, then claude plugin install jafar-perf@btraceio-agent-plugins |
pi (pi) | pi install npm:pi-mcp-adapter (pi has no built-in MCP support), then pi install git:github.com/btraceio/agent-plugins (which also brings the other btraceio skills). The Claude Code subagents are not available in pi. |
Options go after bash -s --:
curl -Ls https://raw.githubusercontent.com/btraceio/jafar/main/install.sh | bash -s -- --harness claude
| Option | Effect |
|---|---|
--harness <list> | Register only these harnesses (claude, pi, or all) |
--no-harness | Install jfr-mcp only |
--daemon | Also run jfr-mcp as a supervised SSE daemon (doc/mcp/Daemon.md) |
--dev | Install the development snapshot (jfr-mcp-dev); the harness plugins still run the stable release |
Re-running the installer updates everything in place. To install only the server, use
jfr-mcp/install.sh, which takes --daemon and JFR_MCP_DEV=1 the same way.
Without the plugin, register just the server:
claude mcp add jafar -- jbang jfr-mcp@btraceio --stdio
{
"mcpServers": {
"jafar": {
"command": "jbang",
"args": ["jfr-mcp@btraceio", "--stdio"]
}
}
}
JAFAR now supports heap dump (HPROF) analysis using the same interactive shell. Query objects, classes, and GC roots with the HdumpPath query language.
# Open a heap dump
jfr-shell dump.hprof
jfr> objects | count
| count |
|---------|
| 1923456 |
jfr> objects | groupBy(class, agg=count) | top(10, count)
| class | count |
|---------------------------|---------|
| java.util.HashMap$Node | 249,734 |
| java.lang.String | 238,750 |
| java.lang.Object[] | 156,234 |
jfr> objects/java.lang.String[shallow > 1KB] | stats(shallow)
| count | sum | min | max | avg |
|-------|-----------|------|--------|--------|
| 1,234 | 2,456,789 | 1024 | 65,536 | 1,991 |
jfr> gcroots | groupBy(type) | sortBy(count desc)
| type | count |
|-------------|-------|
| JAVA_FRAME | 2,345 |
| THREAD_OBJ | 1,234 |
join(session=id) compares two heap dumps side-by-side1KB, 1MB, 1GB in predicates# Objects by class
objects/java.lang.String | top(10, shallow)
# Include subclasses
objects/instanceof/java.util.Map | groupBy(class)
# Filter with predicates
objects[shallow > 1MB and class ~ "com.myapp.*"]
# Class metadata
classes[instanceCount > 1000] | sortBy(instanceCount desc)
# GC roots
gcroots/THREAD_OBJ | select(type, object, threadSerial)
See doc/hdump-shell-quickstart.md for quick start and doc/hdumppath.md for complete reference.
Releases are split into planes: each plane has its own tag, its own version and publishes only its own artifacts to Maven Central, so a parser-only fix re-ships ~1 MiB and does not re-publish the shells or the LLM plugins.
| Plane | Tag | Publishes |
|---|---|---|
| shell | vX.Y.Z | jafar-shell (the interactive shell), jfr-shell-jdk, jfr-shell-jafar plugins |
| core | core/vX.Y.Z | jafar-parser (+core/codegen), jafar-tools, jafar-gradle-plugin; also tags go-parser/vX.Y.Z |
| mcp | mcp/vX.Y.Z | jfr-mcp |
| llm-anthropic | llm-anthropic/vX.Y.Z | llm-anthropic |
| llm-openai | llm-openai/vX.Y.Z | llm-openai |
To release a plane: update CHANGELOG.md, commit to main, then
scripts/release.sh [--dry-run] [plane] <major|minor|patch>. The script derives the version
from the newest tag of that plane (the version script is the single source of truth: no version
is ever stored in a build file), pushes the tag, and the release workflow publishes that plane's
artifacts, moves that plane's plugin-catalog and JBang-catalog entries, and tags the Go module
on core releases.
Every artifact is self-contained or declares its dependencies: the two fat jars (the shell and
the MCP server) record the exact io.btrace modules and versions they embed in their manifest
(unzip -p jar META-INF/MANIFEST.MF | grep Embedded-Modules), so an app's release notes tell
you which parser it got. Backend and LLM plugins resolve the shell's interfaces at run time, so
they release independently unless the SPI breaks - then every SPI-consuming plane tags the same
day, each with its own number.
Development builds report <newest tag of the plane>-SNAPSHOT; the full process, including the
bootstrap of the plane tags and emergency releases, is in RELEASING.md.
Contributions are welcome! Please read CONTRIBUTING.md for guidelines.
To report security vulnerabilities, see SECURITY.md (do not create public issues).
Apache 2.0 (see LICENSE).
504 followers · starred Feb 2026
248 followers · starred Apr 2025
295 followers · starred Dec 2024
617 followers · starred May 2026