A Rails profiler with a debug toolbar on every page, a dashboard to inspect past requests, jobs, console expressions and tests, SQL analysis with N+1 detection and query plans, a flame graph timeline with function profiling, and an MCP server for AI-assisted debugging.
- Web Debug Toolbar: a bar at the bottom of every HTML page with the metrics of the request
- Profiler Dashboard: every past request, job, console expression and test, tab by tab
- Database Profiling: SQL queries, execution time, N+1 detection, EXPLAIN of read-only queries
- Flame Graph: hierarchical timeline of all instrumented events (controller, view, SQL, cache, HTTP, custom)
- Function Profiling: the Ruby methods a request spends its time in, sampled with the optional
stackprofgem or traced in full - View Rendering: template and partial rendering times
- Cache Monitoring: hit and miss rates, reads, writes, deletes
- AJAX Tracking: the
fetch()andXMLHttpRequestcalls of a page, linked to it, Turbo Frame and Turbo Stream requests included - Outbound HTTP Tracking: external API calls via
Net::HTTP, bodies included - Mailers: every mail delivered or queued, with its body on demand
- Log Capture: Rails logger output per request with level filtering
- I18n Tracking: translation lookups and missing key detection
- Background Jobs: Sidekiq and ActiveJob profiling
- Console Profiling: every expression evaluated in
rails console, under IRB or Pry, and everyrails runnerexecution - Environment Overrides: change
ENVfrom the dashboard or MCP, without a restart - Test Profiling: per-test SQL, cache and exception capture for RSpec and Minitest, and a test runner in the dashboard
- MCP Server: 21 tools and 7 resources for AI assistants, over stdio or HTTP
- Cluster (Master/Slave): one dashboard and one MCP server for several applications
- Sensitive Data Masking: your
filter_parametersapplied before anything is stored
- Ruby >= 3.0 (the CI runs the suite on Ruby 3.3 and 3.4)
- Rails >= 7.1
Add to your Gemfile:
gem "rails-profiler", require: "profiler"The library is profiler, not rails-profiler: without require: "profiler", Bundler loads
nothing of the gem. Then run bundle install.
The gem is published on RubyGems.org. Pre-release (canary) versions are available via the GitLab Package Registry:
source "https://git.duplessy.eu/api/v4/projects/sebastien%2Frails-profiler-gem/packages/rubygems" do gem "rails-profiler", "~> 0.1.0.pre", require: "profiler" end
Mount the engine in config/routes.rb:
Rails.application.routes.draw do
mount Profiler::Engine, at: '/_profiler' if Rails.env.development?
endThe profiler depends on none of these; add the ones you use to your application's Gemfile:
| Gem | For |
|---|---|
stackprof |
Function profiling in its default, low-overhead mode (see Function profiling) |
sqlite3 |
config.storage = :sqlite |
redis (5.x) |
config.storage = :redis |
Out of the box the profiler is enabled in development and test, stores profiles as files under
tmp/rails-profiler, and only lets in requests made from the machine itself. Change what you
need in config/initializers/profiler.rb:
Profiler.configure do |config|
# On in development and test by default, off elsewhere. Read once, at boot.
config.enabled = Rails.env.development?
# :file in development and test, :memory elsewhere; also :sqlite and :redis.
config.storage = :file
config.max_profiles = 100
# Who may open /_profiler: :allow_local (default, this machine only), :allow_authorized
# (your block decides), or :allow_all (anyone who can reach the application).
config.authorization_mode = :allow_local
config.slow_query_threshold = 100 # ms
config.capture_mail_body = true # keep mail bodies (off by default)
endEvery option, with its default and the order in which Rails reads them, is in docs/configuration.md.
Docker, a VM or a remote proxy: your browser's requests then come from a bridge or proxy
address, which :allow_local refuses; see Access control below.
Once installed, the profiler:
- injects a toolbar at the bottom of every HTML page, with the request's metrics; hover an item for details, click it to open the matching tab;
- stores a profile for each request, job, console expression and test;
- serves a dashboard at
/_profiler.
Each profile opens on tabs: Request, Dump, Database, AJAX, Outbound HTTP, Timeline, Views, Cache, Logs, Routes, I18n, Jobs, Mailers, Env and Exception. The UI Guide walks through all of them, with a screenshot each.
# Inspect any value in the Dump tab; returns the value, also when the profiler is disabled
user = Profiler.dump(User.find(params[:id]), "Current user")
# Add your own block to the flame graph of the Timeline tab
result = Profiler.measure("payment.stripe_charge", metadata: { amount: 1000 }) do
Stripe::Charge.create(amount: 1000, currency: "usd")
endMore in the Usage guide: explaining a query, background jobs, mailers,
console profiling and ENV overrides.
The Timeline tab shows, below the flame graph of events, which Ruby methods the request spent its time in. It is on by default, in lite mode: a sampler that needs the stackprof gem, which the profiler does not depend on. Add it to enable function profiling:
# Gemfile of your application
gem "stackprof", require: falseWithout stackprof, lite mode records nothing and the tab says why. Full mode, chosen from the
Timeline tab or with Profiler.function_profiling_mode = "full", traces every method call of the
request's thread with a TracePoint instead, stackprof or not, with allocations and memory per
method.
What it costs, measured with the repository's script/bench/request_overhead.rb on Ruby 3.4.6
(synthetic page, two runs): the whole profiler adds 5.3 to 5.8 ms per request with function
profiling off, 6.1 to 6.4 ms in lite mode with stackprof, and 12.9 to 13.4 ms in full mode, which
grows further with the number of method calls of a real page. Ruby 3.3 was not measured. Details
in Configuration and
Performance.
With test profiling hooked into RSpec or Minitest, every example gets a profile, and the dashboard can run your tests. See Tests.
Several applications (microservices, git worktrees) can report to one master: its dashboard and its MCP server then read the profiles of every slave. Nodes authenticate each other with a shared secret and the master only reaches the slave URLs you allow, since it makes HTTP requests to them on your behalf (a server-side request forgery risk if the allow list is too wide). Setup and protections: Cluster.
The profiler ships an MCP server, so that Claude or any other
MCP client reads the profiles itself instead of you pasting SQL, logs and response bodies into the
conversation. Ask about a slow page: the assistant finds the request, reads its queries, spots the
N+1, explains the query and points at the line, with 21 tools and 7 resources covering requests,
SQL, jobs, tests, console expressions, ENV overrides and the cluster.
Try it from the root of your Rails application, with Claude Code, or with the MCP Inspector to call a tool by hand:
claude mcp add rails-profiler -- bundle exec rake profiler:mcp
npx @modelcontextprotocol/inspector --cli bundle exec rake profiler:mcp --method tools/call --tool-name query_profilesAsked "/api/n_plus_one feels slow, why?", the assistant calls analyze_queries on that
request's profile and reads, among the rest:
### Executed 10 times:
```sql
SELECT ?.* FROM ? WHERE ?.? = ? LIMIT ?
```
#### Called from:
app/controllers/api_test_controller.rb:82:in `block in ApiTestController#n_plus_one`
The whole session, from that question to the fix, with the real tool responses, is in the
MCP Guide, along with what the
assistant can and cannot do, both transports (stdio, which opens no endpoint, and HTTP, routed at
/_profiler/mcp once config.mcp_enabled = true and config.mcp_transport = :http are set,
behind the same access checks as the dashboard) and the reference of every tool.
compare_profiles (MCP) and the Compare page of the dashboard diff two profiles: total duration,
allocations, cache hit rate, rendered views, and the SQL queries grouped by normalized statement
(literals replaced by ?) in three blocks: new (only in B), gone (only in A) and slower (total time
per statement grew by more than 20%). Both profiles can come from this instance, or each from
another instance you list in config.peers:
Profiler.configure do |config|
config.peers = [
{ name: "staging", url: "https://staging.example.com" },
{ name: "worker-2", url: "https://worker-2.internal" }
]
config.peer_token = ENV.fetch("PROFILER_PEER_TOKEN") # same value on every instance, 32 characters or more
endcompare_profiles(token_a: "...", token_b: "...", node_a: "local", node_b: "staging")
node_a and node_b are names from config.peers (blank or local means this instance). The dashboard
offers the same choice with a selector per profile.
This is not the master/slave cluster: the list is static and written by you, nothing registers at
runtime, and Profiler.slave_registry is not involved.
config.peersis empty by default: no outgoing call is ever made. Each URL is validated once (plain http(s), no user info or query, HTTPS required unless the host is a loopback address orconfig.peer_allow_insecure_http = true).- The only URLs ever called are those of
config.peers. A URL given in an API parameter or an MCP argument is never used: an unknownnode_a/node_bis refused with an error listing the known nodes, before any request is sent. config.peer_tokenis sent in theX-Profiler-Peer-Tokenheader on every call to a peer. A peer that has the same token configured servesGET /_profiler/api/profiles/:tokento a caller presenting it, and nothing else. Without a token of 32 characters or more, calls to peers are refused (and a warning is logged at boot when peers are configured).- Calls use a 5 second connect and a 10 second read timeout, never follow a redirect, and are not recorded in any profile. An unreachable peer is reported by name.
Off by default. With config.replay_enabled = true, the Replay request button of the Request
tab, the POST /_profiler/api/profiles/:token/replay endpoint and the MCP replay_request tool
send a profiled request again, for real. Each replay is profiled like any other request and is
linked to the original by replay_of (shown on the new profile, readable through
Profiler.storage.find_by_replay_of(token)).
Profiler.configure do |config|
config.replay_enabled = true
config.replay_max_count = 10 # most replays one call may ask for (default 10)
config.replay_timeout = 30 # seconds to wait for each replay (default 30)
config.replay_port = 3000 # the port the application server listens on (see below)
end- The target is the application's own host, on the port it really listens on. A replay goes
to the host name of the original request (the
Hostheader, port removed). The port comes from one of two sources, never from the request:config.replay_portwhen it is set (it always wins), otherwise the port of the listening socket that the profiler captured with the profile, which works under Puma only (env["puma.socket"], kept in the profile asserver_port, with the schemehttporhttpstaken from the socket being plain or TLS).SERVER_PORTis never used: Puma derives it from theHostheader (Host: localhost:6379givesSERVER_PORT=6379), so a client controls it, as it controlsX-Forwarded-Protoand the port in theHostheader. For any other server (Falcon, Unicorn, Passenger, Thin, WEBrick...) nothing is captured: setconfig.replay_portto the port the application server listens on, otherwise the replay is refused with an error that says so. The same goes for a profile captured before the port was recorded: it cannot be replayed withoutconfig.replay_port(profile the request again). The host name must be a loopback name (localhost,127.0.0.1,::1) or be listed literally inconfig.hosts(a plain String entry such as"app.example.com"), otherwise the replay is refused. A match through a wildcard (".example.com"), an IP range (IPAddr), aRegexpor aProcdoes not count, and Rails' permissive development default (0.0.0.0/0,::/0) therefore lets no address through. The name of a literal entry is resolved by DNS when the request is sent. No option can name another host, port, scheme or URL: the ones that try are refused, aHostorX-Forwarded-*header given as an override is ignored, a path override must be a path of the application, and a redirect is never followed. Behind a reverse proxy, the port is the one of the application server, not the public one, which is what a replay must reach. - Variations:
method,path,query,params,body,headers,cookies, andcountto replay several times in a row (each a new profile). Headers and parameters the profiler masked (Authorization,Cookie, filtered parameters) are not sent unless you give them again. - Needs a multi-threaded server. A replay runs synchronously in the thread that serves the
call and sends up to
replay_max_countrequests to the same application, each waiting at mostreplay_timeout(120 s at most). On a single-threaded server (Pumathreads 1,1, WEBrick) the replayed request can never be served while that thread waits, so each one runs into its timeout. Use at least 2 Puma threads (threads 2, 5), and keepcountandreplay_timeoutlow: even then, a largecountholds one thread for as long as the replays take. - Every method is allowed,
POSTandDELETEincluded, and a replay really does what the request did. The dashboard asks you to confirm before it sends. The MCP tool has no confirmation: the client that calls it answers for the request it replays, which is whyreplay_requestis a write tool: a read-only MCP server (config.mcp_read_onlyorPROFILER_MCP_READ_ONLY) neither lists nor runs it. - The call itself is never profiled (
Profiler.untracked_http), so a replay cannot profile itself in a loop. On a cluster master,replay_requesttakes the sameslaveargument as the other tools and the replay runs on that slave.
| Page | Contents |
|---|---|
| docs/configuration.md | Every option, defaults per Rails environment, collectors, custom collectors, rake tasks |
| docs/ui.md | The toolbar, the profile list and every dashboard tab, with screenshots |
| docs/usage.md | EXPLAIN, dumps, custom timeline events, jobs, mailers, console, ENV overrides |
| docs/testing.md | Test profiling for RSpec and Minitest, the test runner |
| docs/mcp.md | How the MCP server works, a real session, setup, the 21 tools and 7 resources, example prompts |
| docs/storage.md | Memory, file, Redis and SQLite stores, how many profiles are kept, files under tmp_path |
| docs/security.md | Access control, forgery protection, CORS, framing, masking of sensitive data |
| docs/cluster.md | Master and slaves, the shared secret, slave URL allow list |
| docs/performance.md | Overhead, what is recorded from which thread, streaming, known limits |
| ARCHITECTURE.md | How the gem is built, for contributors |
| CONTRIBUTING.md | Setting up a development environment, running the tests, proposing a change |
| CHANGELOG.md | What changed in each version |
The profiler shows everything your application does (parameters, SQL, headers, logs, ENV) and
can change ENV and run your tests. Its defaults keep that to you:
- disabled outside development and test, unless you enable it;
- only requests from the machine itself get in (
authorization_mode = :allow_local), on every page and endpoint, and changes need theX-Profiler-Requestheader or a CSRF token; - no CORS, no framing by other sites, no MCP endpoint and no cluster routes until you turn them on;
- sensitive values masked with your
config.filter_parametersbefore a profile is stored; ENVoverrides never applied in production.
Each of these, and how to loosen it knowingly: docs/security.md.
:allow_local lets a request in when it comes from a loopback address, carries no forwarding
header naming another address, and names localhost, a loopback IP or a host of config.hosts.
Behind Docker, a VM or a proxy, the browser's requests reach Rails from another address, and the
profiler refuses them and stops capturing them (it logs why, once). Admit your own network
explicitly:
require "ipaddr"
Profiler.configure do |config|
config.authorization_mode = :allow_authorized
docker = IPAddr.new("172.16.0.0/12") # narrow it to your own network
config.authorize_with do |request|
docker.include?(request.get_header("REMOTE_ADDR"))
end
endRead REMOTE_ADDR there, not request.remote_ip, which trusts X-Forwarded-For. :allow_authorized
also checks the Host header before running authorize_with; if your browser reaches the
application under a non-local name, list it in config.mcp_allowed_hosts too. The modes, the
forgery protection, CORS and framing are detailed in
docs/security.md.
The source is on GitHub, a mirror of the GitLab repository where development happens. Bug reports and merge requests are welcome on GitLab. CONTRIBUTING.md explains how to set up Ruby and Node, build the front-end assets, run the tests and write a commit that the release process understands.
MIT License, see LICENSE. The published gem also bundles third-party JavaScript
(Preact and TanStack Query, compiled into app/assets/builds/); their licenses are reproduced in
THIRD-PARTY-NOTICES.md.



