railwatch 1.5.0 → 1.6.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/CHANGELOG.md +24 -0
- data/app/controllers/railwatch/beacon_controller.rb +2 -1
- data/docs/configuration.md +6 -1
- data/docs/records.md +17 -6
- data/docs/replacing-sentry.md +1 -1
- data/lib/generators/railwatch/install/templates/railwatch.ts +11 -3
- data/lib/railwatch/backtrace.rb +35 -0
- data/lib/railwatch/middleware/request.rb +31 -0
- data/lib/railwatch/subscribers/exceptions.rb +19 -1
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +12 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d52990b8f664355823660082054f5f886d9ec4f3391936984182cf8526141b3
|
|
4
|
+
data.tar.gz: 39936e0eba72d4be5223c20948e01717be021cccaaf2819ccf58156f86b76b4a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3eda104f83588f1cc3c9c12670e9fc335378aa2cd0413e23a811faff4012513b36c0324c0e8cf6ebaae7c0062a5eb537d647a34482c04cf4b2e3fc7c1955ebc
|
|
7
|
+
data.tar.gz: 14f360b2772980266c9fd821e5abd8120af7015fa64d59fb29b21ab8e46ab05d070e5de54fc2d9f34c5fa3b99f3de88ac57d2f85fde1f78045986ce3b1c9a83c
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@
|
|
|
6
6
|
filled in by the release commit, which is also the only commit that
|
|
7
7
|
touches lib/railwatch/version.rb and Gemfile.lock. See CONTRIBUTING.md. -->
|
|
8
8
|
|
|
9
|
+
## 1.6.0 (2026-10-29)
|
|
10
|
+
|
|
11
|
+
- An IPv6 address in an exception message is masked like an IPv4 one, so
|
|
12
|
+
"scrape trap followed from 2607:fb90:..." groups with "...from
|
|
13
|
+
40.77.167.14" instead of opening an issue per address. Full and
|
|
14
|
+
`::`-compressed forms are both covered. A clock time (`12:30:45`) and a
|
|
15
|
+
constant path (`Foo::Bar`) are left alone.
|
|
16
|
+
- A browser error in a content-hashed bundle (`vite/assets/dist-DedfDSJv.js`)
|
|
17
|
+
is fingerprinted on the name without the hash (`vite/assets/dist.js`)
|
|
18
|
+
and without the line, so the same error no longer opens a new issue
|
|
19
|
+
every deploy. In a minified bundle the line moves with any change
|
|
20
|
+
before it. The stored frames keep the real file and line.
|
|
21
|
+
- The browser client ignores "Script error." by default. It is all a
|
|
22
|
+
browser reports for an error in a cross-origin script, with no stack.
|
|
23
|
+
- A file `ActionDispatch::Static` serves from `public/` (a 2xx or 304 for
|
|
24
|
+
a GET or HEAD no route matched) is no longer recorded as a `GET
|
|
25
|
+
unmatched` request, nor counted in its session. In rebulk-system that
|
|
26
|
+
was 3,168 of 36,131 requests a day and about 23 journey rows per page
|
|
27
|
+
load. Unmatched 404s are still recorded. Routed requests never touch
|
|
28
|
+
the filesystem for this.
|
|
29
|
+
- `startRailwatch()` does nothing when there is no `window`, so an
|
|
30
|
+
Inertia SSR entrypoint that calls it at its top level no longer crashes
|
|
31
|
+
with "document is not defined".
|
|
32
|
+
|
|
9
33
|
## 1.5.0 (2026-10-28)
|
|
10
34
|
|
|
11
35
|
- The browser session is one per browser, shared by every tab, as in GA4
|
|
@@ -226,8 +226,9 @@ module Railwatch
|
|
|
226
226
|
message = safe_string(error["message"], MAX_ERROR_MESSAGE)
|
|
227
227
|
frames = Backtrace.js_frames(safe_string(error["stack"], MAX_ERROR_STACK), origin: origin)
|
|
228
228
|
top = Subscribers::Exceptions.top_frame(frames)
|
|
229
|
+
file, line = Backtrace.js_fingerprint_location(top)
|
|
229
230
|
parts = Subscribers::Exceptions.cap_fingerprint(
|
|
230
|
-
[ name,
|
|
231
|
+
[ name, file, line, Subscribers::Exceptions.normalize_message(message) ])
|
|
231
232
|
Railwatch.record(:exception,
|
|
232
233
|
group: Record.group_hash(*parts),
|
|
233
234
|
timestamp: safe_float(error["at"]).positive? ? safe_float(error["at"]) / 1000.0 : nil,
|
data/docs/configuration.md
CHANGED
|
@@ -755,7 +755,8 @@ running in:
|
|
|
755
755
|
```ts
|
|
756
756
|
startRailwatch({
|
|
757
757
|
// Messages never worth an issue, added to the defaults (both
|
|
758
|
-
// "ResizeObserver loop ..." messages
|
|
758
|
+
// "ResizeObserver loop ..." messages, and the bare "Script error." a
|
|
759
|
+
// browser gives for a cross-origin script). Strings match anywhere in the
|
|
759
760
|
// message; regexes are tested against it.
|
|
760
761
|
ignoreErrors: [/Failed to fetch dynamically imported module/],
|
|
761
762
|
// Scripts whose failures are not this app's, matched against the top
|
|
@@ -781,6 +782,10 @@ does the same thing. See
|
|
|
781
782
|
[`docs/replacing-sentry.md`](replacing-sentry.md) for what is and is not
|
|
782
783
|
captured versus `@sentry/react`.
|
|
783
784
|
|
|
785
|
+
The client is safe to import from an SSR bundle: nothing in it touches
|
|
786
|
+
the DOM at import time, and `startRailwatch()` returns without doing
|
|
787
|
+
anything when there is no `window`.
|
|
788
|
+
|
|
784
789
|
SSR timing needs no configuration. `Railwatch::Patches::Inertia` prepends
|
|
785
790
|
`InertiaRails::Renderer#ssr_render` whenever `inertia_rails` SSR is
|
|
786
791
|
enabled. The resulting `ssr_ms` lands on the `request` record's
|
data/docs/records.md
CHANGED
|
@@ -72,7 +72,10 @@ whether child records ship, not whether an error is visible.
|
|
|
72
72
|
### `request`
|
|
73
73
|
|
|
74
74
|
Built by the outermost Rack middleware in
|
|
75
|
-
`lib/railwatch/middleware/request.rb`.
|
|
75
|
+
`lib/railwatch/middleware/request.rb`. A file `ActionDispatch::Static`
|
|
76
|
+
served from `public/` (a GET or HEAD no route matched, answered 2xx or
|
|
77
|
+
304, whose path names a file there or its precompressed `.br`/`.gz`) is not recorded. An unmatched request
|
|
78
|
+
for anything else, a 404 included, is. That middleware also owns the
|
|
76
79
|
`middleware_before`/`action`/`render`/`middleware_after` stage
|
|
77
80
|
boundaries. The `action`/`render` boundaries come from
|
|
78
81
|
`start_processing.action_controller` and `render_template.action_view` in
|
|
@@ -334,8 +337,9 @@ its own `exceptions` sample rate rolled once per execution. See
|
|
|
334
337
|
|
|
335
338
|
The default fingerprint normalizes the message before hashing it, so one
|
|
336
339
|
issue doesn't shatter into thousands. URLs, email addresses, UUIDs, ISO
|
|
337
|
-
timestamps, IPv4 addresses
|
|
338
|
-
|
|
340
|
+
timestamps, IPv4 and IPv6 addresses (lower-case hex, full or compressed
|
|
341
|
+
with `::`), quoted strings, hex runs of six characters or more, and plain
|
|
342
|
+
integers all become `?`. Whitespace collapses, and the
|
|
339
343
|
result is cut at 200 chars. For classes whose message is mostly the data
|
|
340
344
|
that varied, only the message *prefix* is kept. The prefix runs up to the
|
|
341
345
|
first `:` for `ActiveRecord::RecordNotFound`, `ActiveRecord::RecordInvalid`,
|
|
@@ -393,9 +397,16 @@ line with no location on it is dropped:
|
|
|
393
397
|
| `in_app` | True when the script came from the app's own origin and is not under `node_modules/` or `vendor/`. |
|
|
394
398
|
|
|
395
399
|
No source snippets: the file is on the client, not on the server. Frames
|
|
396
|
-
are fingerprinted
|
|
397
|
-
|
|
398
|
-
|
|
400
|
+
are fingerprinted like Ruby ones: class, top in-app frame, and the
|
|
401
|
+
normalized message. So browser errors group, split, merge, resolve, and
|
|
402
|
+
regress through the same Issue machinery. One difference: when the top
|
|
403
|
+
frame's file name carries a bundler's content hash (`dist-DedfDSJv.js`,
|
|
404
|
+
`main.3f2a1b9c.js`, `app-5XDGVJ2T.mjs`; eight or more characters with a
|
|
405
|
+
digit, or mixed case that does not read as camelCase words, so
|
|
406
|
+
`checkout-PaymentForm.js` keeps its name), the fingerprint uses the name without the hash
|
|
407
|
+
(`dist.js`) and no line. Both change on every deploy of a minified
|
|
408
|
+
bundle, so keeping them would open a new issue each release. The frame
|
|
409
|
+
itself keeps the real file and line.
|
|
399
410
|
|
|
400
411
|
`context` carries a `browser` key with the page `url`, the Inertia
|
|
401
412
|
`component`, the `visit` the error happened in if any, the browser
|
data/docs/replacing-sentry.md
CHANGED
|
@@ -421,7 +421,7 @@ startRailwatch({
|
|
|
421
421
|
| `Sentry.init({ dsn })` | `startRailwatch()`. There is no DSN: the beacon posts to the app's own origin and the *server* decides whether to record it (`c.beacon_enabled`, and whether Railwatch is enabled). The gate you already have on whether `startRailwatch()` runs at all is the only gate. |
|
|
422
422
|
| `release` | Automatic. The record is stamped with `c.deploy`, the same release the server records carry, so a browser issue and a server issue from one deploy line up without a matching pair of settings to get wrong. |
|
|
423
423
|
| `environment` | Automatic — the ingest token identifies the environment. |
|
|
424
|
-
| `ignoreErrors` | `startRailwatch({ ignoreErrors })`. Strings match anywhere in the message; regexes are tested against it. Both `ResizeObserver` messages are ignored by default. |
|
|
424
|
+
| `ignoreErrors` | `startRailwatch({ ignoreErrors })`. Strings match anywhere in the message; regexes are tested against it. Both `ResizeObserver` messages and the opaque cross-origin `Script error.` are ignored by default. |
|
|
425
425
|
| `denyUrls` | `startRailwatch({ denyUrls })`, matched against the top stack frame's URL. `/extensions\//i`, `/^chrome:\/\//i`, and `/^moz-extension:\/\//i` are denied by default, **and** any frame from an origin that isn't the app's own is dropped — extensions, injected widgets, tag managers. |
|
|
426
426
|
| `Sentry.setUser` | Server-side. The beacon is a same-origin POST carrying the session cookie, so the server resolves the user the same way it does for a request (`Railwatch.user`) when `Current.user` or Warden is set by middleware; an app that authenticates in a `before_action` gives Railwatch the same lookup with `c.beacon_user { \|request\| ... }`. Nothing the browser sends names the user, so it cannot be forged. |
|
|
427
427
|
| `Sentry.setTag("org", …)` | `startRailwatch({ tenant })`, and `Railwatch.context(...)` for everything else. |
|
|
@@ -84,11 +84,14 @@ export interface RailwatchOptions {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// Browser noise that is never actionable: ResizeObserver fires from benign
|
|
87
|
-
// layout thrash and the spec says to ignore it,
|
|
88
|
-
//
|
|
89
|
-
|
|
87
|
+
// layout thrash and the spec says to ignore it, "Script error." is all a
|
|
88
|
+
// browser says about an error in a cross-origin script (no stack, no file,
|
|
89
|
+
// no line), and the extension URLs are third-party code running in
|
|
90
|
+
// someone's browser that this app cannot fix.
|
|
91
|
+
const DEFAULT_IGNORE_ERRORS: (string | RegExp)[] = [
|
|
90
92
|
"ResizeObserver loop limit exceeded",
|
|
91
93
|
"ResizeObserver loop completed with undelivered notifications",
|
|
94
|
+
/^Script error\.?$/,
|
|
92
95
|
]
|
|
93
96
|
const DEFAULT_DENY_URLS = [
|
|
94
97
|
/extensions\//i,
|
|
@@ -982,7 +985,12 @@ function finalize() {
|
|
|
982
985
|
flush(true)
|
|
983
986
|
}
|
|
984
987
|
|
|
988
|
+
// A no-op under server-side rendering: the Inertia SSR server runs the same
|
|
989
|
+
// entrypoint, with no window to measure and nothing to send a beacon from.
|
|
990
|
+
// Nothing above touches the DOM until this is called, so importing the
|
|
991
|
+
// module is safe there too.
|
|
985
992
|
export function startRailwatch(options: RailwatchOptions = {}) {
|
|
993
|
+
if (typeof window === "undefined") return
|
|
986
994
|
ignoreErrors = [...DEFAULT_IGNORE_ERRORS, ...(options.ignoreErrors ?? [])]
|
|
987
995
|
denyUrls = [...DEFAULT_DENY_URLS, ...(options.denyUrls ?? [])]
|
|
988
996
|
tenantOf = options.tenant
|
data/lib/railwatch/backtrace.rb
CHANGED
|
@@ -110,6 +110,41 @@ module Railwatch
|
|
|
110
110
|
|
|
111
111
|
MAX_JS_FRAMES = 50
|
|
112
112
|
|
|
113
|
+
# A bundler's content hash in a built file's name: Vite and Rollup's
|
|
114
|
+
# "dist-DedfDSJv.js" (base64url, so it can hold a "-" of its own:
|
|
115
|
+
# "dist-CMHaG-3i.js"), esbuild's "app-5XDGVJ2T.js", webpack's
|
|
116
|
+
# "main.3f2a1b9c.js". The name is greedy, so in "yard-canvas-BwMeIV6a.js"
|
|
117
|
+
# only the last segment that can be a hash is one.
|
|
118
|
+
JS_CONTENT_HASH = /\A(?<name>.+)[-.](?<hash>[A-Za-z0-9_-]{8,64})(?<ext>\.[mc]?js)\z/
|
|
119
|
+
|
|
120
|
+
# Where a browser error is filed for grouping. A content hash changes
|
|
121
|
+
# every deploy the bundle changes, so it is taken out of the file name,
|
|
122
|
+
# and so is the line: in a minified bundle the line is wherever the
|
|
123
|
+
# minifier wrapped (Vite writes the whole app on a line or two) and
|
|
124
|
+
# shifts with any change before it, which is why Sentry leaves the line
|
|
125
|
+
# out of a minified frame it has no source map for. The stored frames
|
|
126
|
+
# keep the real file and line; this is for the fingerprint alone.
|
|
127
|
+
def js_fingerprint_location(frame)
|
|
128
|
+
file = frame[:file]
|
|
129
|
+
match = file && JS_CONTENT_HASH.match(file)
|
|
130
|
+
return [ file, frame[:line] ] unless match && content_hash?(match[:hash])
|
|
131
|
+
|
|
132
|
+
[ "#{match[:name]}#{match[:ext]}", nil ]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A word is not a hash: one has a digit, or mixes cases in a way no
|
|
136
|
+
# camelCase or PascalCase name does ("DedfDSJv"). So the "timeline" in
|
|
137
|
+
# "order-timeline.js" and the "PaymentForm" in "checkout-PaymentForm.js"
|
|
138
|
+
# stay part of the name. A hash that happens to read like words (about
|
|
139
|
+
# 1 in 100 of eight letters with no digit) is kept too, which costs that
|
|
140
|
+
# deploy a split issue, never a wrong merge.
|
|
141
|
+
CAMEL_WORDS = /\A[A-Za-z][a-z]+(?:[A-Z][a-z]{2,})+\z/
|
|
142
|
+
|
|
143
|
+
def content_hash?(segment)
|
|
144
|
+
segment.match?(/\d/) ||
|
|
145
|
+
(segment.match?(/[a-z]/) && segment.match?(/[A-Z]/) && !segment.match?(CAMEL_WORDS))
|
|
146
|
+
end
|
|
147
|
+
|
|
113
148
|
# A browser stack, exactly as the engine wrote it, in the same frame
|
|
114
149
|
# shape as a Ruby backtrace. `origin` is the app's own scheme and host: a
|
|
115
150
|
# script served from it is the app's own, so its file is stored relative
|
|
@@ -145,6 +145,8 @@ module Railwatch
|
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
def finish(env, exe, status, headers)
|
|
148
|
+
return Railwatch.discard_execution if static_file?(env, status)
|
|
149
|
+
|
|
148
150
|
exe.finish_stages
|
|
149
151
|
# Resolved here, once, for both the request record and the session
|
|
150
152
|
# key below (the start_processing subscriber ran before the app's
|
|
@@ -165,6 +167,35 @@ module Railwatch
|
|
|
165
167
|
nil
|
|
166
168
|
end
|
|
167
169
|
|
|
170
|
+
# A file ActionDispatch::Static served from public/: a bundle, a
|
|
171
|
+
# stylesheet, a favicon. This middleware sits in front of Static, so
|
|
172
|
+
# without this every one of them is a "GET unmatched" request record
|
|
173
|
+
# -- about 23 per page load, a tenth of rebulk-system's requests --
|
|
174
|
+
# and a row in the session's journey. Only a success or a 304 is
|
|
175
|
+
# skipped: an unmatched 404 is a probe the security tools read.
|
|
176
|
+
#
|
|
177
|
+
# Static's 304 is a bare [304, {}, []], so the response cannot say a
|
|
178
|
+
# file answered it. The path can: it names a file under public/,
|
|
179
|
+
# looked up the way Static looks (unescaped, cleaned, then with
|
|
180
|
+
# ".html" or "/index.html", or only its precompressed ".br" or ".gz",
|
|
181
|
+
# which Static serves in its place). Every check before that one is an
|
|
182
|
+
# env or integer read, so a routed request never touches the
|
|
183
|
+
# filesystem.
|
|
184
|
+
def static_file?(env, status)
|
|
185
|
+
return false if env["railwatch.route"] || env["action_dispatch.route"]
|
|
186
|
+
return false unless status && (status.to_i.between?(200, 299) || status.to_i == 304)
|
|
187
|
+
return false unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
|
|
188
|
+
|
|
189
|
+
path = Rack::Utils.unescape_path(env["PATH_INFO"].to_s.chomp("/"))
|
|
190
|
+
return false unless Rack::Utils.valid_path?(path)
|
|
191
|
+
|
|
192
|
+
file = File.join(Rails.public_path.to_s, Rack::Utils.clean_path_info(path))
|
|
193
|
+
[ file, "#{file}.html", File.join(file, "index.html"), "#{file}.br", "#{file}.gz" ]
|
|
194
|
+
.any? { |candidate| File.file?(candidate) }
|
|
195
|
+
rescue StandardError
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
|
|
168
199
|
def parent_fields(env, exe, status, headers)
|
|
169
200
|
req = ActionDispatch::Request.new(env)
|
|
170
201
|
route = env["railwatch.route"] || {}
|
|
@@ -248,15 +248,33 @@ module Railwatch
|
|
|
248
248
|
error.class.ancestors.any? { |ancestor| (name = ancestor.name) && ignored.include?(name) }
|
|
249
249
|
end
|
|
250
250
|
|
|
251
|
+
# An IPv6 address, full ("2607:fb90:...:74c7") or compressed around
|
|
252
|
+
# "::" ("2600:3c15::2000:22ff:febb:8bb9", "2a03:2880:f802:c::"). It
|
|
253
|
+
# has to carry a digit and stand alone, and it is lower case, as
|
|
254
|
+
# RFC 5952 and IPAddr write it: so a constant path like Foo::Bar or
|
|
255
|
+
# Abc1::Def stays, and a clock time like 12:30:45 has neither eight
|
|
256
|
+
# groups nor a "::".
|
|
257
|
+
IPV6 = /
|
|
258
|
+
(?<![\w:])
|
|
259
|
+
(?=[0-9a-f:]*\d)
|
|
260
|
+
(?:
|
|
261
|
+
[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){7}
|
|
262
|
+
| (?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})?::(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})?
|
|
263
|
+
)
|
|
264
|
+
(?![\w:])
|
|
265
|
+
/x
|
|
266
|
+
|
|
251
267
|
# Variable data that would otherwise split one issue into thousands of
|
|
252
268
|
# them. Applied in this order: a URL before the numbers inside it, a
|
|
253
|
-
# quoted string before the id it quotes, hex before
|
|
269
|
+
# quoted string before the id it quotes, an IPv6 address and hex before
|
|
270
|
+
# plain digits.
|
|
254
271
|
MESSAGE_NOISE = [
|
|
255
272
|
%r{\bhttps?://\S+}, # URLs
|
|
256
273
|
/\b[^\s@]+@[^\s@]+\.[^\s@]+\b/, # email addresses
|
|
257
274
|
/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/, # UUIDs
|
|
258
275
|
/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\S*/, # ISO timestamps
|
|
259
276
|
/\b\d{1,3}(?:\.\d{1,3}){3}\b/, # IPv4 addresses
|
|
277
|
+
IPV6, # IPv6, before hex and digits
|
|
260
278
|
/(?<!\w)'[^']*'|"[^"]*"/, # quoted strings ("won't" is not one)
|
|
261
279
|
/\b(?:0x)?\h{6,}\b/, # hex: digests, object addresses
|
|
262
280
|
/\b\d+\b/ # plain integers
|
data/lib/railwatch/version.rb
CHANGED
data/lib/railwatch.rb
CHANGED
|
@@ -199,6 +199,18 @@ module Railwatch
|
|
|
199
199
|
Current.execution = exe&.parent_execution
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
# Ends the execution and ships nothing of it, for one that turned out
|
|
203
|
+
# not to be worth a record at all (the request middleware's static
|
|
204
|
+
# files). A profile it started is stopped and thrown away.
|
|
205
|
+
def discard_execution
|
|
206
|
+
exe = Current.execution
|
|
207
|
+
return unless exe
|
|
208
|
+
|
|
209
|
+
Profiler.stop if exe.profiler_handle
|
|
210
|
+
Current.execution = exe.parent_execution
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
202
214
|
# --- record writing --------------------------------------------------------
|
|
203
215
|
|
|
204
216
|
# Records that make sense without a parent execution (console, boot).
|