Agent Skills: Performance Optimization Workflow

Captures and analyzes performance traces to identify slow queries and N+1 problems. Use when a page is slow, there are too many queries, or the user asks about performance.

UncategorizedID: dropseed/plain/plain-optimize

Install this agent skill to your local

pnpm dlx add-skill https://github.com/dropseed/plain/tree/HEAD/.claude/skills/plain-optimize

Skill Files

Browse the full folder contents for plain-optimize.

Download Skill

Loading file tree…

.claude/skills/plain-optimize/SKILL.md

Skill Metadata

Name
plain-optimize
Description
Captures and analyzes performance traces to identify slow queries and N+1 problems. Use when a page is slow, there are too many queries, or the user asks about performance.

Performance Optimization Workflow

1. Capture and Analyze

Make a request and get structured JSON — response metadata plus a full trace analysis:

uv run plain request /path --json
uv run plain request /path --json --user 1
uv run plain request /path --json --method POST --data '{"key": "value"}'

The --user flag accepts a user ID or email.

traces is normally a list — one entry per request, so a followed redirect chain returns one per hop in hop order. Analyze each on its own; the last entry is the page that actually rendered. Never sum counts across hops: a query the framework runs once per request would look like a repeat that no change can fix.

Check trace_note before indexing. traces is null when the OpenTelemetry SDK isn't installed and [] when nothing was captured; in both cases a trace_note sibling key says why. Report that instead of indexing into nothing.

A failed request returns a different shape. If the view raised, the command exits 1 and emits {"error": "...", "traces": [...]} — no response key. The trace is still there, and analysis.exceptions carries the stacktrace, so this is the payload to read when debugging a 500. Branch on "response" in payload before reaching for it.

Each entry has name (e.g. GET /admin/p/user, including any query string) and request_id — which matches response.request_id for the hop that rendered — plus analysis (derived) and spans (raw):

  • analysis.query_count — statements executed in this request
  • analysis.transaction_count — transaction-control statements, counted apart from queries. With plain.postgres this is savepoint bookkeeping; BEGIN/COMMIT are issued outside the instrumented cursor and never appear
  • analysis.duration_ms — that request's duration
  • analysis.exceptions — exceptions recorded on any span, with span, error_type, message, stacktrace
  • analysis.queries — each distinct statement with count, total_duration_ms, and sources, slowest first
  • sources — the call sites that issued the statement, as path:line in function strings. The path says whose code it is: project paths vs installed-package (site-packages) paths
  • spans — raw OpenTelemetry spans, a flat list (parent_span_id gives the structure)

count means one thing: how many times that statement ran in that request. Nothing is pre-diagnosed.

2. Identify Bottlenecks

You do the diagnosing. The trace reports what ran; deciding what is wrong with it is the job. Read analysis.queries and look for:

  • Repeatscount > 1 is the N+1 shape. Go read the code at the recorded call sites before concluding: three executions from three branches is not the same as three from a loop, and only the source tells you which. A source is where the query executed, which for lazy querysets can be framework code (a paginator, a template render) even though project code built the queryset — a repeat with only site-packages sources still usually traces back to a queryset the project constructed.
  • Slow statements — high total_duration_ms. A single slow query outranks a dozen fast repeats.
  • Cost the counts don't showduration_ms far exceeding the sum of query time means the time went somewhere else; read spans for it.
  • Missing indexes, unnecessary work in hot paths.

Never sum counts across hops — a query the framework runs once per request would look like a repeat that no change can fix.

3. Apply Fixes

  • Add select_related() / prefetch_related() for N+1
  • Add database indexes for slow queries
  • Cache expensive computations

4. Verify Improvement

Re-run uv run plain request /path --json and compare analysis.query_count, the count on the statement you changed, and analysis.duration_ms for the same trace — match traces by name, not position, since a fix can change the redirect chain.