railwatch 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -23,11 +23,12 @@ module Railwatch
23
23
  class_option :doctor, type: :boolean, default: true,
24
24
  desc: "Run railwatch:doctor when the install finishes."
25
25
 
26
- # Whichever of these exists is where `startRailwatch()` is added.
26
+ # Whichever of these exists, under the app's frontend source directory
27
+ # (Railwatch.frontend_dir), is where `startRailwatch()` is added.
27
28
  INERTIA_ENTRYPOINTS = %w[
28
- app/frontend/entrypoints/inertia.tsx
29
- app/frontend/entrypoints/inertia.ts
30
- app/frontend/entrypoints/inertia.jsx
29
+ entrypoints/inertia.tsx
30
+ entrypoints/inertia.ts
31
+ entrypoints/inertia.jsx
31
32
  ].freeze
32
33
 
33
34
  IMPORT_LINE = 'import { startRailwatch } from "@/lib/railwatch"'
@@ -68,19 +69,22 @@ module Railwatch
68
69
 
69
70
  # The one browser client (Railwatch.browser_client_path). Copied rather
70
71
  # than templated: there is nothing to interpolate, and a copy means the
71
- # host sees exactly what the gem tests.
72
+ # host sees exactly what the gem tests. Re-running the generator after
73
+ # a gem upgrade is how an app picks up a new client: Thor asks before
74
+ # overwriting a copy that differs (--force overwrites, --skip keeps it).
72
75
  def create_browser_client
73
- return unless File.directory?("app/frontend")
74
- copy_file Railwatch.browser_client_path.to_s, "app/frontend/lib/railwatch.ts"
76
+ return unless frontend_dir
77
+ copy_file Railwatch.browser_client_path.to_s, "#{frontend_dir}/lib/railwatch.ts"
75
78
  end
76
79
 
77
80
  # Adds the import and the call to the Inertia entrypoint, so page-visit
78
81
  # timing and Core Web Vitals report with no further editing.
79
82
  def start_browser_client
80
- return unless File.directory?("app/frontend")
83
+ return unless frontend_dir
81
84
 
82
- path = INERTIA_ENTRYPOINTS.find { |candidate| File.exist?(candidate) }
83
- return say(browser_client_instructions(INERTIA_ENTRYPOINTS.first), :yellow) unless path
85
+ candidates = INERTIA_ENTRYPOINTS.map { |entrypoint| "#{frontend_dir}/#{entrypoint}" }
86
+ path = candidates.find { |candidate| File.exist?(candidate) }
87
+ return say(browser_client_instructions(candidates.first), :yellow) unless path
84
88
 
85
89
  contents = File.read(path)
86
90
  return say_status(:identical, path, :blue) if contents.include?(START_CALL)
@@ -279,6 +283,10 @@ module Railwatch
279
283
  "The token value is never printed:\n#{lines.join("\n")}"
280
284
  end
281
285
 
286
+ def frontend_dir
287
+ @frontend_dir ||= Railwatch.frontend_dir(destination_root)
288
+ end
289
+
282
290
  def browser_client_instructions(path)
283
291
  "Add these two lines to #{path} to report Inertia visits and Core Web Vitals:\n" \
284
292
  " #{IMPORT_LINE}\n #{START_CALL}"
@@ -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() {
@@ -29,10 +29,13 @@ interface Visit {
29
29
  redirect_count?: number
30
30
  connect_ms?: number
31
31
  server_ms?: number
32
+ // The browser session and the tab the visit happened in, for the journey.
33
+ session_id?: string
34
+ tab_id?: string
32
35
  }
33
36
 
34
- // The tab's session, for release health. sessionStorage scopes it to the
35
- // tab and it dies with the tab, which is what a browser session is.
37
+ // The browser's session, for release health and the session journey, on the
38
+ // wire. `ended` only when this tab has seen it time out (see `begin`).
36
39
  interface Session {
37
40
  id: string
38
41
  started_at: number
@@ -50,6 +53,10 @@ interface JsError {
50
53
  component?: string
51
54
  url: string
52
55
  visit?: string
56
+ // The session and tab it was thrown in, taken when it was: another tab
57
+ // may move the session on before this one flushes.
58
+ session_id?: string
59
+ tab_id?: string
53
60
  breadcrumbs?: Crumb[]
54
61
  context?: Record<string, unknown>
55
62
  }
@@ -90,14 +97,26 @@ const DEFAULT_DENY_URLS = [
90
97
  ]
91
98
 
92
99
  const SESSION_ID_KEY = "railwatch.session"
100
+ const SESSION_SEEN_KEY = "railwatch.session.seen"
93
101
  const SESSION_STARTED_KEY = "railwatch.session.at"
102
+ const TAB_KEY = "railwatch.tab"
103
+ // The GA4/Amplitude rules: a session is over after 30 minutes with no
104
+ // activity in any tab (config.session_timeout's default, which the server
105
+ // half times out on too), and after 24 hours whatever happens.
106
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000
107
+ const SESSION_CAP_MS = 24 * 60 * 60 * 1000
108
+ // Railwatch::Sessions reads these, ahead of the cookie.
109
+ const SESSION_HEADER = "X-Railwatch-Session"
110
+ const TAB_HEADER = "X-Railwatch-Tab"
94
111
 
95
112
  const queue: Visit[] = []
96
113
  const errors: JsError[] = []
97
114
  const crumbs: Crumb[] = []
98
115
  let current: Visit | null = null
99
116
  let initial: Visit | null = null
100
- let session: Session | null = null
117
+ // `seen` is the session's last activity in any tab, as far as this one knows.
118
+ let session: { id: string; started_at: number; seen: number } | null = null
119
+ let tab = ""
101
120
  // The Inertia page component the user is on, so an error that fires between
102
121
  // visits still says which screen it broke.
103
122
  let component: string | undefined
@@ -170,12 +189,13 @@ function post(payload: {
170
189
  }).catch(() => undefined)
171
190
  }
172
191
 
173
- function flush(ended = false) {
174
- if (queue.length === 0 && errors.length === 0 && !ended) return
192
+ function flush(force = false) {
193
+ if (queue.length === 0 && errors.length === 0 && !force) return
194
+ touch(false)
175
195
  post({
176
196
  visits: queue.splice(0, queue.length),
177
197
  errors: errors.splice(0, errors.length),
178
- session: beat(ended),
198
+ session: beat(),
179
199
  tenant: tenant(),
180
200
  })
181
201
  }
@@ -199,40 +219,220 @@ function randomId() {
199
219
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
200
220
  }
201
221
 
202
- // Mints the tab's session on its first load, or picks up the one an earlier
203
- // page in this tab minted, and mirrors the id into a cookie so every request
204
- // the tab makes carries it -- that is what lets the server side of the
205
- // session (Railwatch::Sessions) join the browser side under one id.
206
- function startSession() {
222
+ interface Store {
223
+ getItem(key: string): string | null
224
+ setItem(key: string, value: string): void
225
+ }
226
+
227
+ // Where the session lives: localStorage, so every tab of the browser shares
228
+ // one; sessionStorage when localStorage throws (storage disabled, a
229
+ // sandboxed frame), which narrows the session to the tab; memory when both
230
+ // throw, which narrows it to the page. The tab id lives in sessionStorage,
231
+ // which survives a reload of the tab and nothing else, or in memory.
232
+ let sessions: Store = memoryStore()
233
+ let tabs: Store = memoryStore()
234
+
235
+ function storage(kind: "localStorage" | "sessionStorage"): Store | undefined {
236
+ try {
237
+ const store = window[kind]
238
+ store.setItem("railwatch.probe", "1")
239
+ store.removeItem("railwatch.probe")
240
+ return store
241
+ } catch {
242
+ // Disabled, or denied to this frame. The caller tries the next one.
243
+ return undefined
244
+ }
245
+ }
246
+
247
+ function memoryStore(): Store {
248
+ const values = new Map<string, string>()
249
+ return {
250
+ getItem: (key) => values.get(key) ?? null,
251
+ setItem: (key, value) => void values.set(key, value),
252
+ }
253
+ }
254
+
255
+ // A full store throws on write. The session then lasts as long as this
256
+ // page's memory of it, which is all a write could have bought.
257
+ function save(store: Store, key: string, value: string) {
258
+ try {
259
+ store.setItem(key, value)
260
+ } catch {
261
+ // Quota exceeded.
262
+ }
263
+ }
264
+
265
+ // Mirrors the id into a cookie so every request the browser makes carries
266
+ // it, full page loads included -- that is what lets the server side of the
267
+ // session (Railwatch::Sessions) join the browser side under one id. It
268
+ // lapses with the session's idle timeout, so the first page load after one
269
+ // does not carry the dead id; every activity in any tab renews it.
270
+ function remember(id: string) {
207
271
  try {
208
- const existing = sessionStorage.getItem(SESSION_ID_KEY)
209
- const id = existing ?? randomId()
210
- const startedAt =
211
- Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
212
- if (!existing) {
213
- sessionStorage.setItem(SESSION_ID_KEY, id)
214
- sessionStorage.setItem(SESSION_STARTED_KEY, String(startedAt))
215
- }
216
272
  // Railwatch::Sessions::COOKIE reads this name to stitch server requests to the browser session.
217
- document.cookie = `railwatch_session=${id}; path=/; SameSite=Lax`
218
- session = { id, started_at: startedAt }
219
- // No duration on the first beat: that is what opens the session.
220
- if (!existing)
221
- post({
222
- visits: [],
223
- errors: [],
224
- session: { ...session },
225
- tenant: tenant(),
226
- })
273
+ document.cookie = `railwatch_session=${id}; path=/; max-age=${SESSION_TIMEOUT_MS / 1000}; SameSite=Lax`
274
+ } catch {
275
+ // A sandboxed frame has no cookies. Visits still carry the headers.
276
+ }
277
+ }
278
+
279
+ // A store that passed the probe can still throw later: the user turns
280
+ // storage off, or the browser revokes it. From then on the page keeps its
281
+ // own copy, seeded with the session it is in, so losing storage neither
282
+ // stops the client nor ends the session.
283
+ function read(key: string): string | null {
284
+ try {
285
+ return sessions.getItem(key)
286
+ } catch {
287
+ sessions = memoryStore()
288
+ if (session) {
289
+ sessions.setItem(SESSION_ID_KEY, session.id)
290
+ sessions.setItem(SESSION_STARTED_KEY, String(session.started_at))
291
+ sessions.setItem(SESSION_SEEN_KEY, String(session.seen))
292
+ }
293
+ return sessions.getItem(key)
294
+ }
295
+ }
296
+
297
+ function readTab(): string | null {
298
+ try {
299
+ return tabs.getItem(TAB_KEY)
227
300
  } catch {
228
- // sessionStorage is unavailable (private mode, storage disabled).
229
- // Everything else still reports; this tab just has no session.
301
+ tabs = memoryStore()
302
+ return null
230
303
  }
231
304
  }
232
305
 
233
- function beat(ended: boolean): Session | undefined {
306
+ function startSession() {
307
+ sessions =
308
+ storage("localStorage") ?? storage("sessionStorage") ?? memoryStore()
309
+ tabs = storage("sessionStorage") ?? memoryStore()
310
+ tab = readTab() ?? randomId()
311
+ save(tabs, TAB_KEY, tab)
312
+ touch(true)
313
+ }
314
+
315
+ // Brings this tab's session up to date with the browser's, joining the one
316
+ // another tab opened meanwhile. `active` is the user doing something -- the
317
+ // page loading, a visit they wait on, the tab coming back into view -- which
318
+ // keeps the session alive, or opens a new one when the last went 30 minutes
319
+ // without activity in any tab or is 24 hours old. Anything else (a
320
+ // background poll, a prefetch, a flush) only joins a live session, so a tab
321
+ // left polling cannot hold its session open until the cap.
322
+ function touch(active: boolean) {
323
+ const now = Date.now()
324
+ const id = read(SESSION_ID_KEY)
325
+ const seen = Number(read(SESSION_SEEN_KEY)) || 0
326
+ const startedAt = Number(read(SESSION_STARTED_KEY)) || 0
327
+ const live = id !== null && !expired(startedAt, seen, now)
328
+ if (live && id === session?.id) session.seen = Math.max(session.seen, seen)
329
+ else if (live) begin(id, startedAt, seen, false)
330
+ else if (active) begin(minted(id) ?? randomId(), now, now, true)
331
+ if (!active || !session) return
332
+ session.seen = now
333
+ save(sessions, SESSION_SEEN_KEY, String(now))
334
+ remember(session.id)
335
+ }
336
+
337
+ // The id the server put in the cookie for this browser's first page load
338
+ // (Railwatch::Sessions.mint), so the session starts with that request rather
339
+ // than after it. Not the session that just expired, whose id the cookie
340
+ // can still hold.
341
+ function minted(expiredId: string | null): string | undefined {
342
+ try {
343
+ const id = /(?:^|;\s*)railwatch_session=([A-Za-z0-9_-]{1,64})(?:;|$)/.exec(
344
+ document.cookie,
345
+ )?.[1]
346
+ return id && id !== expiredId && id !== session?.id ? id : undefined
347
+ } catch {
348
+ // A sandboxed frame has no cookies.
349
+ return undefined
350
+ }
351
+ }
352
+
353
+ function expired(startedAt: number, seen: number, now: number) {
354
+ return now - seen >= SESSION_TIMEOUT_MS || now - startedAt >= SESSION_CAP_MS
355
+ }
356
+
357
+ // The session this tab is in, while it is live. A background visit after it
358
+ // timed out belongs to no session: it is neither tagged nor recorded as one.
359
+ function liveSession(): string | undefined {
360
+ if (!session || expired(session.started_at, session.seen, Date.now()))
361
+ return undefined
362
+ return session.id
363
+ }
364
+
365
+ // Moves this tab into a session. The one it was in is over -- a session is
366
+ // only replaced once it has timed out or reached the cap -- so this tab
367
+ // says so with a last, `ended` beat. Closing a tab sends no such thing: the
368
+ // browser's other tabs may still be in the session, and nothing a closing
369
+ // tab knows says otherwise. A session no tab comes back to ends where the
370
+ // server half's does, on its own timeout.
371
+ function begin(id: string, startedAt: number, seen: number, minted: boolean) {
372
+ if (session)
373
+ post({
374
+ visits: [],
375
+ errors: [],
376
+ session: {
377
+ id: session.id,
378
+ started_at: session.started_at,
379
+ duration_ms: session.seen - session.started_at,
380
+ ended: true,
381
+ },
382
+ tenant: tenant(),
383
+ })
384
+ session = { id, started_at: startedAt, seen }
385
+ if (!minted) return
386
+ save(sessions, SESSION_ID_KEY, id)
387
+ save(sessions, SESSION_STARTED_KEY, String(startedAt))
388
+ save(sessions, SESSION_SEEN_KEY, String(seen))
389
+ // No duration on the first beat: that is what opens the session.
390
+ post({
391
+ visits: [],
392
+ errors: [],
393
+ session: { id, started_at: startedAt },
394
+ tenant: tenant(),
395
+ })
396
+ }
397
+
398
+ // While the session is live it runs to now, whichever tab is beating it. One
399
+ // this tab has watched time out ran to its last activity, and is `ended`.
400
+ function beat(): Session | undefined {
234
401
  if (!session) return undefined
235
- return { ...session, duration_ms: Date.now() - session.started_at, ended }
402
+ const now = Date.now()
403
+ const ended = expired(session.started_at, session.seen, now)
404
+ return {
405
+ id: session.id,
406
+ started_at: session.started_at,
407
+ duration_ms: (ended ? session.seen : now) - session.started_at,
408
+ ended,
409
+ }
410
+ }
411
+
412
+ // Inertia sends a visit with whatever headers the visit object holds once
413
+ // `before` has fired, so setting them here tags every visit, prefetch, poll
414
+ // and partial reload with the session and the tab -- which is how a request
415
+ // record joins its journey under the tab it came from. Only for the page's
416
+ // own origin: a custom header makes a cross-origin request preflight, and
417
+ // another origin is not this app's server anyway. The headers object is
418
+ // replaced, not written into, because it may be the app's own options. The
419
+ // prefetch cache compares headers; a prefetch and the click it serves are
420
+ // tagged alike, so the prefetch still serves.
421
+ function tag(visit: { url: unknown; headers?: Record<string, string> }) {
422
+ const id = liveSession()
423
+ if (!id) return
424
+ try {
425
+ if (new URL(String(visit.url), location.href).origin !== location.origin)
426
+ return
427
+ } catch {
428
+ // Not a URL Inertia could visit either.
429
+ return
430
+ }
431
+ visit.headers = {
432
+ ...visit.headers,
433
+ [SESSION_HEADER]: id,
434
+ [TAB_HEADER]: tab,
435
+ }
236
436
  }
237
437
 
238
438
  // --- Breadcrumbs -------------------------------------------------------
@@ -349,6 +549,8 @@ function capture(
349
549
  component,
350
550
  url: location.pathname + location.search,
351
551
  visit: current?.url,
552
+ session_id: liveSession(),
553
+ tab_id: tab,
352
554
  breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
353
555
  context,
354
556
  }
@@ -755,6 +957,8 @@ function initialVisit(): Visit | null {
755
957
  duration_ms: duration,
756
958
  status: "success",
757
959
  ...ttfbPhases(nav),
960
+ session_id: liveSession(),
961
+ tab_id: tab,
758
962
  }
759
963
  }
760
964
 
@@ -774,7 +978,7 @@ function finalize() {
774
978
  }
775
979
  // Still flushes on every later hide: a tab can be backgrounded, brought
776
980
  // back, navigated some more, and then closed. Each of those carries a
777
- // final session beat, which the platform dedupes by session id.
981
+ // session beat, which the platform dedupes by session id.
778
982
  flush(true)
779
983
  }
780
984
 
@@ -798,7 +1002,10 @@ export function startRailwatch(options: RailwatchOptions = {}) {
798
1002
  // final there.
799
1003
  router.on("before", (event) => {
800
1004
  const v = event.detail.visit
801
- if (!waiting(v) || event.defaultPrevented) return
1005
+ if (event.defaultPrevented) return
1006
+ touch(waiting(v))
1007
+ tag(v)
1008
+ if (!waiting(v)) return
802
1009
  const request = (foregroundRequest = { id: idOf(v, "id") })
803
1010
  queueMicrotask(() => {
804
1011
  if (event.defaultPrevented && foregroundRequest === request)
@@ -817,6 +1024,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
817
1024
  method: v.method,
818
1025
  partial: Boolean(v.only?.length || v.except?.length),
819
1026
  only: v.only,
1027
+ session_id: liveSession(),
1028
+ tab_id: tab,
820
1029
  }
821
1030
  crumb("navigate", `${v.method.toUpperCase()} ${current.url}`)
822
1031
  })
@@ -847,6 +1056,7 @@ export function startRailwatch(options: RailwatchOptions = {}) {
847
1056
  timer ??= window.setInterval(flush, 5000)
848
1057
  document.addEventListener("visibilitychange", () => {
849
1058
  if (document.visibilityState === "hidden") finalize()
1059
+ else touch(true)
850
1060
  })
851
1061
  window.addEventListener("pagehide", finalize)
852
1062
  }
@@ -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, :browser_client
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,18 @@ 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)
248
+ # Whether the app's pages run the browser client, which is what makes
249
+ # it safe to hand a first page load a session cookie (#browser_client?).
250
+ # nil: whether lib/railwatch.ts exists in the app's frontend source
251
+ # directory (Railwatch.frontend_dir), where railwatch:install puts it.
252
+ @browser_client = env_bool("RAILWATCH_BROWSER_CLIENT", nil)
241
253
  @user_resolver = nil
242
254
  @beacon_user_resolver = nil
243
255
  @fingerprint_resolver = nil
@@ -364,6 +376,27 @@ module Railwatch
364
376
  @environment || (defined?(Rails) ? Rails.env.to_s : "production")
365
377
  end
366
378
 
379
+ # Whether the request middleware adds its timing headers (#timing_headers).
380
+ def timing_headers?
381
+ @timing_headers.nil? ? defined?(Rails) && Rails.env.development? : @timing_headers
382
+ end
383
+
384
+ # Whether the browser client is installed (#browser_client). Looked for
385
+ # once, on the first request that asks, when Rails.root is known.
386
+ def browser_client?
387
+ return @browser_client unless @browser_client.nil?
388
+
389
+ @browser_client_installed = browser_client_installed? if @browser_client_installed.nil?
390
+ @browser_client_installed
391
+ end
392
+
393
+ def browser_client_installed?
394
+ return false unless defined?(Rails) && Rails.root
395
+
396
+ dir = Railwatch.frontend_dir(Rails.root)
397
+ dir ? Rails.root.join(dir, "lib/railwatch.ts").exist? : false
398
+ end
399
+
367
400
  # Matches Railwatch.reject_cache_keys entries and DEFAULT_VENDOR_CACHE_KEYS
368
401
  # against a cache key. A Regexp is used as-is. A String starting with "^"
369
402
  # 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