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.
- checksums.yaml +7 -0
- data/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- 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
|
+
}
|