Skip to main content

echolot-lockup-inverse-2x

CLI that helps your agent to find performance issues in Android apps.

checks codeql PyPI Python versions License Status


Contents · What it is · Requirements · Quick start · What you get · What changed · Commands · Detectors · How it works · Project layout · Documentation · Status


What it is

A Perfetto trace of one cold start holds around half a million slices in eighty megabytes. Nobody reads that, and an AI agent pointed at the raw file produces confident guesses instead of answers.

echolot sits in between. It runs twelve SQL detectors over the trace and returns about twenty rows: where the time went, how much of it, and the evidence behind each claim. Same trace in, same report out — the trace_processor version is pinned and verified on every run.

Using Cursor, Codex or something else? echolot init points them at the tool, and echolot guide tells any agent how to work with it. The loop runs in your main context rather than a subagent, so keep the passes short — the guide says where that matters.

echolot reflect works from any of them: with no transcript to read it builds the report from the tool's own run log, and names every check it could not make rather than reporting silence as a clean bill.

Requirements

Python 3.10 or newer
adb on PATH — ships in the Android SDK platform-tools
Device a phone or emulator with USB debugging on
Agent (optional) Claude Code for the full workflow; Cursor, Codex and others via echolot guide
Android 12+ (for one detector) frame_jank reads SurfaceFlinger's frame timeline. Older devices do not have it, and the detector is then silent — which reads exactly like "no bad frames"

Validated on Android 14 (emulator) and Android 13 (Galaxy A51).

Quick start

1. Install

pipx install echolot

2. Set up your project

Run this once inside your Android project:

cd ~/my-app && echolot init

This installs the .claude/ layer — a skill, the perf-hunter agent and three commands — then checks that this machine computes traces correctly.

3. Open the agent and type one word

/echolot

That is the only entry point you need to remember. It asks the tool where the project stands and takes the next step by itself:

  • first run — builds echolot.yml from your repository and a probe trace, asking you four questions along the way;
  • every run after — hunts down the regression you describe.
/echolot                          reads the state, does whatever is next
/echolot why is cold start slow   hunt, with that as the question
/echolot init | setup | hunt | reflect | doctor

Coming back later

echolot

Prints where the project stands — layer, config, traces, last report, last doctor — and one line saying what to do next.

Without an agent

The same work, by hand or in CI:

echolot collect -c echolot.yml -n 5                              # 5 repeats of the scenario
echolot analyze .echolot/traces/*.perfetto-trace -c echolot.yml  # build the report
echolot compare before.json .echolot/out/report.json             # what changed since
echolot doctor -q                                                # exit 0/1: is this environment sane?

Results land in .echolot/out/ — report.md for you, report.json for the agent.

What you get

A Marker Report: one section per detector that fired, nothing else.

Example report (click to expand)
# Marker Report

Runs: **5**, numbers are medians across them
Traces: `coldStart_iter000`, `coldStart_iter001`, `coldStart_iter002`, `coldStart_iter003`, `coldStart_iter004`
Process: `com.example.app` (pid 12903)
Scenario window: **1184 ms** (from 1102 to 1291)
Main thread: 49% on a CPU · 8% waiting for a CPU · 10% blocked in the kernel · 33% sleeping
Device: clock **1481 MHz** (from 1204 to 1622 across repeats), peak 54 °C, 1536 MB free at the low point
Detectors fired: **5 of 12**

## Where the main thread spent its time

_measured as SELF time, children subtracted_

| Where | N | Self, ms | Total, ms | Max, ms |
|---|---|---|---|---|
| draw | 4 | 125.4 | 130.1 | 61.2 |
| TextLayout:initLayout | 61 | 88.0 | 88.0 | 4.1 |
| inflate | 12 | 47.3 | 210.7 | 12.9 |

## Blind spots: threads burning CPU with no instrumentation

_the only detector that finds a problem inside uninstrumented code_

| Where | Total, ms | Instrumented, ms | Evidence |
|---|---|---|---|
| DefaultDispatch | 340.2 | 0.0 | 0 slices |

## Monitor contention

| Where | N | Total, ms | Max, ms | Evidence |
|---|---|---|---|---|
| Lock contention on a monitor lock | 9 | 61.5 | 22.4 | owner tid 12931 |

## Frames that missed their deadline

_time past the deadline; frame duration is in the evidence_

| Where | N | Total, ms | Max, ms | Evidence |
|---|---|---|---|---|
| App Deadline Missed | 14 | 412.0 | 70.1 | Self Jank · 14 of 300 frames · longest 86.2 ms |

## Single occurrences far longer than the same work usually takes

| Where | N | Total, ms | Max, ms | Evidence |
|---|---|---|---|---|
| inflate | 2 | 149.3 | 86.2 | median 12.9 ms of 312 · worst 6.7× |

**Silent:** gc_pressure, binder_txn, runnable_starvation, repeated_work, anr_risk, anr

The report is written for two readers at once: report.md reads like a findings list, report.json carries the same numbers in a shape the agent can walk. An 81 MB trace with 475k slices comes out as a 14 KB report.json in about five seconds.

What changed

The report says where the time went in one set of traces. The question people arrive with has a second half — it was 3 s, now it is 7 s — and that needs two sets.

echolot compare                       # inside an investigation: previous round vs latest
echolot compare old.json new.json     # or name them

One table, sorted by how far each row moved. The top row is usually the answer.

Where Evidence Detector Before After Δ N Ranges
SyncAdapter.onPerformSync — uninstrumented_cpu — 1402.0 ±61 new — → 0 —
TeamRepository.loadAll main main_thread_block 12.1 ±2 883.4 ±40 +871.3 ×73.01 1 → 1 apart
inflate main main_thread_block 47.3 ±31 121.9 ±88 +74.6 ×2.58 12 → 31 overlap

N separates "called more often" from "became slower inside" — two different bugs in two different places. Ranges is the column that decides whether a row is worth acting on: apart means every repeat after fell outside everything seen before, overlap means the runs disagree among themselves by more than the medians moved, and the honest next step is another round of collect rather than a conclusion.

Reports built against different thresholds are compared with the reason printed above the table — a row can cross a moved bar without anything in the app changing. The same goes for the machine: a duration is the work done divided by the speed the device was doing it at, so a clock that dropped 10% or more between the rounds, or a kernel that throttled during one of them, is named above the table too. Silence there means the device was checked and held steady; a round recorded without the platform-state sources says that instead. See Comparing.

Commands

Three audiences share one CLI, and echolot --help says which is which — the grouping below is generated from the same registration, so the two cannot drift apart.

Every argument after /echolot is a verb of the same name, doing the same thing plus whatever loop needs an agent. One word, one meaning, both surfaces.

Yours

command what it does
echolot where this project stands, and the next step
echolot init install or update the .claude/ layer; checks the environment
echolot hunt "<what regressed>" open an investigation — see below
echolot doctor environment + self-check on a synthetic trace; exit 0/1, -q for three lines

The pipeline — for CI, and for traces by hand

command what it does
echolot collect capture N traces of one scenario — launch, command or gradle
echolot analyze run the detectors, build a Marker Report
echolot compare the difference between two reports — see below
The agent's, behind /echolot — you do not call these
command what it does
guide how to work with this tool, printed by the package — what an agent without the .claude/ layer reads instead of it
anr an ANR report from the field — the lock chain, the few threads that were not idle, and where their frames are in this checkout. Crashlytics exports and the device's own dumpsys dropbox record
probe processes, threads by CPU, scenario anchor candidates
names slice name inventory and detector mask coverage
domains slice-to-code map and instrumentation coverage
mark the first temporary markers for a project with none, from the manifest and the SDK, or from an ANR report's own frames with --from-anr — --apply / --remove
calibrate thresholds derived from known-healthy runs
explain list the detectors and their parameters
For improving the tool
command what it does
reflect the same kind of report, over an agent session — how the tool was used, where it got in the way. Full detail for Claude Code; from anywhere else, built from the run log and honest about what it could not see

The investigation

.echolot/traces/ and .echolot/out/report.json mean "the latest set". An investigation is the label that says which question that set was recorded for, so that coming back a week later does not answer a question about scrolling with cold-start traces.

echolot hunt "cold start was 3s, now 7s" --since "the tab redesign"

That opens one, moves the previous set of traces aside without deleting it, and says whether the last investigation left temporary markers in your sources. echolot hunt on its own says what is open.

echolot hunt --list          every investigation, newest first
echolot hunt --show 2        one of them in full — including where its traces went
echolot hunt --resume        carry on with the open one
echolot hunt --done "..."    record what it came to

Each one is numbered, and everything it produces is filed under it: every round of traces by path, every report as a copy in .echolot/hunts/<n>/reports/. So a question asked three weeks ago still knows what was measured to answer it, and what each round concluded on the way.

Nothing moves to make this work — collect still writes to .echolot/traces/ and the latest report is still .echolot/out/report.json, so every example above and any CI job keep working unchanged.

You rarely type any of it. /echolot reads the state and, when an investigation has been sitting untouched with traces behind it, asks whether to carry on or start something new — and never asks inside the hunting loop, which re-records and re-instruments on purpose.

Detectors

detector what it catches
main_thread_block where the main thread spent its time, by self time
gc_pressure frequent or expensive GC, and waits on allocation
monitor_contention lock contention, with the owner's tid as evidence
binder_txn long synchronous IPC, and death by a thousand cuts
runnable_starvation thread ready to run but preempted on CPU
uninstrumented_cpu threads burning CPU with no instrumentation
frame_jank frames that missed their deadline, and whose fault it was
anr_risk stretches where the main thread never got back to the message queue
anr ANRs the system recorded during the trace, with its own reason
main_thread_outlier one occurrence far longer than that work usually takes
repeated_work the same work reached from more than one caller, costing about the same both times
io_wait threads the kernel parked waiting for a block device

Three of them find something where nobody wrote a trace{} call.

uninstrumented_cpu does not guess — it states a fact:

thread DefaultDispatch was Running for 340 ms, zero slices

Which is exactly where to add trace{} and record again. The name is cut at fifteen characters by Linux rather than by echolot: every worker of that pool reaches the trace as DefaultDispatch, so write thread masks with the truncation in mind.

io_wait needs none either, and it is the one that answers a question the other two cannot even ask. A thread waiting for the disk burns no CPU, holds no lock of yours and has no slice around it: there is nothing to profile and nothing to instrument, so a cold start can spend hundreds of milliseconds there with every other detector silent. The kernel is the only witness, and it says so through sched/sched_blocked_reason.

It names the thread and the milliseconds, not the kernel function. Turning the blocking address into a name needs /proc/kallsyms, which a production build does not let anyone read — on an SM-A515F the function came back empty for all 6683 uninterruptible sleeps in the trace while the disk flag was set on 6486 of them. The name appears on a userdebug kernel and the row is worth acting on without it.

frame_jank needs no instrumentation at all: SurfaceFlinger records every frame's deadline and what it actually took, and says whose fault a miss was. Android 12 and up — see requirements.

main_thread_block and main_thread_outlier are a pair, and reading one as the other wastes a round. The first gates on the sum for a name and answers "where did the time go". The second gates on a single occurrence against the median for that same name and answers "which one was out of line". A name can appear in both, saying different things — and they lead to different places: expensive every time means the fix is in that work, usually fine and once not means the cause is the state it hit that once.

How it works

flowchart LR
    A["Android device"]
    B["trace<br/>81 MB · 475k slices"]
    C["12 SQL detectors<br/>pinned trace_processor"]
    D["report.md<br/>~20 rows"]
    E["report.json<br/>14 KB"]
    H["comparison<br/>what moved, and by how much"]
    F(["You"])
    G(["The agent"])

    A -->|"echolot collect"| B
    B -->|"echolot analyze"| C
    C --> D --> F
    C --> E --> G
    E -->|"echolot compare, against an earlier one"| H
    H --> F
    H --> G

Project layout

android-project/
├── echolot.yml       ← the project half, committed
├── local.yml         ← device serials, binary path; in .gitignore
└── .echolot/         ← traces, reports, run log, reflect reports; in .gitignore

Read it the way you read gradle.properties and local.properties: one tool per machine, and the binding to a project living inside that project's repository.

Documentation

Start at the documentation index, or jump straight in:

document about
🎬 Collecting collect, the three modes, merging repeats
🔎 Analysing probe, names, domains — from a trace to a place in the code
🔀 Comparing compare — what changed between two reports, and when the repeats support saying so
🧊 ANRs anr — reading a report from the field, and measuring a freeze
🏷️ Marking mark — first markers for a project with no instrumentation
⚙️ Detectors writing your own, the context views, self time versus total
📏 Calibrating thresholds from healthy runs, why rank beats percentile
🔒 Determinism the pinned trace_processor, doctor, the self-check
🤖 The agent layer the .claude/ layer, and why the loop lives in a subagent
🪞 Reflect the report over an agent session, for improving the tool

Agent-facing reference material ships inside the package under echolot/claude/skills/echolot/references/ — the report schema, the config schema, how ART names things, and how to capture a trace by hand.

Status

v0. Everything planned for it is in place.

The detectors were validated against a synthetic trace — 124 checks inside doctor, one per claim — and against live traces from Android 14 (emulator) and Android 13 (Galaxy A51). The naming masks for GC, locks and binder were narrowed against those real traces, and every narrowing is pinned by a check.

Two are newer than that hardware round and have not had one. frame_jank was built against the pinned trace_processor and a frame timeline written for the purpose — the column names, the jank vocabulary and where display frames live were all read back out of it rather than assumed — but no report from it has been compared with a real device's own frame statistics yet. main_thread_outlier was written for a miss recorded on an A51 and has so far answered only the fixture.

A failed detector never fails the run: the error goes to stderr and into report.json.

Working on the tool

pip install -e '.[dev]'
pytest                       # every check, including the ones doctor runs
pytest -k uninstrumented     # one detector's claims, by name

doctor stays dependency-free: it walks the same list itself, because it runs on a user's laptop where pytest is not installed.

There is no performance gate, on purpose

An earlier plan had analyze exit non-zero against scenario.budget_ms, so a build could fail on a slow run. It is not being built, and this is the reason.

"Did it get slower" is already answered. Macrobenchmark writes percentiles per iteration right next to the traces echolot collects from it, and comparing a median against a number is a few lines of anything. An eleventh implementation of that adds nothing. Worse, detector thresholds on a shared CI runner would fire on properties of the runner — the same caution this tool already gives about runnable_starvation on a loaded machine.

Where echolot is hard to replace is the other question: where the time went. So the useful shape in CI is the opposite of a gate. Run echolot doctor -q as a precondition — it already answers "does this machine compute correctly" with an exit code — then analyze over the traces the benchmark has already written, compare against yesterday's report, and keep both JSON files as build artefacts. When someone asks a day later why the nightly regressed, the window, the thresholds, the evidence and the delta are already sitting next to the commit: no device, no re-recording.

compare exits 0 whatever it finds, for the same reason. It reports; it does not stand guard.

scenario.budget_ms stays in the config. It records what a team considers acceptable, which is worth writing down whether or not anything enforces it.

CI does hold one gate, and it measures this repository rather than a device: pytest fails when statement coverage drops below the threshold in pyproject.toml. That number comes out the same on every runner, which is exactly what a trace threshold does not.

License

Apache 2.0

Metadata

Release files for echolot 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for echolot 0.7.0
File Size Uploaded
echolot-0.7.0.tar.gz 390.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for echolot 0.7.0
File Interpreter ABI Platform
echolot-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 724.1 kB

Release files / echolot-0.7.0.tar.gz

Download URL echolot-0.7.0.tar.gz
Size 390.5 kB
Tags Source
SHA-256 checksum
How to use checksums
2762c4210cd4234f8abf8ab26a7211c28c0b2bf289276866df6a41c96855ee0d
BLAKE2b-256 checksum
How to use checksums
fc3cb0cd202a7a74ee5eb09135be6c5f20110cc0bb92fd6bd7dc5d2274964773
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release files / echolot-0.7.0-py3-none-any.whl

Download URL echolot-0.7.0-py3-none-any.whl
Size 333.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b48338b0c84b42a9ece39f0c0715ec5710357b600461b9ef572f78353f5803a7
BLAKE2b-256 checksum
How to use checksums
cfa886b60b52aab88242e8d78621de2e0abf3a5604da73ab7a2b83a038d24430
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.13.0

2 release files

0.12.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.3

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page