Overview
The script command enables powerful, programmable profile analysis using Starlark (a Python dialect). Use scripts to:
- Implement custom analysis logic
- Automate CI performance checks
- Build advanced filtering and aggregation
- Generate custom reports
- Compare time windows within recordings
Starlark is Python-like but simplified: no classes, no imports, no exceptions, but with functions, loops, conditionals, and rich standard libraries.
Usage
# Run inline script
ap-query script -c '<code>' [-- ARGS...]
# Run script file
ap-query script <file.star> [-- ARGS...]
# With flags
ap-query script [-c CODE | FILE] [--timeout DURATION] [-- ARGS...]
Flags
Execute inline Starlark code (alternative to providing a file)
Script execution timeout (e.g., “1m”, ”90s”)
Arguments passed to the script (accessible via ARGS list)
Quick Start
Hello World
ap-query script -c 'print("Hello from Starlark!")'
Load and Analyze a Profile
ap-query script -c '
p = open("profile.jfr")
for m in p.hot(5):
print(m.name, str(m.self_pct) + "%")
'
Run Script File
Create analyze.star:
p = open("profile.jfr")
print("Total samples:", p.samples)
print("Duration:", p.duration, "seconds")
for m in p.hot(10):
print("%5.1f%% %s" % (m.self_pct, m.name))
Run it:
ap-query script analyze.star
Predeclared Globals
ARGS
ARGS is a list of strings containing script arguments:
ap-query script myscript.star -- profile.jfr 10 --verbose
In myscript.star:
print(ARGS) # ["profile.jfr", "10", "--verbose"]
profile_path = ARGS[0]
top_n = int(ARGS[1])
verbose = len(ARGS) > 2 and ARGS[2] == "--verbose"
Built-in Functions
Starlark includes standard built-ins:
- Iteration:
len, sorted, enumerate, range, reversed
- Conversion:
str, int, float, list, dict, bool
- Aggregation:
min, max, sum, any, all
- Inspection:
type, hasattr, dir, repr
- Functional:
zip, map (via list comprehensions)
Core Functions
open()
Load a profile:
open(path, event="cpu", start="", end="", thread="") → Profile
Path to profile file (JFR, pprof, or collapsed text). Use "-" for stdin.
Event type: cpu, wall, alloc, lock, or hardware counter
Time window start (JFR only), e.g., "5s", "1m30s"
Time window end (JFR only)
Filter to threads matching substring
Examples:
# Basic
p = open("profile.jfr")
# Specific event
p = open("profile.jfr", event="alloc")
# Time window
p = open("profile.jfr", start="1m", end="2m")
# Thread filter
p = open("profile.jfr", thread="worker")
# Combined
p = open("profile.jfr", event="wall", start="30s", thread="http")
diff()
Compare two profiles:
diff(a, b, min_delta=0.5, top=0, fqn=False) → Diff
Minimum percentage change to include
Limit entries per category (0 = unlimited)
Use fully-qualified names
Example:
before = open("before.jfr")
after = open("after.jfr")
d = diff(before, after, min_delta=1.0)
for e in d.regressions:
print(e.name, "+" + str(e.delta) + "%")
emit() and emit_all()
Output stacks in collapsed format:
emit(stack) # Output one stack
emit_all(profile) # Output all stacks
Example pipeline:
p = open("profile.jfr")
for s in p.stacks:
if s.has("HashMap"):
emit(s)
Run and pipe:
ap-query script filter.star | ap-query hot -
match()
Regex matching:
match(string, pattern) → bool
Example:
if match(s.leaf.name, r".*Service$"):
print("Service method:", s.leaf.name)
round()
Round floats (Starlark lacks printf precision):
round(x, decimals=0) → float
Example:
print(str(round(42.6789, 2)) + "%") # "42.68%"
ljust() and rjust()
String alignment (Starlark lacks printf width):
ljust(value, width) → string
rjust(value, width) → string
Example:
for m in p.hot(5):
pct = rjust(round(m.self_pct, 1), 6)
print(pct + "% " + m.name)
Output:
18.2% HashMap.resize
12.1% String.concat
9.7% ArrayList.grow
fail() and warn()
Error handling:
fail(msg) # Print to stderr, exit code 1
warn(msg) # Print to stderr, continue
Example:
p = open("profile.jfr")
if p.samples < 1000:
fail("Too few samples: " + str(p.samples))
Types
Profile
A loaded profile scoped to one event type.
Fields:
p.stacks # list[Stack] - all stacks
p.samples # int - total sample count
p.duration # float - seconds (0 if unavailable)
p.start # float - seconds from recording start
p.end # float - seconds from recording start
p.event # string - selected event type
p.events # list[string] - all available events
p.path # string - source file path
Methods:
p.hot(n=all, fqn=False, sort="self") → list[Method]
# Top methods by self time or total time
# sort: "self" (default) or "total"
p.threads(n=all) → list[Thread]
# Thread sample distribution
p.filter(fn) → Profile
# Keep stacks where fn(stack) returns True
p.group_by(fn) → dict[str, Profile]
# Partition by fn(stack) → key. fn returning None excludes.
p.timeline(resolution="10s", buckets=None) → list[Bucket]
# Time-series buckets (JFR only)
# resolution: duration string or keyword arg with numeric seconds
p.split(times) → list[Profile]
# Split at time boundaries (JFR only)
# times: list of floats (seconds) or duration strings
p.tree(method="", depth=4, min_pct=1.0) → string
# Call tree from method (or root if "")
p.trace(method, min_pct=0.5, fqn=False) → string
# Hottest path from method
p.callers(method, depth=4, min_pct=1.0) → string
# Callers tree toward root
p.no_idle() → Profile
# Remove idle leaf frames
p.summary() → string
# One-line summary
Example:
p = open("profile.jfr")
print(p.summary())
# Top 5 by self time
for m in p.hot(5):
print(m.name, m.self_pct)
# Top 5 by total time
for m in p.hot(5, sort="total"):
print(m.name, m.total_pct)
# Filter
serialize = p.filter(lambda s: s.has("Serialize"))
print("Serialization samples:", serialize.samples)
# Group by thread pool
groups = p.group_by(lambda s: s.thread.split("-")[0] if s.thread else None)
for name, prof in groups.items():
print(name, prof.samples)
Stack
A call stack with sample count.
Fields:
s.frames # list[Frame] - frames[0] is root, frames[-1] is leaf
s.thread # string - thread name
s.samples # int - sample count
s.leaf # Frame or None
s.root # Frame or None
s.depth # int - frame count
Methods:
s.has(pattern) → bool
# Substring match on any frame (short name or FQN)
s.has_seq(p1, p2, ...) → bool
# Patterns appear in order (not necessarily adjacent)
s.above(pattern) → list[Frame]
# Frames toward leaf from first match
# above[0] is direct callee
s.below(pattern) → list[Frame]
# Frames toward root from first match
# below[-1] is direct caller
s.thread_has(pattern) → bool
# Substring match on thread name
Example:
for s in p.stacks:
if s.has("HashMap") and s.depth > 10:
print(s.thread, s.samples, s.leaf.name)
if s.has_seq("Service.handle", "HashMap.put"):
print("Service → HashMap path found")
if s.leaf.name == "__sched_yield":
callers = s.below("__sched_yield")
if len(callers) > 0:
print("Yield called by:", callers[-1].name)
Frame
A single stack frame.
Fields:
f.name # string - short name (e.g., "HashMap.put")
f.fqn # string - fully-qualified (e.g., "java.util.HashMap.put")
f.pkg # string - package (e.g., "java.util")
f.cls # string - class (e.g., "HashMap")
f.method # string - method (e.g., "put")
f.line # int - source line (0 if unavailable)
Example:
for s in p.stacks:
leaf = s.leaf
if leaf and leaf.pkg.startswith("com.example"):
loc = leaf.name
if leaf.line > 0:
loc += ":" + str(leaf.line)
print(loc, s.samples)
Method
Returned by Profile.hot().
Fields:
m.name # string - method name
m.fqn # string - fully-qualified name
m.self # int - self samples
m.self_pct # float - self percentage
m.total # int - total samples (self + descendants)
m.total_pct # float - total percentage
Example:
for m in p.hot(10):
print("%5.1f%% self, %5.1f%% total %s" % (m.self_pct, m.total_pct, m.name))
Thread
Returned by Profile.threads().
Fields:
t.name # string - thread name
t.samples # int - sample count
t.pct # float - percentage of total
Example:
for t in p.threads(5):
print(t.name, str(t.pct) + "%", t.samples)
Bucket
Returned by Profile.timeline().
Fields:
b.start # float - start time in seconds
b.end # float - end time in seconds
b.samples # int - sample count
b.stacks # list[Stack] - stacks in this bucket
b.label # string - formatted time range (e.g., "4m20.0s-4m30.0s")
b.profile # Profile - full profile for this bucket
Methods:
b.hot(n=5, sort="self") → list[Method]
# Top methods in this bucket
Example:
p = open("profile.jfr")
buckets = p.timeline(resolution="10s")
for b in buckets:
print(b.label, "samples:", b.samples)
if b.samples > 0:
top = b.hot(1)
if len(top) > 0:
print(" Top:", top[0].name, str(top[0].self_pct) + "%")
Diff
Returned by diff().
Fields:
d.regressions # list[DiffEntry] - got worse
d.improvements # list[DiffEntry] - got better
d.added # list[DiffEntry] - new in second profile
d.removed # list[DiffEntry] - only in first profile
d.all # list[DiffEntry] - all above, sorted by |delta|
Example:
d = diff(before, after, min_delta=2.0)
if len(d.regressions) > 0:
print("REGRESSIONS:")
for e in d.regressions:
print(" %s: %.1f%% → %.1f%% (+%.1f%%)" %
(e.name, e.before, e.after, e.delta))
DiffEntry
Entry in a Diff.
Fields:
e.name # string - method name
e.fqn # string - fully-qualified name
e.before # float - percentage in first profile
e.after # float - percentage in second profile
e.delta # float - change (after - before)
Examples
Fail if serialization exceeds 10%:
p = open(ARGS[0])
ser = p.filter(lambda s: s.has("Serialization"))
pct = 100.0 * ser.samples / p.samples
if pct > 10.0:
fail("Serialization too high: " + str(round(pct, 1)) + "%")
print("Serialization OK: " + str(round(pct, 1)) + "%")
Run:
ap-query script check-budget.star -- profile.jfr
Window Comparison
Compare early vs late execution:
p = open("profile.jfr")
parts = p.split([p.duration / 2])
d = diff(parts[0], parts[1])
if len(d.regressions) > 0:
print("Methods got worse in second half:")
for e in d.regressions:
print(" " + e.name + " +" + str(round(e.delta, 1)) + "%")
else:
print("No significant regressions")
Timeline Hot Methods
Show top method per time bucket:
p = open("profile.jfr")
buckets = p.timeline(resolution="30s")
for b in buckets:
if b.samples > 0:
top = b.hot(1)
if len(top) > 0:
print(b.label, top[0].name, str(round(top[0].self_pct, 1)) + "%")
Thread Pool Analysis
Group by thread pool prefix:
p = open("profile.jfr")
groups = p.group_by(lambda s: s.thread.split("-")[0] if s.thread else None)
print("Thread pool distribution:")
for name in sorted(groups.keys()):
prof = groups[name]
pct = 100.0 * prof.samples / p.samples
print(" " + ljust(name, 20) + rjust(str(round(pct, 1)) + "%", 8))
Caller Analysis
Find who calls a leaf method:
p = open("profile.jfr")
callers = {}
for s in p.stacks:
if s.leaf and s.leaf.name == "__sched_yield":
below = s.below("__sched_yield")
if len(below) > 0:
caller = below[-1].name
callers[caller] = callers.get(caller, 0) + s.samples
for name, count in sorted(callers.items(), key=lambda x: x[1], reverse=True)[:10]:
print(str(count) + " " + name)
Custom Report
Generate formatted report:
p = open(ARGS[0])
print("=" * 60)
print("Profile Report")
print("=" * 60)
print("File:", p.path)
print("Event:", p.event)
print("Samples:", p.samples)
print("Duration:", str(round(p.duration, 1)) + "s")
print()
print("Top 10 Methods (self time):")
for i, m in enumerate(p.hot(10)):
pct = rjust(str(round(m.self_pct, 1)) + "%", 7)
print(str(i+1) + ". " + pct + " " + m.name)
print()
print("Top 5 Threads:")
for t in p.threads(5):
pct = rjust(str(round(t.pct, 1)) + "%", 7)
print(" " + pct + " " + t.name)
Starlark Language Notes
# Basic interpolation
name = "World"
print("Hello " + name)
# % formatting (no width/precision)
print("Value: %d" % 42)
print("Name: %s, Count: %d" % (name, 10))
# Use ljust/rjust for alignment, round() for precision
print(rjust(str(round(3.14159, 2)), 8)) # " 3.14"
Loops
# For loop
for m in p.hot(5):
print(m.name)
# While loop
i = 0
while i < 10:
print(i)
i += 1
# Range
for i in range(10):
print(i)
# Enumerate
for i, m in enumerate(p.hot(5)):
print(str(i+1) + ". " + m.name)
Conditionals
if p.samples > 1000:
print("Large profile")
elif p.samples > 100:
print("Medium profile")
else:
print("Small profile")
Dictionaries
counts = {}
for s in p.stacks:
key = s.thread
counts[key] = counts.get(key, 0) + s.samples
for name, count in counts.items():
print(name, count)
# Check key
if "worker-1" in counts:
print(counts["worker-1"])
Lists
items = [1, 2, 3, 4, 5]
print(len(items))
print(items[0]) # 1
print(items[-1]) # 5
print(items[1:3]) # [2, 3]
# Append
items.append(6)
# Sort
sorted_items = sorted(items)
# Reverse
for i in reversed(items):
print(i)
Lambdas
# Filter
workers = p.filter(lambda s: "worker" in s.thread)
# Group by
groups = p.group_by(lambda s: s.thread.split("-")[0] if s.thread else None)
# Sorted with key
sorted_methods = sorted(methods, key=lambda m: m.samples, reverse=True)
No Try/Except
Starlark has no exception handling. Validate inputs explicitly:
if len(ARGS) == 0:
fail("Usage: script.star <profile>")
path = ARGS[0]
p = open(path) # Error exits script automatically
Timeline caching: Calling timeline() on a profile the first time parses timestamps. Subsequent calls on the same profile (or filtered variants) reuse cached data.
Filter then timeline: Filter first, then timeline to avoid parsing timestamps for stacks you’ll discard.
Timeout for long scripts: Use --timeout 5m for scripts analyzing large files.
Limitations
No I/O: Starlark scripts cannot read/write files directly (except via open() for profiles). All output must go to stdout.
No imports: You cannot import other scripts or modules. Keep logic self-contained or use helper functions within the same file.
No network: No HTTP, no sockets. Scripts are sandboxed for safety.
See Also
- hot - Programmatically via
Profile.hot()
- diff - Programmatically via
diff()
- filter - Programmatically via
Profile.filter()
- collapse - Programmatically via
emit_all()