railwatch 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 954add09993af5d0756ed9ff1875061f25eed72867139116fd020a90b961627c
4
- data.tar.gz: 6fbb618b6eb38d867f9ea7648dfce28d7c46003e1ed9499303a3889de06373af
3
+ metadata.gz: fd59ae7a65fbd673b2069d096802144c04277a08379fa42285d32677ad5502c6
4
+ data.tar.gz: 49ff48d1ec8340609c23c12a117ae1e381ac938f7fbdfb6aa914b7e33505a25c
5
5
  SHA512:
6
- metadata.gz: 88f0a50d4769269d565513d59b03567413424f6566da76d1056d08a1e734d7ce244bc609a06c8a25cd7651873ae1519af968a5f6708b225cb984cb764ab77387
7
- data.tar.gz: b882ad5f4cb5a5a3ed16975d1cab018fdd04a0e75be8677baa29882dcc61c56b561096568241e023843d687dc950324a16dedc20200dd4035f51f949535b20c5
6
+ metadata.gz: 9c45c3639f2787b16b9104afed286ff712bfb63028e13fd83eda44323544ddb9a156c60566287d5465d6daaeffb46b4a8379addb8a51ebf90ab178f9c17a41d4
7
+ data.tar.gz: 31fe3a1492400bf823ef7dbd04ee22a45edda63349168032b93a3f8e429525cf9cb205b15b96eb6238819a6386916457a75ed3f4de52164227c39e39c8a1a576
data/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@
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.5.0 (2026-10-28)
10
+
11
+ - The browser session is one per browser, shared by every tab, as in GA4
12
+ and Amplitude: kept in `localStorage`, it ends after 30 minutes with no
13
+ activity in any tab or 24 hours after it started. Each tab also has its
14
+ own id. Every visit record carries `session_id` and `tab_id`, and every
15
+ request record carries the `session_id` from the `X-Railwatch-Session`
16
+ header or `railwatch_session` cookie and the `tab_id` from
17
+ `X-Railwatch-Tab`, which the client now sends on same-origin Inertia
18
+ visits. A browser error's `context.browser` carries the `session` and
19
+ `tab` it was thrown in, stamped when it fired, and both are dropped
20
+ rather than cut when they are not shaped like an id. A browser's first
21
+ page load, which arrives before the client has set its cookie, is handed
22
+ one by the request middleware (successful HTML `GET`s only, never a
23
+ publicly cacheable one, and only when the browser client is installed:
24
+ `browser_client`), so that page is in the session's journey and no
25
+ longer opens a second `user:<id>` session in release health. A closing
26
+ tab no longer marks the session `ended`: the browser's other tabs may
27
+ still be in it. Apps that copied the client
28
+ (`app/frontend/lib/railwatch.ts`) need the same change in their copy:
29
+ `bin/rails generate railwatch:install --force --no-doctor` updates it.
30
+ - `railwatch:install`, `railwatch:doctor` and the `browser_client`
31
+ detection find the app's frontend sources in ViteRuby's `sourceCodeDir`,
32
+ else in `app/frontend` or `app/javascript`. They only looked in
33
+ `app/frontend` before, so an `app/javascript` app got no client and no
34
+ entrypoint wiring.
35
+
9
36
  ## 1.4.0 (2026-10-26)
10
37
 
11
38
  - In development each recorded request's response carries
@@ -55,6 +55,7 @@ module Railwatch
55
55
  inp: metric_ms(v["inp"]),
56
56
  ttfb: metric_ms(v["ttfb"]),
57
57
  **ttfb_phases(v),
58
+ **browser_fields(v),
58
59
  user: user_id,
59
60
  tenant: tenant,
60
61
  user_agent: safe_string(request.user_agent, 256))
@@ -193,7 +194,7 @@ module Railwatch
193
194
  next unless error
194
195
  name = safe_string(error["name"], 255)
195
196
  next if name.empty?
196
- record_error(error, name, origin, session_id, user_id, tenant)
197
+ record_error(error, name, origin, Sessions.browser_id(error["session_id"]) || session_id, user_id, tenant)
197
198
  end
198
199
  end
199
200
 
@@ -206,12 +207,19 @@ module Railwatch
206
207
  Context.current_tenant || (hint.is_a?(String) ? safe_string(hint, 255).presence : nil)
207
208
  end
208
209
 
209
- # The tab's session id, so a browser error can be lined up with the
210
- # session it crashed. Shaped like every other read off this payload:
211
- # anything but a hash carrying an id yields no id at all.
210
+ # The flush's session id, for an error that does not name its own (a
211
+ # client copied before errors carried one). Anything but a hash carrying
212
+ # an id shaped like one the client mints yields no id at all.
212
213
  def session_id_from(session)
213
214
  session = object(session)
214
- session ? safe_string(session["id"], 64) : ""
215
+ Sessions.browser_id(session["id"]) if session
216
+ end
217
+
218
+ # The session and tab each visit says it happened in, which can differ
219
+ # within one beacon: visits queued before the session timed out ship
220
+ # with the next session's beat. Omitted unless shaped like an id.
221
+ def browser_fields(visit)
222
+ { session_id: Sessions.browser_id(visit["session_id"]), tab_id: Sessions.browser_id(visit["tab_id"]) }.compact
215
223
  end
216
224
 
217
225
  def record_error(error, name, origin, session_id, user_id, tenant)
@@ -235,7 +243,8 @@ module Railwatch
235
243
  url: safe_string(error["url"], 2048).presence,
236
244
  component: safe_string(error["component"], 255).presence,
237
245
  visit: safe_string(error["visit"], 2048).presence,
238
- session: session_id.presence,
246
+ session: session_id,
247
+ tab: Sessions.browser_id(error["tab_id"]),
239
248
  user_agent: safe_string(request.user_agent, 256).presence,
240
249
  breadcrumbs: breadcrumbs(error["breadcrumbs"]).presence
241
250
  }.compact)),
@@ -523,15 +523,21 @@ crash-free session and crash-free user rates are computed from. The
523
523
  | `track_sessions` | `RAILWATCH_TRACK_SESSIONS` | `true` | Master switch for both session sources. Off means the request middleware does nothing extra and no flusher thread is started. |
524
524
  | `session_flush_interval` | `RAILWATCH_SESSION_FLUSH_INTERVAL` | `60.0` | Seconds between server-session flushes. One background thread per web process, re-armed after `fork` exactly like the health sampler, and flushed once more on shutdown. |
525
525
  | `session_timeout` | `RAILWATCH_SESSION_TIMEOUT` | `1800.0` | Seconds a server session may sit idle before it ships with `ended` and is forgotten. |
526
+ | `browser_client` | `RAILWATCH_BROWSER_CLIENT` | whether `lib/railwatch.ts` exists in the app's frontend source directory (ViteRuby's `sourceCodeDir`, else `app/frontend` or `app/javascript`) | Whether the app's pages run the browser client. When they do, a browser's first page load gets its session id from the server (below). Set it when the client lives somewhere else, or `false` to never set the cookie. |
526
527
 
527
528
  There are two sources, and they meet on the same id:
528
529
 
529
530
  - **The browser client** is `app/frontend/lib/railwatch.ts`, installed by
530
- `railwatch:install`. It mints one id per tab in `sessionStorage` and
531
- mirrors it into a `railwatch_session` cookie. It rides along on the
532
- beacon flushes the client already sends for visits, so this costs no
533
- extra requests. This is the primary source for a web app, and it is
534
- what makes session duration mean "how long the tab was open".
531
+ `railwatch:install`. It keeps one id per browser in `localStorage`,
532
+ shared by every tab and replaced after 30 minutes with no activity in
533
+ any tab or 24 hours after it started, and mirrors it into a
534
+ `railwatch_session` cookie. It also sends the id, with the tab's own
535
+ id, as `X-Railwatch-Session` and `X-Railwatch-Tab` on every Inertia
536
+ visit to the page's own origin. It rides along on the beacon flushes
537
+ the client already sends for visits, so this costs no extra requests.
538
+ This is the primary source for a web app, and it is what makes session
539
+ duration mean "how long the user was active". The client's 30 minutes
540
+ is fixed; it matches this setting's default.
535
541
  - **The request middleware** aggregates, in memory, every request that
536
542
  either resolves a user or carries that cookie or an
537
543
  `X-Railwatch-Session` header. It is the only source for an API-only
@@ -541,6 +547,25 @@ There are two sources, and they meet on the same id:
541
547
  When a browser session's requests carry the cookie both sources produce
542
548
  records under the same id and the platform dedupes them.
543
549
 
550
+ A browser's first page load arrives before the client has run, so it
551
+ carries no cookie. With `browser_client` on, the request middleware gives
552
+ it one: a successful `GET` whose response is an HTML document, that is not
553
+ an Inertia visit and carries no `X-Railwatch-Session` or cookie, gets
554
+ `Set-Cookie: railwatch_session=<new id>; path=/; max-age=1800;
555
+ samesite=lax` (plus `secure` over HTTPS, including behind a proxy that
556
+ sets `X-Forwarded-Proto`). That request's record carries the id, and so does its
557
+ server session when it resolved a user (an anonymous one opened no server
558
+ session before and still opens none; its browser opens the session with
559
+ the client's first beat).
560
+ The client adopts it when the browser has no live session of its own, so
561
+ that first page is the first entry of the session's journey and the
562
+ session is not counted twice (once under the id, once as `user:<id>`). It
563
+ is never set on a response whose `Cache-Control` says `public` or
564
+ `s-maxage`, since a shared cache would hand one visitor's cookie to every
565
+ visitor; on such a page the session starts with the client, as before.
566
+ Requests with neither cookie nor header, from an API client or an app
567
+ without the browser client, still key on `user:<id>`.
568
+
544
569
  ## Vendor noise defaults
545
570
 
546
571
  Framework/vendor activity excluded by default so a fresh install isn't
@@ -719,7 +744,9 @@ credential, so it is throttled per client IP by `beacon_rate_limit`,
719
744
  default 120 a minute, `0` to disable. A client past the limit gets a 429
720
745
  with `Retry-After` and nothing from that POST is recorded. Client setup:
721
746
  call `startRailwatch()` from your Inertia entrypoint. It is generated at
722
- `app/frontend/lib/railwatch.ts`.
747
+ `lib/railwatch.ts` in the app's frontend source directory, usually
748
+ `app/frontend/lib/railwatch.ts` (see Getting started for how the directory
749
+ is chosen and how to update the copy).
723
750
 
724
751
  `startRailwatch` takes three optional settings, none of which has a
725
752
  server-side equivalent. They are decisions about the browser the code is
@@ -57,9 +57,25 @@ bin/rails generate railwatch:install \
57
57
  beacon endpoint the browser client posts to (`POST /railwatch/beacon`).
58
58
  - `.kamal/hooks/post-deploy` — only if `config/deploy.yml` already
59
59
  exists.
60
- - `app/frontend/lib/railwatch.ts` plus the `startRailwatch()` call in your
61
- Inertia entrypoint — only if `app/frontend/` exists. If it can't find
62
- an entrypoint it prints the two lines to add.
60
+ - `lib/railwatch.ts` in the app's frontend source directory, plus the
61
+ `startRailwatch()` call in its Inertia entrypoint
62
+ (`entrypoints/inertia.tsx`, `.ts` or `.jsx`). The directory is ViteRuby's
63
+ `sourceCodeDir` when the app uses vite_ruby, else the first of
64
+ `app/frontend` and `app/javascript` that exists; with neither, nothing
65
+ is copied. If it can't find an entrypoint it prints the two lines to add.
66
+
67
+ The browser client is copied into the app, so upgrading the gem does not
68
+ update it. After an upgrade whose CHANGELOG mentions the client, run
69
+ `bin/rails generate railwatch:install --force --no-doctor`, or copy the
70
+ file by hand:
71
+
72
+ ```sh
73
+ cp "$(bundle exec ruby -e 'require "railwatch"; print Railwatch.browser_client_path')" app/frontend/lib/railwatch.ts # or app/javascript/lib/...
74
+ ```
75
+
76
+ Without `--force` the generator asks before overwriting a copy that
77
+ differs. Either way, review the diff if you have edited your copy. The
78
+ generator's other steps leave what they already wrote alone.
63
79
  - `require "railwatch/rspec"` in `spec/rails_helper.rb`, or
64
80
  `require "railwatch/minitest"` in `test/test_helper.rb`.
65
81
 
data/docs/records.md CHANGED
@@ -104,6 +104,8 @@ boundaries. The `action`/`render` boundaries come from
104
104
  | `payload` | Filtered request params — only captured when `config.capture_request_payload` is on **and** the request raised an exception (never for successful requests). |
105
105
  | `user_agent` | Truncated to 256 chars. |
106
106
  | `files` | Array of `{name, size, content_type, error}` for each uploaded file in a multipart request (metadata only, never contents). A non-multipart request body is never parsed to fill this in. |
107
+ | `session_id` | The browser client's session id, from `X-Railwatch-Session` or else the `railwatch_session` cookie, so a full page load carries it too. On a browser's first page load, the id the middleware set in that cookie on this response (see Release health in configuration.md). Never the `"user:<id>"` key the server session falls back to. At most 64 of `[A-Za-z0-9_-]`; absent when missing or not that shape. |
108
+ | `tab_id` | The browser client's tab id, from `X-Railwatch-Tab`, which it sends only on Inertia visits: a full page load has none. Same validation, absent otherwise. |
107
109
  | `profiled` | `true` when a `profile` record shipped for this request; absent otherwise. |
108
110
 
109
111
  ### `job_attempt`
@@ -396,8 +398,10 @@ the normalized message. So browser errors group, split, merge, resolve,
396
398
  and regress through the same Issue machinery.
397
399
 
398
400
  `context` carries a `browser` key with the page `url`, the Inertia
399
- `component`, the `visit` the error happened in if any, the tab's
400
- `session` id, the `user_agent`, and up to 20 `breadcrumbs`. Each
401
+ `component`, the `visit` the error happened in if any, the browser
402
+ `session` id and the `tab` id it was thrown in, both stamped by the client
403
+ when the error fired (the flush's session stands in for a client that
404
+ sends none; each at most 64 of `[A-Za-z0-9_-]`, absent otherwise), the `user_agent`, and up to 20 `breadcrumbs`. Each
401
405
  breadcrumb is `{at, kind, text}`, with `kind` being `console`, `click`,
402
406
  or `navigate`. The breadcrumbs are the trail the client recorded before
403
407
  the crash. Anything the app passed as `reportError(error, context)` is
@@ -776,6 +780,8 @@ entirely if `config.beacon_enabled` is off.
776
780
  | `user` | Resolved server-side from the beacon request's session/cookies, same resolver as every other record. |
777
781
  | `tenant` | Current tenant context. |
778
782
  | `user_agent` | Truncated to 256 chars. |
783
+ | `session_id` | The browser session the visit happened in (see `session`), when the client sent one shaped like an id: at most 64 of `[A-Za-z0-9_-]`. Absent otherwise. |
784
+ | `tab_id` | The tab the visit happened in: a 16-hex id the client keeps in `sessionStorage` (`railwatch.tab`), so it survives a reload of the tab but not a new tab. "Duplicate tab" copies `sessionStorage`, so a duplicated tab shares its original's id until one of them is reloaded fresh. Same validation, absent otherwise. |
779
785
 
780
786
  The **initial page load** is reported as a visit too, even though Inertia
781
787
  never routed it. It has `method` `"GET"`, `status` `"success"`, and
@@ -799,13 +805,25 @@ per deploy and reports crash-free rates from them. Two sources produce
799
805
  the same record:
800
806
 
801
807
  - **Browser**, `source: "browser"`. The client,
802
- `app/frontend/lib/railwatch.ts`, mints a 16-hex id per tab in
803
- `sessionStorage` under the key `railwatch.session`, so it dies with the
804
- tab. It mirrors the id into a `railwatch_session` cookie and sends it
805
- with every beacon flush. `Railwatch::BeaconController` writes at most
806
- one `session` record per flush. The first, with no `duration_ms` yet,
807
- opens the session. Later ones beat it along, and the
808
- `pagehide`/`visibilitychange` flush closes it with `ended`.
808
+ `app/frontend/lib/railwatch.ts`, keeps one session per browser, shared
809
+ by every tab, the way GA4 and Amplitude do: a 16-hex id in
810
+ `localStorage` (`railwatch.session`, with `railwatch.session.at` when it
811
+ started and `railwatch.session.seen` for its last activity in any tab).
812
+ Activity is a page load, an Inertia visit the user waits on, and a tab
813
+ coming back into view. After 30 minutes with no activity in any tab, or
814
+ 24 hours after it started, the next activity mints a new id. When
815
+ `localStorage` throws the session lives in `sessionStorage`, and when
816
+ that throws too, in memory; a new tab still joins the session through
817
+ the cookie. A browser's first page load gets its id from the server, in
818
+ that cookie, and the client adopts it when no live session is stored.
819
+ The client mirrors the id into a `railwatch_session` cookie that lapses
820
+ after 30 minutes, renewed on every activity, and sends it with every
821
+ beacon flush. `Railwatch::BeaconController` writes at most one `session` record
822
+ per flush. The first, with no `duration_ms` yet, opens the session.
823
+ Later ones beat it along. Closing a tab does not end the session, since
824
+ other tabs may still be in it; a tab that sees the session time out
825
+ sends one last beat with `ended`, and a session no tab comes back to
826
+ ends only by the platform's own timeout.
809
827
  - **Server**, `source: "server"`. `lib/railwatch/sessions.rb` aggregates,
810
828
  per process, every request that resolves a user or carries that cookie
811
829
  or an `X-Railwatch-Session` header. A background thread ships one
@@ -830,7 +848,7 @@ halves of one session rather than counting it twice.
830
848
  | `requests` | Requests in this session so far (server only). |
831
849
  | `visits` | Inertia visits in this beacon flush (browser only). |
832
850
  | `errors` | Requests that 5xx'd or raised (server), or visits with status `"error"` in this flush (browser). |
833
- | `ended` | Whether this is the session's last record. |
851
+ | `ended` | Whether this is the session's last record. A browser session is only `ended` when a tab saw it time out, never because a tab closed. |
834
852
  | `user` | Resolved user id, when there is one. |
835
853
 
836
854
  ### `process`
@@ -26,7 +26,7 @@ the install is broken.
26
26
  | `ignored record types` | Never fails; it prints what `c.ignore` is dropping. |
27
27
  | `interactive sessions` | Never fails; it prints whether consoles are captured and the runner scratch paths. |
28
28
  | `kamal post-deploy hook` | `.kamal/hooks/post-deploy` is missing or doesn't mention Railwatch. Only matters if you deploy with Kamal. |
29
- | `browser client` | `app/frontend/lib/railwatch.ts` isn't there. Only matters for Inertia visit timing. |
29
+ | `browser client` | `lib/railwatch.ts` isn't in the app's frontend source directory (ViteRuby's `sourceCodeDir`, else `app/frontend` or `app/javascript`). Only matters for Inertia visit timing. |
30
30
  | `browser client imported` | The client exists but nothing calls `startRailwatch()` — no `startRailwatch` found in `app/frontend/entrypoints`. Visits won't report. |
31
31
  | `profiler backend` | Neither `vernier` nor `stackprof` is installed, so `Railwatch::Profiler.available?` is false and the profiling settings are inert. |
32
32
  | `test matchers` | Neither `spec/rails_helper.rb` requires `railwatch/rspec` nor `test/test_helper.rb` requires `railwatch/minitest`. |
@@ -23,11 +23,12 @@ module Railwatch
23
23
  class_option :doctor, type: :boolean, default: true,
24
24
  desc: "Run railwatch:doctor when the install finishes."
25
25
 
26
- # Whichever of these exists is where `startRailwatch()` is added.
26
+ # Whichever of these exists, under the app's frontend source directory
27
+ # (Railwatch.frontend_dir), is where `startRailwatch()` is added.
27
28
  INERTIA_ENTRYPOINTS = %w[
28
- app/frontend/entrypoints/inertia.tsx
29
- app/frontend/entrypoints/inertia.ts
30
- app/frontend/entrypoints/inertia.jsx
29
+ entrypoints/inertia.tsx
30
+ entrypoints/inertia.ts
31
+ entrypoints/inertia.jsx
31
32
  ].freeze
32
33
 
33
34
  IMPORT_LINE = 'import { startRailwatch } from "@/lib/railwatch"'
@@ -68,19 +69,22 @@ module Railwatch
68
69
 
69
70
  # The one browser client (Railwatch.browser_client_path). Copied rather
70
71
  # than templated: there is nothing to interpolate, and a copy means the
71
- # host sees exactly what the gem tests.
72
+ # host sees exactly what the gem tests. Re-running the generator after
73
+ # a gem upgrade is how an app picks up a new client: Thor asks before
74
+ # overwriting a copy that differs (--force overwrites, --skip keeps it).
72
75
  def create_browser_client
73
- return unless File.directory?("app/frontend")
74
- copy_file Railwatch.browser_client_path.to_s, "app/frontend/lib/railwatch.ts"
76
+ return unless frontend_dir
77
+ copy_file Railwatch.browser_client_path.to_s, "#{frontend_dir}/lib/railwatch.ts"
75
78
  end
76
79
 
77
80
  # Adds the import and the call to the Inertia entrypoint, so page-visit
78
81
  # timing and Core Web Vitals report with no further editing.
79
82
  def start_browser_client
80
- return unless File.directory?("app/frontend")
83
+ return unless frontend_dir
81
84
 
82
- path = INERTIA_ENTRYPOINTS.find { |candidate| File.exist?(candidate) }
83
- return say(browser_client_instructions(INERTIA_ENTRYPOINTS.first), :yellow) unless path
85
+ candidates = INERTIA_ENTRYPOINTS.map { |entrypoint| "#{frontend_dir}/#{entrypoint}" }
86
+ path = candidates.find { |candidate| File.exist?(candidate) }
87
+ return say(browser_client_instructions(candidates.first), :yellow) unless path
84
88
 
85
89
  contents = File.read(path)
86
90
  return say_status(:identical, path, :blue) if contents.include?(START_CALL)
@@ -279,6 +283,10 @@ module Railwatch
279
283
  "The token value is never printed:\n#{lines.join("\n")}"
280
284
  end
281
285
 
286
+ def frontend_dir
287
+ @frontend_dir ||= Railwatch.frontend_dir(destination_root)
288
+ end
289
+
282
290
  def browser_client_instructions(path)
283
291
  "Add these two lines to #{path} to report Inertia visits and Core Web Vitals:\n" \
284
292
  " #{IMPORT_LINE}\n #{START_CALL}"
@@ -29,10 +29,13 @@ interface Visit {
29
29
  redirect_count?: number
30
30
  connect_ms?: number
31
31
  server_ms?: number
32
+ // The browser session and the tab the visit happened in, for the journey.
33
+ session_id?: string
34
+ tab_id?: string
32
35
  }
33
36
 
34
- // The tab's session, for release health. sessionStorage scopes it to the
35
- // tab and it dies with the tab, which is what a browser session is.
37
+ // The browser's session, for release health and the session journey, on the
38
+ // wire. `ended` only when this tab has seen it time out (see `begin`).
36
39
  interface Session {
37
40
  id: string
38
41
  started_at: number
@@ -50,6 +53,10 @@ interface JsError {
50
53
  component?: string
51
54
  url: string
52
55
  visit?: string
56
+ // The session and tab it was thrown in, taken when it was: another tab
57
+ // may move the session on before this one flushes.
58
+ session_id?: string
59
+ tab_id?: string
53
60
  breadcrumbs?: Crumb[]
54
61
  context?: Record<string, unknown>
55
62
  }
@@ -90,14 +97,26 @@ const DEFAULT_DENY_URLS = [
90
97
  ]
91
98
 
92
99
  const SESSION_ID_KEY = "railwatch.session"
100
+ const SESSION_SEEN_KEY = "railwatch.session.seen"
93
101
  const SESSION_STARTED_KEY = "railwatch.session.at"
102
+ const TAB_KEY = "railwatch.tab"
103
+ // The GA4/Amplitude rules: a session is over after 30 minutes with no
104
+ // activity in any tab (config.session_timeout's default, which the server
105
+ // half times out on too), and after 24 hours whatever happens.
106
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000
107
+ const SESSION_CAP_MS = 24 * 60 * 60 * 1000
108
+ // Railwatch::Sessions reads these, ahead of the cookie.
109
+ const SESSION_HEADER = "X-Railwatch-Session"
110
+ const TAB_HEADER = "X-Railwatch-Tab"
94
111
 
95
112
  const queue: Visit[] = []
96
113
  const errors: JsError[] = []
97
114
  const crumbs: Crumb[] = []
98
115
  let current: Visit | null = null
99
116
  let initial: Visit | null = null
100
- let session: Session | null = null
117
+ // `seen` is the session's last activity in any tab, as far as this one knows.
118
+ let session: { id: string; started_at: number; seen: number } | null = null
119
+ let tab = ""
101
120
  // The Inertia page component the user is on, so an error that fires between
102
121
  // visits still says which screen it broke.
103
122
  let component: string | undefined
@@ -170,12 +189,13 @@ function post(payload: {
170
189
  }).catch(() => undefined)
171
190
  }
172
191
 
173
- function flush(ended = false) {
174
- if (queue.length === 0 && errors.length === 0 && !ended) return
192
+ function flush(force = false) {
193
+ if (queue.length === 0 && errors.length === 0 && !force) return
194
+ touch(false)
175
195
  post({
176
196
  visits: queue.splice(0, queue.length),
177
197
  errors: errors.splice(0, errors.length),
178
- session: beat(ended),
198
+ session: beat(),
179
199
  tenant: tenant(),
180
200
  })
181
201
  }
@@ -199,40 +219,220 @@ function randomId() {
199
219
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
200
220
  }
201
221
 
202
- // Mints the tab's session on its first load, or picks up the one an earlier
203
- // page in this tab minted, and mirrors the id into a cookie so every request
204
- // the tab makes carries it -- that is what lets the server side of the
205
- // session (Railwatch::Sessions) join the browser side under one id.
206
- function startSession() {
222
+ interface Store {
223
+ getItem(key: string): string | null
224
+ setItem(key: string, value: string): void
225
+ }
226
+
227
+ // Where the session lives: localStorage, so every tab of the browser shares
228
+ // one; sessionStorage when localStorage throws (storage disabled, a
229
+ // sandboxed frame), which narrows the session to the tab; memory when both
230
+ // throw, which narrows it to the page. The tab id lives in sessionStorage,
231
+ // which survives a reload of the tab and nothing else, or in memory.
232
+ let sessions: Store = memoryStore()
233
+ let tabs: Store = memoryStore()
234
+
235
+ function storage(kind: "localStorage" | "sessionStorage"): Store | undefined {
236
+ try {
237
+ const store = window[kind]
238
+ store.setItem("railwatch.probe", "1")
239
+ store.removeItem("railwatch.probe")
240
+ return store
241
+ } catch {
242
+ // Disabled, or denied to this frame. The caller tries the next one.
243
+ return undefined
244
+ }
245
+ }
246
+
247
+ function memoryStore(): Store {
248
+ const values = new Map<string, string>()
249
+ return {
250
+ getItem: (key) => values.get(key) ?? null,
251
+ setItem: (key, value) => void values.set(key, value),
252
+ }
253
+ }
254
+
255
+ // A full store throws on write. The session then lasts as long as this
256
+ // page's memory of it, which is all a write could have bought.
257
+ function save(store: Store, key: string, value: string) {
258
+ try {
259
+ store.setItem(key, value)
260
+ } catch {
261
+ // Quota exceeded.
262
+ }
263
+ }
264
+
265
+ // Mirrors the id into a cookie so every request the browser makes carries
266
+ // it, full page loads included -- that is what lets the server side of the
267
+ // session (Railwatch::Sessions) join the browser side under one id. It
268
+ // lapses with the session's idle timeout, so the first page load after one
269
+ // does not carry the dead id; every activity in any tab renews it.
270
+ function remember(id: string) {
207
271
  try {
208
- const existing = sessionStorage.getItem(SESSION_ID_KEY)
209
- const id = existing ?? randomId()
210
- const startedAt =
211
- Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
212
- if (!existing) {
213
- sessionStorage.setItem(SESSION_ID_KEY, id)
214
- sessionStorage.setItem(SESSION_STARTED_KEY, String(startedAt))
215
- }
216
272
  // Railwatch::Sessions::COOKIE reads this name to stitch server requests to the browser session.
217
- document.cookie = `railwatch_session=${id}; path=/; SameSite=Lax`
218
- session = { id, started_at: startedAt }
219
- // No duration on the first beat: that is what opens the session.
220
- if (!existing)
221
- post({
222
- visits: [],
223
- errors: [],
224
- session: { ...session },
225
- tenant: tenant(),
226
- })
273
+ document.cookie = `railwatch_session=${id}; path=/; max-age=${SESSION_TIMEOUT_MS / 1000}; SameSite=Lax`
274
+ } catch {
275
+ // A sandboxed frame has no cookies. Visits still carry the headers.
276
+ }
277
+ }
278
+
279
+ // A store that passed the probe can still throw later: the user turns
280
+ // storage off, or the browser revokes it. From then on the page keeps its
281
+ // own copy, seeded with the session it is in, so losing storage neither
282
+ // stops the client nor ends the session.
283
+ function read(key: string): string | null {
284
+ try {
285
+ return sessions.getItem(key)
286
+ } catch {
287
+ sessions = memoryStore()
288
+ if (session) {
289
+ sessions.setItem(SESSION_ID_KEY, session.id)
290
+ sessions.setItem(SESSION_STARTED_KEY, String(session.started_at))
291
+ sessions.setItem(SESSION_SEEN_KEY, String(session.seen))
292
+ }
293
+ return sessions.getItem(key)
294
+ }
295
+ }
296
+
297
+ function readTab(): string | null {
298
+ try {
299
+ return tabs.getItem(TAB_KEY)
227
300
  } catch {
228
- // sessionStorage is unavailable (private mode, storage disabled).
229
- // Everything else still reports; this tab just has no session.
301
+ tabs = memoryStore()
302
+ return null
230
303
  }
231
304
  }
232
305
 
233
- function beat(ended: boolean): Session | undefined {
306
+ function startSession() {
307
+ sessions =
308
+ storage("localStorage") ?? storage("sessionStorage") ?? memoryStore()
309
+ tabs = storage("sessionStorage") ?? memoryStore()
310
+ tab = readTab() ?? randomId()
311
+ save(tabs, TAB_KEY, tab)
312
+ touch(true)
313
+ }
314
+
315
+ // Brings this tab's session up to date with the browser's, joining the one
316
+ // another tab opened meanwhile. `active` is the user doing something -- the
317
+ // page loading, a visit they wait on, the tab coming back into view -- which
318
+ // keeps the session alive, or opens a new one when the last went 30 minutes
319
+ // without activity in any tab or is 24 hours old. Anything else (a
320
+ // background poll, a prefetch, a flush) only joins a live session, so a tab
321
+ // left polling cannot hold its session open until the cap.
322
+ function touch(active: boolean) {
323
+ const now = Date.now()
324
+ const id = read(SESSION_ID_KEY)
325
+ const seen = Number(read(SESSION_SEEN_KEY)) || 0
326
+ const startedAt = Number(read(SESSION_STARTED_KEY)) || 0
327
+ const live = id !== null && !expired(startedAt, seen, now)
328
+ if (live && id === session?.id) session.seen = Math.max(session.seen, seen)
329
+ else if (live) begin(id, startedAt, seen, false)
330
+ else if (active) begin(minted(id) ?? randomId(), now, now, true)
331
+ if (!active || !session) return
332
+ session.seen = now
333
+ save(sessions, SESSION_SEEN_KEY, String(now))
334
+ remember(session.id)
335
+ }
336
+
337
+ // The id the server put in the cookie for this browser's first page load
338
+ // (Railwatch::Sessions.mint), so the session starts with that request rather
339
+ // than after it. Not the session that just expired, whose id the cookie
340
+ // can still hold.
341
+ function minted(expiredId: string | null): string | undefined {
342
+ try {
343
+ const id = /(?:^|;\s*)railwatch_session=([A-Za-z0-9_-]{1,64})(?:;|$)/.exec(
344
+ document.cookie,
345
+ )?.[1]
346
+ return id && id !== expiredId && id !== session?.id ? id : undefined
347
+ } catch {
348
+ // A sandboxed frame has no cookies.
349
+ return undefined
350
+ }
351
+ }
352
+
353
+ function expired(startedAt: number, seen: number, now: number) {
354
+ return now - seen >= SESSION_TIMEOUT_MS || now - startedAt >= SESSION_CAP_MS
355
+ }
356
+
357
+ // The session this tab is in, while it is live. A background visit after it
358
+ // timed out belongs to no session: it is neither tagged nor recorded as one.
359
+ function liveSession(): string | undefined {
360
+ if (!session || expired(session.started_at, session.seen, Date.now()))
361
+ return undefined
362
+ return session.id
363
+ }
364
+
365
+ // Moves this tab into a session. The one it was in is over -- a session is
366
+ // only replaced once it has timed out or reached the cap -- so this tab
367
+ // says so with a last, `ended` beat. Closing a tab sends no such thing: the
368
+ // browser's other tabs may still be in the session, and nothing a closing
369
+ // tab knows says otherwise. A session no tab comes back to ends where the
370
+ // server half's does, on its own timeout.
371
+ function begin(id: string, startedAt: number, seen: number, minted: boolean) {
372
+ if (session)
373
+ post({
374
+ visits: [],
375
+ errors: [],
376
+ session: {
377
+ id: session.id,
378
+ started_at: session.started_at,
379
+ duration_ms: session.seen - session.started_at,
380
+ ended: true,
381
+ },
382
+ tenant: tenant(),
383
+ })
384
+ session = { id, started_at: startedAt, seen }
385
+ if (!minted) return
386
+ save(sessions, SESSION_ID_KEY, id)
387
+ save(sessions, SESSION_STARTED_KEY, String(startedAt))
388
+ save(sessions, SESSION_SEEN_KEY, String(seen))
389
+ // No duration on the first beat: that is what opens the session.
390
+ post({
391
+ visits: [],
392
+ errors: [],
393
+ session: { id, started_at: startedAt },
394
+ tenant: tenant(),
395
+ })
396
+ }
397
+
398
+ // While the session is live it runs to now, whichever tab is beating it. One
399
+ // this tab has watched time out ran to its last activity, and is `ended`.
400
+ function beat(): Session | undefined {
234
401
  if (!session) return undefined
235
- return { ...session, duration_ms: Date.now() - session.started_at, ended }
402
+ const now = Date.now()
403
+ const ended = expired(session.started_at, session.seen, now)
404
+ return {
405
+ id: session.id,
406
+ started_at: session.started_at,
407
+ duration_ms: (ended ? session.seen : now) - session.started_at,
408
+ ended,
409
+ }
410
+ }
411
+
412
+ // Inertia sends a visit with whatever headers the visit object holds once
413
+ // `before` has fired, so setting them here tags every visit, prefetch, poll
414
+ // and partial reload with the session and the tab -- which is how a request
415
+ // record joins its journey under the tab it came from. Only for the page's
416
+ // own origin: a custom header makes a cross-origin request preflight, and
417
+ // another origin is not this app's server anyway. The headers object is
418
+ // replaced, not written into, because it may be the app's own options. The
419
+ // prefetch cache compares headers; a prefetch and the click it serves are
420
+ // tagged alike, so the prefetch still serves.
421
+ function tag(visit: { url: unknown; headers?: Record<string, string> }) {
422
+ const id = liveSession()
423
+ if (!id) return
424
+ try {
425
+ if (new URL(String(visit.url), location.href).origin !== location.origin)
426
+ return
427
+ } catch {
428
+ // Not a URL Inertia could visit either.
429
+ return
430
+ }
431
+ visit.headers = {
432
+ ...visit.headers,
433
+ [SESSION_HEADER]: id,
434
+ [TAB_HEADER]: tab,
435
+ }
236
436
  }
237
437
 
238
438
  // --- Breadcrumbs -------------------------------------------------------
@@ -349,6 +549,8 @@ function capture(
349
549
  component,
350
550
  url: location.pathname + location.search,
351
551
  visit: current?.url,
552
+ session_id: liveSession(),
553
+ tab_id: tab,
352
554
  breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
353
555
  context,
354
556
  }
@@ -755,6 +957,8 @@ function initialVisit(): Visit | null {
755
957
  duration_ms: duration,
756
958
  status: "success",
757
959
  ...ttfbPhases(nav),
960
+ session_id: liveSession(),
961
+ tab_id: tab,
758
962
  }
759
963
  }
760
964
 
@@ -774,7 +978,7 @@ function finalize() {
774
978
  }
775
979
  // Still flushes on every later hide: a tab can be backgrounded, brought
776
980
  // back, navigated some more, and then closed. Each of those carries a
777
- // final session beat, which the platform dedupes by session id.
981
+ // session beat, which the platform dedupes by session id.
778
982
  flush(true)
779
983
  }
780
984
 
@@ -798,7 +1002,10 @@ export function startRailwatch(options: RailwatchOptions = {}) {
798
1002
  // final there.
799
1003
  router.on("before", (event) => {
800
1004
  const v = event.detail.visit
801
- if (!waiting(v) || event.defaultPrevented) return
1005
+ if (event.defaultPrevented) return
1006
+ touch(waiting(v))
1007
+ tag(v)
1008
+ if (!waiting(v)) return
802
1009
  const request = (foregroundRequest = { id: idOf(v, "id") })
803
1010
  queueMicrotask(() => {
804
1011
  if (event.defaultPrevented && foregroundRequest === request)
@@ -817,6 +1024,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
817
1024
  method: v.method,
818
1025
  partial: Boolean(v.only?.length || v.except?.length),
819
1026
  only: v.only,
1027
+ session_id: liveSession(),
1028
+ tab_id: tab,
820
1029
  }
821
1030
  crumb("navigate", `${v.method.toUpperCase()} ${current.url}`)
822
1031
  })
@@ -847,6 +1056,7 @@ export function startRailwatch(options: RailwatchOptions = {}) {
847
1056
  timer ??= window.setInterval(flush, 5000)
848
1057
  document.addEventListener("visibilitychange", () => {
849
1058
  if (document.visibilityState === "hidden") finalize()
1059
+ else touch(true)
850
1060
  })
851
1061
  window.addEventListener("pagehide", finalize)
852
1062
  }
@@ -86,7 +86,7 @@ module Railwatch
86
86
  :capture_job_arguments, :capture_job_retry_errors, :capture_response_body_on_error, :max_attachment_bytes,
87
87
  :track_sessions, :session_flush_interval, :session_timeout,
88
88
  :capture_console, :interactive_runner_paths, :ignored_request_paths,
89
- :capture_llm_content, :timing_headers
89
+ :capture_llm_content, :timing_headers, :browser_client
90
90
 
91
91
  attr_reader :deploy, :deploy_source, :detect_deploy, :user_resolver, :beacon_user_resolver,
92
92
  :fingerprint_resolver, :redactors, :rejectors, :before_ingest, :backpressure_high_water
@@ -245,6 +245,11 @@ module Railwatch
245
245
  # (#timing_headers?), since elsewhere they tell every visitor how the
246
246
  # app spends its time.
247
247
  @timing_headers = env_bool("RAILWATCH_TIMING_HEADERS", nil)
248
+ # Whether the app's pages run the browser client, which is what makes
249
+ # it safe to hand a first page load a session cookie (#browser_client?).
250
+ # nil: whether lib/railwatch.ts exists in the app's frontend source
251
+ # directory (Railwatch.frontend_dir), where railwatch:install puts it.
252
+ @browser_client = env_bool("RAILWATCH_BROWSER_CLIENT", nil)
248
253
  @user_resolver = nil
249
254
  @beacon_user_resolver = nil
250
255
  @fingerprint_resolver = nil
@@ -376,6 +381,22 @@ module Railwatch
376
381
  @timing_headers.nil? ? defined?(Rails) && Rails.env.development? : @timing_headers
377
382
  end
378
383
 
384
+ # Whether the browser client is installed (#browser_client). Looked for
385
+ # once, on the first request that asks, when Rails.root is known.
386
+ def browser_client?
387
+ return @browser_client unless @browser_client.nil?
388
+
389
+ @browser_client_installed = browser_client_installed? if @browser_client_installed.nil?
390
+ @browser_client_installed
391
+ end
392
+
393
+ def browser_client_installed?
394
+ return false unless defined?(Rails) && Rails.root
395
+
396
+ dir = Railwatch.frontend_dir(Rails.root)
397
+ dir ? Rails.root.join(dir, "lib/railwatch.ts").exist? : false
398
+ end
399
+
379
400
  # Matches Railwatch.reject_cache_keys entries and DEFAULT_VENDOR_CACHE_KEYS
380
401
  # against a cache key. A Regexp is used as-is. A String starting with "^"
381
402
  # or containing another regex metacharacter is compiled as a Regexp; a
@@ -64,6 +64,7 @@ module Railwatch
64
64
  begin
65
65
  status, headers, body = @app.call(env)
66
66
  server_timing(env, exe, headers) if timing && headers
67
+ Sessions.mint(env, status, headers) if headers
67
68
  rescue Exception => e # rubocop:disable Lint/RescueException
68
69
  Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "railwatch.middleware")
69
70
  raise
@@ -202,7 +203,8 @@ module Railwatch
202
203
  payload: payload,
203
204
  queue_time: queue_time(env, exe),
204
205
  user_agent: req.user_agent.to_s[0, 256],
205
- files: env["railwatch.files"] || uploaded_files_fallback(req, env)
206
+ files: env["railwatch.files"] || uploaded_files_fallback(req, env),
207
+ **Sessions.browser_fields(env)
206
208
  }
207
209
  end
208
210
 
@@ -26,6 +26,18 @@ module Railwatch
26
26
  # a tab carries the id its beacons already use.
27
27
  COOKIE = /(?:\A|;\s*)railwatch_session=([^;]+)/
28
28
  KEY_LIMIT = 64
29
+ # What the browser client mints for a session or a tab id. Anything else
30
+ # in a header, cookie, or beacon is not one, and is not recorded as one.
31
+ BROWSER_ID = /\A[A-Za-z0-9_-]{1,64}\z/
32
+ # The id Sessions.mint handed this request, read by key_for and
33
+ # browser_fields as if the browser had sent it.
34
+ MINTED = "railwatch.session_id"
35
+ # The client's idle timeout, which the cookie lapses with (railwatch.ts).
36
+ COOKIE_MAX_AGE = 30 * 60
37
+ HTML = "text/html"
38
+ # A response a shared cache may store and serve to anyone. One visitor's
39
+ # cookie on it would be every visitor's.
40
+ SHARED_CACHE = /\b(?:public|s-maxage)\b/i
29
41
 
30
42
  @mutex = Mutex.new
31
43
  @wakeup = ConditionVariable.new
@@ -78,13 +90,61 @@ module Railwatch
78
90
  end
79
91
 
80
92
  # The browser client's id when the request carries one -- so the two
81
- # halves of the same session share a key -- else the resolved user.
93
+ # halves of the same session share a key -- else the resolved user. A
94
+ # first page load the middleware minted an id for keys on that id, but
95
+ # only where it would have keyed on the user: an anonymous request with
96
+ # no cookie (a crawler, curl) opened no session before and opens none
97
+ # now. Its browser, if it runs the client, opens it with the first beat.
82
98
  def key_for(exe, env)
83
- id = env["HTTP_X_RAILWATCH_SESSION"] || COOKIE.match(env["HTTP_COOKIE"])&.[](1)
99
+ id = sent_id(env)
84
100
  return id[0, KEY_LIMIT] unless id.nil? || id.empty?
85
101
 
86
102
  user = exe.user_id
87
- user && "user:#{user}"[0, KEY_LIMIT]
103
+ return nil unless user
104
+ env[MINTED] || "user:#{user}"[0, KEY_LIMIT]
105
+ end
106
+
107
+ # The browser session and tab a request came from, for the request
108
+ # record: only what the browser client sent, never the user fallback
109
+ # above, which names a person rather than a session.
110
+ def browser_fields(env)
111
+ session = browser_id(sent_id(env) || env[MINTED])
112
+ tab = browser_id(env["HTTP_X_RAILWATCH_TAB"])
113
+ { session_id: session, tab_id: tab }.compact
114
+ end
115
+
116
+ def sent_id(env)
117
+ env["HTTP_X_RAILWATCH_SESSION"] || COOKIE.match(env["HTTP_COOKIE"])&.[](1)
118
+ end
119
+
120
+ # A browser's first page load carries no session cookie: the client sets
121
+ # it once the page runs, after this request is over. Without one the
122
+ # request would key on the user, a session no browser ever beats, and
123
+ # the journey would start a page late. So a page the client will run on
124
+ # -- a successful GET of an HTML document, not an Inertia visit, from an
125
+ # app with the client installed -- that arrives with no id gets one here,
126
+ # in the cookie the client reads and adopts. Never on a response a
127
+ # shared cache may keep (Cache-Control public or s-maxage).
128
+ def mint(env, status, headers)
129
+ return unless Railwatch.config.browser_client?
130
+ return unless env["REQUEST_METHOD"] == "GET" && status.to_i == 200
131
+ return if env["HTTP_X_INERTIA"] || sent_id(env)
132
+ return unless headers[Rack::CONTENT_TYPE].to_s.start_with?(HTML)
133
+ return if SHARED_CACHE.match?(headers[Rack::CACHE_CONTROL].to_s)
134
+
135
+ id = env[MINTED] = SecureRandom.hex(8)
136
+ # Secure over HTTPS (behind a TLS proxy too: ssl? reads
137
+ # X-Forwarded-Proto), so the id never rides a plain-HTTP request.
138
+ Rack::Utils.set_cookie_header!(headers, "railwatch_session",
139
+ { value: id, path: "/", max_age: COOKIE_MAX_AGE, same_site: :lax, secure: Rack::Request.new(env).ssl? })
140
+ rescue StandardError => e
141
+ # A frozen header hash: the client mints the id once the page runs.
142
+ env.delete(MINTED)
143
+ Railwatch.debug { "session cookie skipped: #{e.class}: #{e.message}" }
144
+ end
145
+
146
+ def browser_id(value)
147
+ value if value.is_a?(String) && BROWSER_ID.match?(value)
88
148
  end
89
149
 
90
150
  def emit(key, entry, ended)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "1.4.0"
4
+ VERSION = "1.5.0"
5
5
  end
@@ -57,12 +57,16 @@
57
57
  "headers": {
58
58
  "Host": "www.example.com",
59
59
  "Accept": "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5",
60
+ "X-Railwatch-Session": "3f2a9c1e7b4d6a08",
61
+ "X-Railwatch-Tab": "9e8d7c6b5a4f3e21",
60
62
  "Cookie": "[FILTERED]"
61
63
  },
62
64
  "payload": null,
63
65
  "queue_time": null,
64
66
  "user_agent": "",
65
- "files": []
67
+ "files": [],
68
+ "session_id": "3f2a9c1e7b4d6a08",
69
+ "tab_id": "9e8d7c6b5a4f3e21"
66
70
  },
67
71
  "job_attempt": {
68
72
  "v": 1,
@@ -609,6 +613,8 @@
609
613
  "redirect_count": null,
610
614
  "connect_ms": null,
611
615
  "server_ms": null,
616
+ "session_id": "3f2a9c1e7b4d6a08",
617
+ "tab_id": "9e8d7c6b5a4f3e21",
612
618
  "user": null,
613
619
  "tenant": null,
614
620
  "user_agent": ""
data/lib/railwatch.rb CHANGED
@@ -607,13 +607,28 @@ require "railwatch/subscribers"
607
607
 
608
608
  module Railwatch
609
609
  # The Inertia browser client, relative to the gem root. The install
610
- # generator copies it into a host's app/frontend/lib.
610
+ # generator copies it into a host's frontend source directory, at lib/.
611
611
  BROWSER_CLIENT = "lib/generators/railwatch/install/templates/railwatch.ts"
612
+ # Where a Rails app keeps its Vite sources when ViteRuby does not say:
613
+ # vite_ruby's default, then the jsbundling/rails_vite one.
614
+ FRONTEND_DIRS = %w[app/frontend app/javascript].freeze
612
615
 
613
616
  def self.browser_client_path
614
617
  File.expand_path("../#{BROWSER_CLIENT}", __dir__)
615
618
  end
616
619
 
620
+ # The app's frontend source directory, relative to `root`: ViteRuby's
621
+ # configured sourceCodeDir when ViteRuby is loaded, else the first of
622
+ # FRONTEND_DIRS that exists, else nil. The install generator copies the
623
+ # client into it, and Configuration#browser_client? looks for it there.
624
+ def self.frontend_dir(root)
625
+ root = Pathname(root)
626
+ configured = defined?(ViteRuby) && (ViteRuby.config.source_code_dir rescue nil)
627
+ return configured.to_s if configured.present? && root.join(configured.to_s).directory?
628
+
629
+ FRONTEND_DIRS.find { |dir| root.join(dir).directory? }
630
+ end
631
+
617
632
  # One realistic wire record per record type, exactly as the gem's own
618
633
  # subscribers emit it, generated by spec/railwatch/wire_fixtures_spec.rb.
619
634
  # A receiver tests its mapper against these instead of hand-writing what
@@ -144,21 +144,22 @@ namespace :railwatch do
144
144
  check.call(hook.exist? && hook.read.include?("railwatch"), "kamal post-deploy hook",
145
145
  hook.exist? ? hook.to_s : "not found (only needed when deploying with Kamal)")
146
146
 
147
- client = Rails.root.join("app/frontend/lib/railwatch.ts")
147
+ frontend = Railwatch.frontend_dir(Rails.root) || "app/frontend"
148
+ client = Rails.root.join(frontend, "lib/railwatch.ts")
148
149
  check.call(client.exist?, "browser client",
149
150
  client.exist? ? "#{client} (call startRailwatch() from your Inertia entrypoint)" : "not found (only needed for Inertia visit timing)")
150
151
 
151
152
  # The client file existing is not the same as it running: startRailwatch()
152
153
  # has to be called from an entrypoint or no visit is ever timed.
153
- entrypoints = Dir[Rails.root.join("app/frontend/entrypoints/*")].select { |f| File.file?(f) }
154
+ entrypoints = Dir[Rails.root.join(frontend, "entrypoints/*")].select { |f| File.file?(f) }
154
155
  started = entrypoints.select { |f| File.read(f).include?("startRailwatch") }
155
156
  check.call(started.any?, "browser client imported",
156
157
  if started.any?
157
158
  started.map { |f| Pathname.new(f).relative_path_from(Rails.root).to_s }.join(", ")
158
159
  elsif entrypoints.any?
159
- "no entrypoint in app/frontend/entrypoints calls startRailwatch()"
160
+ "no entrypoint in #{frontend}/entrypoints calls startRailwatch()"
160
161
  else
161
- "no app/frontend/entrypoints (only needed for Inertia visit timing)"
162
+ "no #{frontend}/entrypoints (only needed for Inertia visit timing)"
162
163
  end)
163
164
 
164
165
  backend = Railwatch::Profiler.backend
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.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cole Robertson