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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd59ae7a65fbd673b2069d096802144c04277a08379fa42285d32677ad5502c6
4
- data.tar.gz: 49ff48d1ec8340609c23c12a117ae1e381ac938f7fbdfb6aa914b7e33505a25c
3
+ metadata.gz: 7d52990b8f664355823660082054f5f886d9ec4f3391936984182cf8526141b3
4
+ data.tar.gz: 39936e0eba72d4be5223c20948e01717be021cccaaf2819ccf58156f86b76b4a
5
5
  SHA512:
6
- metadata.gz: 9c45c3639f2787b16b9104afed286ff712bfb63028e13fd83eda44323544ddb9a156c60566287d5465d6daaeffb46b4a8379addb8a51ebf90ab178f9c17a41d4
7
- data.tar.gz: 31fe3a1492400bf823ef7dbd04ee22a45edda63349168032b93a3f8e429525cf9cb205b15b96eb6238819a6386916457a75ed3f4de52164227c39e39c8a1a576
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, top[:file], top[:line], Subscribers::Exceptions.normalize_message(message) ])
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,
@@ -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). Strings match anywhere in the
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`. That middleware also owns the
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, quoted strings, hex runs of six characters or
338
- more, and plain integers all become `?`. Whitespace collapses, and the
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 exactly like Ruby ones: class, top in-app frame, and
397
- the normalized message. So browser errors group, split, merge, resolve,
398
- and regress through the same Issue machinery.
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
@@ -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, and the extension URLs are
88
- // third-party code running in someone's browser that this app cannot fix.
89
- const DEFAULT_IGNORE_ERRORS = [
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
@@ -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 plain digits.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
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).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cole Robertson