flame-graph-analyzer
Reads CPU flame-graph output from py-spy (Python), async-profiler (JVM), Go pprof, or Node.js `perf_hooks` / clinic.js: identifies the hot path (top sample-time frames), classifies the bottleneck (CPU-bound vs lock contention vs allocator pressure), and proposes the next investigation step. Use when a perf regression is bisected to a commit but the hot path inside it is unclear; for tail-latency percentiles use the latency-percentiles reference in k6-load-testing, and for a slow SQL hot path use db-query-plan-analyzer.
Install with skills.sh (any agent)
npx skills add testland/qa --skill flame-graph-analyzerflame-graph-analyzer
Overview
Canonical flame-graph reference: brendan-gregg-flame (opens in new window). The widest leaf (bottom of the stack) is the hot path.
Each runtime produces flame graphs from its own profiler - py-spy (Python), async-profiler (JVM), Go's built-in pprof, clinic.js flame (Node.js), and perf / eBPF (native C/C++/Rust). This skill is language-agnostic: it consumes the profiler output (SVG, JSON, or folded-stacks .txt) and surfaces a hypothesis the engineer can act on. Per-runtime capture commands live in references/capturing-and-interpreting-flame-graphs.md.
When to use
How to use
Reading the hot path
Read the folded stacks (or extract them from the SVG / JSON), sort by sample count, and identify the top leaves - the bottom-of-stack frames that are the actual working code, not framework wrappers:
# top 5 leaves by sample count
sort -k2 -n -r folded.txt | head -5Each hot leaf's sample-time signature points to one category:
| Category | Signature in the flame graph |
|---|---|
| CPU-bound (hot algo) | A wide leaf in user code (a regex, a JSON serializer, a hash function). |
| Allocator pressure | Wide GC frames (gc::scavenge, Java GC, gc.collect). |
| Lock contention | Wide synchronization frames (pthread_mutex_lock, Object.wait, parking). |
| I/O wait misclassified | On-CPU profilers don't show I/O blocks; switch to wall-clock profiling. |
| Reflection / dynamic dispatch | Wide reflection.invoke, method_missing, getattr chains. |
| Logging overhead | Wide log.format, Logger.debug, serialization for log lines. |
Worked example
A Node.js endpoint regressed after a bisected commit. Capture under load, then read the hot path end to end.
Capture (Node.js clinic.js flame; other runtimes in the reference):
npx clinic flame -- node app.js
# drive load against the process, then stop it; clinic writes flame.html + folded stacksSort the folded stacks and read the top leaves:
sort -k2 -n -r folded.txt | head -5main;handleRequest;serializeJson;JSON.stringify 4521
main;handleRequest;dbQuery;Array.from 2103
main;handleRequest;dbQuery;parseRows 1832
main;authCheck;jwt.verify;crypto.createHash 904
main;handleRequest;serializeJson;Buffer.from 312Emit the analysis:
## Flame graph analysis - `flame.html`
**Runtime:** node
**Profile duration:** 30s under load
| Rank | Sample share | Stack (leaf) | Category |
|-----:|-------------:|---------------------------------------|----------|
| 1 | 38% | `JSON.stringify` (in `serializeJson`) | CPU-bound hot algo |
| 2 | 17% | `Array.from` (in `dbQuery`) | Allocator pressure |
| 3 | 15% | `parseRows` (in `dbQuery`) | CPU-bound hot algo |
| 4 | 8% | `jwt.verify` (in `authCheck`) | CPU-bound hot algo (crypto) |
| 5 | 3% | `Buffer.from` (in `serializeJson`) | Allocator pressure |
### Hypothesis
The serialization path is load-bearing: rank 1 (`JSON.stringify`, 38%) plus
rank 5 (`Buffer.from`, 3%) account for ~41% of sampled time combined.
### Recommended next step
1. Switch to a streaming JSON serializer (`fast-json-stringify` in Node,
`orjson` in Python, Jackson's `JsonGenerator` in the JVM) to eliminate the
intermediate string allocation.
2. Re-profile; expect rank 1 to drop below 10%.
3. Hand off to `perf-budget-gate` to confirm the regression delta closes.More interpreted cases (GC pressure, lock contention, reflection overhead) and the category-to-fix remediation catalog are in references/capturing-and-interpreting-flame-graphs.md.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Reading the SVG visually only, no quantitative data | Easy to mis-judge widths; biases toward dramatic-looking deep stacks. | Always work from folded stacks; sort by sample count. |
| Profiling under load that is too low | One request per second can't expose contention or allocator pressure. | Profile under realistic load - pair with k6-load-testing. |
| Optimizing rank 5 first because rank 1 looks "structural" | Premature optimization; misses the dominant cost. | Always start with rank 1; only descend if rank 1 is genuinely framework-bound (e.g. event_loop). |
| On-CPU profiler for an I/O-bound workload | I/O wait doesn't appear; the flame graph shows what's running, not what's waiting. | Use wall-clock / off-CPU profiling for I/O-bound workloads. |
| Single 30-second capture under highly variable load | The sample is unrepresentative. | Capture multiple samples across the load-test duration; merge. |
Limitations
References
Capturing and interpreting flame graphs per runtime
View source (opens in new window)Capturing and interpreting flame graphs per runtime
Deep reference for flame-graph-analyzer SKILL.md. Consult for the full per-runtime capture wiring, the category-to-fix remediation catalog, and worked interpretation cases beyond the single inline example.
Per-runtime capture wiring
Each runtime produces flame-graph data from its own profiler. Export folded stacks (.txt, one line per unique stack + sample count) wherever the profiler supports it - that is the machine-readable form the analyzer reads.
Python - py-spy
py-spy record -o flame.svg -d 30 --pid <pid>
# or run-and-record
py-spy record -o flame.svg -d 30 -- python app.py
# raw folded stacks for analysis
py-spy record -f raw -o folded.txt -d 30 --pid <pid>JVM - async-profiler
async-profiler (opens in new window):
java -agentpath:/path/to/libasyncProfiler.so=start,event=cpu,duration=30s,file=flame.html ...
# JFR output
java -agentpath:.../libasyncProfiler.so=start,event=cpu,file=profile.jfr ...Output: HTML flame graph or JFR (Java Flight Recorder) format.
Go - pprof
Go's built-in pprof (opens in new window):
# in-process: import _ "net/http/pprof"; http.ListenAndServe(":6060", nil)
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30Open the served URL, then VIEW -> Flame Graph.
Node.js - clinic.js flame
Clinic.js (opens in new window):
npx clinic flame -- node app.js
# writes flame.html when the process exitsNative (C/C++/Rust) - perf
perf record piped through Brendan Gregg's stackcollapse-perf.pl and flamegraph.pl produces folded stacks and the SVG (bg (opens in new window)).
Folded stacks - the universal format
All major profilers can emit folded stacks: one line per unique stack with its sample count.
main;handleRequest;serializeJson;Buffer.from 4521
main;handleRequest;dbQuery;parseRows 1832
main;handleRequest;authCheck;jwtVerify 904flamegraph.pl consumes this format directly. Folded stacks are the canonical machine-readable input for this skill's analysis.
Category-to-fix remediation catalog
Once a hot leaf is classified, map the category to a typical fix:
| Category | Typical fix |
|---|---|
| CPU-bound hot algo | Cache the result; switch to a faster algorithm; move it out of the hot path. |
| Allocator pressure | Reuse buffers / pools; switch to streaming serialization; escape-analysis fixes for the JVM. |
| Lock contention | Reduce critical-section scope; move to lock-free data structures; per-shard locking. |
| Reflection overhead | Replace dynamic dispatch with cached call-sites or codegen. |
| Logging overhead | Lazy log-message construction; level-check before format. |
Worked interpretation cases
GC pressure
| Rank | Share | Stack (leaf) | Category |
|-----:|------:|------------------|----------|
| 1 | 32% | `gc.collect` | Allocator pressure |
| 2 | 18% | `dict.update` | (callsite) |
| 3 | 14% | `parse_response` | CPU-bound hot algo |GC at 32% of samples means allocator pressure dominates. The fix is not making any one function faster - it is reducing the rate of allocations from dict.update and parse_response (object pooling, streaming parsing).
Lock contention
| Rank | Share | Stack (leaf) | Category |
|-----:|------:|-----------------------|----------|
| 1 | 41% | `pthread_mutex_lock` | Lock contention |
| 2 | 12% | `cache.get` | (callsite) |41% of samples in lock acquisition. The fix is not "make cache.get faster" - it is "reduce the contention" (per-shard locks, lock-free structures, or a lock-free cache such as Caffeine for the JVM).
Reflection / dynamic-dispatch overhead
| Rank | Share | Stack (leaf) | Category |
|-----:|------:|-----------------------------|----------|
| 1 | 28% | `Method.invoke` / `getattr` | Reflection overhead |A common surprise - an ORM's reflective field access dominates the profile of an otherwise simple endpoint. The fix is the ORM-equivalent of "compile the mapping": cached method handles in the JVM, __slots__ in Python, generated SQL in Go.
Related skills
db-query-plan-analyzer
Reads `EXPLAIN` / `EXPLAIN ANALYZE` output from PostgreSQL, MySQL, or SQLite - identifies the dominant cost (sequential scan, nested loop, sort spill, missing index, type-cast preventing index use), proposes the specific index or query rewrite to fix it, and emits the candidate `CREATE INDEX` statement. Use when load testing or production telemetry shows the database as the bottleneck and the team needs targeted query-level remediation.
jmeter-load-testing
Authors Apache JMeter `.jmx` test plans (Thread Groups + HTTP samplers + assertions + listeners) in the JMeter GUI, runs them headlessly via `jmeter -n -t plan.jmx -l results.jtl`, generates an HTML dashboard with `-e -o`, and gates CI on JTL parsing. Use when the project has an existing JMeter investment, needs JVM-native load tooling, or works in domains with strong JMeter community support (banking, telecom, enterprise).
k6-load-testing
Authors k6 JavaScript load-test scripts (VU loops + checks + sleeps), configures the `options` block with `stages` (ramp-up patterns) and `thresholds` (p(95) latency, error rate), runs via `k6 run script.js` or `--vus / --duration` ad-hoc flags, and uses thresholds as the CI pass/fail signal. Includes a latency-percentile interpretation reference: tail ratio (p99/p50), bimodal-distribution detection, coordinated omission and why naive p99 is optimistic, and constant-vus vs constant-arrival-rate executors. Use when the project ships HTTP / WebSocket / gRPC load tests and the team wants developer-friendly JavaScript authoring, or when a k6 threshold passes but the system still feels slow.
lighthouse-perf
Configures Lighthouse CI (`@lhci/cli`) to audit Web Vitals (LCP, INP, CLS) on every PR, asserts against canonical thresholds (LCP ≤2.5s, INP ≤200ms, CLS ≤0.1 at the 75th percentile), uploads Lighthouse reports as build artifacts, and posts deltas as PR comments. Includes a budget-authoring reference: per-route LCP/INP/CLS thresholds by traffic class (cached / dynamic / api-heavy / form-heavy / media-heavy) via `assertMatrix`, plus `budget.json` resource-size caps (JS / CSS / images / total bytes). Use when the project ships a web frontend and the team needs continuous Web Vitals monitoring tied to PR gating, or needs its first Lighthouse budgets drafted.
load-testing-overview
Teaches load and performance testing from zero: a tool-selection table choosing between k6, JMeter, Gatling, Locust, and Artillery from observable project facts; the six load profiles (smoke, average-load, stress, spike, soak, breakpoint); open vs closed workload models; why percentiles beat averages; turning a run into a pass/fail CI gate with a first runnable k6 script; a performance-incident triage workflow (confirm with a k6 smoke run, flame-graph the hot path, check slow queries, localize the cause); and full Gatling (Simulation DSL, injectOpen/injectClosed, setUp().assertions()) and Locust (HttpUser + @task locustfile, headless / distributed runs, CSV gating) deep dives in references. Use when a service needs performance coverage and the tool, load profile, or pass/fail threshold has not been decided yet, or when a live performance incident needs cause localization.
perf-budget-gate
Builds a unified release-readiness gate that aggregates verdicts from any combination of k6 / JMeter / Gatling / Locust load runners and Lighthouse CI Web Vitals, applies severity-aware pass/fail thresholds, and emits a single go / no-go decision with per-metric deltas vs the main-branch baseline. Posts the delta as a PR comment when the team has the integration set up. Use when authoring a CI step that gates a deployment on cross-runner perf compatibility.
slo-load-test-plan
Turns a service's SLOs and endpoint traffic mix into a named scenario matrix: one scenario per SLO boundary condition, a load profile (smoke, average-load, stress, soak, spike, breakpoint) per scenario, an open or closed workload injection model, a threshold expression derived from the SLO the scenario guards, and an error-budget calculation that sets the soak run's failure allowance. Stays runner-agnostic and fixes the pass/fail line before any tool is configured. Use when an SLO document and an endpoint list both exist but nobody has decided which load runs to make, what shape of load each carries, or what number would count as a failure.
web-vitals-inp-deep
Deep INP (Interaction to Next Paint) testing: decomposes input delay, processing duration, and presentation delay via the web-vitals/attribution build, asserts per-interaction INP budgets in Playwright using PerformanceObserver plus the web-vitals visibilitychange flush, and identifies long tasks blocking the main thread. Use when a page feels unresponsive while LCP and CLS are green, or to gate key interactions (form submit, modal open, route change) under an INP budget in CI. Covers interactions only - for page-load Web Vitals gating use lighthouse-perf; for service-worker cache-strategy latency use the qa-pwa plugin's service-worker skills.