railwatch 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +7 -0
- data/CHANGELOG.md +27 -0
- data/app/controllers/railwatch/beacon_controller.rb +4 -6
- data/docs/ai-and-mcp.md +44 -6
- data/docs/configuration.md +1 -0
- data/lib/generators/railwatch/agents/agents_generator.rb +151 -0
- data/lib/generators/railwatch/agents/templates/SKILL.md.tt +32 -0
- data/lib/generators/railwatch/agents/templates/agents_block.md.tt +26 -0
- data/lib/generators/railwatch/install/templates/post-deploy +6 -0
- data/lib/railwatch/configuration.rb +13 -1
- data/lib/railwatch/engine.rb +4 -0
- data/lib/railwatch/middleware/beacon_limit.rb +53 -0
- data/lib/railwatch/middleware/request.rb +47 -3
- data/lib/railwatch/transport/http.rb +3 -3
- data/lib/railwatch/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 954add09993af5d0756ed9ff1875061f25eed72867139116fd020a90b961627c
|
|
4
|
+
data.tar.gz: 6fbb618b6eb38d867f9ea7648dfce28d7c46003e1ed9499303a3889de06373af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88f0a50d4769269d565513d59b03567413424f6566da76d1056d08a1e734d7ce244bc609a06c8a25cd7651873ae1519af968a5f6708b225cb984cb764ab77387
|
|
7
|
+
data.tar.gz: b882ad5f4cb5a5a3ed16975d1cab018fdd04a0e75be8677baa29882dcc61c56b561096568241e023843d687dc950324a16dedc20200dd4035f51f949535b20c5
|
data/AGENTS.md
CHANGED
|
@@ -129,6 +129,13 @@ claude mcp add railwatch --transport http https://railwatch.rebulk.com/mcp \
|
|
|
129
129
|
--header "Authorization: Bearer rwp_your_token_here"
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
In development with railwatch-server no token is needed from the same
|
|
133
|
+
machine: `claude mcp add --transport http railwatch http://localhost:3000/railwatch/mcp`,
|
|
134
|
+
or `bin/rails generate railwatch:agents` to write `.mcp.json`, a marked
|
|
135
|
+
block in `AGENTS.md`/`CLAUDE.md` and a Claude Code skill. Development
|
|
136
|
+
responses carry `Server-Timing` and `X-Railwatch-Execution-Id` for
|
|
137
|
+
`get_execution`.
|
|
138
|
+
|
|
132
139
|
`bin/rails railwatch:mcp` prints this and the Claude Desktop, Cursor, VS Code, and
|
|
133
140
|
Zed equivalents for whichever platform the app points at. Call
|
|
134
141
|
`list_applications` first; then `list_issues`, `get_issue` (stack frames,
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@
|
|
|
6
6
|
filled in by the release commit, which is also the only commit that
|
|
7
7
|
touches lib/railwatch/version.rb and Gemfile.lock. See CONTRIBUTING.md. -->
|
|
8
8
|
|
|
9
|
+
## 1.4.0 (2026-10-26)
|
|
10
|
+
|
|
11
|
+
- In development each recorded request's response carries
|
|
12
|
+
`Server-Timing` (`total`, `db` with the query count, `view`, `other`) and
|
|
13
|
+
`X-Railwatch-Execution-Id`, so `curl -i`, the browser's network panel or a
|
|
14
|
+
coding agent sees what the request cost and which execution to open.
|
|
15
|
+
`timing_headers` / `RAILWATCH_TIMING_HEADERS` turns it on or off
|
|
16
|
+
elsewhere; about 4 µs per request when on.
|
|
17
|
+
- `bin/rails generate railwatch:agents` points coding agents at the
|
|
18
|
+
development Railwatch: `.mcp.json` with the local MCP server, a marked
|
|
19
|
+
block in `AGENTS.md` and/or `CLAUDE.md` describing the dev loop, and a
|
|
20
|
+
Claude Code skill. Re-running it changes only its own block and entry.
|
|
21
|
+
- The beacon refuses a body over 256 KiB before Rails parses it.
|
|
22
|
+
`limit_payload` ran as a before_action, after ActionController had
|
|
23
|
+
already parsed the params, and a chunked body was read whole to find its
|
|
24
|
+
length, so an unauthenticated client could make a worker buffer an
|
|
25
|
+
unbounded body. A middleware in the engine's stack now refuses an
|
|
26
|
+
oversized Content-Length unread and reads at most one byte past the
|
|
27
|
+
limit of a chunked body.
|
|
28
|
+
- A plain-HTTP ingest URL that is not allowed is a permanent failure. The
|
|
29
|
+
result carried no status, which read as retryable, so the reporter kept
|
|
30
|
+
the batch and walked its whole backoff ladder for a setting that cannot
|
|
31
|
+
change between attempts.
|
|
32
|
+
- The Kamal post-deploy hook skips an ingest URL that is not https,
|
|
33
|
+
loopback, or allowed plain HTTP. An upper-case scheme or a bare host fell
|
|
34
|
+
through the `case` to curl, which sent the token over plain HTTP.
|
|
35
|
+
|
|
9
36
|
## 1.3.0 (2026-10-25)
|
|
10
37
|
|
|
11
38
|
- The initial-load visit splits its TTFB into `redirect_ms`,
|
|
@@ -13,7 +13,7 @@ module Railwatch
|
|
|
13
13
|
# Browsers stop following a redirect chain at 20 (Fetch's limit).
|
|
14
14
|
MAX_REDIRECTS = 20
|
|
15
15
|
|
|
16
|
-
MAX_REQUEST_BYTES =
|
|
16
|
+
MAX_REQUEST_BYTES = Middleware::BeaconLimit::MAX_BYTES
|
|
17
17
|
MAX_VISITS = 50
|
|
18
18
|
MAX_ERRORS = 50
|
|
19
19
|
MAX_ERROR_MESSAGE = 1024
|
|
@@ -26,7 +26,9 @@ module Railwatch
|
|
|
26
26
|
|
|
27
27
|
RATE_LIMIT_WINDOW = 60 # seconds
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# The body size is enforced before this controller parses anything, by
|
|
30
|
+
# Middleware::BeaconLimit in the engine's stack.
|
|
31
|
+
before_action :verify_origin, :throttle, if: -> { Railwatch.config.beacon_enabled }
|
|
30
32
|
|
|
31
33
|
def create
|
|
32
34
|
return head :no_content unless Railwatch.config.beacon_enabled
|
|
@@ -143,10 +145,6 @@ module Railwatch
|
|
|
143
145
|
head :too_many_requests
|
|
144
146
|
end
|
|
145
147
|
|
|
146
|
-
def limit_payload
|
|
147
|
-
head :content_too_large if request.raw_post.bytesize > MAX_REQUEST_BYTES
|
|
148
|
-
end
|
|
149
|
-
|
|
150
148
|
# The browser half of release health: one `session` record per beacon
|
|
151
149
|
# flush, never more, whatever the flush carried. The first one the client
|
|
152
150
|
# sends has no duration yet and opens the session; every later flush
|
data/docs/ai-and-mcp.md
CHANGED
|
@@ -16,7 +16,9 @@ The endpoint is `<your ingest host>/mcp`. For the hosted platform that's
|
|
|
16
16
|
server runs under the app's dashboard path, so the endpoint is the app's own
|
|
17
17
|
host plus `/railwatch/mcp`; see the
|
|
18
18
|
[railwatch-server README](https://github.com/Rebulk/railwatch-server#readme).
|
|
19
|
-
|
|
19
|
+
In development that endpoint needs no token from the same machine (see
|
|
20
|
+
[In development](#in-development) below). For Railwatch Cloud and a
|
|
21
|
+
self-hosted server the gem knows which one you're on:
|
|
20
22
|
|
|
21
23
|
```sh
|
|
22
24
|
bin/rails railwatch:mcp
|
|
@@ -24,6 +26,28 @@ bin/rails railwatch:mcp
|
|
|
24
26
|
|
|
25
27
|
prints every block below with your platform's host already filled in.
|
|
26
28
|
|
|
29
|
+
### In development
|
|
30
|
+
|
|
31
|
+
With `railwatch-server`, the app's own `bin/dev` serves MCP at
|
|
32
|
+
`http://localhost:3000/railwatch/mcp`, and the proxy signs agent requests
|
|
33
|
+
for you, so no token is needed:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
bin/rails generate railwatch:agents # .mcp.json, AGENTS.md/CLAUDE.md block, Claude Code skill
|
|
37
|
+
# or by hand:
|
|
38
|
+
claude mcp add --transport http railwatch http://localhost:3000/railwatch/mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That holds only in development, for a request from the same machine to a
|
|
42
|
+
`localhost`/`127.0.0.1`/`::1` host name, so anyone who can reach the dev
|
|
43
|
+
server's port from this machine can read and comment on its development
|
|
44
|
+
data, as they can already open its dashboard. Other environments need a
|
|
45
|
+
token unless the app sets `Railwatch::Server.config.agent_access = true`.
|
|
46
|
+
Each recorded development response also carries `Server-Timing` and
|
|
47
|
+
`X-Railwatch-Execution-Id` (`timing_headers` in
|
|
48
|
+
[`configuration.md`](configuration.md)), so an agent can `curl -i` a page and
|
|
49
|
+
pass the id to `get_execution`.
|
|
50
|
+
|
|
27
51
|
## 1. Get a token
|
|
28
52
|
|
|
29
53
|
MCP tokens are **per person**, not per application: sign in to the platform,
|
|
@@ -146,19 +170,24 @@ they're configured.
|
|
|
146
170
|
|
|
147
171
|
Every tool returns JSON as the text body of a single content block. **All
|
|
148
172
|
durations are milliseconds.** Every `window` argument takes `1h`, `6h`,
|
|
149
|
-
`24h`, `7d`, or `30d`, and defaults to `24h`.
|
|
173
|
+
`24h`, `7d`, or `30d`, and defaults to `24h`. `application_slug` and
|
|
174
|
+
`environment` may be left out when the token sees one application, or the
|
|
175
|
+
application has one environment. A result over 60 KB comes back as
|
|
176
|
+
`{"truncated": {…, "hint"}, "result": …}`, still valid JSON, with a hint on how
|
|
177
|
+
to narrow the call. Rows carry a dashboard `url` for the human.
|
|
150
178
|
|
|
151
179
|
| Tool | Arguments | Returns |
|
|
152
180
|
|---|---|---|
|
|
153
181
|
| `list_applications` | — | applications, their account, and their environments. Start here — everything else takes the `application_slug` and `environment` this returns (plus `account_slug`/`application_id` when two accounts share a slug). |
|
|
154
|
-
| `list_issues` | `application_slug?`, `environment?`, `status?` (`open`/`resolved`/`ignored`, default `open`), `limit?` | issues with key, title, kind, status, priority, occurrence and affected-user counts, culprit, first/last seen. |
|
|
155
|
-
| `get_issue` | `key` or `issue_id` | one issue
|
|
182
|
+
| `list_issues` | `application_slug?`, `environment?`, `status?` (`open`/`resolved`/`ignored`, default `open`), `sort?` (`impact`), `limit?` | issues with key, title, kind, status, priority, occurrence and affected-user counts, culprit, first/last seen and the dashboard `url`. Newest first by default; `sort: impact` ranks by occurrences discounted by days since last seen. |
|
|
183
|
+
| `get_issue` | `key` or `issue_id`, `frames?` (`all`) | one issue and its dashboard `url`. For an exception, the sample occurrence with stored stack frames (runs of framework and gem frames collapsed to a count unless `frames: "all"`), cause, locals, parsed context, browser breadcrumbs, and the execution it happened inside; the latest occurrence is shown only when it differs from the sample. For a performance or anomaly issue, the metric, limit or baseline, representative records, trend, and deploy comparison. |
|
|
156
184
|
| `update_issue` | `key`, `status?`, `priority?`, `assignee_email?`, `agent?` | the updated issue. Writes an activity entry attributed to your user and `agent`. |
|
|
157
185
|
| `add_comment` | `key`, `body`, `agent?` | the created comment, attributed the same way. |
|
|
158
|
-
| `list_slow_routes` | `application_slug
|
|
186
|
+
| `list_slow_routes` | `application_slug?`, `environment?`, `window?`, `sort?` (`total`/`avg`/`p95`) | the 20 routes that cost the most request time (count × average) by default, each with `group_hash`, count, errors, `total_ms`, `avg_ms`, `p95_ms` and `url`. `sort: p95` ranks by per-request latency. |
|
|
159
187
|
| `get_route` | `application_slug`, `environment`, `route` or `group_hash`, `window?` | one route's count/errors, p50/p95/p99/max, its slowest individual requests, the slowest queries those requests ran, and each N+1 with a concrete Active Record fix. |
|
|
160
188
|
| `search_requests` | `application_slug`, `environment`, `q?`, `window?`, `limit?` | individual requests. `q` is the dashboard's filter grammar: `method:GET`, `route:/checkout`, `status:500` or `status:5xx`, `deploy:…`, `tenant:…`, `user:…`, `min_ms:250`; bare words match the route name. |
|
|
161
|
-
| `get_execution` | `application_slug
|
|
189
|
+
| `get_execution` | `application_slug?`, `environment?`, `execution_id`, `timeline_offset?`, `timeline_limit?`, `timeline_type?` | why one execution took as long as it did. `summary` first: time split by sql, transactions, view, external, cache and untracked; the repeated queries and N+1s with their `source` (`file:line`) and an Active Record fix; the slowest records; notes on what to look at. Then one page of the timeline (25 rows by default, `timeline_offset` for more). |
|
|
190
|
+
| `last_execution` | `application_slug?`, `environment?`, `path?`, `kind?` (`request`/`job`), `timeline_offset?`, `timeline_limit?`, `timeline_type?` | the newest request or job in `get_execution`'s shape: the dev loop. `path` matches the request path, route pattern or execution name. With no executions yet, it says why records may be missing. |
|
|
162
191
|
| `explain_query` | `application_slug`, `environment`, `group_hash`, `window?` | the stored query plan for a query group, with the SQL and the sample's duration. `explain` is null unless the app sets `RAILWATCH_CAPTURE_QUERY_EXPLAIN`. `sql` is the normalized shape unless the app also sets `RAILWATCH_CAPTURE_SQL_VALUES`. |
|
|
163
192
|
| `get_profile` | `application_slug`, `environment`, `profile_id?`, `execution_id?`, `limit?` | the hottest frames of a stack profile — self and total samples, each with a percentage. |
|
|
164
193
|
| `search_logs` | `application_slug`, `environment`, `q`, `level?`, `limit?` | matching log lines, each with the `execution_id` to expand with `get_execution`. |
|
|
@@ -224,6 +253,15 @@ working *in a Rails app that uses Railwatch*:
|
|
|
224
253
|
|
|
225
254
|
Copy either into your own app's repo to give its agent the same context.
|
|
226
255
|
|
|
256
|
+
For the development loop, `bin/rails generate railwatch:agents` writes into
|
|
257
|
+
the app: `.mcp.json` with the local MCP server (Claude Code's
|
|
258
|
+
[project scope](https://code.claude.com/docs/en/mcp)), a block between
|
|
259
|
+
`<!-- BEGIN railwatch ... -->` and `<!-- END railwatch -->` in `AGENTS.md`
|
|
260
|
+
and/or `CLAUDE.md` (whichever exist; `AGENTS.md` when neither does), and a
|
|
261
|
+
[Claude Code skill](https://code.claude.com/docs/en/skills) at
|
|
262
|
+
`.claude/skills/railwatch/SKILL.md` (`--no-skill` skips it). Re-running it
|
|
263
|
+
replaces only its own block and entry; `--url` points it at another port.
|
|
264
|
+
|
|
227
265
|
## See also
|
|
228
266
|
|
|
229
267
|
- [`getting-started.md`](getting-started.md) — install, the token,
|
data/docs/configuration.md
CHANGED
|
@@ -20,6 +20,7 @@ values set in the initializer always win over the env var.
|
|
|
20
20
|
| `server` | `RAILWATCH_SERVER` | `KAMAL_HOST`, else `Socket.gethostname` | Host stamped on every record. Under Kamal the container hostname carries a per-deploy container id, so the Kamal host wins; it is what the post-deploy hook registers as an expected server, which is what silent-host detection compares against. |
|
|
21
21
|
| `environment` | — | resolved lazily from `Rails.env` | Set `c.environment = "staging"` to report under a name other than the actual Rails env. |
|
|
22
22
|
| `ignored_request_paths` | `RAILWATCH_IGNORED_REQUEST_PATHS` (comma-separated) | `/up,/railwatch/beacon` | Exact request paths that bypass Railwatch's request execution entirely. In Ruby configuration, `Regexp` entries are also supported. Setting the env var replaces the defaults; append with `c.ignored_request_paths += ["/healthz"]` to keep them. |
|
|
23
|
+
| `timing_headers` | `RAILWATCH_TIMING_HEADERS` | on in development, off elsewhere | Adds `Server-Timing` (`total`, `db` with the query count in `desc`, `view`, `other`) and `X-Railwatch-Execution-Id` to every recorded request's response, so `curl -i`, the browser's network panel, or a coding agent sees the cost and the execution to pass to `get_execution`. The id is left out when the request was sampled out. An existing `Server-Timing` (Rails' `config.server_timing`) is appended to. Off outside development by default because it tells every visitor how the app spends its time. Costs about 4 µs per request when on. |
|
|
23
24
|
| `beacon_allowed_origins` | `RAILWATCH_BEACON_ALLOWED_ORIGINS` | `[]` | Extra origins allowed to post to the beacon beyond the app's own, comma-separated: a full origin (`https://app.example.com`) or a bare host. Like Sentry's allowed domains this bounds abuse rather than authenticating, since an endpoint a browser posts to cannot hold a secret. |
|
|
24
25
|
| `beacon_global_rate_limit` | `RAILWATCH_BEACON_GLOBAL_RATE_LIMIT` | `6000` | Beacons accepted per minute across every client, so a rotating address cannot multiply past the per-client limit. 0 disables it. |
|
|
25
26
|
| `beacon_rate_limit` | `RAILWATCH_BEACON_RATE_LIMIT` | `120` | Beacon POSTs accepted per client IP per minute before `POST /railwatch/beacon` answers 429. The beacon is unauthenticated and keeps every browser error it is sent, so this is what stops a script from spending the app's event quota. Counted in the app's cache store; `0` turns it off. |
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rails/generators"
|
|
6
|
+
|
|
7
|
+
module Railwatch
|
|
8
|
+
module Generators
|
|
9
|
+
# Points the coding agents working on this app at its development
|
|
10
|
+
# Railwatch: the MCP server the railwatch-server gem serves at
|
|
11
|
+
# <mount>/mcp (no token needed from this machine in development), and a
|
|
12
|
+
# short note on the dev loop. Every file it touches is either its own or
|
|
13
|
+
# changed only between its markers, so it can be re-run after an upgrade.
|
|
14
|
+
class AgentsGenerator < Rails::Generators::Base
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
desc "Writes .mcp.json (Claude Code's project MCP servers) with this app's development Railwatch, " \
|
|
18
|
+
"a marked Railwatch block in AGENTS.md and/or CLAUDE.md, and a Claude Code skill. Safe to re-run."
|
|
19
|
+
|
|
20
|
+
class_option :url, type: :string,
|
|
21
|
+
desc: "The MCP endpoint. Default: http://localhost:<PORT or 3000><mount>/mcp."
|
|
22
|
+
class_option :skill, type: :boolean, default: true,
|
|
23
|
+
desc: "Write .claude/skills/railwatch/SKILL.md."
|
|
24
|
+
|
|
25
|
+
SERVER_NAME = "railwatch"
|
|
26
|
+
BEGIN_MARKER = "<!-- BEGIN railwatch (bin/rails generate railwatch:agents) -->"
|
|
27
|
+
END_MARKER = "<!-- END railwatch -->"
|
|
28
|
+
AGENT_FILES = %w[AGENTS.md CLAUDE.md].freeze
|
|
29
|
+
|
|
30
|
+
# Claude Code's project scope: { "mcpServers": { name: { "type": "http",
|
|
31
|
+
# "url": ... } } } in .mcp.json at the root
|
|
32
|
+
# (https://code.claude.com/docs/en/mcp, "Project scope"). Only the
|
|
33
|
+
# railwatch entry is written; other servers and keys are kept.
|
|
34
|
+
def write_mcp_json
|
|
35
|
+
path = File.join(destination_root, ".mcp.json")
|
|
36
|
+
config = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
37
|
+
return say("Not touching .mcp.json: its top level is not a JSON object.", :red) unless config.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
servers = config["mcpServers"] ||= {}
|
|
40
|
+
return say("Not touching .mcp.json: its mcpServers is not a JSON object.", :red) unless servers.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
entry = { "type" => "http", "url" => mcp_url }
|
|
43
|
+
return say_status(:identical, ".mcp.json", :blue) if servers[SERVER_NAME] == entry
|
|
44
|
+
|
|
45
|
+
servers[SERVER_NAME] = entry
|
|
46
|
+
create_file ".mcp.json", "#{JSON.pretty_generate(config)}\n", force: true
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
say("Not touching .mcp.json: it is not valid JSON (#{e.message.lines.first.strip}). Add this under mcpServers yourself: " \
|
|
49
|
+
"#{JSON.generate(SERVER_NAME => { "type" => "http", "url" => mcp_url })}", :red)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The block goes into whichever of AGENTS.md and CLAUDE.md exist, and
|
|
53
|
+
# AGENTS.md is created when neither does. Between its markers it is
|
|
54
|
+
# replaced on every run; nothing outside them is changed.
|
|
55
|
+
def write_agent_instructions
|
|
56
|
+
existing = AGENT_FILES.select { |name| File.exist?(File.join(destination_root, name)) }
|
|
57
|
+
(existing.empty? ? [ "AGENTS.md" ] : existing).each { |name| write_block(name, block) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A Claude Code project skill (https://code.claude.com/docs/en/skills):
|
|
61
|
+
# .claude/skills/<name>/SKILL.md, frontmatter `name` and `description`.
|
|
62
|
+
# The whole file is this generator's, rewritten on every run.
|
|
63
|
+
def write_skill
|
|
64
|
+
return unless options[:skill]
|
|
65
|
+
|
|
66
|
+
template "SKILL.md", ".claude/skills/railwatch/SKILL.md", force: true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def show_next_steps
|
|
70
|
+
say <<~STEPS, :green
|
|
71
|
+
|
|
72
|
+
Railwatch MCP for this app (development): #{mcp_url}
|
|
73
|
+
Claude Code reads .mcp.json and asks once before it uses a project server.
|
|
74
|
+
Other clients: add the same URL as a streamable HTTP MCP server.
|
|
75
|
+
No token is needed from this machine in development (railwatch-server).
|
|
76
|
+
Anywhere else, send one: Authorization: Bearer rwp_... (Settings -> Profile).
|
|
77
|
+
STEPS
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# `contents` with the marked block replaced by `block`, or with it
|
|
81
|
+
# appended (after a blank line) when there is none. The block replaced
|
|
82
|
+
# is the first BEGIN whose END comes before any other BEGIN, so a BEGIN
|
|
83
|
+
# left without its END is never paired with a later block's END (which
|
|
84
|
+
# would delete everything between them): it stays as it is, and the
|
|
85
|
+
# block is appended once and then found on every re-run.
|
|
86
|
+
def self.with_block(contents, block)
|
|
87
|
+
return block if contents.nil? || contents.strip.empty?
|
|
88
|
+
|
|
89
|
+
start = finish = nil
|
|
90
|
+
at = 0
|
|
91
|
+
while (candidate = contents.index(BEGIN_MARKER, at))
|
|
92
|
+
after = candidate + BEGIN_MARKER.length
|
|
93
|
+
close = contents.index(END_MARKER, after)
|
|
94
|
+
reopen = contents.index(BEGIN_MARKER, after)
|
|
95
|
+
if close && (reopen.nil? || close < reopen)
|
|
96
|
+
start, finish = candidate, close
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
at = after
|
|
100
|
+
end
|
|
101
|
+
if finish
|
|
102
|
+
tail = contents[(finish + END_MARKER.length)..].sub(/\A\n/, "")
|
|
103
|
+
"#{contents[0...start]}#{block}#{tail}"
|
|
104
|
+
else
|
|
105
|
+
"#{contents.sub(/\n*\z/, "\n")}\n#{block}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def mcp_url
|
|
112
|
+
options[:url].presence || "http://localhost:#{ENV.fetch("PORT", "3000")}#{mount_path}/mcp"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def dashboard_url
|
|
116
|
+
mcp_url.delete_suffix("/mcp")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The app itself, for the curl in the instructions.
|
|
120
|
+
def app_url
|
|
121
|
+
dashboard_url.delete_suffix(mount_path)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The slug railwatch-server gives the app it provisions: the app's
|
|
125
|
+
# module name, parameterized.
|
|
126
|
+
def app_slug
|
|
127
|
+
name = defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : File.basename(destination_root)
|
|
128
|
+
name.to_s.parameterize
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def mount_path
|
|
132
|
+
path = defined?(Railwatch::Server) && Railwatch::Server.respond_to?(:config) ? Railwatch::Server.config.normalized_mount_path : "/railwatch"
|
|
133
|
+
path.to_s.empty? ? "/railwatch" : path
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def block
|
|
137
|
+
content = ERB.new(File.read(File.join(self.class.source_root, "agents_block.md.tt")), trim_mode: "-").result(binding).strip
|
|
138
|
+
"#{BEGIN_MARKER}\n#{content}\n#{END_MARKER}\n"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def write_block(name, text)
|
|
142
|
+
path = File.join(destination_root, name)
|
|
143
|
+
current = File.exist?(path) ? File.read(path) : nil
|
|
144
|
+
updated = self.class.with_block(current, text)
|
|
145
|
+
return say_status(:identical, name, :blue) if updated == current
|
|
146
|
+
|
|
147
|
+
create_file name, updated, force: true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: railwatch
|
|
3
|
+
description: Find out why a page or job in this Rails app is slow or failing in development, using Railwatch (SQL with file:line, N+1s, views, logs, exceptions per request). Use after changing code that touches the database or views, when a request is slow, or to check a performance fix.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Railwatch in development
|
|
7
|
+
|
|
8
|
+
The `railwatch` MCP server (<%= mcp_url %>, in `.mcp.json`) reads what
|
|
9
|
+
this app recorded while it ran under `bin/dev`. No token is needed from this
|
|
10
|
+
machine. Arguments: `application_slug: "<%= app_slug %>"`, `environment: "development"`.
|
|
11
|
+
|
|
12
|
+
1. Make the request and read its cost from the response headers:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
curl -si <%= app_url %>/path | grep -i -e server-timing -e x-railwatch
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`Server-Timing: total;dur=…, db;dur=…;desc="N queries", view;dur=…, other;dur=…`
|
|
19
|
+
and `X-Railwatch-Execution-Id: <id>`. A request with a session needs its
|
|
20
|
+
cookie (`-b`), or use the id from the browser's network panel.
|
|
21
|
+
2. `get_execution(execution_id: <id>)`, or `last_execution(path: "/path")`
|
|
22
|
+
without the id: `summary` first (where the time went, the repeated
|
|
23
|
+
queries and N+1s with their `source` `file:line` and a fix), then a page
|
|
24
|
+
of the timeline (`timeline_offset` for more).
|
|
25
|
+
3. `get_route(route: "GET /path")`: the route's N+1s with the line and the
|
|
26
|
+
`includes` that fixes each, and its slowest queries. `explain_query` for a
|
|
27
|
+
query's plan.
|
|
28
|
+
4. Change the code, repeat 1-2, and report the before and after query count
|
|
29
|
+
and db time.
|
|
30
|
+
|
|
31
|
+
For a person, link the dashboard: <%= dashboard_url %>. Errors:
|
|
32
|
+
`list_issues`, then `get_issue(key:)`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
## Railwatch (performance and errors, in development)
|
|
2
|
+
|
|
3
|
+
Every request, job and command this app runs in development is recorded by
|
|
4
|
+
the `railwatch` gem into the server `railwatch-server` runs beside it: SQL
|
|
5
|
+
(with the `file:line` that ran it), N+1s, views, logs and exceptions, all
|
|
6
|
+
linked to one execution.
|
|
7
|
+
|
|
8
|
+
- MCP server `<%= SERVER_NAME %>` at <%= mcp_url %> (in `.mcp.json`). No token from
|
|
9
|
+
this machine in development. Tools take `application_slug: "<%= app_slug %>"`
|
|
10
|
+
and `environment: "development"`; `list_applications` lists them.
|
|
11
|
+
- Dashboard, for the human: <%= dashboard_url %>
|
|
12
|
+
|
|
13
|
+
The loop after a change:
|
|
14
|
+
|
|
15
|
+
1. Hit the page: `curl -si <%= app_url %>/path | grep -i -e server-timing -e x-railwatch`.
|
|
16
|
+
`Server-Timing` has total, db (with the query count), view and other;
|
|
17
|
+
`X-Railwatch-Execution-Id` names the execution.
|
|
18
|
+
2. `get_execution` with that id, or `last_execution` (optionally with a
|
|
19
|
+
`path`): a summary of where the time went, the repeated queries and N+1s
|
|
20
|
+
with their `source` (`file:line`), then a page of the timeline.
|
|
21
|
+
3. `get_route` for the route: its N+1s, each with the source line and an
|
|
22
|
+
`includes` to add, and its slowest queries.
|
|
23
|
+
4. Fix, hit the page again, compare the query count and db time.
|
|
24
|
+
|
|
25
|
+
`search_requests`, `list_slow_routes`, `list_issues` and `get_issue` cover the
|
|
26
|
+
rest.
|
|
@@ -37,6 +37,12 @@ case "$INGEST" in
|
|
|
37
37
|
exit 0
|
|
38
38
|
fi
|
|
39
39
|
;;
|
|
40
|
+
*)
|
|
41
|
+
# An upper-case scheme or a bare host: curl would send the token over
|
|
42
|
+
# plain HTTP, which none of the branches above allowed.
|
|
43
|
+
echo "Railwatch deploy marker skipped: unsupported ingest URL (use https://)" >&2
|
|
44
|
+
exit 0
|
|
45
|
+
;;
|
|
40
46
|
esac
|
|
41
47
|
|
|
42
48
|
post() {
|
|
@@ -86,7 +86,7 @@ module Railwatch
|
|
|
86
86
|
:capture_job_arguments, :capture_job_retry_errors, :capture_response_body_on_error, :max_attachment_bytes,
|
|
87
87
|
:track_sessions, :session_flush_interval, :session_timeout,
|
|
88
88
|
:capture_console, :interactive_runner_paths, :ignored_request_paths,
|
|
89
|
-
:capture_llm_content
|
|
89
|
+
:capture_llm_content, :timing_headers
|
|
90
90
|
|
|
91
91
|
attr_reader :deploy, :deploy_source, :detect_deploy, :user_resolver, :beacon_user_resolver,
|
|
92
92
|
:fingerprint_resolver, :redactors, :rejectors, :before_ingest, :backpressure_high_water
|
|
@@ -238,6 +238,13 @@ module Railwatch
|
|
|
238
238
|
DEFAULT_INTERACTIVE_RUNNER_PATHS.dup
|
|
239
239
|
@ignored_request_paths = ENV["RAILWATCH_IGNORED_REQUEST_PATHS"]&.split(",")&.map(&:strip)&.reject(&:empty?) ||
|
|
240
240
|
DEFAULT_IGNORED_REQUEST_PATHS.dup
|
|
241
|
+
# Server-Timing (total, db with the query count, view, other) and
|
|
242
|
+
# X-Railwatch-Execution-Id on each recorded request's response, for
|
|
243
|
+
# `curl -i`, the browser's network panel, and an agent that wants the
|
|
244
|
+
# execution it just caused. nil: on in development only
|
|
245
|
+
# (#timing_headers?), since elsewhere they tell every visitor how the
|
|
246
|
+
# app spends its time.
|
|
247
|
+
@timing_headers = env_bool("RAILWATCH_TIMING_HEADERS", nil)
|
|
241
248
|
@user_resolver = nil
|
|
242
249
|
@beacon_user_resolver = nil
|
|
243
250
|
@fingerprint_resolver = nil
|
|
@@ -364,6 +371,11 @@ module Railwatch
|
|
|
364
371
|
@environment || (defined?(Rails) ? Rails.env.to_s : "production")
|
|
365
372
|
end
|
|
366
373
|
|
|
374
|
+
# Whether the request middleware adds its timing headers (#timing_headers).
|
|
375
|
+
def timing_headers?
|
|
376
|
+
@timing_headers.nil? ? defined?(Rails) && Rails.env.development? : @timing_headers
|
|
377
|
+
end
|
|
378
|
+
|
|
367
379
|
# Matches Railwatch.reject_cache_keys entries and DEFAULT_VENDOR_CACHE_KEYS
|
|
368
380
|
# against a cache key. A Regexp is used as-is. A String starting with "^"
|
|
369
381
|
# or containing another regex metacharacter is compiled as a Regexp; a
|
data/lib/railwatch/engine.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "railwatch/middleware/beacon_limit"
|
|
4
|
+
|
|
3
5
|
module Railwatch
|
|
4
6
|
# Mounted for the browser beacon (POST <mount>/beacon), its only route.
|
|
5
7
|
class Engine < ::Rails::Engine
|
|
6
8
|
isolate_namespace Railwatch
|
|
7
9
|
|
|
10
|
+
config.middleware.use Railwatch::Middleware::BeaconLimit
|
|
11
|
+
|
|
8
12
|
# The request middleware goes first so wall time includes every other
|
|
9
13
|
# middleware, exactly like Nightwatch's GlobalMiddleware.
|
|
10
14
|
initializer "railwatch.middleware", before: :load_config_initializers do |app|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Middleware
|
|
5
|
+
# Refuses an oversized beacon body before Rails parses it. The check
|
|
6
|
+
# cannot live in BeaconController: ActionController parses the params
|
|
7
|
+
# (ParamsWrapper, parameter filtering for the log line) before any
|
|
8
|
+
# before_action, and Request#content_length on a chunked body reads it
|
|
9
|
+
# whole. In the engine's own stack, so it sees only the beacon route.
|
|
10
|
+
class BeaconLimit
|
|
11
|
+
MAX_BYTES = 256 * 1024
|
|
12
|
+
|
|
13
|
+
def initialize(app, limit = MAX_BYTES)
|
|
14
|
+
@app = app
|
|
15
|
+
@limit = limit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
return too_large if env["CONTENT_LENGTH"].to_i > @limit
|
|
20
|
+
return @app.call(env) if env["CONTENT_LENGTH"] || !(input = env["rack.input"])
|
|
21
|
+
|
|
22
|
+
# Chunked: no length up front, so read at most one byte past the
|
|
23
|
+
# limit and hand the rest of the stack a body it can read again.
|
|
24
|
+
body = read_bounded(input)
|
|
25
|
+
return too_large if body.bytesize > @limit
|
|
26
|
+
|
|
27
|
+
env["rack.input"] = StringIO.new(body)
|
|
28
|
+
env["CONTENT_LENGTH"] = body.bytesize.to_s
|
|
29
|
+
env.delete("HTTP_TRANSFER_ENCODING")
|
|
30
|
+
@app.call(env)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Rack lets read(n) return fewer than n bytes before the end, so keep
|
|
36
|
+
# reading until the end or one byte past the limit, never further.
|
|
37
|
+
def read_bounded(input)
|
|
38
|
+
body = +"".b
|
|
39
|
+
while body.bytesize <= @limit
|
|
40
|
+
chunk = input.read(@limit + 1 - body.bytesize) or break
|
|
41
|
+
break if chunk.empty?
|
|
42
|
+
|
|
43
|
+
body << chunk
|
|
44
|
+
end
|
|
45
|
+
body
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def too_large
|
|
49
|
+
[ 413, { "content-type" => "text/plain" }, [] ]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -20,6 +20,10 @@ module Railwatch
|
|
|
20
20
|
# CONTENT_TYPE test Subscribers::Requests uses before its params walk.
|
|
21
21
|
MULTIPART = "multipart/form-data"
|
|
22
22
|
NO_FILES = [].freeze
|
|
23
|
+
# Response headers written with config.timing_headers. Lower case, as
|
|
24
|
+
# Rack 3 requires; Rack::Headers matches any case.
|
|
25
|
+
SERVER_TIMING = "server-timing"
|
|
26
|
+
EXECUTION_ID = "x-railwatch-execution-id"
|
|
23
27
|
|
|
24
28
|
# Returns [trace_id, parent_id, sampled] from an inbound traceparent,
|
|
25
29
|
# or nil when the header is absent or malformed.
|
|
@@ -55,21 +59,58 @@ module Railwatch
|
|
|
55
59
|
exe.keep! if upstream_sampled
|
|
56
60
|
exe.enter_stage(:middleware_before)
|
|
57
61
|
env["railwatch.execution"] = exe
|
|
58
|
-
status = headers = body = nil
|
|
62
|
+
status = headers = body = parent = nil
|
|
63
|
+
timing = Railwatch.config.timing_headers?
|
|
59
64
|
begin
|
|
60
65
|
status, headers, body = @app.call(env)
|
|
66
|
+
server_timing(env, exe, headers) if timing && headers
|
|
61
67
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
62
68
|
Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "railwatch.middleware")
|
|
63
69
|
raise
|
|
64
70
|
ensure
|
|
65
71
|
exe.enter_stage(:middleware_after) unless exe.stage == :middleware_after
|
|
66
|
-
finish(env, exe, status, headers)
|
|
72
|
+
parent = finish(env, exe, status, headers)
|
|
67
73
|
end
|
|
74
|
+
execution_id(exe, headers) if timing && headers && parent
|
|
68
75
|
[ status, headers, body ]
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
private
|
|
72
79
|
|
|
80
|
+
# config.timing_headers (development by default): what this request
|
|
81
|
+
# cost, on the response, so `curl -i` or the browser's network panel
|
|
82
|
+
# shows it without opening the dashboard. Everything here is already
|
|
83
|
+
# known: the counters, and the runtimes the process_action subscriber
|
|
84
|
+
# put in env. Appended to a Server-Timing the app (Rails' own, with
|
|
85
|
+
# config.server_timing) already set, whether one string or several.
|
|
86
|
+
def server_timing(env, exe, headers)
|
|
87
|
+
total = exe.duration / 1000.0
|
|
88
|
+
db = env["railwatch.db_runtime"]
|
|
89
|
+
view = env["railwatch.view_runtime"]
|
|
90
|
+
queries = exe.counters[:queries]
|
|
91
|
+
cached = exe.counters[:cached_queries]
|
|
92
|
+
desc = cached.positive? ? "#{queries} queries, #{cached} cached" : "#{queries} #{queries == 1 ? "query" : "queries"}"
|
|
93
|
+
metrics = [ format("total;dur=%.1f", total), db ? format("db;dur=%.1f;desc=\"%s\"", db, desc) : "db;desc=\"#{desc}\"" ]
|
|
94
|
+
metrics << format("view;dur=%.1f", view) if view
|
|
95
|
+
metrics << format("other;dur=%.1f;desc=\"not db or view\"", [ total - db.to_f - view.to_f, 0 ].max) if db || view
|
|
96
|
+
# A plain Hash may hold it title-cased; Rack::Headers finds either.
|
|
97
|
+
existing = Array(headers[SERVER_TIMING] || headers.delete("Server-Timing")).join(", ")
|
|
98
|
+
headers[SERVER_TIMING] = existing.empty? ? metrics.join(", ") : "#{existing}, #{metrics.join(", ")}"
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
# A frozen header hash (a Rack app that returns a constant): the
|
|
101
|
+
# response is the app's, unchanged.
|
|
102
|
+
Railwatch.debug { "timing headers skipped: #{e.class}: #{e.message}" }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The execution an agent passes to get_execution. Only once the
|
|
106
|
+
# request record has shipped (head-sampled in, kept, or rescued by the
|
|
107
|
+
# tail decision): any other id is one the server never has.
|
|
108
|
+
def execution_id(exe, headers)
|
|
109
|
+
headers[EXECUTION_ID] = exe.id
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
Railwatch.debug { "execution id header skipped: #{e.class}: #{e.message}" }
|
|
112
|
+
end
|
|
113
|
+
|
|
73
114
|
def ignored_request?(env)
|
|
74
115
|
path = env["PATH_INFO"].to_s
|
|
75
116
|
Railwatch.config.ignored_request_paths.any? do |pattern|
|
|
@@ -111,13 +152,16 @@ module Railwatch
|
|
|
111
152
|
# The block is only called when the request record is going to ship;
|
|
112
153
|
# a head-sampled-out request nothing rescued skips the
|
|
113
154
|
# ActionDispatch::Request and the header walk entirely.
|
|
114
|
-
Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
|
|
155
|
+
parent = Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
|
|
115
156
|
# A request with no user and no session cookie has no session, and
|
|
116
157
|
# Sessions.touch returns without writing anything.
|
|
117
158
|
Sessions.touch(exe, env, status) if Railwatch.config.track_sessions
|
|
159
|
+
# The request record, when one shipped.
|
|
160
|
+
parent
|
|
118
161
|
rescue StandardError => e
|
|
119
162
|
Railwatch.debug { "request finish failed: #{e.class}: #{e.message}" }
|
|
120
163
|
Railwatch.finish_execution
|
|
164
|
+
nil
|
|
121
165
|
end
|
|
122
166
|
|
|
123
167
|
def parent_fields(env, exe, status, headers)
|
|
@@ -69,9 +69,9 @@ module Railwatch
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def deliver(records, dropped: 0, dropped_bytes: 0, backpressure_factor: 1.0, batch_id: SecureRandom.uuid)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
# Permanent: it cannot change between attempts, and with no status
|
|
73
|
+
# the result would read as retryable and walk the backoff ladder.
|
|
74
|
+
return permanent("plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true") unless destination_allowed?
|
|
75
75
|
return Result.new(ok: false, status: UNAUTHORIZED_STATUS, error: "unauthorized, flushing stopped") if @unauthorized
|
|
76
76
|
|
|
77
77
|
encoded = @encoder.encode(records)
|
data/lib/railwatch/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: railwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole Robertson
|
|
@@ -70,6 +70,9 @@ files:
|
|
|
70
70
|
- docs/source-maps.md
|
|
71
71
|
- docs/testing.md
|
|
72
72
|
- docs/troubleshooting.md
|
|
73
|
+
- lib/generators/railwatch/agents/agents_generator.rb
|
|
74
|
+
- lib/generators/railwatch/agents/templates/SKILL.md.tt
|
|
75
|
+
- lib/generators/railwatch/agents/templates/agents_block.md.tt
|
|
73
76
|
- lib/generators/railwatch/install/install_generator.rb
|
|
74
77
|
- lib/generators/railwatch/install/templates/initializer.rb.tt
|
|
75
78
|
- lib/generators/railwatch/install/templates/post-deploy
|
|
@@ -91,6 +94,7 @@ files:
|
|
|
91
94
|
- lib/railwatch/health.rb
|
|
92
95
|
- lib/railwatch/job_tracing.rb
|
|
93
96
|
- lib/railwatch/json_compat.rb
|
|
97
|
+
- lib/railwatch/middleware/beacon_limit.rb
|
|
94
98
|
- lib/railwatch/middleware/request.rb
|
|
95
99
|
- lib/railwatch/minitest.rb
|
|
96
100
|
- lib/railwatch/patches.rb
|