railwatch 0.1.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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. metadata +157 -0
@@ -0,0 +1,658 @@
1
+ // Railwatch browser client for Inertia. Reports each visit's duration,
2
+ // component, and prop payload size to /railwatch/beacon so the platform can
3
+ // show real page-load timing, plus Core Web Vitals (LCP, CLS, INP, TTFB)
4
+ // for the initial page load, plus every JavaScript error the page throws
5
+ // with the breadcrumb trail that led to it.
6
+ // No dependencies -- every metric comes from PerformanceObserver or the
7
+ // navigation timing entry, and every API is feature-detected, so browsers
8
+ // missing one just report the rest.
9
+ // Batches and sends with sendBeacon on pagehide, or every 5s.
10
+ import { router } from "@inertiajs/react"
11
+
12
+ interface Visit {
13
+ started_at: number
14
+ url: string
15
+ method: string
16
+ component?: string
17
+ duration_ms?: number
18
+ status?: "success" | "error" | "cancelled"
19
+ partial?: boolean
20
+ only?: string[]
21
+ props_bytes?: number
22
+ lcp?: number
23
+ cls?: number
24
+ inp?: number
25
+ ttfb?: number
26
+ }
27
+
28
+ // The tab's session, for release health. sessionStorage scopes it to the
29
+ // tab and it dies with the tab, which is what a browser session is.
30
+ interface Session {
31
+ id: string
32
+ started_at: number
33
+ duration_ms?: number
34
+ ended?: boolean
35
+ }
36
+
37
+ // A JavaScript error the page threw, with the stack exactly as the browser
38
+ // wrote it -- the server parses it into frames.
39
+ interface JsError {
40
+ at: number
41
+ name: string
42
+ message: string
43
+ stack?: string
44
+ component?: string
45
+ url: string
46
+ visit?: string
47
+ breadcrumbs?: Crumb[]
48
+ context?: Record<string, unknown>
49
+ }
50
+
51
+ // What the user did in the run-up to a crash. The same idea as the server
52
+ // side's breadcrumbs, which are what the execution did before it raised.
53
+ interface Crumb {
54
+ at: number
55
+ kind: "console" | "click" | "navigate"
56
+ text: string
57
+ }
58
+
59
+ export interface RailwatchOptions {
60
+ // Messages that are never worth an issue, added to the defaults below. A
61
+ // string matches anywhere in the message; a regex is tested against it.
62
+ ignoreErrors?: (string | RegExp)[]
63
+ // Scripts whose failures are not this app's to fix, added to the defaults
64
+ // below and matched against the top stack frame's URL.
65
+ denyUrls?: RegExp[]
66
+ // The tenant the user is looking at. The beacon posts to /railwatch/beacon,
67
+ // which is outside whatever path or subdomain the app scopes tenants by,
68
+ // so the server cannot work this out for itself. Read on every flush, so
69
+ // it follows the user across tenants without a page load.
70
+ tenant?: () => string | undefined
71
+ }
72
+
73
+ // Browser noise that is never actionable: ResizeObserver fires from benign
74
+ // layout thrash and the spec says to ignore it, and the extension URLs are
75
+ // third-party code running in someone's browser that this app cannot fix.
76
+ const DEFAULT_IGNORE_ERRORS = [
77
+ "ResizeObserver loop limit exceeded",
78
+ "ResizeObserver loop completed with undelivered notifications",
79
+ ]
80
+ const DEFAULT_DENY_URLS = [/extensions\//i, /^chrome:\/\//i, /^moz-extension:\/\//i]
81
+
82
+ const SESSION_ID_KEY = "railwatch.session"
83
+ const SESSION_STARTED_KEY = "railwatch.session.at"
84
+
85
+ const queue: Visit[] = []
86
+ const errors: JsError[] = []
87
+ const crumbs: Crumb[] = []
88
+ let current: Visit | null = null
89
+ let initial: Visit | null = null
90
+ let session: Session | null = null
91
+ // The Inertia page component the user is on, so an error that fires between
92
+ // visits still says which screen it broke.
93
+ let component: string | undefined
94
+ let finalized = false
95
+ let timer: number | undefined
96
+ let ignoreErrors: (string | RegExp)[] = DEFAULT_IGNORE_ERRORS
97
+ let denyUrls: RegExp[] = DEFAULT_DENY_URLS
98
+ let tenantOf: (() => string | undefined) | undefined
99
+ // Whether the user is waiting on a request. A visit the page starts by
100
+ // itself -- a poll, a refresh when the tab comes back, a prefetch on hover
101
+ // -- runs without the progress bar (`showProgress: false`, which Inertia
102
+ // derives from `async: true`), and when one drops its connection nothing
103
+ // the user did has failed: the page keeps what it has and the next tick
104
+ // refreshes it. A laptop waking on a new network used to open an issue
105
+ // that way. So a dropped request is reported only while the user is
106
+ // waiting, which is one of two things. `foregroundVisits` counts visits
107
+ // with the progress bar (or a deferred-props load, which the user watches
108
+ // as a skeleton) between `start` and `finish`. `foregroundRequest` is a
109
+ // visit the user asked for that has no request of its own: Inertia serves
110
+ // a click from a prefetch already in the air without ever firing `start`
111
+ // for it, so `before` is the one event it gets, and it stands until that
112
+ // visit lands (`success`/`error` naming its id), a waiting request starts,
113
+ // its drop is reported, or a `before` listener cancels it. Not `navigate`:
114
+ // an instant visit fires that for its placeholder before the prefetch is
115
+ // even looked up. Counted and flagged rather than matched, because the
116
+ // failure event names only the error, not the visit. An app that wants a
117
+ // particular background refresh reported passes `showProgress: true`.
118
+ let foregroundVisits = 0
119
+ let foregroundRequest: { id?: string } | null = null
120
+ // Errors Inertia has already delivered through its own failure event. It
121
+ // re-rejects the same object afterwards and nothing awaits it, so the error
122
+ // arrives a second time as an unhandled rejection; that copy is dropped
123
+ // whether or not the first was reported.
124
+ const requestFailures = new WeakSet<object>()
125
+
126
+ function endpoint() {
127
+ return "/railwatch/beacon"
128
+ }
129
+
130
+ function csrf() {
131
+ return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? ""
132
+ }
133
+
134
+ function post(payload: { visits: Visit[]; errors: JsError[]; session?: Session; tenant?: string }) {
135
+ const body = JSON.stringify(payload)
136
+ const blob = new Blob([body], { type: "application/json" })
137
+ if (navigator.sendBeacon?.(endpoint(), blob)) return
138
+ fetch(endpoint(), {
139
+ method: "POST",
140
+ body,
141
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf() },
142
+ keepalive: true,
143
+ }).catch(() => undefined)
144
+ }
145
+
146
+ function flush(ended = false) {
147
+ if (queue.length === 0 && errors.length === 0 && !ended) return
148
+ post({ visits: queue.splice(0, queue.length), errors: errors.splice(0, errors.length), session: beat(ended), tenant: tenant() })
149
+ }
150
+
151
+ // The app's tenant resolver runs on the flush path, where a throw would cost
152
+ // the whole batch, so it never gets to.
153
+ function tenant() {
154
+ try {
155
+ return tenantOf?.()
156
+ } catch {
157
+ // The app's resolver raised. These records just carry no tenant.
158
+ return undefined
159
+ }
160
+ }
161
+
162
+ // --- Session -----------------------------------------------------------
163
+
164
+ function randomId() {
165
+ const bytes = new Uint8Array(8)
166
+ crypto.getRandomValues(bytes)
167
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
168
+ }
169
+
170
+ // Mints the tab's session on its first load, or picks up the one an earlier
171
+ // page in this tab minted, and mirrors the id into a cookie so every request
172
+ // the tab makes carries it -- that is what lets the server side of the
173
+ // session (Railwatch::Sessions) join the browser side under one id.
174
+ function startSession() {
175
+ try {
176
+ const existing = sessionStorage.getItem(SESSION_ID_KEY)
177
+ const id = existing ?? randomId()
178
+ const startedAt = Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
179
+ if (!existing) {
180
+ sessionStorage.setItem(SESSION_ID_KEY, id)
181
+ sessionStorage.setItem(SESSION_STARTED_KEY, String(startedAt))
182
+ }
183
+ document.cookie = `railwatch_session=${id}; path=/; SameSite=Lax`
184
+ session = { id, started_at: startedAt }
185
+ // No duration on the first beat: that is what opens the session.
186
+ if (!existing) post({ visits: [], errors: [], session: { ...session }, tenant: tenant() })
187
+ } catch {
188
+ // sessionStorage is unavailable (private mode, storage disabled).
189
+ // Everything else still reports; this tab just has no session.
190
+ }
191
+ }
192
+
193
+ function beat(ended: boolean): Session | undefined {
194
+ if (!session) return undefined
195
+ return { ...session, duration_ms: Date.now() - session.started_at, ended }
196
+ }
197
+
198
+ // --- Breadcrumbs -------------------------------------------------------
199
+
200
+ const MAX_CRUMBS = 20
201
+ const MAX_CRUMB_TEXT = 500
202
+ // A budget for one error's whole trail, so a page that logs War and Peace to
203
+ // the console cannot crowd out the errors themselves.
204
+ const MAX_CRUMB_BYTES = 8000
205
+
206
+ function crumb(kind: Crumb["kind"], text: string) {
207
+ if (!text) return
208
+ crumbs.push({ at: Date.now(), kind, text: text.slice(0, MAX_CRUMB_TEXT) })
209
+ if (crumbs.length > MAX_CRUMBS) crumbs.shift()
210
+ }
211
+
212
+ // The trail as it stood when an error fired, oldest first, giving up its
213
+ // oldest entries until it fits the byte budget.
214
+ function trail(): Crumb[] {
215
+ const taken = crumbs.slice()
216
+ while (taken.length > 0 && JSON.stringify(taken).length > MAX_CRUMB_BYTES) taken.shift()
217
+ return taken
218
+ }
219
+
220
+ // "button#save.btn.primary "Save order"" -- enough to recognise what was
221
+ // clicked, and never an input's value, which is the user's data and not
222
+ // ours to ship.
223
+ function describeTarget(target: EventTarget | null): string {
224
+ if (!(target instanceof Element)) return ""
225
+ const id = target.id ? `#${target.id}` : ""
226
+ const className = typeof target.className === "string" ? target.className.trim() : ""
227
+ const classes = className ? `.${className.split(/\s+/).join(".")}` : ""
228
+ const text = target instanceof HTMLInputElement ? "" : (target.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80)
229
+ return `${target.tagName.toLowerCase()}${id}${classes}${text ? ` "${text}"` : ""}`
230
+ }
231
+
232
+ function startBreadcrumbs() {
233
+ document.addEventListener("click", (event) => crumb("click", describeTarget(event.target)), true)
234
+ for (const level of ["error", "warn"] as const) {
235
+ const original = console[level].bind(console) as (...args: unknown[]) => void
236
+ console[level] = (...args: unknown[]) => {
237
+ crumb("console", `${level}: ${args.map(stringify).join(" ")}`)
238
+ original(...args)
239
+ }
240
+ }
241
+ }
242
+
243
+ // --- JavaScript errors -------------------------------------------------
244
+
245
+ const MAX_MESSAGE = 1000
246
+ const MAX_STACK = 8000
247
+ // The server caps a beacon at 50 errors too. This is what stops a component
248
+ // that throws on every render from growing the queue without bound between
249
+ // flushes.
250
+ const MAX_ERRORS = 50
251
+
252
+ // The script a stack line points at: a URL or a bare path, followed by the
253
+ // line (and column) every engine appends. Anchored to the end of the line so
254
+ // a path quoted in the error's own message is not mistaken for a frame.
255
+ const FRAME_URL = /((?:[a-z][a-z0-9+.-]*:\/\/|\/)[^\s()'"]+):\d+(?::\d+)?\)?$/i
256
+
257
+ function topFrameUrl(stack: string): string | undefined {
258
+ for (const line of stack.split("\n")) {
259
+ const match = FRAME_URL.exec(line.trim())
260
+ if (match) return match[1]
261
+ }
262
+ return undefined
263
+ }
264
+
265
+ // Everything that gets an error dropped before it costs a beacon: a message
266
+ // the app said it never wants, a denied script, or a top frame that is not
267
+ // the app's own code at all -- an extension, an injected widget, a tag
268
+ // manager. None of those are anything this app can fix.
269
+ function ignored(message: string, stack?: string): boolean {
270
+ if (ignoreErrors.some((pattern) => (typeof pattern === "string" ? message.includes(pattern) : pattern.test(message)))) return true
271
+ const url = stack ? topFrameUrl(stack) : undefined
272
+ if (!url) return false
273
+ if (denyUrls.some((pattern) => pattern.test(url))) return true
274
+ return !url.startsWith("/") && !url.startsWith(`${location.origin}/`)
275
+ }
276
+
277
+ function capture(name: string, message: string, stack?: string, context?: Record<string, unknown>) {
278
+ if (errors.length >= MAX_ERRORS) return
279
+ if (ignored(message, stack)) return
280
+ const breadcrumbs = trail()
281
+ const error: JsError = {
282
+ at: Date.now(),
283
+ name: name.slice(0, 200) || "Error",
284
+ message: message.slice(0, MAX_MESSAGE),
285
+ stack: stack?.slice(0, MAX_STACK),
286
+ component,
287
+ url: location.pathname + location.search,
288
+ visit: current?.url,
289
+ breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
290
+ context,
291
+ }
292
+ // Deduped within the flush, not across the page's life: a render loop
293
+ // throws the same error every retry. (Inertia's re-rejection of a failed
294
+ // request is dropped by identity in the rejection handler instead.)
295
+ if (errors.some((e) => e.name === error.name && e.message === error.message && e.stack === error.stack)) return
296
+ errors.push(error)
297
+ }
298
+
299
+ // Anything at all can be thrown or rejected in JavaScript, not just an
300
+ // Error. A non-Error value is reported under `fallback` with whatever it
301
+ // stringifies to as the message.
302
+ function captureValue(value: unknown, fallback: string, context?: Record<string, unknown>) {
303
+ if (value instanceof Error) capture(value.name, value.message, value.stack, context)
304
+ else capture(fallback, stringify(value), undefined, context)
305
+ }
306
+
307
+ // An error the app caught itself. On React 18, whose roots take no error
308
+ // options, this is how a boundary reports what it caught -- and it has to,
309
+ // because a production React 18 build does not re-throw a caught error to
310
+ // window.onerror:
311
+ //
312
+ // componentDidCatch(error: Error, info: ErrorInfo) {
313
+ // reportError(error, { componentStack: info.componentStack })
314
+ // }
315
+ export function reportError(error: unknown, context?: Record<string, unknown>) {
316
+ captureValue(error, "Error", context)
317
+ }
318
+
319
+ // React 19's root error options, for `createRoot(el, railwatchRootOptions())`.
320
+ //
321
+ // onCaughtError is the one that matters: an error a boundary catches goes to
322
+ // console.error and no further, so without this every render error a
323
+ // boundary handles is invisible in production. onUncaughtError would reach
324
+ // the window listener on its own (React's default hands it to
325
+ // window.reportError), but taking it here attaches the component stack,
326
+ // which exists nowhere else. onRecoverableError is deliberately left to
327
+ // React: its default also goes through window.reportError, so a hydration
328
+ // mismatch already arrives, and overriding it would take React's own
329
+ // console warning away from whoever is debugging one.
330
+ interface ReactErrorInfo {
331
+ componentStack?: string | null
332
+ }
333
+
334
+ export function railwatchRootOptions(): {
335
+ onCaughtError: (error: unknown, info: ReactErrorInfo) => void
336
+ onUncaughtError: (error: unknown, info: ReactErrorInfo) => void
337
+ } {
338
+ const report = (error: unknown, info: ReactErrorInfo) =>
339
+ captureValue(error, "Error", info.componentStack ? { componentStack: info.componentStack } : undefined)
340
+ return { onCaughtError: report, onUncaughtError: report }
341
+ }
342
+
343
+ // What a console argument or a rejected value reads as in a breadcrumb or
344
+ // an error message. A plain object or array is shown as JSON (capped) rather
345
+ // than "[object Object]"; an Error keeps its own message.
346
+ function stringify(value: unknown) {
347
+ try {
348
+ if (value instanceof Error) return `${value.name}: ${value.message}`
349
+ if (Array.isArray(value) || (typeof value === "object" && value !== null && value.toString === Object.prototype.toString)) {
350
+ return JSON.stringify(value).slice(0, MAX_CRUMB_TEXT)
351
+ }
352
+ return String(value)
353
+ } catch {
354
+ // A Symbol, a cyclic object, or an object whose toString throws.
355
+ return `<${typeof value}>`
356
+ }
357
+ }
358
+
359
+ // Every route a JavaScript error can take to get here. A failed Inertia
360
+ // request has two: the request itself threw (a dropped connection arrives
361
+ // as an axios "Network Error"), or the server answered with something that
362
+ // was not an Inertia response at all -- a 403 page from an authorization
363
+ // filter, a login redirect, an error page from a proxy. Inertia 2 calls
364
+ // those `exception` and `invalid`; Inertia 3 renamed them `networkError`
365
+ // and `httpException`. Both versions dispatch every router event as a
366
+ // CustomEvent "inertia:<name>" on document, so listening there for all
367
+ // four names works on either without the typed router.on, whose event map
368
+ // only knows its own version's names. The first kind is reported only for
369
+ // a visit the user is waiting on -- see `foregroundVisits`.
370
+ function startErrorCapture() {
371
+ window.addEventListener("error", (event) => {
372
+ // Neither an error object nor a message means there is nothing to
373
+ // report -- a failed <img> or <script> load, not a JavaScript error.
374
+ if (!event.error && !event.message) return
375
+ captureValue((event.error ?? event.message) as unknown, "Error")
376
+ })
377
+ window.addEventListener("unhandledrejection", (event) => {
378
+ const reason = event.reason as unknown
379
+ if (typeof reason === "object" && reason !== null && requestFailures.has(reason)) return
380
+ captureValue(reason, "UnhandledRejection")
381
+ })
382
+ onInertia("exception", (detail) => captureRequestFailure(detail.exception))
383
+ onInertia("networkError", (detail) => captureRequestFailure(detail.error))
384
+ onInertia("invalid", (detail) => captureInvalidResponse(detail.response))
385
+ onInertia("httpException", (detail) => captureInvalidResponse(detail.response))
386
+ }
387
+
388
+ // A visit the user is waiting on: one that shows the progress bar, or the
389
+ // load of a page's deferred props, which Inertia starts by itself but the
390
+ // user watches as a skeleton. Read structurally so the same file compiles
391
+ // against Inertia 2 and 3, whose visit types differ.
392
+ function waiting(visit: object): boolean {
393
+ return (
394
+ ("showProgress" in visit && visit.showProgress === true) ||
395
+ ("deferredProps" in visit && visit.deferredProps === true)
396
+ )
397
+ }
398
+
399
+ // Inertia 3 stamps every visit with an id and names it on `success` and
400
+ // `error`. Inertia 2 has neither, so there a landing cannot be told apart
401
+ // from a background poll's and clears nothing: the request stands until
402
+ // the next waiting `before`, a waiting `start`, or a reported failure,
403
+ // which can only over-report.
404
+ function idOf(record: object, key: string): string | undefined {
405
+ const value = key in record ? (record as Record<string, unknown>)[key] : undefined
406
+ return typeof value === "string" ? value : undefined
407
+ }
408
+
409
+ function landed(detail: object) {
410
+ if (foregroundRequest?.id !== undefined && idOf(detail, "visitId") === foregroundRequest.id) {
411
+ foregroundRequest = null
412
+ }
413
+ }
414
+
415
+ // The failure fires before the visit's `finish`, so the visit that failed
416
+ // is still counted: nothing counted and nothing requested means only
417
+ // background visits were in the air, and one of those is what dropped. A
418
+ // requested visit whose request dropped is over; the next thing the user
419
+ // does is a new `before`.
420
+ function captureRequestFailure(error: unknown) {
421
+ if (typeof error === "object" && error !== null) requestFailures.add(error)
422
+ if (foregroundVisits === 0 && !foregroundRequest) return
423
+ foregroundRequest = null
424
+ captureValue(error, "InertiaException")
425
+ }
426
+
427
+ function onInertia(name: string, handler: (detail: Record<string, unknown>) => void) {
428
+ document.addEventListener(`inertia:${name}`, (event) => {
429
+ const detail = (event as CustomEvent<unknown>).detail
430
+ handler(typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : {})
431
+ })
432
+ }
433
+
434
+ // A response Inertia could not apply: an auth redirect's HTML, a 404 page,
435
+ // a proxy error. Inertia 3's httpException ALSO fires for a perfectly valid
436
+ // Inertia response carrying a 4xx status -- a form re-rendered with
437
+ // validation errors at 422, a not-found page the app renders on purpose --
438
+ // and those are the app working as designed, not errors. Inertia's own
439
+ // predicate for the two cases is the x-inertia response header, so that is
440
+ // the gate here; Inertia 2's `invalid` only ever fired for the first kind.
441
+ function captureInvalidResponse(response: unknown) {
442
+ const res = (response ?? {}) as { status?: unknown; headers?: Record<string, unknown> }
443
+ if (inertiaResponse(res.headers)) return
444
+ const status = typeof res.status === "number" ? res.status : "unknown status"
445
+ const contentType = res.headers?.["content-type"]
446
+ capture("InertiaInvalidResponse", `Inertia invalid response (${status})`, undefined, {
447
+ status,
448
+ ...(typeof contentType === "string" ? { content_type: contentType } : {}),
449
+ })
450
+ }
451
+
452
+ function inertiaResponse(headers: Record<string, unknown> | undefined): boolean {
453
+ if (!headers || typeof headers !== "object") return false
454
+ return Object.keys(headers).some((key) => key.toLowerCase() === "x-inertia" && headers[key])
455
+ }
456
+
457
+ // --- Core Web Vitals ---------------------------------------------------
458
+
459
+ let lcp = 0
460
+ let cls = 0
461
+ let inp = 0
462
+ let ttfb = 0
463
+
464
+ // The entry types the observers read. lib.dom lacks the layout-shift and
465
+ // event-timing shapes (and durationThreshold), so they are declared here.
466
+ interface VitalEntry extends PerformanceEntry {
467
+ value?: number
468
+ hadRecentInput?: boolean
469
+ interactionId?: number
470
+ }
471
+ interface ObserveOptions {
472
+ durationThreshold?: number
473
+ }
474
+
475
+ function observe(type: string, callback: (entries: VitalEntry[]) => void, options: ObserveOptions = {}) {
476
+ if (typeof PerformanceObserver === "undefined") return
477
+ try {
478
+ const observer = new PerformanceObserver((list) => callback(list.getEntries()))
479
+ observer.observe({ type, buffered: true, ...options })
480
+ } catch {
481
+ // This browser does not support this entry type. Skip that metric only.
482
+ }
483
+ }
484
+
485
+ // Guarded by entryType rather than `instanceof PerformanceNavigationTiming`:
486
+ // that constructor is a bare global that jsdom (and any non-browser runtime
487
+ // this file is imported into) does not define, and a ReferenceError here
488
+ // would take the whole client down with it.
489
+ function navigationEntry(): PerformanceNavigationTiming | undefined {
490
+ const entries: PerformanceEntry[] = performance.getEntriesByType?.("navigation") ?? []
491
+ const nav = entries[0]
492
+ return nav?.entryType === "navigation" ? (nav as PerformanceNavigationTiming) : undefined
493
+ }
494
+
495
+ function startVitals() {
496
+ const nav = navigationEntry()
497
+ if (nav) ttfb = nav.responseStart
498
+
499
+ // LCP: the last candidate the browser reported wins.
500
+ observe("largest-contentful-paint", (entries) => {
501
+ const last = entries[entries.length - 1]
502
+ if (last) lcp = last.startTime
503
+ })
504
+
505
+ // CLS: the largest session window, per the web-vitals spec -- shifts with
506
+ // no recent input, grouped by 1s gaps and capped at 5s per window.
507
+ let sessionValue = 0
508
+ let sessionFirst = 0
509
+ let sessionLast = 0
510
+ observe("layout-shift", (entries) => {
511
+ for (const entry of entries) {
512
+ if (entry.hadRecentInput) continue
513
+ const value = entry.value ?? 0
514
+ if (sessionValue && entry.startTime - sessionLast < 1000 && entry.startTime - sessionFirst < 5000) {
515
+ sessionValue += value
516
+ sessionLast = entry.startTime
517
+ } else {
518
+ sessionValue = value
519
+ sessionFirst = entry.startTime
520
+ sessionLast = entry.startTime
521
+ }
522
+ if (sessionValue > cls) cls = sessionValue
523
+ }
524
+ })
525
+
526
+ // INP: the slowest interaction. The spec discards the worst few once a
527
+ // page has 50+ interactions; a plain max is close enough here and is what
528
+ // you want a regression alert to fire on anyway.
529
+ observe(
530
+ "event",
531
+ (entries) => {
532
+ for (const entry of entries) {
533
+ if (entry.interactionId && entry.duration > inp) inp = entry.duration
534
+ }
535
+ },
536
+ { durationThreshold: 40 },
537
+ )
538
+ }
539
+
540
+ // The component the server rendered this page with, off the root element's
541
+ // serialized page object. Inertia's own events take over from here.
542
+ function pageComponent(): string | undefined {
543
+ try {
544
+ const page = JSON.parse(document.getElementById("app")?.dataset.page ?? "{}") as { component?: string }
545
+ return page.component
546
+ } catch {
547
+ // Not an Inertia-rendered page, or the payload moved. Report it anyway.
548
+ return undefined
549
+ }
550
+ }
551
+
552
+ // The first page load is a visit too -- it just wasn't routed by Inertia, so
553
+ // the component name comes off the root element's serialized page object.
554
+ function initialVisit(): Visit | null {
555
+ const nav = navigationEntry()
556
+ if (!nav) return null
557
+
558
+ const duration = (nav.loadEventEnd || nav.responseEnd) - nav.startTime
559
+ return {
560
+ started_at: Math.round((performance.timeOrigin ?? Date.now() - performance.now()) + nav.startTime),
561
+ url: location.pathname + location.search,
562
+ method: "GET",
563
+ component,
564
+ duration_ms: duration,
565
+ status: "success",
566
+ }
567
+ }
568
+
569
+ // Vitals keep moving until the page is backgrounded, so the initial visit is
570
+ // held back until then and shipped with its final numbers.
571
+ function finalize() {
572
+ if (!finalized) {
573
+ finalized = true
574
+ if (initial) {
575
+ initial.lcp = Math.round(lcp)
576
+ initial.cls = Math.round(cls * 10000) / 10000
577
+ initial.inp = Math.round(inp)
578
+ initial.ttfb = Math.round(ttfb)
579
+ queue.push(initial)
580
+ initial = null
581
+ }
582
+ }
583
+ // Still flushes on every later hide: a tab can be backgrounded, brought
584
+ // back, navigated some more, and then closed. Each of those carries a
585
+ // final session beat, which the platform dedupes by session id.
586
+ flush(true)
587
+ }
588
+
589
+ export function startRailwatch(options: RailwatchOptions = {}) {
590
+ ignoreErrors = [ ...DEFAULT_IGNORE_ERRORS, ...(options.ignoreErrors ?? []) ]
591
+ denyUrls = [ ...DEFAULT_DENY_URLS, ...(options.denyUrls ?? []) ]
592
+ tenantOf = options.tenant
593
+ startVitals()
594
+ startSession()
595
+ component = pageComponent()
596
+ initial = initialVisit()
597
+ startBreadcrumbs()
598
+ startErrorCapture()
599
+
600
+ // `before` fires for a click before Inertia looks for a prefetch to serve
601
+ // it with, so it is the one event a prefetch-served visit has of its own.
602
+ // A prefetch's own `before` carries no progress bar and is not counted.
603
+ // Listeners registered after this one may still cancel the visit (an
604
+ // unsaved-changes guard, say), and a cancelled visit fires nothing more;
605
+ // the dispatch is over by the time the microtask runs, so the answer is
606
+ // final there.
607
+ router.on("before", (event) => {
608
+ const v = event.detail.visit
609
+ if (!waiting(v) || event.defaultPrevented) return
610
+ const request = (foregroundRequest = { id: idOf(v, "id") })
611
+ queueMicrotask(() => {
612
+ if (event.defaultPrevented && foregroundRequest === request) foregroundRequest = null
613
+ })
614
+ })
615
+ router.on("start", (event) => {
616
+ const v = event.detail.visit
617
+ if (waiting(v)) {
618
+ foregroundVisits++
619
+ foregroundRequest = null
620
+ }
621
+ current = {
622
+ started_at: Date.now(),
623
+ url: v.url.toString(),
624
+ method: v.method,
625
+ partial: Boolean(v.only?.length || v.except?.length),
626
+ only: v.only,
627
+ }
628
+ crumb("navigate", `${v.method.toUpperCase()} ${current.url}`)
629
+ })
630
+ router.on("success", (event) => {
631
+ landed(event.detail)
632
+ component = event.detail.page.component
633
+ if (!current) return
634
+ current.component = component
635
+ current.props_bytes = JSON.stringify(event.detail.page.props ?? {}).length
636
+ current.status = "success"
637
+ })
638
+ router.on("error", (event) => {
639
+ landed(event.detail)
640
+ if (current) current.status = "error"
641
+ })
642
+ router.on("finish", (event) => {
643
+ // Fires once whether the visit completed, failed, or was cancelled.
644
+ // Clamped so a missed start can only over-report, never silence.
645
+ if (waiting(event.detail.visit)) foregroundVisits = Math.max(0, foregroundVisits - 1)
646
+ if (!current) return
647
+ current.duration_ms = Date.now() - current.started_at
648
+ current.status ??= "cancelled"
649
+ queue.push(current)
650
+ current = null
651
+ if (queue.length >= 20) flush()
652
+ })
653
+ timer ??= window.setInterval(flush, 5000)
654
+ document.addEventListener("visibilitychange", () => {
655
+ if (document.visibilityState === "hidden") finalize()
656
+ })
657
+ window.addEventListener("pagehide", finalize)
658
+ }