Find where Android startup time actually goes — from a Perfetto trace down to a line of code.
Contents · What it is · Requirements · Quick start · What you get · 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 six 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.
[!TIP] The intended way to use it is through Claude Code: you describe the regression in plain words, the agent collects traces, reads the report and walks down to the code. The command line works on its own too — see without an agent.
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 guided workflow |
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.ymlfrom 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.
[!IMPORTANT] After upgrading the package, run
echolot initagain. It brings the.claude/layer up to date and leaves files you edited alone.
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 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
Process: `com.example.app` (pid 12903)
Scenario window: **1184 ms** (from 1102 to 1291)
Detectors fired: **3 of 6**
## 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 |
|---|---|---|---|
| DefaultDispatcher-worker-2 | 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 |
**Silent:** gc_pressure, binder_txn, runnable_starvation
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.
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 |
The agent's, behind /echolot — you do not call these
| command | what it does |
|---|---|
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 — --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 |
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; --resume, --done and
--list do the rest.
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 |
That last one is the only detector that finds a problem inside code nobody instrumented. It does not guess — it states a fact:
thread
DefaultDispatcher-worker-2was Running for 340 ms, zero slices
Which is exactly where to add trace{} and record again.
[!NOTE] Each detector is one self-contained
.sqlfile with its metadata in the header. Drop a file intoecholot/sql/detectors/and it is picked up — there is no registration step in code. See docs/detectors.md.
How it works
flowchart LR
A["Android device"]
B["trace<br/>81 MB · 475k slices"]
C["6 SQL detectors<br/>pinned trace_processor"]
D["report.md<br/>~20 rows"]
E["report.json<br/>14 KB"]
F(["You"])
G(["The agent"])
A -->|"echolot collect"| B
B -->|"echolot analyze"| C
C --> D --> F
C --> E --> 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 |
| 🏷️ | 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 except CI mode.
The detectors were validated against a synthetic trace — 46 checks inside
doctor — 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.
[!WARNING] CI mode is not done.
scenario.budget_msis declared in the config but never read. What is missing isanalyzewith an exit code — either against the budget or on "any detector fired", which aftercalibrateamounts to a comparison against the baseline.
A failed detector never fails the run: the error goes to stderr and into
report.json.
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file echolot-0.2.0.tar.gz.
File metadata
- Download URL: echolot-0.2.0.tar.gz
- Upload date:
- Size: 150.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
25fbcd359ab1f7a5467e97549063c22faa87cb7de3436df7d4e6e7ed5e3c7f19
|
|
| MD5 |
048018b4c4ac3b52e5b5dd1a0f18ea2c
|
|
| BLAKE2b-256 |
7ea58e51b953b7908326006b0622221e76421150da8cf92ec2581d597bcc488d
|
Provenance
The following attestation bundles were made for echolot-0.2.0.tar.gz:
Publisher:
publish.yml on grishan0v/echolot
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
echolot-0.2.0.tar.gz -
Subject digest:
25fbcd359ab1f7a5467e97549063c22faa87cb7de3436df7d4e6e7ed5e3c7f19 - Sigstore transparency entry: 2505594285
- Sigstore integration time:
-
Permalink:
grishan0v/echolot@432fd72e57c5fee587f749d02b1c210f719d98e1 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/grishan0v
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@432fd72e57c5fee587f749d02b1c210f719d98e1 -
Trigger Event:
push
-
Statement type:
File details
Details for the file echolot-0.2.0-py3-none-any.whl.
File metadata
- Download URL: echolot-0.2.0-py3-none-any.whl
- Upload date:
- Size: 164.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
695a64a736ead79748d37c9dafa9ff3581c3b4cb09ed1b0425294c27ecb9b634
|
|
| MD5 |
44ac726622c0232ea32b771d975766e8
|
|
| BLAKE2b-256 |
be859a618947087064e0f0add8311744c8256072fee2324f9c99ac1bec020a35
|
Provenance
The following attestation bundles were made for echolot-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on grishan0v/echolot
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
echolot-0.2.0-py3-none-any.whl -
Subject digest:
695a64a736ead79748d37c9dafa9ff3581c3b4cb09ed1b0425294c27ecb9b634 - Sigstore transparency entry: 2505594352
- Sigstore integration time:
-
Permalink:
grishan0v/echolot@432fd72e57c5fee587f749d02b1c210f719d98e1 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/grishan0v
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@432fd72e57c5fee587f749d02b1c210f719d98e1 -
Trigger Event:
push
-
Statement type: