railwatch 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +7 -0
- data/CHANGELOG.md +54 -0
- data/app/controllers/railwatch/beacon_controller.rb +19 -12
- data/docs/ai-and-mcp.md +44 -6
- data/docs/configuration.md +34 -6
- data/docs/getting-started.md +19 -3
- data/docs/records.md +28 -10
- data/docs/troubleshooting.md +1 -1
- data/lib/generators/railwatch/agents/agents_generator.rb +151 -0
- data/lib/generators/railwatch/agents/templates/SKILL.md.tt +32 -0
- data/lib/generators/railwatch/agents/templates/agents_block.md.tt +26 -0
- data/lib/generators/railwatch/install/install_generator.rb +18 -10
- data/lib/generators/railwatch/install/templates/post-deploy +6 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +245 -35
- data/lib/railwatch/configuration.rb +34 -1
- data/lib/railwatch/engine.rb +4 -0
- data/lib/railwatch/middleware/beacon_limit.rb +53 -0
- data/lib/railwatch/middleware/request.rb +50 -4
- data/lib/railwatch/sessions.rb +63 -3
- data/lib/railwatch/transport/http.rb +3 -3
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch/wire_fixtures.json +7 -1
- data/lib/railwatch.rb +16 -1
- data/lib/tasks/railwatch_tasks.rake +5 -4
- metadata +5 -1
|
@@ -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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
74
|
-
copy_file Railwatch.browser_client_path.to_s, "
|
|
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
|
|
83
|
+
return unless frontend_dir
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
35
|
-
//
|
|
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
|
-
|
|
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(
|
|
174
|
-
if (queue.length === 0 && errors.length === 0 && !
|
|
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(
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
301
|
+
tabs = memoryStore()
|
|
302
|
+
return null
|
|
230
303
|
}
|
|
231
304
|
}
|
|
232
305
|
|
|
233
|
-
function
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
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
|