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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af26db042e24211ffde4ad4e82dd90a7bc27e37c0bc9f74674947a30109b0036
4
- data.tar.gz: 8c8033a1fa3f4b97142dd6b3593cf4bcc7fa16d2bea97fed6d580ee1584133de
3
+ metadata.gz: fd59ae7a65fbd673b2069d096802144c04277a08379fa42285d32677ad5502c6
4
+ data.tar.gz: 49ff48d1ec8340609c23c12a117ae1e381ac938f7fbdfb6aa914b7e33505a25c
5
5
  SHA512:
6
- metadata.gz: 1ab758eed04fbcc9088989d5c411c88e0e80d8c3fbc08195c2908f04fcf38a21ad6b9ed8ec3ab8363081fcad59886f1abff8efb93bd6abfaeb156bada0bb66dc
7
- data.tar.gz: 81e0e6e114d46a70619e21ae97816f451b83ccee7ee3f8e8aad8f2ccce8bd487beb31bfdea4cbbdf467357da7e7c4ae1b32dad69eae317e55083755897f3aa23
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 = 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
@@ -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 tab's session id, so a browser error can be lined up with the
212
- # session it crashed. Shaped like every other read off this payload:
213
- # anything but a hash carrying an id yields no id at all.
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
- session ? safe_string(session["id"], 64) : ""
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.presence,
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
- 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. |
@@ -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 mints one id per tab in `sessionStorage` and
530
- mirrors it into a `railwatch_session` cookie. It rides along on the
531
- beacon flushes the client already sends for visits, so this costs no
532
- extra requests. This is the primary source for a web app, and it is
533
- what makes session duration mean "how long the tab was open".
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
- `app/frontend/lib/railwatch.ts`.
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
@@ -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
- - `app/frontend/lib/railwatch.ts` plus the `startRailwatch()` call in your
61
- Inertia entrypoint — only if `app/frontend/` exists. If it can't find
62
- an entrypoint it prints the two lines to add.
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 tab's
400
- `session` id, the `user_agent`, and up to 20 `breadcrumbs`. Each
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`, mints a 16-hex id per tab in
803
- `sessionStorage` under the key `railwatch.session`, so it dies with the
804
- tab. It mirrors the id into a `railwatch_session` cookie and sends it
805
- with every beacon flush. `Railwatch::BeaconController` writes at most
806
- one `session` record per flush. The first, with no `duration_ms` yet,
807
- opens the session. Later ones beat it along, and the
808
- `pagehide`/`visibilitychange` flush closes it with `ended`.
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`
@@ -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` | `app/frontend/lib/railwatch.ts` isn't there. Only matters for Inertia visit timing. |
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:)`.