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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af26db042e24211ffde4ad4e82dd90a7bc27e37c0bc9f74674947a30109b0036
4
- data.tar.gz: 8c8033a1fa3f4b97142dd6b3593cf4bcc7fa16d2bea97fed6d580ee1584133de
3
+ metadata.gz: 954add09993af5d0756ed9ff1875061f25eed72867139116fd020a90b961627c
4
+ data.tar.gz: 6fbb618b6eb38d867f9ea7648dfce28d7c46003e1ed9499303a3889de06373af
5
5
  SHA512:
6
- metadata.gz: 1ab758eed04fbcc9088989d5c411c88e0e80d8c3fbc08195c2908f04fcf38a21ad6b9ed8ec3ab8363081fcad59886f1abff8efb93bd6abfaeb156bada0bb66dc
7
- data.tar.gz: 81e0e6e114d46a70619e21ae97816f451b83ccee7ee3f8e8aad8f2ccce8bd487beb31bfdea4cbbdf467357da7e7c4ae1b32dad69eae317e55083755897f3aa23
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 = 256 * 1024
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
- before_action :limit_payload, :verify_origin, :throttle, if: -> { Railwatch.config.beacon_enabled }
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
- For Railwatch Cloud and a self-hosted server the gem knows which one you're on:
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. For an exception, the sample and latest occurrences with stored stack frames, cause, locals, parsed context, browser breadcrumbs, and the execution it happened inside. For a performance or anomaly issue, the metric, limit or baseline, representative records, trend, and deploy comparison. |
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`, `environment`, `window?` | the 20 slowest routes by p95, each with `group_hash`, count, errors, `p95_ms`. |
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`, `environment`, `execution_id` | one execution and its full child timeline — every query, cache read, log line, outgoing request, view render, and exception, each offset in ms from the start. |
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,
@@ -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
@@ -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
- unless destination_allowed?
73
- return Result.new(ok: false, error: "plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true")
74
- end
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
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.3.0
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