railwatch 1.2.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: 072de4fbe3a2b0854d5e00b3b41b033a901d05d9f681f8e6083ae9c6882e4751
4
- data.tar.gz: f24164cbf663302b6df3e302aadf59e7328316a4e6d1c86d1b8b5ad49ee6f85f
3
+ metadata.gz: 954add09993af5d0756ed9ff1875061f25eed72867139116fd020a90b961627c
4
+ data.tar.gz: 6fbb618b6eb38d867f9ea7648dfce28d7c46003e1ed9499303a3889de06373af
5
5
  SHA512:
6
- metadata.gz: 9d3907a21539f08dd1e71d4eb83e0305d749673a51e9eb4d8404e4cff2924665d0c0dd28c5f4e8d62b50174ff0618f7e43d41a5984cd52b24a1d6fe63745dae7
7
- data.tar.gz: 1bc7449ec96e9c85432d9f75746a28e4f5152ba71b97d3d32e6b7a9ee4b53044abdf34281a78bf4bf546436b0b8168376b51635daf640fe396fd853163cde638
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,47 @@
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
+
36
+ ## 1.3.0 (2026-10-25)
37
+
38
+ - The initial-load visit splits its TTFB into `redirect_ms`,
39
+ `connect_ms` and `server_ms`, which add up to `ttfb`, plus
40
+ `redirect_count`. TTFB is measured from navigation start, so it holds
41
+ every redirect before the page: a 1.2 s TTFB on a route the server
42
+ answers in 100 ms was a redirect chain, and nothing said so.
43
+ `redirect_ms` (navigation start to `fetchStart`) is still measured when
44
+ a hop is cross-origin, where browsers report `redirectCount` 0; the
45
+ client then leaves `redirect_count` out rather than claim no redirect.
46
+ The beacon accepts and clamps the four fields. Receivers must store them
47
+ for them to show anywhere. Apps that copied the client
48
+ (`app/frontend/lib/railwatch.ts`) need the same change in their copy.
49
+
9
50
  ## 1.2.0 (2026-10-25)
10
51
 
11
52
  - Executions send only the counters that fired. Every parent record
@@ -10,8 +10,10 @@ module Railwatch
10
10
  # forged beacon, not a page load, so it is clamped rather than stored.
11
11
  MAX_METRIC_MS = 120_000
12
12
  MAX_CLS = 100.0
13
+ # Browsers stop following a redirect chain at 20 (Fetch's limit).
14
+ MAX_REDIRECTS = 20
13
15
 
14
- MAX_REQUEST_BYTES = 256 * 1024
16
+ MAX_REQUEST_BYTES = Middleware::BeaconLimit::MAX_BYTES
15
17
  MAX_VISITS = 50
16
18
  MAX_ERRORS = 50
17
19
  MAX_ERROR_MESSAGE = 1024
@@ -24,7 +26,9 @@ module Railwatch
24
26
 
25
27
  RATE_LIMIT_WINDOW = 60 # seconds
26
28
 
27
- 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 }
28
32
 
29
33
  def create
30
34
  return head :no_content unless Railwatch.config.beacon_enabled
@@ -50,6 +54,7 @@ module Railwatch
50
54
  cls: cumulative_layout_shift(v["cls"]),
51
55
  inp: metric_ms(v["inp"]),
52
56
  ttfb: metric_ms(v["ttfb"]),
57
+ **ttfb_phases(v),
53
58
  user: user_id,
54
59
  tenant: tenant,
55
60
  user_agent: safe_string(request.user_agent, 256))
@@ -140,10 +145,6 @@ module Railwatch
140
145
  head :too_many_requests
141
146
  end
142
147
 
143
- def limit_payload
144
- head :content_too_large if request.raw_post.bytesize > MAX_REQUEST_BYTES
145
- end
146
-
147
148
  # The browser half of release health: one `session` record per beacon
148
149
  # flush, never more, whatever the flush carried. The first one the client
149
150
  # sends has no duration yet and opens the session; every later flush
@@ -274,9 +275,38 @@ module Railwatch
274
275
  # Web vitals only ride along on the initial-load visit, and only from
275
276
  # browsers that support the entry type behind them, so every one of these
276
277
  # is nil far more often than not.
278
+ # A value that is not a finite number ("1e309" parses to Infinity, which
279
+ # #round refuses) is nil, like a missing one: raising here would drop the
280
+ # whole beacon at the rescue below, every other visit in it included.
277
281
  def metric_ms(value)
282
+ finite_ms(value)&.round&.clamp(0, MAX_METRIC_MS)
283
+ end
284
+
285
+ # The initial load's TTFB split into phases. They add up to ttfb, so if
286
+ # clamping any of them (or ttfb itself) would break that sum, the split is
287
+ # dropped rather than stored inconsistent: ttfb stays, the phases are nil.
288
+ def ttfb_phases(visit)
289
+ raw = %w[redirect_ms connect_ms server_ms].to_h { |key| [ key, finite_ms(visit[key]) ] }
290
+ phases = raw.transform_values { |ms| ms&.round&.clamp(0, MAX_METRIC_MS) }
291
+ total = finite_ms(visit["ttfb"])
292
+ clamped = raw.any? { |key, ms| ms && ms.round != phases[key] }
293
+ over = total && total.round > MAX_METRIC_MS
294
+ phases = phases.transform_values { nil } if clamped || over
295
+ phases.transform_keys(&:to_sym).merge(redirect_count: redirect_count(visit["redirect_count"]))
296
+ end
297
+
298
+ def finite_ms(value)
299
+ return nil if value.nil? || value == ""
300
+ float = safe_float(value)
301
+ float.finite? ? float : nil
302
+ end
303
+
304
+ # How many redirects the browser SHOWED. It hides them when any hop was
305
+ # cross-origin, so the client sends nothing then rather than 0, and a
306
+ # nil here must stay nil: "none" and "hidden" are different answers.
307
+ def redirect_count(value)
278
308
  return nil if value.nil? || value == ""
279
- safe_float(value).round.clamp(0, MAX_METRIC_MS)
309
+ safe_integer(value).clamp(0, MAX_REDIRECTS)
280
310
  end
281
311
 
282
312
  def cumulative_layout_shift(value)
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. |
data/docs/records.md CHANGED
@@ -768,7 +768,11 @@ entirely if `config.beacon_enabled` is off.
768
768
  | `lcp` | Largest Contentful Paint, integer ms, clamped to 0–120000. Initial load only. |
769
769
  | `cls` | Cumulative Layout Shift — the largest session window, float rounded to 4 decimals, clamped to 0–100. Initial load only. |
770
770
  | `inp` | Interaction to Next Paint, integer ms, clamped to 0–120000. The slowest interaction, not the spec's high percentile. Initial load only. |
771
- | `ttfb` | Time to First Byte from navigation timing's `responseStart`, integer ms, clamped to 0–120000. Initial load only. |
771
+ | `ttfb` | Time to First Byte from navigation timing's `responseStart`, integer ms, clamped to 0–120000. Initial load only. Includes every redirect before the page. |
772
+ | `redirect_ms` | The part of `ttfb` before the final request started: navigation start to `fetchStart` (`workerStart` when a service worker started first). Every redirect hop, plus a few ms of the browser's own work. Still measured when a hop is cross-origin, when the browser hides the count. Integer ms, clamped to 0–120000. Initial load only. |
773
+ | `redirect_count` | Redirects the browser **showed** (`redirectCount`), clamped to 0–20. Nil when it showed none, which is either no redirect or a cross-origin hop it hid: never read nil as "no redirect", read `redirect_ms`. Initial load only. |
774
+ | `connect_ms` | `ttfb` from the final request's start to `requestStart`: service worker startup, HTTP cache, DNS, TCP and TLS. Integer ms, clamped to 0–120000. Initial load only. |
775
+ | `server_ms` | `ttfb` from `requestStart` to `responseStart`: the network round trip plus the server's time. Integer ms, clamped to 0–120000. Initial load only. |
772
776
  | `user` | Resolved server-side from the beacon request's session/cookies, same resolver as every other record. |
773
777
  | `tenant` | Current tenant context. |
774
778
  | `user_agent` | Truncated to 256 chars. |
@@ -781,7 +785,11 @@ never routed it. It has `method` `"GET"`, `status` `"success"`, and
781
785
  four Core Web Vitals. It is held back until the page is first hidden, on
782
786
  `visibilitychange`/`pagehide`, so those numbers are final when it ships.
783
787
  Every vital is nil on a browser that doesn't support the
784
- `PerformanceObserver` entry type behind it.
788
+ `PerformanceObserver` entry type behind it. `redirect_ms`, `connect_ms`
789
+ and `server_ms` add up to `ttfb` exactly (the boundaries are rounded
790
+ before subtracting); all three are nil when the browser's timestamps are
791
+ missing or out of order, and when clamping `ttfb` or any phase to the
792
+ 120,000 ms cap would leave a split that no longer adds up.
785
793
 
786
794
  ### `session`
787
795
 
@@ -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() {
@@ -1,7 +1,8 @@
1
1
  // Railwatch browser client for Inertia. Reports each visit's duration,
2
2
  // component, and prop payload size to /railwatch/beacon so the platform can
3
- // show real page-load timing, plus Core Web Vitals (LCP, CLS, INP, TTFB)
4
- // for the initial page load, plus every JavaScript error the page throws
3
+ // show real page-load timing, plus Core Web Vitals (LCP, CLS, INP, TTFB,
4
+ // with TTFB split into redirect / connection / server time) for the
5
+ // initial page load, plus every JavaScript error the page throws
5
6
  // with the breadcrumb trail that led to it.
6
7
  // No dependencies -- every metric comes from PerformanceObserver or the
7
8
  // navigation timing entry, and every API is feature-detected, so browsers
@@ -23,6 +24,11 @@ interface Visit {
23
24
  cls?: number
24
25
  inp?: number
25
26
  ttfb?: number
27
+ // TTFB split into phases (initial load only; see ttfbPhases).
28
+ redirect_ms?: number
29
+ redirect_count?: number
30
+ connect_ms?: number
31
+ server_ms?: number
26
32
  }
27
33
 
28
34
  // The tab's session, for release health. sessionStorage scopes it to the
@@ -679,6 +685,58 @@ function pageComponent(): string | undefined {
679
685
  }
680
686
  }
681
687
 
688
+ // Where the initial load's TTFB went, in three phases that add up to it:
689
+ //
690
+ // redirect_ms navigation start -> the final request's start: every
691
+ // redirect hop, plus the browser's own few ms (so never
692
+ // quite 0).
693
+ // connect_ms -> requestStart: service worker startup, HTTP cache, DNS,
694
+ // TCP and TLS for the final request.
695
+ // server_ms -> responseStart: the network round trip and the server's
696
+ // time.
697
+ //
698
+ // The final request starts at fetchStart, or at workerStart when a service
699
+ // worker had to start first (the web-vitals library cuts there too).
700
+ //
701
+ // redirect_ms stays honest when a hop is cross-origin. The browser then
702
+ // zeroes redirectCount, redirectStart and redirectEnd (Navigation Timing 2),
703
+ // but fetchStart is still the final request's start (Fetch's "post-redirect
704
+ // start time"), so the hidden hops' time is still in it.
705
+ //
706
+ // redirect_count is sent only when the browser shows the redirects. A 0
707
+ // means "no redirect" or "hidden", and a hidden redirect must never be
708
+ // reported as no redirect.
709
+ //
710
+ // The boundaries are rounded before subtracting, so the three phases add up
711
+ // to the reported ttfb exactly. A browser whose timestamps are missing or
712
+ // out of order sends no phases rather than a wrong split.
713
+ function ttfbPhases(nav: PerformanceNavigationTiming): Partial<Visit> {
714
+ const fetchStart =
715
+ nav.workerStart > 0 && nav.workerStart < nav.fetchStart
716
+ ? nav.workerStart
717
+ : nav.fetchStart
718
+ // Order is judged on the raw timestamps: rounding first would let a
719
+ // requestStart a hair before fetchStart through as a 0 ms phase.
720
+ const ordered =
721
+ nav.responseStart > 0 &&
722
+ fetchStart >= nav.startTime &&
723
+ nav.requestStart >= fetchStart &&
724
+ nav.responseStart >= nav.requestStart
725
+ if (!ordered) return {}
726
+ const [start, fetched, requested, responded] = [
727
+ nav.startTime,
728
+ fetchStart,
729
+ nav.requestStart,
730
+ nav.responseStart,
731
+ ].map(Math.round)
732
+ return {
733
+ redirect_ms: fetched - start,
734
+ redirect_count: nav.redirectCount > 0 ? nav.redirectCount : undefined,
735
+ connect_ms: requested - fetched,
736
+ server_ms: responded - requested,
737
+ }
738
+ }
739
+
682
740
  // The first page load is a visit too -- it just wasn't routed by Inertia, so
683
741
  // the component name comes off the root element's serialized page object.
684
742
  function initialVisit(): Visit | null {
@@ -696,6 +754,7 @@ function initialVisit(): Visit | null {
696
754
  component,
697
755
  duration_ms: duration,
698
756
  status: "success",
757
+ ...ttfbPhases(nav),
699
758
  }
700
759
  }
701
760
 
@@ -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.2.0"
4
+ VERSION = "1.4.0"
5
5
  end
@@ -605,6 +605,10 @@
605
605
  "cls": null,
606
606
  "inp": null,
607
607
  "ttfb": null,
608
+ "redirect_ms": null,
609
+ "redirect_count": null,
610
+ "connect_ms": null,
611
+ "server_ms": null,
608
612
  "user": null,
609
613
  "tenant": null,
610
614
  "user_agent": ""
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.2.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