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 +4 -4
- data/AGENTS.md +7 -0
- data/CHANGELOG.md +41 -0
- data/app/controllers/railwatch/beacon_controller.rb +37 -7
- data/docs/ai-and-mcp.md +44 -6
- data/docs/configuration.md +1 -0
- data/docs/records.md +10 -2
- data/lib/generators/railwatch/agents/agents_generator.rb +151 -0
- data/lib/generators/railwatch/agents/templates/SKILL.md.tt +32 -0
- data/lib/generators/railwatch/agents/templates/agents_block.md.tt +26 -0
- data/lib/generators/railwatch/install/templates/post-deploy +6 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +61 -2
- data/lib/railwatch/configuration.rb +13 -1
- data/lib/railwatch/engine.rb +4 -0
- data/lib/railwatch/middleware/beacon_limit.rb +53 -0
- data/lib/railwatch/middleware/request.rb +47 -3
- data/lib/railwatch/transport/http.rb +3 -3
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 954add09993af5d0756ed9ff1875061f25eed72867139116fd020a90b961627c
|
|
4
|
+
data.tar.gz: 6fbb618b6eb38d867f9ea7648dfce28d7c46003e1ed9499303a3889de06373af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88f0a50d4769269d565513d59b03567413424f6566da76d1056d08a1e734d7ce244bc609a06c8a25cd7651873ae1519af968a5f6708b225cb984cb764ab77387
|
|
7
|
+
data.tar.gz: b882ad5f4cb5a5a3ed16975d1cab018fdd04a0e75be8677baa29882dcc61c56b561096568241e023843d687dc950324a16dedc20200dd4035f51f949535b20c5
|
data/AGENTS.md
CHANGED
|
@@ -129,6 +129,13 @@ claude mcp add railwatch --transport http https://railwatch.rebulk.com/mcp \
|
|
|
129
129
|
--header "Authorization: Bearer rwp_your_token_here"
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
In development with railwatch-server no token is needed from the same
|
|
133
|
+
machine: `claude mcp add --transport http railwatch http://localhost:3000/railwatch/mcp`,
|
|
134
|
+
or `bin/rails generate railwatch:agents` to write `.mcp.json`, a marked
|
|
135
|
+
block in `AGENTS.md`/`CLAUDE.md` and a Claude Code skill. Development
|
|
136
|
+
responses carry `Server-Timing` and `X-Railwatch-Execution-Id` for
|
|
137
|
+
`get_execution`.
|
|
138
|
+
|
|
132
139
|
`bin/rails railwatch:mcp` prints this and the Claude Desktop, Cursor, VS Code, and
|
|
133
140
|
Zed equivalents for whichever platform the app points at. Call
|
|
134
141
|
`list_applications` first; then `list_issues`, `get_issue` (stack frames,
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. |
|
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
|
-
//
|
|
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
|
data/lib/railwatch/engine.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "railwatch/middleware/beacon_limit"
|
|
4
|
+
|
|
3
5
|
module Railwatch
|
|
4
6
|
# Mounted for the browser beacon (POST <mount>/beacon), its only route.
|
|
5
7
|
class Engine < ::Rails::Engine
|
|
6
8
|
isolate_namespace Railwatch
|
|
7
9
|
|
|
10
|
+
config.middleware.use Railwatch::Middleware::BeaconLimit
|
|
11
|
+
|
|
8
12
|
# The request middleware goes first so wall time includes every other
|
|
9
13
|
# middleware, exactly like Nightwatch's GlobalMiddleware.
|
|
10
14
|
initializer "railwatch.middleware", before: :load_config_initializers do |app|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Middleware
|
|
5
|
+
# Refuses an oversized beacon body before Rails parses it. The check
|
|
6
|
+
# cannot live in BeaconController: ActionController parses the params
|
|
7
|
+
# (ParamsWrapper, parameter filtering for the log line) before any
|
|
8
|
+
# before_action, and Request#content_length on a chunked body reads it
|
|
9
|
+
# whole. In the engine's own stack, so it sees only the beacon route.
|
|
10
|
+
class BeaconLimit
|
|
11
|
+
MAX_BYTES = 256 * 1024
|
|
12
|
+
|
|
13
|
+
def initialize(app, limit = MAX_BYTES)
|
|
14
|
+
@app = app
|
|
15
|
+
@limit = limit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
return too_large if env["CONTENT_LENGTH"].to_i > @limit
|
|
20
|
+
return @app.call(env) if env["CONTENT_LENGTH"] || !(input = env["rack.input"])
|
|
21
|
+
|
|
22
|
+
# Chunked: no length up front, so read at most one byte past the
|
|
23
|
+
# limit and hand the rest of the stack a body it can read again.
|
|
24
|
+
body = read_bounded(input)
|
|
25
|
+
return too_large if body.bytesize > @limit
|
|
26
|
+
|
|
27
|
+
env["rack.input"] = StringIO.new(body)
|
|
28
|
+
env["CONTENT_LENGTH"] = body.bytesize.to_s
|
|
29
|
+
env.delete("HTTP_TRANSFER_ENCODING")
|
|
30
|
+
@app.call(env)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Rack lets read(n) return fewer than n bytes before the end, so keep
|
|
36
|
+
# reading until the end or one byte past the limit, never further.
|
|
37
|
+
def read_bounded(input)
|
|
38
|
+
body = +"".b
|
|
39
|
+
while body.bytesize <= @limit
|
|
40
|
+
chunk = input.read(@limit + 1 - body.bytesize) or break
|
|
41
|
+
break if chunk.empty?
|
|
42
|
+
|
|
43
|
+
body << chunk
|
|
44
|
+
end
|
|
45
|
+
body
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def too_large
|
|
49
|
+
[ 413, { "content-type" => "text/plain" }, [] ]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -20,6 +20,10 @@ module Railwatch
|
|
|
20
20
|
# CONTENT_TYPE test Subscribers::Requests uses before its params walk.
|
|
21
21
|
MULTIPART = "multipart/form-data"
|
|
22
22
|
NO_FILES = [].freeze
|
|
23
|
+
# Response headers written with config.timing_headers. Lower case, as
|
|
24
|
+
# Rack 3 requires; Rack::Headers matches any case.
|
|
25
|
+
SERVER_TIMING = "server-timing"
|
|
26
|
+
EXECUTION_ID = "x-railwatch-execution-id"
|
|
23
27
|
|
|
24
28
|
# Returns [trace_id, parent_id, sampled] from an inbound traceparent,
|
|
25
29
|
# or nil when the header is absent or malformed.
|
|
@@ -55,21 +59,58 @@ module Railwatch
|
|
|
55
59
|
exe.keep! if upstream_sampled
|
|
56
60
|
exe.enter_stage(:middleware_before)
|
|
57
61
|
env["railwatch.execution"] = exe
|
|
58
|
-
status = headers = body = nil
|
|
62
|
+
status = headers = body = parent = nil
|
|
63
|
+
timing = Railwatch.config.timing_headers?
|
|
59
64
|
begin
|
|
60
65
|
status, headers, body = @app.call(env)
|
|
66
|
+
server_timing(env, exe, headers) if timing && headers
|
|
61
67
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
62
68
|
Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "railwatch.middleware")
|
|
63
69
|
raise
|
|
64
70
|
ensure
|
|
65
71
|
exe.enter_stage(:middleware_after) unless exe.stage == :middleware_after
|
|
66
|
-
finish(env, exe, status, headers)
|
|
72
|
+
parent = finish(env, exe, status, headers)
|
|
67
73
|
end
|
|
74
|
+
execution_id(exe, headers) if timing && headers && parent
|
|
68
75
|
[ status, headers, body ]
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
private
|
|
72
79
|
|
|
80
|
+
# config.timing_headers (development by default): what this request
|
|
81
|
+
# cost, on the response, so `curl -i` or the browser's network panel
|
|
82
|
+
# shows it without opening the dashboard. Everything here is already
|
|
83
|
+
# known: the counters, and the runtimes the process_action subscriber
|
|
84
|
+
# put in env. Appended to a Server-Timing the app (Rails' own, with
|
|
85
|
+
# config.server_timing) already set, whether one string or several.
|
|
86
|
+
def server_timing(env, exe, headers)
|
|
87
|
+
total = exe.duration / 1000.0
|
|
88
|
+
db = env["railwatch.db_runtime"]
|
|
89
|
+
view = env["railwatch.view_runtime"]
|
|
90
|
+
queries = exe.counters[:queries]
|
|
91
|
+
cached = exe.counters[:cached_queries]
|
|
92
|
+
desc = cached.positive? ? "#{queries} queries, #{cached} cached" : "#{queries} #{queries == 1 ? "query" : "queries"}"
|
|
93
|
+
metrics = [ format("total;dur=%.1f", total), db ? format("db;dur=%.1f;desc=\"%s\"", db, desc) : "db;desc=\"#{desc}\"" ]
|
|
94
|
+
metrics << format("view;dur=%.1f", view) if view
|
|
95
|
+
metrics << format("other;dur=%.1f;desc=\"not db or view\"", [ total - db.to_f - view.to_f, 0 ].max) if db || view
|
|
96
|
+
# A plain Hash may hold it title-cased; Rack::Headers finds either.
|
|
97
|
+
existing = Array(headers[SERVER_TIMING] || headers.delete("Server-Timing")).join(", ")
|
|
98
|
+
headers[SERVER_TIMING] = existing.empty? ? metrics.join(", ") : "#{existing}, #{metrics.join(", ")}"
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
# A frozen header hash (a Rack app that returns a constant): the
|
|
101
|
+
# response is the app's, unchanged.
|
|
102
|
+
Railwatch.debug { "timing headers skipped: #{e.class}: #{e.message}" }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The execution an agent passes to get_execution. Only once the
|
|
106
|
+
# request record has shipped (head-sampled in, kept, or rescued by the
|
|
107
|
+
# tail decision): any other id is one the server never has.
|
|
108
|
+
def execution_id(exe, headers)
|
|
109
|
+
headers[EXECUTION_ID] = exe.id
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
Railwatch.debug { "execution id header skipped: #{e.class}: #{e.message}" }
|
|
112
|
+
end
|
|
113
|
+
|
|
73
114
|
def ignored_request?(env)
|
|
74
115
|
path = env["PATH_INFO"].to_s
|
|
75
116
|
Railwatch.config.ignored_request_paths.any? do |pattern|
|
|
@@ -111,13 +152,16 @@ module Railwatch
|
|
|
111
152
|
# The block is only called when the request record is going to ship;
|
|
112
153
|
# a head-sampled-out request nothing rescued skips the
|
|
113
154
|
# ActionDispatch::Request and the header walk entirely.
|
|
114
|
-
Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
|
|
155
|
+
parent = Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
|
|
115
156
|
# A request with no user and no session cookie has no session, and
|
|
116
157
|
# Sessions.touch returns without writing anything.
|
|
117
158
|
Sessions.touch(exe, env, status) if Railwatch.config.track_sessions
|
|
159
|
+
# The request record, when one shipped.
|
|
160
|
+
parent
|
|
118
161
|
rescue StandardError => e
|
|
119
162
|
Railwatch.debug { "request finish failed: #{e.class}: #{e.message}" }
|
|
120
163
|
Railwatch.finish_execution
|
|
164
|
+
nil
|
|
121
165
|
end
|
|
122
166
|
|
|
123
167
|
def parent_fields(env, exe, status, headers)
|
|
@@ -69,9 +69,9 @@ module Railwatch
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def deliver(records, dropped: 0, dropped_bytes: 0, backpressure_factor: 1.0, batch_id: SecureRandom.uuid)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
# Permanent: it cannot change between attempts, and with no status
|
|
73
|
+
# the result would read as retryable and walk the backoff ladder.
|
|
74
|
+
return permanent("plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true") unless destination_allowed?
|
|
75
75
|
return Result.new(ok: false, status: UNAUTHORIZED_STATUS, error: "unauthorized, flushing stopped") if @unauthorized
|
|
76
76
|
|
|
77
77
|
encoded = @encoder.encode(records)
|
data/lib/railwatch/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: railwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cole Robertson
|
|
@@ -70,6 +70,9 @@ files:
|
|
|
70
70
|
- docs/source-maps.md
|
|
71
71
|
- docs/testing.md
|
|
72
72
|
- docs/troubleshooting.md
|
|
73
|
+
- lib/generators/railwatch/agents/agents_generator.rb
|
|
74
|
+
- lib/generators/railwatch/agents/templates/SKILL.md.tt
|
|
75
|
+
- lib/generators/railwatch/agents/templates/agents_block.md.tt
|
|
73
76
|
- lib/generators/railwatch/install/install_generator.rb
|
|
74
77
|
- lib/generators/railwatch/install/templates/initializer.rb.tt
|
|
75
78
|
- lib/generators/railwatch/install/templates/post-deploy
|
|
@@ -91,6 +94,7 @@ files:
|
|
|
91
94
|
- lib/railwatch/health.rb
|
|
92
95
|
- lib/railwatch/job_tracing.rb
|
|
93
96
|
- lib/railwatch/json_compat.rb
|
|
97
|
+
- lib/railwatch/middleware/beacon_limit.rb
|
|
94
98
|
- lib/railwatch/middleware/request.rb
|
|
95
99
|
- lib/railwatch/minitest.rb
|
|
96
100
|
- lib/railwatch/patches.rb
|