railwatch 1.3.0 → 1.5.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 +54 -0
- data/app/controllers/railwatch/beacon_controller.rb +19 -12
- data/docs/ai-and-mcp.md +44 -6
- data/docs/configuration.md +34 -6
- data/docs/getting-started.md +19 -3
- data/docs/records.md +28 -10
- data/docs/troubleshooting.md +1 -1
- 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/install_generator.rb +18 -10
- data/lib/generators/railwatch/install/templates/post-deploy +6 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +245 -35
- data/lib/railwatch/configuration.rb +34 -1
- data/lib/railwatch/engine.rb +4 -0
- data/lib/railwatch/middleware/beacon_limit.rb +53 -0
- data/lib/railwatch/middleware/request.rb +50 -4
- data/lib/railwatch/sessions.rb +63 -3
- data/lib/railwatch/transport/http.rb +3 -3
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +7 -1
- data/lib/railwatch.rb +16 -1
- data/lib/tasks/railwatch_tasks.rake +5 -4
- 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: fd59ae7a65fbd673b2069d096802144c04277a08379fa42285d32677ad5502c6
|
|
4
|
+
data.tar.gz: 49ff48d1ec8340609c23c12a117ae1e381ac938f7fbdfb6aa914b7e33505a25c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c45c3639f2787b16b9104afed286ff712bfb63028e13fd83eda44323544ddb9a156c60566287d5465d6daaeffb46b4a8379addb8a51ebf90ab178f9c17a41d4
|
|
7
|
+
data.tar.gz: 31fe3a1492400bf823ef7dbd04ee22a45edda63349168032b93a3f8e429525cf9cb205b15b96eb6238819a6386916457a75ed3f4de52164227c39e39c8a1a576
|
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,60 @@
|
|
|
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.5.0 (2026-10-28)
|
|
10
|
+
|
|
11
|
+
- The browser session is one per browser, shared by every tab, as in GA4
|
|
12
|
+
and Amplitude: kept in `localStorage`, it ends after 30 minutes with no
|
|
13
|
+
activity in any tab or 24 hours after it started. Each tab also has its
|
|
14
|
+
own id. Every visit record carries `session_id` and `tab_id`, and every
|
|
15
|
+
request record carries the `session_id` from the `X-Railwatch-Session`
|
|
16
|
+
header or `railwatch_session` cookie and the `tab_id` from
|
|
17
|
+
`X-Railwatch-Tab`, which the client now sends on same-origin Inertia
|
|
18
|
+
visits. A browser error's `context.browser` carries the `session` and
|
|
19
|
+
`tab` it was thrown in, stamped when it fired, and both are dropped
|
|
20
|
+
rather than cut when they are not shaped like an id. A browser's first
|
|
21
|
+
page load, which arrives before the client has set its cookie, is handed
|
|
22
|
+
one by the request middleware (successful HTML `GET`s only, never a
|
|
23
|
+
publicly cacheable one, and only when the browser client is installed:
|
|
24
|
+
`browser_client`), so that page is in the session's journey and no
|
|
25
|
+
longer opens a second `user:<id>` session in release health. A closing
|
|
26
|
+
tab no longer marks the session `ended`: the browser's other tabs may
|
|
27
|
+
still be in it. Apps that copied the client
|
|
28
|
+
(`app/frontend/lib/railwatch.ts`) need the same change in their copy:
|
|
29
|
+
`bin/rails generate railwatch:install --force --no-doctor` updates it.
|
|
30
|
+
- `railwatch:install`, `railwatch:doctor` and the `browser_client`
|
|
31
|
+
detection find the app's frontend sources in ViteRuby's `sourceCodeDir`,
|
|
32
|
+
else in `app/frontend` or `app/javascript`. They only looked in
|
|
33
|
+
`app/frontend` before, so an `app/javascript` app got no client and no
|
|
34
|
+
entrypoint wiring.
|
|
35
|
+
|
|
36
|
+
## 1.4.0 (2026-10-26)
|
|
37
|
+
|
|
38
|
+
- In development each recorded request's response carries
|
|
39
|
+
`Server-Timing` (`total`, `db` with the query count, `view`, `other`) and
|
|
40
|
+
`X-Railwatch-Execution-Id`, so `curl -i`, the browser's network panel or a
|
|
41
|
+
coding agent sees what the request cost and which execution to open.
|
|
42
|
+
`timing_headers` / `RAILWATCH_TIMING_HEADERS` turns it on or off
|
|
43
|
+
elsewhere; about 4 µs per request when on.
|
|
44
|
+
- `bin/rails generate railwatch:agents` points coding agents at the
|
|
45
|
+
development Railwatch: `.mcp.json` with the local MCP server, a marked
|
|
46
|
+
block in `AGENTS.md` and/or `CLAUDE.md` describing the dev loop, and a
|
|
47
|
+
Claude Code skill. Re-running it changes only its own block and entry.
|
|
48
|
+
- The beacon refuses a body over 256 KiB before Rails parses it.
|
|
49
|
+
`limit_payload` ran as a before_action, after ActionController had
|
|
50
|
+
already parsed the params, and a chunked body was read whole to find its
|
|
51
|
+
length, so an unauthenticated client could make a worker buffer an
|
|
52
|
+
unbounded body. A middleware in the engine's stack now refuses an
|
|
53
|
+
oversized Content-Length unread and reads at most one byte past the
|
|
54
|
+
limit of a chunked body.
|
|
55
|
+
- A plain-HTTP ingest URL that is not allowed is a permanent failure. The
|
|
56
|
+
result carried no status, which read as retryable, so the reporter kept
|
|
57
|
+
the batch and walked its whole backoff ladder for a setting that cannot
|
|
58
|
+
change between attempts.
|
|
59
|
+
- The Kamal post-deploy hook skips an ingest URL that is not https,
|
|
60
|
+
loopback, or allowed plain HTTP. An upper-case scheme or a bare host fell
|
|
61
|
+
through the `case` to curl, which sent the token over plain HTTP.
|
|
62
|
+
|
|
9
63
|
## 1.3.0 (2026-10-25)
|
|
10
64
|
|
|
11
65
|
- 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
|
|
@@ -53,6 +55,7 @@ module Railwatch
|
|
|
53
55
|
inp: metric_ms(v["inp"]),
|
|
54
56
|
ttfb: metric_ms(v["ttfb"]),
|
|
55
57
|
**ttfb_phases(v),
|
|
58
|
+
**browser_fields(v),
|
|
56
59
|
user: user_id,
|
|
57
60
|
tenant: tenant,
|
|
58
61
|
user_agent: safe_string(request.user_agent, 256))
|
|
@@ -143,10 +146,6 @@ module Railwatch
|
|
|
143
146
|
head :too_many_requests
|
|
144
147
|
end
|
|
145
148
|
|
|
146
|
-
def limit_payload
|
|
147
|
-
head :content_too_large if request.raw_post.bytesize > MAX_REQUEST_BYTES
|
|
148
|
-
end
|
|
149
|
-
|
|
150
149
|
# The browser half of release health: one `session` record per beacon
|
|
151
150
|
# flush, never more, whatever the flush carried. The first one the client
|
|
152
151
|
# sends has no duration yet and opens the session; every later flush
|
|
@@ -195,7 +194,7 @@ module Railwatch
|
|
|
195
194
|
next unless error
|
|
196
195
|
name = safe_string(error["name"], 255)
|
|
197
196
|
next if name.empty?
|
|
198
|
-
record_error(error, name, origin, session_id, user_id, tenant)
|
|
197
|
+
record_error(error, name, origin, Sessions.browser_id(error["session_id"]) || session_id, user_id, tenant)
|
|
199
198
|
end
|
|
200
199
|
end
|
|
201
200
|
|
|
@@ -208,12 +207,19 @@ module Railwatch
|
|
|
208
207
|
Context.current_tenant || (hint.is_a?(String) ? safe_string(hint, 255).presence : nil)
|
|
209
208
|
end
|
|
210
209
|
|
|
211
|
-
# The
|
|
212
|
-
#
|
|
213
|
-
#
|
|
210
|
+
# The flush's session id, for an error that does not name its own (a
|
|
211
|
+
# client copied before errors carried one). Anything but a hash carrying
|
|
212
|
+
# an id shaped like one the client mints yields no id at all.
|
|
214
213
|
def session_id_from(session)
|
|
215
214
|
session = object(session)
|
|
216
|
-
|
|
215
|
+
Sessions.browser_id(session["id"]) if session
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The session and tab each visit says it happened in, which can differ
|
|
219
|
+
# within one beacon: visits queued before the session timed out ship
|
|
220
|
+
# with the next session's beat. Omitted unless shaped like an id.
|
|
221
|
+
def browser_fields(visit)
|
|
222
|
+
{ session_id: Sessions.browser_id(visit["session_id"]), tab_id: Sessions.browser_id(visit["tab_id"]) }.compact
|
|
217
223
|
end
|
|
218
224
|
|
|
219
225
|
def record_error(error, name, origin, session_id, user_id, tenant)
|
|
@@ -237,7 +243,8 @@ module Railwatch
|
|
|
237
243
|
url: safe_string(error["url"], 2048).presence,
|
|
238
244
|
component: safe_string(error["component"], 255).presence,
|
|
239
245
|
visit: safe_string(error["visit"], 2048).presence,
|
|
240
|
-
session: session_id
|
|
246
|
+
session: session_id,
|
|
247
|
+
tab: Sessions.browser_id(error["tab_id"]),
|
|
241
248
|
user_agent: safe_string(request.user_agent, 256).presence,
|
|
242
249
|
breadcrumbs: breadcrumbs(error["breadcrumbs"]).presence
|
|
243
250
|
}.compact)),
|
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. |
|
|
@@ -522,15 +523,21 @@ crash-free session and crash-free user rates are computed from. The
|
|
|
522
523
|
| `track_sessions` | `RAILWATCH_TRACK_SESSIONS` | `true` | Master switch for both session sources. Off means the request middleware does nothing extra and no flusher thread is started. |
|
|
523
524
|
| `session_flush_interval` | `RAILWATCH_SESSION_FLUSH_INTERVAL` | `60.0` | Seconds between server-session flushes. One background thread per web process, re-armed after `fork` exactly like the health sampler, and flushed once more on shutdown. |
|
|
524
525
|
| `session_timeout` | `RAILWATCH_SESSION_TIMEOUT` | `1800.0` | Seconds a server session may sit idle before it ships with `ended` and is forgotten. |
|
|
526
|
+
| `browser_client` | `RAILWATCH_BROWSER_CLIENT` | whether `lib/railwatch.ts` exists in the app's frontend source directory (ViteRuby's `sourceCodeDir`, else `app/frontend` or `app/javascript`) | Whether the app's pages run the browser client. When they do, a browser's first page load gets its session id from the server (below). Set it when the client lives somewhere else, or `false` to never set the cookie. |
|
|
525
527
|
|
|
526
528
|
There are two sources, and they meet on the same id:
|
|
527
529
|
|
|
528
530
|
- **The browser client** is `app/frontend/lib/railwatch.ts`, installed by
|
|
529
|
-
`railwatch:install`. It
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
531
|
+
`railwatch:install`. It keeps one id per browser in `localStorage`,
|
|
532
|
+
shared by every tab and replaced after 30 minutes with no activity in
|
|
533
|
+
any tab or 24 hours after it started, and mirrors it into a
|
|
534
|
+
`railwatch_session` cookie. It also sends the id, with the tab's own
|
|
535
|
+
id, as `X-Railwatch-Session` and `X-Railwatch-Tab` on every Inertia
|
|
536
|
+
visit to the page's own origin. It rides along on the beacon flushes
|
|
537
|
+
the client already sends for visits, so this costs no extra requests.
|
|
538
|
+
This is the primary source for a web app, and it is what makes session
|
|
539
|
+
duration mean "how long the user was active". The client's 30 minutes
|
|
540
|
+
is fixed; it matches this setting's default.
|
|
534
541
|
- **The request middleware** aggregates, in memory, every request that
|
|
535
542
|
either resolves a user or carries that cookie or an
|
|
536
543
|
`X-Railwatch-Session` header. It is the only source for an API-only
|
|
@@ -540,6 +547,25 @@ There are two sources, and they meet on the same id:
|
|
|
540
547
|
When a browser session's requests carry the cookie both sources produce
|
|
541
548
|
records under the same id and the platform dedupes them.
|
|
542
549
|
|
|
550
|
+
A browser's first page load arrives before the client has run, so it
|
|
551
|
+
carries no cookie. With `browser_client` on, the request middleware gives
|
|
552
|
+
it one: a successful `GET` whose response is an HTML document, that is not
|
|
553
|
+
an Inertia visit and carries no `X-Railwatch-Session` or cookie, gets
|
|
554
|
+
`Set-Cookie: railwatch_session=<new id>; path=/; max-age=1800;
|
|
555
|
+
samesite=lax` (plus `secure` over HTTPS, including behind a proxy that
|
|
556
|
+
sets `X-Forwarded-Proto`). That request's record carries the id, and so does its
|
|
557
|
+
server session when it resolved a user (an anonymous one opened no server
|
|
558
|
+
session before and still opens none; its browser opens the session with
|
|
559
|
+
the client's first beat).
|
|
560
|
+
The client adopts it when the browser has no live session of its own, so
|
|
561
|
+
that first page is the first entry of the session's journey and the
|
|
562
|
+
session is not counted twice (once under the id, once as `user:<id>`). It
|
|
563
|
+
is never set on a response whose `Cache-Control` says `public` or
|
|
564
|
+
`s-maxage`, since a shared cache would hand one visitor's cookie to every
|
|
565
|
+
visitor; on such a page the session starts with the client, as before.
|
|
566
|
+
Requests with neither cookie nor header, from an API client or an app
|
|
567
|
+
without the browser client, still key on `user:<id>`.
|
|
568
|
+
|
|
543
569
|
## Vendor noise defaults
|
|
544
570
|
|
|
545
571
|
Framework/vendor activity excluded by default so a fresh install isn't
|
|
@@ -718,7 +744,9 @@ credential, so it is throttled per client IP by `beacon_rate_limit`,
|
|
|
718
744
|
default 120 a minute, `0` to disable. A client past the limit gets a 429
|
|
719
745
|
with `Retry-After` and nothing from that POST is recorded. Client setup:
|
|
720
746
|
call `startRailwatch()` from your Inertia entrypoint. It is generated at
|
|
721
|
-
`
|
|
747
|
+
`lib/railwatch.ts` in the app's frontend source directory, usually
|
|
748
|
+
`app/frontend/lib/railwatch.ts` (see Getting started for how the directory
|
|
749
|
+
is chosen and how to update the copy).
|
|
722
750
|
|
|
723
751
|
`startRailwatch` takes three optional settings, none of which has a
|
|
724
752
|
server-side equivalent. They are decisions about the browser the code is
|
data/docs/getting-started.md
CHANGED
|
@@ -57,9 +57,25 @@ bin/rails generate railwatch:install \
|
|
|
57
57
|
beacon endpoint the browser client posts to (`POST /railwatch/beacon`).
|
|
58
58
|
- `.kamal/hooks/post-deploy` — only if `config/deploy.yml` already
|
|
59
59
|
exists.
|
|
60
|
-
- `
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
- `lib/railwatch.ts` in the app's frontend source directory, plus the
|
|
61
|
+
`startRailwatch()` call in its Inertia entrypoint
|
|
62
|
+
(`entrypoints/inertia.tsx`, `.ts` or `.jsx`). The directory is ViteRuby's
|
|
63
|
+
`sourceCodeDir` when the app uses vite_ruby, else the first of
|
|
64
|
+
`app/frontend` and `app/javascript` that exists; with neither, nothing
|
|
65
|
+
is copied. If it can't find an entrypoint it prints the two lines to add.
|
|
66
|
+
|
|
67
|
+
The browser client is copied into the app, so upgrading the gem does not
|
|
68
|
+
update it. After an upgrade whose CHANGELOG mentions the client, run
|
|
69
|
+
`bin/rails generate railwatch:install --force --no-doctor`, or copy the
|
|
70
|
+
file by hand:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
cp "$(bundle exec ruby -e 'require "railwatch"; print Railwatch.browser_client_path')" app/frontend/lib/railwatch.ts # or app/javascript/lib/...
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Without `--force` the generator asks before overwriting a copy that
|
|
77
|
+
differs. Either way, review the diff if you have edited your copy. The
|
|
78
|
+
generator's other steps leave what they already wrote alone.
|
|
63
79
|
- `require "railwatch/rspec"` in `spec/rails_helper.rb`, or
|
|
64
80
|
`require "railwatch/minitest"` in `test/test_helper.rb`.
|
|
65
81
|
|
data/docs/records.md
CHANGED
|
@@ -104,6 +104,8 @@ boundaries. The `action`/`render` boundaries come from
|
|
|
104
104
|
| `payload` | Filtered request params — only captured when `config.capture_request_payload` is on **and** the request raised an exception (never for successful requests). |
|
|
105
105
|
| `user_agent` | Truncated to 256 chars. |
|
|
106
106
|
| `files` | Array of `{name, size, content_type, error}` for each uploaded file in a multipart request (metadata only, never contents). A non-multipart request body is never parsed to fill this in. |
|
|
107
|
+
| `session_id` | The browser client's session id, from `X-Railwatch-Session` or else the `railwatch_session` cookie, so a full page load carries it too. On a browser's first page load, the id the middleware set in that cookie on this response (see Release health in configuration.md). Never the `"user:<id>"` key the server session falls back to. At most 64 of `[A-Za-z0-9_-]`; absent when missing or not that shape. |
|
|
108
|
+
| `tab_id` | The browser client's tab id, from `X-Railwatch-Tab`, which it sends only on Inertia visits: a full page load has none. Same validation, absent otherwise. |
|
|
107
109
|
| `profiled` | `true` when a `profile` record shipped for this request; absent otherwise. |
|
|
108
110
|
|
|
109
111
|
### `job_attempt`
|
|
@@ -396,8 +398,10 @@ the normalized message. So browser errors group, split, merge, resolve,
|
|
|
396
398
|
and regress through the same Issue machinery.
|
|
397
399
|
|
|
398
400
|
`context` carries a `browser` key with the page `url`, the Inertia
|
|
399
|
-
`component`, the `visit` the error happened in if any, the
|
|
400
|
-
`session` id
|
|
401
|
+
`component`, the `visit` the error happened in if any, the browser
|
|
402
|
+
`session` id and the `tab` id it was thrown in, both stamped by the client
|
|
403
|
+
when the error fired (the flush's session stands in for a client that
|
|
404
|
+
sends none; each at most 64 of `[A-Za-z0-9_-]`, absent otherwise), the `user_agent`, and up to 20 `breadcrumbs`. Each
|
|
401
405
|
breadcrumb is `{at, kind, text}`, with `kind` being `console`, `click`,
|
|
402
406
|
or `navigate`. The breadcrumbs are the trail the client recorded before
|
|
403
407
|
the crash. Anything the app passed as `reportError(error, context)` is
|
|
@@ -776,6 +780,8 @@ entirely if `config.beacon_enabled` is off.
|
|
|
776
780
|
| `user` | Resolved server-side from the beacon request's session/cookies, same resolver as every other record. |
|
|
777
781
|
| `tenant` | Current tenant context. |
|
|
778
782
|
| `user_agent` | Truncated to 256 chars. |
|
|
783
|
+
| `session_id` | The browser session the visit happened in (see `session`), when the client sent one shaped like an id: at most 64 of `[A-Za-z0-9_-]`. Absent otherwise. |
|
|
784
|
+
| `tab_id` | The tab the visit happened in: a 16-hex id the client keeps in `sessionStorage` (`railwatch.tab`), so it survives a reload of the tab but not a new tab. "Duplicate tab" copies `sessionStorage`, so a duplicated tab shares its original's id until one of them is reloaded fresh. Same validation, absent otherwise. |
|
|
779
785
|
|
|
780
786
|
The **initial page load** is reported as a visit too, even though Inertia
|
|
781
787
|
never routed it. It has `method` `"GET"`, `status` `"success"`, and
|
|
@@ -799,13 +805,25 @@ per deploy and reports crash-free rates from them. Two sources produce
|
|
|
799
805
|
the same record:
|
|
800
806
|
|
|
801
807
|
- **Browser**, `source: "browser"`. The client,
|
|
802
|
-
`app/frontend/lib/railwatch.ts`,
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
808
|
+
`app/frontend/lib/railwatch.ts`, keeps one session per browser, shared
|
|
809
|
+
by every tab, the way GA4 and Amplitude do: a 16-hex id in
|
|
810
|
+
`localStorage` (`railwatch.session`, with `railwatch.session.at` when it
|
|
811
|
+
started and `railwatch.session.seen` for its last activity in any tab).
|
|
812
|
+
Activity is a page load, an Inertia visit the user waits on, and a tab
|
|
813
|
+
coming back into view. After 30 minutes with no activity in any tab, or
|
|
814
|
+
24 hours after it started, the next activity mints a new id. When
|
|
815
|
+
`localStorage` throws the session lives in `sessionStorage`, and when
|
|
816
|
+
that throws too, in memory; a new tab still joins the session through
|
|
817
|
+
the cookie. A browser's first page load gets its id from the server, in
|
|
818
|
+
that cookie, and the client adopts it when no live session is stored.
|
|
819
|
+
The client mirrors the id into a `railwatch_session` cookie that lapses
|
|
820
|
+
after 30 minutes, renewed on every activity, and sends it with every
|
|
821
|
+
beacon flush. `Railwatch::BeaconController` writes at most one `session` record
|
|
822
|
+
per flush. The first, with no `duration_ms` yet, opens the session.
|
|
823
|
+
Later ones beat it along. Closing a tab does not end the session, since
|
|
824
|
+
other tabs may still be in it; a tab that sees the session time out
|
|
825
|
+
sends one last beat with `ended`, and a session no tab comes back to
|
|
826
|
+
ends only by the platform's own timeout.
|
|
809
827
|
- **Server**, `source: "server"`. `lib/railwatch/sessions.rb` aggregates,
|
|
810
828
|
per process, every request that resolves a user or carries that cookie
|
|
811
829
|
or an `X-Railwatch-Session` header. A background thread ships one
|
|
@@ -830,7 +848,7 @@ halves of one session rather than counting it twice.
|
|
|
830
848
|
| `requests` | Requests in this session so far (server only). |
|
|
831
849
|
| `visits` | Inertia visits in this beacon flush (browser only). |
|
|
832
850
|
| `errors` | Requests that 5xx'd or raised (server), or visits with status `"error"` in this flush (browser). |
|
|
833
|
-
| `ended` | Whether this is the session's last record. |
|
|
851
|
+
| `ended` | Whether this is the session's last record. A browser session is only `ended` when a tab saw it time out, never because a tab closed. |
|
|
834
852
|
| `user` | Resolved user id, when there is one. |
|
|
835
853
|
|
|
836
854
|
### `process`
|
data/docs/troubleshooting.md
CHANGED
|
@@ -26,7 +26,7 @@ the install is broken.
|
|
|
26
26
|
| `ignored record types` | Never fails; it prints what `c.ignore` is dropping. |
|
|
27
27
|
| `interactive sessions` | Never fails; it prints whether consoles are captured and the runner scratch paths. |
|
|
28
28
|
| `kamal post-deploy hook` | `.kamal/hooks/post-deploy` is missing or doesn't mention Railwatch. Only matters if you deploy with Kamal. |
|
|
29
|
-
| `browser client` | `
|
|
29
|
+
| `browser client` | `lib/railwatch.ts` isn't in the app's frontend source directory (ViteRuby's `sourceCodeDir`, else `app/frontend` or `app/javascript`). Only matters for Inertia visit timing. |
|
|
30
30
|
| `browser client imported` | The client exists but nothing calls `startRailwatch()` — no `startRailwatch` found in `app/frontend/entrypoints`. Visits won't report. |
|
|
31
31
|
| `profiler backend` | Neither `vernier` nor `stackprof` is installed, so `Railwatch::Profiler.available?` is false and the profiling settings are inert. |
|
|
32
32
|
| `test matchers` | Neither `spec/rails_helper.rb` requires `railwatch/rspec` nor `test/test_helper.rb` requires `railwatch/minitest`. |
|
|
@@ -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:)`.
|