railwatch 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/{lib/generators/railwatch/install/templates → app/frontend/lib}/railwatch.ts +162 -42
  4. data/app/models/railwatch/ingest/batch.rb +1 -0
  5. data/app/models/railwatch/ingest/mapper.rb +12 -0
  6. data/docs/configuration.md +8 -3
  7. data/lib/generators/railwatch/install/install_generator.rb +5 -1
  8. data/lib/railwatch/patches/rake_task.rb +10 -1
  9. data/lib/railwatch/patches/runner_command.rb +3 -1
  10. data/lib/railwatch/transport/http.rb +15 -5
  11. data/lib/railwatch/version.rb +1 -1
  12. data/lib/railwatch/wire_fixtures.json +856 -0
  13. data/lib/railwatch.rb +22 -0
  14. data/lib/tasks/railwatch_tasks.rake +7 -0
  15. data/public/railwatch/assets/{app-layout-B5Fcdwfz.js → app-layout-DUk54qUM.js} +1 -1
  16. data/public/railwatch/assets/{app-wordmark-1SRllgHv.js → app-wordmark-DDNuuq5_.js} +1 -1
  17. data/public/railwatch/assets/{appearance-DbwahDrc.js → appearance-C3LMdsDy.js} +1 -1
  18. data/public/railwatch/assets/{arrow-up-DwKQcab4.js → arrow-up-DYTCBENO.js} +1 -1
  19. data/public/railwatch/assets/{auth-layout-DXfxXAN_.js → auth-layout-CzPRawuI.js} +1 -1
  20. data/public/railwatch/assets/{badge-CApjyWcg.js → badge-s7YDycYg.js} +1 -1
  21. data/public/railwatch/assets/{braces-kGSgxWnS.js → braces-BFrg-bvp.js} +1 -1
  22. data/public/railwatch/assets/{card-BaJ8kNkp.js → card-DiW-JhK3.js} +1 -1
  23. data/public/railwatch/assets/{chart-hover-XYPNE2CX.js → chart-hover-DMWx5-Rs.js} +1 -1
  24. data/public/railwatch/assets/{chart-Xnrz1Nqw.js → chart-nsdZV0Dv.js} +1 -1
  25. data/public/railwatch/assets/{chart-panel-BYk68mJJ.js → chart-panel-BlirnMrQ.js} +1 -1
  26. data/public/railwatch/assets/{checkbox-Bf41R8W6.js → checkbox-C2AqEZXX.js} +1 -1
  27. data/public/railwatch/assets/{code-a-9sh36A.js → code-DVJtYP7h.js} +1 -1
  28. data/public/railwatch/assets/{copy-block-zykZWQym.js → copy-block-D1myf7is.js} +1 -1
  29. data/public/railwatch/assets/{copy-id-b6CW5ESL.js → copy-id-CyRCMfdH.js} +1 -1
  30. data/public/railwatch/assets/{cursor-load-more-0cHzbTPT.js → cursor-load-more-JfSVBn9C.js} +1 -1
  31. data/public/railwatch/assets/{data-table-IvA7xvrK.js → data-table-CwtPkw_i.js} +1 -1
  32. data/public/railwatch/assets/{edit-7lmf3k80.js → edit-BvTugIpj.js} +1 -1
  33. data/public/railwatch/assets/{edit-ZKE15r-F.js → edit-DvpDUM-t.js} +1 -1
  34. data/public/railwatch/assets/{edit-DTtRV1fO.js → edit-fB83T9Yh.js} +1 -1
  35. data/public/railwatch/assets/{empty-state-BFTnQ2oT.js → empty-state-CshhuRUU.js} +1 -1
  36. data/public/railwatch/assets/{env-layout-erSDpIXg.js → env-layout-Dkp_4NvM.js} +1 -1
  37. data/public/railwatch/assets/{execution-path-Di5uUHi-.js → execution-path-CdEerUGH.js} +1 -1
  38. data/public/railwatch/assets/{filter-bar-Btm__gQi.js → filter-bar-BMHf8xBb.js} +1 -1
  39. data/public/railwatch/assets/{flamegraph-C-mZdqa7.js → flamegraph-uYWk1Yov.js} +1 -1
  40. data/public/railwatch/assets/{frames-KZBsjg6-.js → frames-B5ZECkvt.js} +1 -1
  41. data/public/railwatch/assets/{google-sign-in-button-DJRoD72g.js → google-sign-in-button-CAvsXYb4.js} +1 -1
  42. data/public/railwatch/assets/{index-CeqMDByC.js → index-1-78ZewP.js} +1 -1
  43. data/public/railwatch/assets/{index-P_e_cI0O.js → index-B-qRsj0W.js} +1 -1
  44. data/public/railwatch/assets/{index-kNhZPLVj.js → index-B4IRsZXK.js} +1 -1
  45. data/public/railwatch/assets/{index-QyHE9WsU.js → index-BIZsdfdM.js} +1 -1
  46. data/public/railwatch/assets/{index-EGU__DOB.js → index-BIom4V2g.js} +1 -1
  47. data/public/railwatch/assets/{index-Bot7kHMA.js → index-BMgA9NNc.js} +1 -1
  48. data/public/railwatch/assets/{index-CX8Br7m9.js → index-BpnfuXi-.js} +1 -1
  49. data/public/railwatch/assets/{index-D4968-TC.js → index-Bw_ByIps.js} +1 -1
  50. data/public/railwatch/assets/{index-CU6yhEyt.js → index-C7bU7oUF.js} +1 -1
  51. data/public/railwatch/assets/{index-Z1c4nFL0.js → index-CAkCcAty.js} +1 -1
  52. data/public/railwatch/assets/{index-B-bSm1jB.js → index-CRShH-fR.js} +1 -1
  53. data/public/railwatch/assets/{index-DBTxQw77.js → index-CZWzIE8F.js} +1 -1
  54. data/public/railwatch/assets/{index-Cj3_jD1E.js → index-CaJvo0Oc.js} +1 -1
  55. data/public/railwatch/assets/{index-HCJmFcZu.js → index-CdM3F3jz.js} +1 -1
  56. data/public/railwatch/assets/{index-DcItJxa5.js → index-ClG89uia.js} +1 -1
  57. data/public/railwatch/assets/{index-CAzQkw-h.js → index-ClJtZh5Q.js} +1 -1
  58. data/public/railwatch/assets/{index-Bjw4sTXU.js → index-Czq2bi1U.js} +1 -1
  59. data/public/railwatch/assets/{index-CP6jrrPT.js → index-D-O1LUPq.js} +1 -1
  60. data/public/railwatch/assets/{index-CYYmZpZu.js → index-DJIXGkfB.js} +1 -1
  61. data/public/railwatch/assets/{index-BAkZNNEh.js → index-DMcsT2ES.js} +1 -1
  62. data/public/railwatch/assets/{index-wOj5j0KC.js → index-DVUwgfqP.js} +1 -1
  63. data/public/railwatch/assets/{index-DAEzO_vq.js → index-DWMKU6mG.js} +1 -1
  64. data/public/railwatch/assets/{index-BWSyQRZI.js → index-DYrF-A96.js} +1 -1
  65. data/public/railwatch/assets/{index-BsrCnQqg.js → index-DcuyMiuG.js} +1 -1
  66. data/public/railwatch/assets/{index-BoTOVM0v.js → index-DpWQMikK.js} +1 -1
  67. data/public/railwatch/assets/{index-CIUl9POV.js → index-DuGGTdIJ.js} +1 -1
  68. data/public/railwatch/assets/{index-uHBVO8Lh.js → index-DzoUj38Q.js} +1 -1
  69. data/public/railwatch/assets/{index-CBJZQgHA.js → index-QxAKxnHK.js} +1 -1
  70. data/public/railwatch/assets/{index-9LL_C3eG.js → index-X13K9BbO.js} +1 -1
  71. data/public/railwatch/assets/{index-DEfu_YvU.js → index-Zf9xXwq7.js} +1 -1
  72. data/public/railwatch/assets/{index-Bo8hrjbs.js → index-dKn1tKSX.js} +1 -1
  73. data/public/railwatch/assets/{index-DCAho97K.js → index-fA7h22qS.js} +1 -1
  74. data/public/railwatch/assets/{index-BtSlLmkg.js → index-iVNm3uzI.js} +1 -1
  75. data/public/railwatch/assets/{index-B0VXzXYG.js → index-n16aviHx.js} +1 -1
  76. data/public/railwatch/assets/{index-DXUwhSCQ.js → index-vC9KU2bt.js} +1 -1
  77. data/public/railwatch/assets/{inertia-Degv_G9N.js → inertia-CUTKXjxg.js} +3 -3
  78. data/public/railwatch/assets/{input-error-C6SqO_iU.js → input-error-C8ycyeD0.js} +1 -1
  79. data/public/railwatch/assets/{json-viewer-DcQIxT86.js → json-viewer-CJECqZ0l.js} +1 -1
  80. data/public/railwatch/assets/{klass-WgQEfWpo.js → klass-Br9dng39.js} +1 -1
  81. data/public/railwatch/assets/{label-B_vK19tR.js → label-B32Ks8n7.js} +1 -1
  82. data/public/railwatch/assets/{layout-C8q7GUlZ.js → layout-5ZhRtywp.js} +1 -1
  83. data/public/railwatch/assets/{live-dot-DaXEbkXo.js → live-dot-Bb1UYT9F.js} +1 -1
  84. data/public/railwatch/assets/{nav-BnD5XArZ.js → nav-XUcwnBso.js} +1 -1
  85. data/public/railwatch/assets/{new-BL9OqXHe.js → new-0xyEF6NL.js} +1 -1
  86. data/public/railwatch/assets/{new-DIjwu2jF.js → new-CV011uLi.js} +1 -1
  87. data/public/railwatch/assets/{new-BqP0NhOF.js → new-CdvlEBjJ.js} +1 -1
  88. data/public/railwatch/assets/{new-BhRnQn4w.js → new-D9-ORiNW.js} +1 -1
  89. data/public/railwatch/assets/{new-uxMnBtoY.js → new-GIkDYLt8.js} +1 -1
  90. data/public/railwatch/assets/{new-CWP3wtwt.js → new-YBWOo9_V.js} +1 -1
  91. data/public/railwatch/assets/{onboarding-C_Y3xTsH.js → onboarding-BqEuP-Ml.js} +1 -1
  92. data/public/railwatch/assets/{origin-identity-CNhOnfBa.js → origin-identity-DrJ4D2TX.js} +1 -1
  93. data/public/railwatch/assets/{percentile-picker-BujVEhP6.js → percentile-picker-_wllqYoJ.js} +1 -1
  94. data/public/railwatch/assets/{relative-time-6CoKr1lN.js → relative-time-CV7V8aSP.js} +1 -1
  95. data/public/railwatch/assets/{release-health-1Qzc_XhT.js → release-health-DRNLZtT7.js} +1 -1
  96. data/public/railwatch/assets/{route-xqgYjOwJ.js → route-D9slUz_a.js} +1 -1
  97. data/public/railwatch/assets/{segmented-CVKq2Xpa.js → segmented-Bj70Htrs.js} +1 -1
  98. data/public/railwatch/assets/{select-CsmSXsJ4.js → select-DJBZX2ky.js} +1 -1
  99. data/public/railwatch/assets/{separator-CrM10Oot.js → separator-UUo0bkyc.js} +1 -1
  100. data/public/railwatch/assets/{series-chart-D33UhdG2.js → series-chart-C1YrNlID.js} +1 -1
  101. data/public/railwatch/assets/{show-zxVxhUel.js → show-BQtEcTeD.js} +1 -1
  102. data/public/railwatch/assets/{show-B0nHy31L.js → show-BT3tKCgP.js} +1 -1
  103. data/public/railwatch/assets/{show-DZwcZZrz.js → show-BoG7jcwk.js} +1 -1
  104. data/public/railwatch/assets/{show-ixK2r2f1.js → show-BpyCRL-I.js} +1 -1
  105. data/public/railwatch/assets/{show-rysBbvt-.js → show-C0uC3gnA.js} +1 -1
  106. data/public/railwatch/assets/{show-B3izvRdU.js → show-COfP8yWt.js} +1 -1
  107. data/public/railwatch/assets/{show-B7NhcWgp.js → show-CefJwag7.js} +1 -1
  108. data/public/railwatch/assets/{show-8H9WAGav.js → show-ChDJZjoj.js} +1 -1
  109. data/public/railwatch/assets/{show-CJMeX75E.js → show-D7IL22Hy.js} +1 -1
  110. data/public/railwatch/assets/{show-DUwQlohF.js → show-D7tP881O.js} +1 -1
  111. data/public/railwatch/assets/{show-BM-E2iYc.js → show-DR6u4CR7.js} +1 -1
  112. data/public/railwatch/assets/{show-CMisHNAI.js → show-DnBCvlnd.js} +1 -1
  113. data/public/railwatch/assets/{show-BlNR3ZOZ.js → show-DoCg0ifP.js} +1 -1
  114. data/public/railwatch/assets/{show-9jDeBNpc.js → show-GruxlCov.js} +1 -1
  115. data/public/railwatch/assets/{show-CvNV23gH.js → show-Pu3Iehw5.js} +1 -1
  116. data/public/railwatch/assets/{show-DMZlwzL_.js → show-TTCk2Met.js} +1 -1
  117. data/public/railwatch/assets/{show-CQ4Ze-Un.js → show-yQipmite.js} +1 -1
  118. data/public/railwatch/assets/{sort-header-CKj_7G3U.js → sort-header-CqQQhaxg.js} +1 -1
  119. data/public/railwatch/assets/{sparkline-cell-Ckg29yVq.js → sparkline-cell-BwdBz0yv.js} +1 -1
  120. data/public/railwatch/assets/{stat-TxvoBiC8.js → stat-IghK3WjD.js} +1 -1
  121. data/public/railwatch/assets/{status-badge-5NCVJHjo.js → status-badge-XYUN9gUA.js} +1 -1
  122. data/public/railwatch/assets/{tenant-path-B5ZRK4x3.js → tenant-path-SxnZbfU1.js} +1 -1
  123. data/public/railwatch/assets/{text-link-BPllRj0F.js → text-link-NCL9N5D5.js} +1 -1
  124. data/public/railwatch/assets/{textarea-DtD5yZp_.js → textarea-DhI1dmQa.js} +1 -1
  125. data/public/railwatch/assets/{timeline-DZz5ZnHN.js → timeline-CLAwsAlo.js} +1 -1
  126. data/public/railwatch/assets/{transition-Ds6Cakuc.js → transition-DpTb9Yiv.js} +1 -1
  127. data/public/railwatch/assets/{use-clipboard-BLjjYH8V.js → use-clipboard-D5i6e5kf.js} +1 -1
  128. data/public/railwatch/assets/{use-live-GKze1mNC.js → use-live-V-slSdFJ.js} +1 -1
  129. data/public/railwatch/manifest.json +1287 -1287
  130. metadata +117 -116
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7670085bcbae37c58def37c8e23ffee964be9277a57462869346017016c33a3c
4
- data.tar.gz: fb68d72c0863778eb607cfd2bded53714651caaecffefbff20994e25422a1411
3
+ metadata.gz: e3cbe26df2ef93b1488b1cfb67d2cb15bb9c2930f63607651af4e5a9d4f526cb
4
+ data.tar.gz: b62edbbb492f16ef449e4bc7ccc57ad90206f67c0e538c24232b30d931290339
5
5
  SHA512:
6
- metadata.gz: 429bb5789a56059dce9848403622511df0f5094c2efe4a6ed8c51e5086fb11eaf56b8c762ee9ae68d1ae393a5fae7af65d628df019f01b00466644e207aa6b1d
7
- data.tar.gz: 0c38280c362f3529ca2af1e93a6982d77ed15a872351072727dd1152396ea2c3f1a2e634f1132a6cac83bf0ee152f818fda15db76e2251a3f0f1a7a24db5bfb1
6
+ metadata.gz: dc2578bf58e017c5f311d9642c9b51f52c7c203a84328d8bbe7845568c0836afe5bc7cbec5327aa286cd9e4bb378e930111cedce72b27337f2608e36a6305484
7
+ data.tar.gz: 2e4f1467282564b4ef5d6d3f1446acb80f6cbfdf116588df899bb5a0cb3172330b817282d7d797d29d0cced2b7ab6aa1688d6fa266324d1634fcdafbb3cfe0df
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-09-21)
4
+
5
+ The gem is the one author of what goes over the wire and what runs in the
6
+ browser. Anything that receives from it, Railwatch Cloud first, reads these
7
+ from the gem instead of keeping a copy.
8
+
9
+ - **One browser client.** The gem carried two: the install template wrote
10
+ the `railwatch_session` cookie that `Railwatch::Sessions` reads, while the
11
+ dashboard's own copy still wrote `lantern_session`, so every embedded
12
+ dashboard shipped a beacon whose sessions never stitched to their server
13
+ requests. There is one file now, `app/frontend/lib/railwatch.ts`, shipped
14
+ in the gem (`Railwatch.browser_client_path`): the dashboard bundle is built
15
+ from it, `railwatch:install` copies it, and a host with its own frontend
16
+ build imports it. A spec holds the cookie name to the regex.
17
+ - **Shipped wire fixtures.** `lib/railwatch/wire_fixtures.json` holds one
18
+ record per type in `Railwatch::Record::VERSIONS`, produced by the gem's own
19
+ subscribers and middleware, stabilized, and checked in.
20
+ `Railwatch.wire_fixtures` loads it. A receiver tests its mapper against
21
+ these rather than hand-writing what it remembers the gem sending. The spec
22
+ that generates them fails when the file is stale; `rake
23
+ railwatch:wire_fixtures` regenerates it, and that diff is the review
24
+ surface for a wire change.
25
+ - **Record versions are enforced.** Every record has carried a `v` since the
26
+ first release and no receiver read it. `Railwatch::Ingest::Mapper` now
27
+ refuses a record whose version is not the one this gem emits for its type,
28
+ by name (`log v2 is not v1`), on both the trusted embedded path and the
29
+ untrusted one. A shape change without a version bump is caught by the
30
+ fixture spec; a version bump the receiver has not shipped is refused
31
+ instead of misread.
32
+ - `Railwatch::Transport::Http::HEADERS` names the delivery headers in one
33
+ place for the receiver to read.
34
+
35
+ ## 0.3.7 (2026-09-20)
36
+
37
+ - Stop a rake task or `rails runner` waiting on the network as it finishes.
38
+ The command patches called `Railwatch.flush` when the execution closed --
39
+ on the application's own thread, with no time limit. The engine's `at_exit`
40
+ already calls `Reporter#shutdown`, which wakes the reporter thread and joins
41
+ it for `shutdown_timeout`, so the same records were already being delivered,
42
+ already bounded, and a failed task's exception already went with them. The
43
+ flush was a second, unbounded way to do it.
44
+
45
+ Against a receiver that accepts connections and never answers, that second
46
+ way cost a full timeout ladder per process -- measured at ~6s, and ~12s when
47
+ it queued behind a delivery the reporter thread was already stuck in. An app
48
+ whose container entrypoint boots Rails eleven times before starting Puma
49
+ spent that on every boot and lost the deploy to its proxy's timeout. With
50
+ the call removed the task returns immediately and the process still leaves
51
+ within `shutdown_timeout`, carrying the same records.
52
+
53
+ Only short-lived processes were affected: web and worker processes flush on
54
+ the reporter's own thread and never blocked. `Railwatch.flush` is unchanged
55
+ and still public, for a caller who wants to wait on purpose.
56
+
57
+ - Add a process-level regression that runs a failing rake task against a
58
+ socket that accepts and never answers, and asserts the task returns without
59
+ waiting on it and the process exits inside the shutdown bound. In-process
60
+ examples cannot see this: the cost lives in a socket read and the bound in
61
+ an `at_exit` join.
62
+
3
63
  ## 0.3.6 (2026-09-20)
4
64
 
5
65
  - Harden embedded live-stream authorization and telemetry delivery. Bound
@@ -77,7 +77,11 @@ const DEFAULT_IGNORE_ERRORS = [
77
77
  "ResizeObserver loop limit exceeded",
78
78
  "ResizeObserver loop completed with undelivered notifications",
79
79
  ]
80
- const DEFAULT_DENY_URLS = [/extensions\//i, /^chrome:\/\//i, /^moz-extension:\/\//i]
80
+ const DEFAULT_DENY_URLS = [
81
+ /extensions\//i,
82
+ /^chrome:\/\//i,
83
+ /^moz-extension:\/\//i,
84
+ ]
81
85
 
82
86
  const SESSION_ID_KEY = "railwatch.session"
83
87
  const SESSION_STARTED_KEY = "railwatch.session.at"
@@ -128,10 +132,18 @@ function endpoint() {
128
132
  }
129
133
 
130
134
  function csrf() {
131
- return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? ""
135
+ return (
136
+ document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
137
+ ?.content ?? ""
138
+ )
132
139
  }
133
140
 
134
- function post(payload: { visits: Visit[]; errors: JsError[]; session?: Session; tenant?: string }) {
141
+ function post(payload: {
142
+ visits: Visit[]
143
+ errors: JsError[]
144
+ session?: Session
145
+ tenant?: string
146
+ }) {
135
147
  const body = JSON.stringify(payload)
136
148
  const blob = new Blob([body], { type: "application/json" })
137
149
  if (navigator.sendBeacon?.(endpoint(), blob)) return
@@ -145,7 +157,12 @@ function post(payload: { visits: Visit[]; errors: JsError[]; session?: Session;
145
157
 
146
158
  function flush(ended = false) {
147
159
  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() })
160
+ post({
161
+ visits: queue.splice(0, queue.length),
162
+ errors: errors.splice(0, errors.length),
163
+ session: beat(ended),
164
+ tenant: tenant(),
165
+ })
149
166
  }
150
167
 
151
168
  // The app's tenant resolver runs on the flush path, where a throw would cost
@@ -175,15 +192,23 @@ function startSession() {
175
192
  try {
176
193
  const existing = sessionStorage.getItem(SESSION_ID_KEY)
177
194
  const id = existing ?? randomId()
178
- const startedAt = Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
195
+ const startedAt =
196
+ Number(sessionStorage.getItem(SESSION_STARTED_KEY)) || Date.now()
179
197
  if (!existing) {
180
198
  sessionStorage.setItem(SESSION_ID_KEY, id)
181
199
  sessionStorage.setItem(SESSION_STARTED_KEY, String(startedAt))
182
200
  }
201
+ // Railwatch::Sessions::COOKIE reads this name to stitch server requests to the browser session.
183
202
  document.cookie = `railwatch_session=${id}; path=/; SameSite=Lax`
184
203
  session = { id, started_at: startedAt }
185
204
  // No duration on the first beat: that is what opens the session.
186
- if (!existing) post({ visits: [], errors: [], session: { ...session }, tenant: tenant() })
205
+ if (!existing)
206
+ post({
207
+ visits: [],
208
+ errors: [],
209
+ session: { ...session },
210
+ tenant: tenant(),
211
+ })
187
212
  } catch {
188
213
  // sessionStorage is unavailable (private mode, storage disabled).
189
214
  // Everything else still reports; this tab just has no session.
@@ -213,7 +238,8 @@ function crumb(kind: Crumb["kind"], text: string) {
213
238
  // oldest entries until it fits the byte budget.
214
239
  function trail(): Crumb[] {
215
240
  const taken = crumbs.slice()
216
- while (taken.length > 0 && JSON.stringify(taken).length > MAX_CRUMB_BYTES) taken.shift()
241
+ while (taken.length > 0 && JSON.stringify(taken).length > MAX_CRUMB_BYTES)
242
+ taken.shift()
217
243
  return taken
218
244
  }
219
245
 
@@ -223,16 +249,26 @@ function trail(): Crumb[] {
223
249
  function describeTarget(target: EventTarget | null): string {
224
250
  if (!(target instanceof Element)) return ""
225
251
  const id = target.id ? `#${target.id}` : ""
226
- const className = typeof target.className === "string" ? target.className.trim() : ""
252
+ const className =
253
+ typeof target.className === "string" ? target.className.trim() : ""
227
254
  const classes = className ? `.${className.split(/\s+/).join(".")}` : ""
228
- const text = target instanceof HTMLInputElement ? "" : (target.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80)
255
+ const text =
256
+ target instanceof HTMLInputElement
257
+ ? ""
258
+ : (target.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80)
229
259
  return `${target.tagName.toLowerCase()}${id}${classes}${text ? ` "${text}"` : ""}`
230
260
  }
231
261
 
232
262
  function startBreadcrumbs() {
233
- document.addEventListener("click", (event) => crumb("click", describeTarget(event.target)), true)
263
+ document.addEventListener(
264
+ "click",
265
+ (event) => crumb("click", describeTarget(event.target)),
266
+ true,
267
+ )
234
268
  for (const level of ["error", "warn"] as const) {
235
- const original = console[level].bind(console) as (...args: unknown[]) => void
269
+ const original = console[level].bind(console) as (
270
+ ...args: unknown[]
271
+ ) => void
236
272
  console[level] = (...args: unknown[]) => {
237
273
  crumb("console", `${level}: ${args.map(stringify).join(" ")}`)
238
274
  original(...args)
@@ -267,14 +303,26 @@ function topFrameUrl(stack: string): string | undefined {
267
303
  // the app's own code at all -- an extension, an injected widget, a tag
268
304
  // manager. None of those are anything this app can fix.
269
305
  function ignored(message: string, stack?: string): boolean {
270
- if (ignoreErrors.some((pattern) => (typeof pattern === "string" ? message.includes(pattern) : pattern.test(message)))) return true
306
+ if (
307
+ ignoreErrors.some((pattern) =>
308
+ typeof pattern === "string"
309
+ ? message.includes(pattern)
310
+ : pattern.test(message),
311
+ )
312
+ )
313
+ return true
271
314
  const url = stack ? topFrameUrl(stack) : undefined
272
315
  if (!url) return false
273
316
  if (denyUrls.some((pattern) => pattern.test(url))) return true
274
317
  return !url.startsWith("/") && !url.startsWith(`${location.origin}/`)
275
318
  }
276
319
 
277
- function capture(name: string, message: string, stack?: string, context?: Record<string, unknown>) {
320
+ function capture(
321
+ name: string,
322
+ message: string,
323
+ stack?: string,
324
+ context?: Record<string, unknown>,
325
+ ) {
278
326
  if (errors.length >= MAX_ERRORS) return
279
327
  if (ignored(message, stack)) return
280
328
  const breadcrumbs = trail()
@@ -292,15 +340,28 @@ function capture(name: string, message: string, stack?: string, context?: Record
292
340
  // Deduped within the flush, not across the page's life: a render loop
293
341
  // throws the same error every retry. (Inertia's re-rejection of a failed
294
342
  // 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
343
+ if (
344
+ errors.some(
345
+ (e) =>
346
+ e.name === error.name &&
347
+ e.message === error.message &&
348
+ e.stack === error.stack,
349
+ )
350
+ )
351
+ return
296
352
  errors.push(error)
297
353
  }
298
354
 
299
355
  // Anything at all can be thrown or rejected in JavaScript, not just an
300
356
  // Error. A non-Error value is reported under `fallback` with whatever it
301
357
  // 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)
358
+ function captureValue(
359
+ value: unknown,
360
+ fallback: string,
361
+ context?: Record<string, unknown>,
362
+ ) {
363
+ if (value instanceof Error)
364
+ capture(value.name, value.message, value.stack, context)
304
365
  else capture(fallback, stringify(value), undefined, context)
305
366
  }
306
367
 
@@ -336,7 +397,11 @@ export function railwatchRootOptions(): {
336
397
  onUncaughtError: (error: unknown, info: ReactErrorInfo) => void
337
398
  } {
338
399
  const report = (error: unknown, info: ReactErrorInfo) =>
339
- captureValue(error, "Error", info.componentStack ? { componentStack: info.componentStack } : undefined)
400
+ captureValue(
401
+ error,
402
+ "Error",
403
+ info.componentStack ? { componentStack: info.componentStack } : undefined,
404
+ )
340
405
  return { onCaughtError: report, onUncaughtError: report }
341
406
  }
342
407
 
@@ -346,7 +411,12 @@ export function railwatchRootOptions(): {
346
411
  function stringify(value: unknown) {
347
412
  try {
348
413
  if (value instanceof Error) return `${value.name}: ${value.message}`
349
- if (Array.isArray(value) || (typeof value === "object" && value !== null && value.toString === Object.prototype.toString)) {
414
+ if (
415
+ Array.isArray(value) ||
416
+ (typeof value === "object" &&
417
+ value !== null &&
418
+ value.toString === Object.prototype.toString)
419
+ ) {
350
420
  return JSON.stringify(value).slice(0, MAX_CRUMB_TEXT)
351
421
  }
352
422
  return String(value)
@@ -376,13 +446,20 @@ function startErrorCapture() {
376
446
  })
377
447
  window.addEventListener("unhandledrejection", (event) => {
378
448
  const reason = event.reason as unknown
379
- if (typeof reason === "object" && reason !== null && requestFailures.has(reason)) return
449
+ if (
450
+ typeof reason === "object" &&
451
+ reason !== null &&
452
+ requestFailures.has(reason)
453
+ )
454
+ return
380
455
  captureValue(reason, "UnhandledRejection")
381
456
  })
382
457
  onInertia("exception", (detail) => captureRequestFailure(detail.exception))
383
458
  onInertia("networkError", (detail) => captureRequestFailure(detail.error))
384
459
  onInertia("invalid", (detail) => captureInvalidResponse(detail.response))
385
- onInertia("httpException", (detail) => captureInvalidResponse(detail.response))
460
+ onInertia("httpException", (detail) =>
461
+ captureInvalidResponse(detail.response),
462
+ )
386
463
  }
387
464
 
388
465
  // A visit the user is waiting on: one that shows the progress bar, or the
@@ -402,12 +479,16 @@ function waiting(visit: object): boolean {
402
479
  // the next waiting `before`, a waiting `start`, or a reported failure,
403
480
  // which can only over-report.
404
481
  function idOf(record: object, key: string): string | undefined {
405
- const value = key in record ? (record as Record<string, unknown>)[key] : undefined
482
+ const value =
483
+ key in record ? (record as Record<string, unknown>)[key] : undefined
406
484
  return typeof value === "string" ? value : undefined
407
485
  }
408
486
 
409
487
  function landed(detail: object) {
410
- if (foregroundRequest?.id !== undefined && idOf(detail, "visitId") === foregroundRequest.id) {
488
+ if (
489
+ foregroundRequest?.id !== undefined &&
490
+ idOf(detail, "visitId") === foregroundRequest.id
491
+ ) {
411
492
  foregroundRequest = null
412
493
  }
413
494
  }
@@ -424,10 +505,17 @@ function captureRequestFailure(error: unknown) {
424
505
  captureValue(error, "InertiaException")
425
506
  }
426
507
 
427
- function onInertia(name: string, handler: (detail: Record<string, unknown>) => void) {
508
+ function onInertia(
509
+ name: string,
510
+ handler: (detail: Record<string, unknown>) => void,
511
+ ) {
428
512
  document.addEventListener(`inertia:${name}`, (event) => {
429
513
  const detail = (event as CustomEvent<unknown>).detail
430
- handler(typeof detail === "object" && detail !== null ? (detail as Record<string, unknown>) : {})
514
+ handler(
515
+ typeof detail === "object" && detail !== null
516
+ ? (detail as Record<string, unknown>)
517
+ : {},
518
+ )
431
519
  })
432
520
  }
433
521
 
@@ -439,19 +527,31 @@ function onInertia(name: string, handler: (detail: Record<string, unknown>) => v
439
527
  // predicate for the two cases is the x-inertia response header, so that is
440
528
  // the gate here; Inertia 2's `invalid` only ever fired for the first kind.
441
529
  function captureInvalidResponse(response: unknown) {
442
- const res = (response ?? {}) as { status?: unknown; headers?: Record<string, unknown> }
530
+ const res = (response ?? {}) as {
531
+ status?: unknown
532
+ headers?: Record<string, unknown>
533
+ }
443
534
  if (inertiaResponse(res.headers)) return
444
535
  const status = typeof res.status === "number" ? res.status : "unknown status"
445
536
  const contentType = res.headers?.["content-type"]
446
- capture("InertiaInvalidResponse", `Inertia invalid response (${status})`, undefined, {
447
- status,
448
- ...(typeof contentType === "string" ? { content_type: contentType } : {}),
449
- })
537
+ capture(
538
+ "InertiaInvalidResponse",
539
+ `Inertia invalid response (${status})`,
540
+ undefined,
541
+ {
542
+ status,
543
+ ...(typeof contentType === "string" ? { content_type: contentType } : {}),
544
+ },
545
+ )
450
546
  }
451
547
 
452
- function inertiaResponse(headers: Record<string, unknown> | undefined): boolean {
548
+ function inertiaResponse(
549
+ headers: Record<string, unknown> | undefined,
550
+ ): boolean {
453
551
  if (!headers || typeof headers !== "object") return false
454
- return Object.keys(headers).some((key) => key.toLowerCase() === "x-inertia" && headers[key])
552
+ return Object.keys(headers).some(
553
+ (key) => key.toLowerCase() === "x-inertia" && headers[key],
554
+ )
455
555
  }
456
556
 
457
557
  // --- Core Web Vitals ---------------------------------------------------
@@ -472,10 +572,16 @@ interface ObserveOptions {
472
572
  durationThreshold?: number
473
573
  }
474
574
 
475
- function observe(type: string, callback: (entries: VitalEntry[]) => void, options: ObserveOptions = {}) {
575
+ function observe(
576
+ type: string,
577
+ callback: (entries: VitalEntry[]) => void,
578
+ options: ObserveOptions = {},
579
+ ) {
476
580
  if (typeof PerformanceObserver === "undefined") return
477
581
  try {
478
- const observer = new PerformanceObserver((list) => callback(list.getEntries()))
582
+ const observer = new PerformanceObserver((list) =>
583
+ callback(list.getEntries()),
584
+ )
479
585
  observer.observe({ type, buffered: true, ...options })
480
586
  } catch {
481
587
  // This browser does not support this entry type. Skip that metric only.
@@ -487,9 +593,12 @@ function observe(type: string, callback: (entries: VitalEntry[]) => void, option
487
593
  // this file is imported into) does not define, and a ReferenceError here
488
594
  // would take the whole client down with it.
489
595
  function navigationEntry(): PerformanceNavigationTiming | undefined {
490
- const entries: PerformanceEntry[] = performance.getEntriesByType?.("navigation") ?? []
596
+ const entries: PerformanceEntry[] =
597
+ performance.getEntriesByType?.("navigation") ?? []
491
598
  const nav = entries[0]
492
- return nav?.entryType === "navigation" ? (nav as PerformanceNavigationTiming) : undefined
599
+ return nav?.entryType === "navigation"
600
+ ? (nav as PerformanceNavigationTiming)
601
+ : undefined
493
602
  }
494
603
 
495
604
  function startVitals() {
@@ -511,7 +620,11 @@ function startVitals() {
511
620
  for (const entry of entries) {
512
621
  if (entry.hadRecentInput) continue
513
622
  const value = entry.value ?? 0
514
- if (sessionValue && entry.startTime - sessionLast < 1000 && entry.startTime - sessionFirst < 5000) {
623
+ if (
624
+ sessionValue &&
625
+ entry.startTime - sessionLast < 1000 &&
626
+ entry.startTime - sessionFirst < 5000
627
+ ) {
515
628
  sessionValue += value
516
629
  sessionLast = entry.startTime
517
630
  } else {
@@ -541,7 +654,9 @@ function startVitals() {
541
654
  // serialized page object. Inertia's own events take over from here.
542
655
  function pageComponent(): string | undefined {
543
656
  try {
544
- const page = JSON.parse(document.getElementById("app")?.dataset.page ?? "{}") as { component?: string }
657
+ const page = JSON.parse(
658
+ document.getElementById("app")?.dataset.page ?? "{}",
659
+ ) as { component?: string }
545
660
  return page.component
546
661
  } catch {
547
662
  // Not an Inertia-rendered page, or the payload moved. Report it anyway.
@@ -557,7 +672,10 @@ function initialVisit(): Visit | null {
557
672
 
558
673
  const duration = (nav.loadEventEnd || nav.responseEnd) - nav.startTime
559
674
  return {
560
- started_at: Math.round((performance.timeOrigin ?? Date.now() - performance.now()) + nav.startTime),
675
+ started_at: Math.round(
676
+ (performance.timeOrigin ?? Date.now() - performance.now()) +
677
+ nav.startTime,
678
+ ),
561
679
  url: location.pathname + location.search,
562
680
  method: "GET",
563
681
  component,
@@ -587,8 +705,8 @@ function finalize() {
587
705
  }
588
706
 
589
707
  export function startRailwatch(options: RailwatchOptions = {}) {
590
- ignoreErrors = [ ...DEFAULT_IGNORE_ERRORS, ...(options.ignoreErrors ?? []) ]
591
- denyUrls = [ ...DEFAULT_DENY_URLS, ...(options.denyUrls ?? []) ]
708
+ ignoreErrors = [...DEFAULT_IGNORE_ERRORS, ...(options.ignoreErrors ?? [])]
709
+ denyUrls = [...DEFAULT_DENY_URLS, ...(options.denyUrls ?? [])]
592
710
  tenantOf = options.tenant
593
711
  startVitals()
594
712
  startSession()
@@ -609,7 +727,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
609
727
  if (!waiting(v) || event.defaultPrevented) return
610
728
  const request = (foregroundRequest = { id: idOf(v, "id") })
611
729
  queueMicrotask(() => {
612
- if (event.defaultPrevented && foregroundRequest === request) foregroundRequest = null
730
+ if (event.defaultPrevented && foregroundRequest === request)
731
+ foregroundRequest = null
613
732
  })
614
733
  })
615
734
  router.on("start", (event) => {
@@ -642,7 +761,8 @@ export function startRailwatch(options: RailwatchOptions = {}) {
642
761
  router.on("finish", (event) => {
643
762
  // Fires once whether the visit completed, failed, or was cancelled.
644
763
  // Clamped so a missed start can only over-report, never silence.
645
- if (waiting(event.detail.visit)) foregroundVisits = Math.max(0, foregroundVisits - 1)
764
+ if (waiting(event.detail.visit))
765
+ foregroundVisits = Math.max(0, foregroundVisits - 1)
646
766
  if (!current) return
647
767
  current.duration_ms = Date.now() - current.started_at
648
768
  current.status ??= "cancelled"
@@ -182,6 +182,7 @@ module Railwatch
182
182
  # batch does not need.
183
183
  raise TypeError, "record must be an object" unless rec.is_a?(Hash)
184
184
  raise TypeError, "t must be a string" unless rec["t"].is_a?(String)
185
+ Ingest::Mapper.validate_version!(rec)
185
186
  else
186
187
  Ingest::Mapper.validate_record!(rec)
187
188
  end
@@ -111,6 +111,7 @@ module Railwatch
111
111
  def validate_record!(rec, identifiers: true)
112
112
  raise TypeError, "record must be an object" unless rec.is_a?(Hash)
113
113
  raise TypeError, "t must be a string" unless rec["t"].is_a?(String)
114
+ validate_version!(rec)
114
115
 
115
116
  if identifiers
116
117
  validate_identifier!(rec, "_group", GROUP_HASH, "32 hexadecimal characters")
@@ -129,6 +130,17 @@ module Railwatch
129
130
  validate_locals!(rec["locals"])
130
131
  end
131
132
 
133
+ # A record shape is named by its type and version (Railwatch::Record::VERSIONS).
134
+ # This mapper only knows the versions the gem it ships with emits; a
135
+ # record from a newer or older gem is refused by name rather than read
136
+ # through columns that may no longer mean the same thing.
137
+ def validate_version!(rec)
138
+ expected = Record::VERSIONS[rec["t"].to_sym]
139
+ return if expected.nil? # unknown type: row_for answers nil and the batch rejects it as such
140
+
141
+ raise TypeError, "#{rec["t"]} v#{rec["v"].inspect} is not v#{expected}" unless rec["v"].is_a?(Integer) && rec["v"] == expected
142
+ end
143
+
132
144
  def validate_identifier!(rec, field, pattern, description)
133
145
  value = rec[field]
134
146
  return if value.nil?
@@ -431,9 +431,14 @@ affects one probabilistic decision. Set `backpressure` false to keep the
431
431
  factor at 1. The current value is sent as
432
432
  `X-Railwatch-Backpressure-Factor` whenever it is greater than 1.
433
433
 
434
- `Railwatch.flush` forces an immediate flush. The `command` patches also
435
- call it after a rake task/runner invocation finishes, so short-lived
436
- processes don't lose their last batch to the flush interval. An unhandled
434
+ `Railwatch.flush` forces an immediate flush, on the calling thread and
435
+ without a time limit, so it is yours to call when you know you want to
436
+ wait. Nothing in the gem calls it for you: a short-lived process keeps its
437
+ last batch because the engine's `at_exit` runs `Reporter#shutdown`, which
438
+ wakes the reporter thread and joins it for `shutdown_timeout`. That is a
439
+ bounded wait, which is what a rake task, a runner or a cron job needs --
440
+ a receiver that accepts connections and never answers then costs
441
+ `shutdown_timeout`, not a timeout ladder per process. An unhandled
437
442
  exception goes through `Railwatch.record_now` → `Reporter#write_now`,
438
443
  which enqueues the record and asks for an urgent flush. It never performs
439
444
  network I/O or a timeout cycle on the application thread. Urgent means
@@ -119,9 +119,13 @@ module Railwatch
119
119
  chmod ".kamal/hooks/post-deploy", 0o755
120
120
  end
121
121
 
122
+ # The one browser client, the same file the gem's own dashboard bundle
123
+ # is built from (Railwatch.browser_client_path). Copied rather than
124
+ # templated: there is nothing to interpolate, and a copy means the host
125
+ # sees exactly what the gem tests.
122
126
  def create_browser_client
123
127
  return unless File.directory?("app/frontend")
124
- template "railwatch.ts", "app/frontend/lib/railwatch.ts"
128
+ copy_file Railwatch.browser_client_path.to_s, "app/frontend/lib/railwatch.ts"
125
129
  end
126
130
 
127
131
  # Adds the import and the call to the Inertia entrypoint, so page-visit
@@ -72,7 +72,16 @@ module Railwatch
72
72
  name: name,
73
73
  command: "rake #{name}#{command_suffix}",
74
74
  exit_code: exit_code.to_i.clamp(0, 255))
75
- Railwatch.flush
75
+ # Deliberately no flush. The engine's at_exit calls Reporter#shutdown,
76
+ # which wakes the reporter thread and joins it for shutdown_timeout --
77
+ # already a bounded last delivery, and already the one that ships a
78
+ # failed task's exception. Flushing here as well delivered the same
79
+ # records a second way, on the application's own thread, with no
80
+ # bound: against a receiver that accepts connections and never
81
+ # answers it cost a full timeout ladder per process, measured at ~6s,
82
+ # and ~12s when it queued behind a delivery the reporter thread was
83
+ # already stuck in. An app whose entrypoint boots Rails eleven times
84
+ # spent that eleven times over and lost the deploy.
76
85
  end
77
86
  end
78
87
 
@@ -91,7 +91,9 @@ module Railwatch
91
91
  }
92
92
  fields[:interactive] = true if interactive
93
93
  Railwatch.finish_execution(:command, **fields)
94
- Railwatch.flush
94
+ # No flush here either; see the note in patches/rake_task.rb. The
95
+ # at_exit shutdown is the bounded delivery, and it runs for a runner
96
+ # exactly as it does for a task.
95
97
  end
96
98
  end
97
99
 
@@ -13,6 +13,16 @@ module Railwatch
13
13
  # Reporter owns retention and backoff between calls. A 401 marks the
14
14
  # transport unauthorized so no further requests are made.
15
15
  class Http
16
+ # The request headers a delivery carries besides the body. The receiver
17
+ # reads them by these names; Railwatch Cloud does so through this table.
18
+ HEADERS = {
19
+ dropped: "X-Railwatch-Dropped",
20
+ dropped_bytes: "X-Railwatch-Dropped-Bytes",
21
+ backpressure_factor: "X-Railwatch-Backpressure-Factor",
22
+ version: "X-Railwatch-Version",
23
+ batch_id: "X-Railwatch-Batch-Id"
24
+ }.freeze
25
+
16
26
  RETRYABLE_STATUSES = [ 402, 408, 429 ].freeze
17
27
  UNAUTHORIZED_STATUS = 401
18
28
 
@@ -143,17 +153,17 @@ module Railwatch
143
153
  headers.each { |name, value| req[name] = value.to_s }
144
154
  req["Content-Type"] = "application/x-ndjson"
145
155
  req["Content-Encoding"] = "gzip"
146
- req["X-Railwatch-Dropped"] = dropped.to_s if dropped.positive?
147
- req["X-Railwatch-Dropped-Bytes"] = dropped_bytes.to_s if dropped_bytes.positive?
156
+ req[HEADERS[:dropped]] = dropped.to_s if dropped.positive?
157
+ req[HEADERS[:dropped_bytes]] = dropped_bytes.to_s if dropped_bytes.positive?
148
158
  if backpressure_factor > 1.0
149
- req["X-Railwatch-Backpressure-Factor"] = backpressure_factor.to_s
159
+ req[HEADERS[:backpressure_factor]] = backpressure_factor.to_s
150
160
  end
151
161
  # The version this payload was built by, which for a stored delivery is
152
162
  # not the version running now. The receiver digests this header, so
153
163
  # sending today's value would turn an upgrade into a conflict and
154
164
  # destroy a delivery it had already accepted.
155
- req["X-Railwatch-Version"] = gem_version
156
- req["X-Railwatch-Batch-Id"] = batch_id
165
+ req[HEADERS[:version]] = gem_version
166
+ req[HEADERS[:batch_id]] = batch_id
157
167
  req.body = body
158
168
  request(req)
159
169
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.3.6"
4
+ VERSION = "0.4.0"
5
5
  end