Add dev query reporter and skill

Ecto telemetry reporter that captures executed SQL queries
to a log file with interpolated parameters and source
locations. Activated/deactivated at runtime via Tidewave
for LLM-driven query analysis workflows.
This commit is contained in:
Claudio Ortolina
2026-04-10 10:27:12 +01:00
parent b5686fa0f9
commit 27148c6507
2 changed files with 210 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
---
name: query-reporter
description: Capture Ecto SQL queries to a log file for analysis. Use this skill when the user asks to trace, capture, log, or inspect database queries, or mentions "SQL log", "query trace", "query capture", "what queries does this page run". Also use proactively when investigating slow pages, debugging N+1 queries, analyzing database performance, or when you need to understand what SQL a LiveView or action produces. Requires a running Phoenix server with Tidewave MCP.
---
# Query Reporter
Captures all Ecto queries executed by the main Repo to a log file as executable
SQL with interpolated parameters and source location comments.
## Before You Start
This skill requires the Tidewave MCP tools. Before proceeding, check whether
`mcp__tidewave__project_eval` is available in your tool list.
If Tidewave is not available, stop and tell the user:
"The query reporter requires a running Phoenix server with Tidewave. Please start
the server with `mise run dev:console` and ensure Tidewave is loaded, then try again."
## Workflow
### 1. Start capturing
Pick a file path (use `/tmp/` to avoid cluttering the project):
```elixir
mcp__tidewave__project_eval(code: ~s[MusicLibrary.QueryReporter.start("/tmp/queries.sql")])
```
This truncates the file if it exists. Calling `start/1` again restarts with a fresh file.
### 2. Trigger the queries you want to capture
Navigate to a page, click a button, run a context function — whatever action you want
to trace. Use Chrome DevTools MCP tools to visit pages if needed.
### 3. Stop capturing
```elixir
mcp__tidewave__project_eval(code: ~s[MusicLibrary.QueryReporter.stop()])
```
### 4. Read the log
Read the file you specified in step 1 to see all captured queries.
## Output Format
Each query in the log file looks like:
```sql
-- MusicLibrary.Collection.list_records/2, at: lib/music_library/collection.ex:42
SELECT r0."id", r0."title" FROM "records" AS r0 WHERE (r0."purchased_at" IS NOT NULL) LIMIT 20;
```
- The SQL comment shows the Elixir function and file:line that originated the query
- Parameters are fully interpolated (no `?` placeholders)
- Each query ends with `;` and is separated by a blank line
- Queries are executable — you can paste them into `sqlite3` or `mcp__tidewave__execute_sql_query`
## What Gets Captured
- All queries through `MusicLibrary.Repo` (the main application database)
- Includes SELECT, INSERT, UPDATE, DELETE, and transaction statements (BEGIN/COMMIT)
- Does **not** capture BackgroundRepo (Oban) or TelemetryRepo queries
## Tips
- The reporter adds minimal overhead (one file append per query), but remember to
stop it when you're done to avoid filling the log with unrelated queries
- LiveView pages fire queries twice on initial load (once for the static mount,
once for the connected mount) — this is normal Phoenix behaviour
- To capture queries from a specific action, start the reporter, perform only that
action, then stop immediately
+136
View File
@@ -0,0 +1,136 @@
defmodule MusicLibrary.QueryReporter do
@moduledoc """
Dev-only Ecto query reporter that captures executed SQL queries to a file.
Attaches to the main Repo's telemetry event and writes each query as
executable SQL with interpolated parameters and source location comments.
Designed to be activated at runtime via Tidewave's `project_eval`.
## Usage
MusicLibrary.QueryReporter.start("/tmp/queries.sql")
# ... trigger actions ...
MusicLibrary.QueryReporter.stop()
# read /tmp/queries.sql for captured queries
"""
alias Ecto.Adapters.SQL
require Logger
@handler_id "music-library-query-reporter"
@event [:music_library, :repo, :query]
@doc """
Starts capturing queries to the given file path.
Truncates the file if it exists. Calling `start/1` while already running
restarts with a new file.
"""
# sobelow_skip ["Traversal.FileModule"]
@spec start(String.t()) :: :ok
def start(file_path) when is_binary(file_path) do
# Path is provided by the developer at runtime via IEx/Tidewave
File.write!(file_path, "-- Query Reporter started at #{DateTime.utc_now()}\n\n")
:telemetry.detach(@handler_id)
:ok =
:telemetry.attach(
@handler_id,
@event,
&__MODULE__.handle_event/4,
file_path
)
end
@doc """
Stops capturing queries.
"""
@spec stop() :: :ok | {:error, String.t()}
def stop do
case :telemetry.detach(@handler_id) do
:ok -> :ok
{:error, :not_found} -> {:error, "Query reporter is not running"}
end
end
# sobelow_skip ["Traversal.FileModule"]
@doc false
def handle_event(@event, _measurements, metadata, file_path) do
entry = format_entry(metadata)
# Path is provided by the developer at runtime via IEx/Tidewave
File.write!(file_path, entry, [:append])
rescue
error ->
Logger.warning("QueryReporter failed to write: #{inspect(error)}")
end
defp format_entry(metadata) do
params = metadata.cast_params || metadata.params || []
query = interpolate_query(metadata.query, params)
location = extract_source_location(metadata.stacktrace)
IO.iodata_to_binary([
if(location, do: [location, "\n"], else: []),
String.trim_trailing(query),
";\n\n"
])
end
defp interpolate_query(query, []), do: query
defp interpolate_query(query, params) do
parts = String.split(query, "?")
parts
|> Enum.zip_reduce(params ++ [:done], [], fn
part, :done, acc -> [part | acc]
part, param, acc -> [format_param(param), part | acc]
end)
|> Enum.reverse()
|> IO.iodata_to_binary()
end
defp format_param(nil), do: "NULL"
defp format_param(value) when is_integer(value), do: Integer.to_string(value)
defp format_param(value) when is_float(value), do: Float.to_string(value)
defp format_param(true), do: "1"
defp format_param(false), do: "0"
defp format_param(value) when is_binary(value) do
if String.printable?(value) do
"'" <> String.replace(value, "'", "''") <> "'"
else
"X'" <> Base.encode16(value) <> "'"
end
end
defp format_param(value), do: "'" <> String.replace(to_string(value), "'", "''") <> "'"
defp extract_source_location(nil), do: nil
defp extract_source_location([]), do: nil
defp extract_source_location(stacktrace) do
case SQL.first_non_ecto_stacktrace(stacktrace, %{repo: MusicLibrary.Repo}, 1) do
[{module, function, arity, info}] ->
format_location(module, function, arity, info)
_ ->
nil
end
end
defp format_location(module, function, arity, info) do
mfa = Exception.format_mfa(module, function, arity)
case Keyword.fetch(info, :file) do
{:ok, file} ->
line = Keyword.get(info, :line, "?")
"-- #{mfa}, at: #{file}:#{line}"
:error ->
"-- #{mfa}"
end
end
end