railwatch 0.3.4 → 0.3.6

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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -2
  3. data/app/channels/railwatch/environment_channel.rb +17 -4
  4. data/app/jobs/railwatch/prune_telemetry_job.rb +20 -1
  5. data/app/models/railwatch/ingest/batch.rb +1 -1
  6. data/app/models/railwatch/ingest/mapper.rb +1 -3
  7. data/app/models/railwatch/telemetry_record.rb +48 -0
  8. data/db/railwatch_telemetry_migrate/20260903000000_enable_incremental_vacuum.rb +62 -0
  9. data/docs/embedded.md +64 -7
  10. data/docs/getting-started.md +0 -2
  11. data/docs/source-maps.md +1 -3
  12. data/lib/generators/railwatch/install/install_generator.rb +0 -8
  13. data/lib/railwatch/backtrace.rb +12 -2
  14. data/lib/railwatch/configuration.rb +6 -5
  15. data/lib/railwatch/engine.rb +2 -2
  16. data/lib/railwatch/export/client.rb +10 -3
  17. data/lib/railwatch/reporter.rb +2 -12
  18. data/lib/railwatch/source_maps.rb +13 -1
  19. data/lib/railwatch/spec_helper.rb +1 -1
  20. data/lib/railwatch/transport/http.rb +14 -5
  21. data/lib/railwatch/version.rb +1 -1
  22. data/lib/tasks/railwatch_tasks.rake +161 -0
  23. data/public/railwatch/assets/{app-layout-yh-sPWgK.js → app-layout-B5Fcdwfz.js} +1 -1
  24. data/public/railwatch/assets/{app-wordmark-nbjkzxwQ.js → app-wordmark-1SRllgHv.js} +1 -1
  25. data/public/railwatch/assets/{appearance-CDuRvQTB.js → appearance-DbwahDrc.js} +1 -1
  26. data/public/railwatch/assets/application-CwshqwM6.css +1 -0
  27. data/public/railwatch/assets/{arrow-up-DVtOGdVA.js → arrow-up-DwKQcab4.js} +1 -1
  28. data/public/railwatch/assets/{auth-layout-TCwPpS1K.js → auth-layout-DXfxXAN_.js} +1 -1
  29. data/public/railwatch/assets/{badge-Daw4Hvr8.js → badge-CApjyWcg.js} +1 -1
  30. data/public/railwatch/assets/{braces-DhFbHPsz.js → braces-kGSgxWnS.js} +1 -1
  31. data/public/railwatch/assets/{card-BX_3HXcJ.js → card-BaJ8kNkp.js} +1 -1
  32. data/public/railwatch/assets/{chart-B14-N9g7.js → chart-Xnrz1Nqw.js} +1 -1
  33. data/public/railwatch/assets/{chart-hover-Vy52H4uD.js → chart-hover-XYPNE2CX.js} +1 -1
  34. data/public/railwatch/assets/{chart-panel-Cb4S_ej_.js → chart-panel-BYk68mJJ.js} +1 -1
  35. data/public/railwatch/assets/{checkbox-D3aRSsBj.js → checkbox-Bf41R8W6.js} +1 -1
  36. data/public/railwatch/assets/{code-KvW8k7Jr.js → code-a-9sh36A.js} +1 -1
  37. data/public/railwatch/assets/{copy-block-BKFGd11J.js → copy-block-zykZWQym.js} +1 -1
  38. data/public/railwatch/assets/{copy-id-Djks1fXB.js → copy-id-b6CW5ESL.js} +1 -1
  39. data/public/railwatch/assets/{cursor-load-more-BXV0f1D_.js → cursor-load-more-0cHzbTPT.js} +1 -1
  40. data/public/railwatch/assets/{data-table-iv1bdF6u.js → data-table-IvA7xvrK.js} +1 -1
  41. data/public/railwatch/assets/{edit-BZc_Iawe.js → edit-7lmf3k80.js} +1 -1
  42. data/public/railwatch/assets/{edit-__9yJlO3.js → edit-DTtRV1fO.js} +1 -1
  43. data/public/railwatch/assets/{edit-DMKUF8Zi.js → edit-ZKE15r-F.js} +1 -1
  44. data/public/railwatch/assets/{empty-state-6j_0AaQQ.js → empty-state-BFTnQ2oT.js} +1 -1
  45. data/public/railwatch/assets/env-layout-erSDpIXg.js +1 -0
  46. data/public/railwatch/assets/{execution-path-CzgBUi5e.js → execution-path-Di5uUHi-.js} +1 -1
  47. data/public/railwatch/assets/{filter-bar-9SU5NrzX.js → filter-bar-Btm__gQi.js} +1 -1
  48. data/public/railwatch/assets/{flamegraph-qcekju8V.js → flamegraph-C-mZdqa7.js} +1 -1
  49. data/public/railwatch/assets/{frames-Cyu7KMxZ.js → frames-KZBsjg6-.js} +1 -1
  50. data/public/railwatch/assets/{google-sign-in-button-DsTSfmzY.js → google-sign-in-button-DJRoD72g.js} +1 -1
  51. data/public/railwatch/assets/index-9LL_C3eG.js +1 -0
  52. data/public/railwatch/assets/index-B-bSm1jB.js +1 -0
  53. data/public/railwatch/assets/{index-OqI59zPb.js → index-B0VXzXYG.js} +1 -1
  54. data/public/railwatch/assets/index-BAkZNNEh.js +1 -0
  55. data/public/railwatch/assets/{index-Cmlwy1-V.js → index-BWSyQRZI.js} +1 -1
  56. data/public/railwatch/assets/index-Bjw4sTXU.js +1 -0
  57. data/public/railwatch/assets/index-Bo8hrjbs.js +1 -0
  58. data/public/railwatch/assets/index-BoTOVM0v.js +1 -0
  59. data/public/railwatch/assets/index-Bot7kHMA.js +1 -0
  60. data/public/railwatch/assets/index-BsrCnQqg.js +1 -0
  61. data/public/railwatch/assets/index-BtSlLmkg.js +1 -0
  62. data/public/railwatch/assets/{index-BRiq3SNR.js → index-CAzQkw-h.js} +1 -1
  63. data/public/railwatch/assets/index-CBJZQgHA.js +1 -0
  64. data/public/railwatch/assets/index-CIUl9POV.js +1 -0
  65. data/public/railwatch/assets/{index-DlumCsWY.js → index-CP6jrrPT.js} +2 -2
  66. data/public/railwatch/assets/index-CU6yhEyt.js +1 -0
  67. data/public/railwatch/assets/{index-1ol1-QWI.js → index-CX8Br7m9.js} +1 -1
  68. data/public/railwatch/assets/index-CYYmZpZu.js +1 -0
  69. data/public/railwatch/assets/{index-DU9F5b5d.js → index-CeqMDByC.js} +1 -1
  70. data/public/railwatch/assets/index-Cj3_jD1E.js +1 -0
  71. data/public/railwatch/assets/index-D4968-TC.js +1 -0
  72. data/public/railwatch/assets/index-DAEzO_vq.js +1 -0
  73. data/public/railwatch/assets/index-DBTxQw77.js +1 -0
  74. data/public/railwatch/assets/index-DCAho97K.js +1 -0
  75. data/public/railwatch/assets/index-DEfu_YvU.js +1 -0
  76. data/public/railwatch/assets/{index-CeYKnapu.js → index-DXUwhSCQ.js} +1 -1
  77. data/public/railwatch/assets/index-DcItJxa5.js +1 -0
  78. data/public/railwatch/assets/index-EGU__DOB.js +1 -0
  79. data/public/railwatch/assets/index-HCJmFcZu.js +1 -0
  80. data/public/railwatch/assets/index-P_e_cI0O.js +1 -0
  81. data/public/railwatch/assets/index-QyHE9WsU.js +1 -0
  82. data/public/railwatch/assets/index-Z1c4nFL0.js +1 -0
  83. data/public/railwatch/assets/index-kNhZPLVj.js +1 -0
  84. data/public/railwatch/assets/index-uHBVO8Lh.js +1 -0
  85. data/public/railwatch/assets/index-wOj5j0KC.js +1 -0
  86. data/public/railwatch/assets/{inertia-TViv6kNv.js → inertia-Degv_G9N.js} +2 -2
  87. data/public/railwatch/assets/{input-error-LxImUkxv.js → input-error-C6SqO_iU.js} +1 -1
  88. data/public/railwatch/assets/{json-viewer-Ar4cjPDW.js → json-viewer-DcQIxT86.js} +1 -1
  89. data/public/railwatch/assets/klass-WgQEfWpo.js +1 -0
  90. data/public/railwatch/assets/{label-COUKWqE_.js → label-B_vK19tR.js} +1 -1
  91. data/public/railwatch/assets/{layout-DNSLAkw_.js → layout-C8q7GUlZ.js} +1 -1
  92. data/public/railwatch/assets/{live-dot-ChfUtY3p.js → live-dot-DaXEbkXo.js} +1 -1
  93. data/public/railwatch/assets/{nav-CNnDqPlm.js → nav-BnD5XArZ.js} +1 -1
  94. data/public/railwatch/assets/{new-Be55nmt9.js → new-BL9OqXHe.js} +1 -1
  95. data/public/railwatch/assets/{new-D05SajFR.js → new-BhRnQn4w.js} +1 -1
  96. data/public/railwatch/assets/{new-Bi_xQiIb.js → new-BqP0NhOF.js} +1 -1
  97. data/public/railwatch/assets/{new-BvCT8TMg.js → new-CWP3wtwt.js} +1 -1
  98. data/public/railwatch/assets/{new-D4uewYC8.js → new-DIjwu2jF.js} +1 -1
  99. data/public/railwatch/assets/{new-84S8ZJq9.js → new-uxMnBtoY.js} +1 -1
  100. data/public/railwatch/assets/{onboarding-CYZi5Cqc.js → onboarding-C_Y3xTsH.js} +1 -1
  101. data/public/railwatch/assets/{origin-identity-6q1-CBts.js → origin-identity-CNhOnfBa.js} +1 -1
  102. data/public/railwatch/assets/{percentile-picker-gFZCXtdb.js → percentile-picker-BujVEhP6.js} +1 -1
  103. data/public/railwatch/assets/{relative-time-IOOgl5n2.js → relative-time-6CoKr1lN.js} +1 -1
  104. data/public/railwatch/assets/release-health-1Qzc_XhT.js +1 -0
  105. data/public/railwatch/assets/route-xqgYjOwJ.js +1 -0
  106. data/public/railwatch/assets/{segmented-h1VdDTqE.js → segmented-CVKq2Xpa.js} +1 -1
  107. data/public/railwatch/assets/{select-_AJsUa7X.js → select-CsmSXsJ4.js} +1 -1
  108. data/public/railwatch/assets/{separator-BwwTYtCF.js → separator-CrM10Oot.js} +1 -1
  109. data/public/railwatch/assets/{series-chart-DaFPefku.js → series-chart-D33UhdG2.js} +1 -1
  110. data/public/railwatch/assets/{show-C8sHalnw.js → show-8H9WAGav.js} +1 -1
  111. data/public/railwatch/assets/show-9jDeBNpc.js +1 -0
  112. data/public/railwatch/assets/{show-DACku6AD.js → show-B0nHy31L.js} +3 -3
  113. data/public/railwatch/assets/{show-C4Ltf5i9.js → show-B3izvRdU.js} +1 -1
  114. data/public/railwatch/assets/show-B7NhcWgp.js +1 -0
  115. data/public/railwatch/assets/show-BM-E2iYc.js +1 -0
  116. data/public/railwatch/assets/show-BlNR3ZOZ.js +1 -0
  117. data/public/railwatch/assets/{show-DgSIoRvA.js → show-CJMeX75E.js} +1 -1
  118. data/public/railwatch/assets/{show-CeTL4B37.js → show-CMisHNAI.js} +2 -2
  119. data/public/railwatch/assets/{show-JxFtB4eK.js → show-CQ4Ze-Un.js} +2 -2
  120. data/public/railwatch/assets/{show-BM6X2Mpo.js → show-CvNV23gH.js} +1 -1
  121. data/public/railwatch/assets/show-DMZlwzL_.js +1 -0
  122. data/public/railwatch/assets/show-DUwQlohF.js +1 -0
  123. data/public/railwatch/assets/{show-DIOSGcXV.js → show-DZwcZZrz.js} +2 -2
  124. data/public/railwatch/assets/show-ixK2r2f1.js +1 -0
  125. data/public/railwatch/assets/{show-DYteoYWW.js → show-rysBbvt-.js} +1 -1
  126. data/public/railwatch/assets/show-zxVxhUel.js +1 -0
  127. data/public/railwatch/assets/{sort-header-DpFzXblu.js → sort-header-CKj_7G3U.js} +1 -1
  128. data/public/railwatch/assets/{sparkline-cell-C3-5vFkP.js → sparkline-cell-Ckg29yVq.js} +1 -1
  129. data/public/railwatch/assets/stat-TxvoBiC8.js +1 -0
  130. data/public/railwatch/assets/{status-badge-8jVV-LA4.js → status-badge-5NCVJHjo.js} +1 -1
  131. data/public/railwatch/assets/{tenant-path-G-6u9A-o.js → tenant-path-B5ZRK4x3.js} +1 -1
  132. data/public/railwatch/assets/{text-link-DfsiaCcP.js → text-link-BPllRj0F.js} +1 -1
  133. data/public/railwatch/assets/{textarea-Dye72uP7.js → textarea-DtD5yZp_.js} +1 -1
  134. data/public/railwatch/assets/{timeline-CD7WHnbo.js → timeline-DZz5ZnHN.js} +1 -1
  135. data/public/railwatch/assets/{transition-B_AW8rMK.js → transition-Ds6Cakuc.js} +1 -1
  136. data/public/railwatch/assets/{use-clipboard-ColgLyQ2.js → use-clipboard-BLjjYH8V.js} +1 -1
  137. data/public/railwatch/assets/use-live-GKze1mNC.js +1 -0
  138. data/public/railwatch/manifest.json +1291 -1238
  139. metadata +117 -115
  140. data/public/railwatch/assets/application-DD4nJA5I.css +0 -1
  141. data/public/railwatch/assets/env-layout-DrT8rO6P.js +0 -1
  142. data/public/railwatch/assets/index-5jI4aFzC.js +0 -1
  143. data/public/railwatch/assets/index-9KTrVnrc.js +0 -1
  144. data/public/railwatch/assets/index-B0-8lcTp.js +0 -1
  145. data/public/railwatch/assets/index-B7jjfNfO.js +0 -1
  146. data/public/railwatch/assets/index-BBchRy0M.js +0 -1
  147. data/public/railwatch/assets/index-BgKj9xhr.js +0 -1
  148. data/public/railwatch/assets/index-BkTZqqOu.js +0 -1
  149. data/public/railwatch/assets/index-BprKx8QO.js +0 -1
  150. data/public/railwatch/assets/index-C3jzvPs3.js +0 -1
  151. data/public/railwatch/assets/index-Cbs6gGyQ.js +0 -1
  152. data/public/railwatch/assets/index-CdRZ6AWF.js +0 -1
  153. data/public/railwatch/assets/index-Cwx6058d.js +0 -1
  154. data/public/railwatch/assets/index-D4CSdbHv.js +0 -1
  155. data/public/railwatch/assets/index-DEFMSkdG.js +0 -1
  156. data/public/railwatch/assets/index-DFiHEBSh.js +0 -1
  157. data/public/railwatch/assets/index-DL4vWdWJ.js +0 -1
  158. data/public/railwatch/assets/index-DaXgPcGL.js +0 -1
  159. data/public/railwatch/assets/index-DbtaU-EE.js +0 -1
  160. data/public/railwatch/assets/index-DeOe83F4.js +0 -1
  161. data/public/railwatch/assets/index-DiucHN4B.js +0 -1
  162. data/public/railwatch/assets/index-DlnR_l9o.js +0 -1
  163. data/public/railwatch/assets/index-DmRd7aIG.js +0 -1
  164. data/public/railwatch/assets/index-DxSh2UpM.js +0 -1
  165. data/public/railwatch/assets/index-MIMGuFNt.js +0 -1
  166. data/public/railwatch/assets/index-P4rC7IlX.js +0 -1
  167. data/public/railwatch/assets/index-gpPOcFWq.js +0 -1
  168. data/public/railwatch/assets/index-oVkururr.js +0 -1
  169. data/public/railwatch/assets/index-p9puqVge.js +0 -1
  170. data/public/railwatch/assets/klass-CJ-J4INB.js +0 -1
  171. data/public/railwatch/assets/release-health-DC8oc7uw.js +0 -1
  172. data/public/railwatch/assets/route-Dv6LAWvT.js +0 -1
  173. data/public/railwatch/assets/show-B7NCgkEo.js +0 -1
  174. data/public/railwatch/assets/show-BKqyKjBK.js +0 -1
  175. data/public/railwatch/assets/show-BNw4tN5q.js +0 -1
  176. data/public/railwatch/assets/show-BO3bnG5h.js +0 -1
  177. data/public/railwatch/assets/show-BhrAVAEA.js +0 -1
  178. data/public/railwatch/assets/show-CpfgV1jP.js +0 -1
  179. data/public/railwatch/assets/show-DQp_1n-B.js +0 -1
  180. data/public/railwatch/assets/show-DVNz46RI.js +0 -1
  181. data/public/railwatch/assets/stat-s4RpOS9w.js +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d212beaade3dc390933ea8a41bd49eb1bbf2c896056071516aac8242b39eb07
4
- data.tar.gz: e647976f9634c1e0ba390f6dec52866d190401728363e3dcd1fa93aa2976af3d
3
+ metadata.gz: 7670085bcbae37c58def37c8e23ffee964be9277a57462869346017016c33a3c
4
+ data.tar.gz: fb68d72c0863778eb607cfd2bded53714651caaecffefbff20994e25422a1411
5
5
  SHA512:
6
- metadata.gz: c669c17f558dec72be16f175b06abc5769680591349c2169f6598fc26575191558bd00b903ead0f1f4287bab718c5097b0550ff60e7cfe5fe82fa53940c7a953
7
- data.tar.gz: fc9a32a877b22e12e22b7738b4b479c2aa8b9d1dcc5c97f7ed003f5b421ea3e1a95f917221c195c9b01a487ac124d413dc561a22f96e7483e4891bb5db95b685
6
+ metadata.gz: 429bb5789a56059dce9848403622511df0f5094c2efe4a6ed8c51e5086fb11eaf56b8c762ee9ae68d1ae393a5fae7af65d628df019f01b00466644e207aa6b1d
7
+ data.tar.gz: 0c38280c362f3529ca2af1e93a6982d77ed15a872351072727dd1152396ea2c3f1a2e634f1132a6cac83bf0ee152f818fda15db76e2251a3f0f1a7a24db5bfb1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6 (2026-09-20)
4
+
5
+ - Harden embedded live-stream authorization and telemetry delivery. Bound
6
+ source-file access and HTTP response reads.
7
+ - Simplify prelaunch interfaces: use prompt, stdin, or environment token input;
8
+ pass the complete transport delivery metadata; use the current process-version
9
+ field. Update public install examples and remove obsolete upgrade wording.
10
+
11
+ ## 0.3.5 (2026-09-20)
12
+
13
+ - Make the embedded telemetry database give disk back. `PruneTelemetryJob`
14
+ deleted rows past `retention_days` and nothing ever vacuumed, so the freed
15
+ pages went on SQLite's freelist to be reused and never returned to the
16
+ filesystem: the file only ever grew. Measured on a copy of a real production
17
+ database of the same shape (bulk deletes, continuous inserts), deleting
18
+ 40,148 rows left the file at 21M with 5,164 pages on the freelist; a single
19
+ `PRAGMA incremental_vacuum` took it to 44K.
20
+
21
+ Two halves, and neither is worth anything alone. A new telemetry migration,
22
+ `EnableIncrementalVacuum`, puts the database into `auto_vacuum=incremental`;
23
+ and the nightly prune now runs `PRAGMA incremental_vacuum` after its deletes,
24
+ bounded to 2,000 pages a slice and 25 slices (~200MB at SQLite's 4K default)
25
+ so a backlog drains over successive nights instead of stalling one.
26
+
27
+ The mode cannot be declared in `config/database.yml`: Rails applies its own
28
+ `DEFAULT_PRAGMAS` before any you declare, and `journal_mode = wal` writes the
29
+ file header, so by the time `auto_vacuum` runs the database is no longer new
30
+ and SQLite accepts the statement and ignores it. The migration is numbered
31
+ below `CreateTelemetry` for the same reason -- it has to run while the file
32
+ still holds nothing.
33
+
34
+ - Add `bin/rails railwatch:vacuum:status` and `bin/rails railwatch:vacuum`.
35
+ An **existing** install cannot change mode without a full `VACUUM`, which
36
+ rewrites the whole file with the write lock held, so nothing does that on its
37
+ own: the migration leaves an existing database exactly as it found it.
38
+ `railwatch:vacuum:status` reports the file, its size, its mode and its
39
+ freelist and changes nothing; `railwatch:vacuum` says what the conversion
40
+ will cost and then does it. Installs created from this version on need
41
+ neither.
42
+
3
43
  ## 0.3.4 (2026-09-20)
4
44
 
5
45
  - Fix the embedded dashboard offering its install steps to an install that is
@@ -644,8 +684,7 @@ First public release.
644
684
  - The installer now prefers a hidden prompt, stdin, or `RAILWATCH_TOKEN`, never
645
685
  prints token values, and refuses to put a token in a tracked or non-ignored
646
686
  `.env`. `railwatch:doctor` also fails when it finds a plaintext `lt_...` token
647
- in likely secret-bearing files tracked by Git. The legacy `--token=` option
648
- remains compatible but warns about shell-history and process-list exposure.
687
+ in likely secret-bearing files tracked by Git.
649
688
 
650
689
  - A forked worker profiles again. `Process._fork` now resets the profiler's
651
690
  process-global state in the child: it used to inherit `@running` holding
@@ -2,15 +2,15 @@
2
2
 
3
3
  module Railwatch
4
4
  # Live dashboard updates: Ingest::Batch broadcasts "ingested" on
5
- # environment_<id> after each write (throttled to one per 2 s), and the
5
+ # railwatch:environment:<id> after each write (throttled to one per 2 s), and the
6
6
  # page reloads its props. Under HTTP Basic the browser sends the same
7
7
  # Authorization header on the WebSocket handshake, so the channel checks
8
8
  # the same credentials as the pages.
9
9
  #
10
10
  # With Basic off, Configuration#dashboard_channel_allowed? asks whichever
11
11
  # gate the host declared: a dashboard_user resolver can refuse this
12
- # request, a named base controller or an explicit dashboard_open is taken
13
- # at its word, and an undeclared gate refuses. That last case matters
12
+ # request, or dashboard_open explicitly allows public live updates.
13
+ # A named base controller alone cannot authorize a channel. That last case matters
14
14
  # because Action Cable runs on the host's own /cable endpoint: a routes
15
15
  # constraint around the engine's mount does not cover it and a base
16
16
  # controller cannot reach it, so "something in front of /railwatch" is not
@@ -19,7 +19,9 @@ module Railwatch
19
19
  class EnvironmentChannel < ActionCable::Channel::Base
20
20
  def subscribed
21
21
  if params[:id].to_i == Environment::ID && Railwatch.config.dashboard_channel_allowed?(connection_request)
22
- stream_from "environment_#{Environment::ID}"
22
+ stream_from "railwatch:environment:#{Environment::ID}", coder: ActiveSupport::JSON do |message|
23
+ transmit_authorized(message)
24
+ end
23
25
  else
24
26
  reject
25
27
  end
@@ -27,6 +29,17 @@ module Railwatch
27
29
 
28
30
  private
29
31
 
32
+ # A host's resolver can revoke an operator while their socket is open.
33
+ def transmit_authorized(message)
34
+ if Railwatch.config.dashboard_channel_allowed?(connection_request)
35
+ transmit message
36
+ else
37
+ stop_all_streams
38
+ reject
39
+ reject_subscription
40
+ end
41
+ end
42
+
30
43
  # Built from the connection's env rather than asking it for its request:
31
44
  # ActionCable::Connection::Base#request is private (Rails documents it for
32
45
  # use inside a Connection subclass, not from a channel), so the obvious
@@ -20,6 +20,21 @@ module Railwatch
20
20
  # writer that long hold would look like a wedge and end the process.
21
21
  MAX_BATCHES_PER_TABLE = 40
22
22
 
23
+ # Reclaim, bounded the same way the deletes are. Deleting rows only moves
24
+ # their pages to the freelist; PRAGMA incremental_vacuum is what hands
25
+ # them back to the filesystem, and it is only possible at all on a
26
+ # database in auto_vacuum=incremental (EnableIncrementalVacuum, or
27
+ # `bin/rails railwatch:vacuum` for a database that predates it).
28
+ # Measured at 80k-190k pages/s warm, so a full run is well under a second
29
+ # there; the slicing is for the cold, large file, where each slice takes
30
+ # and releases the write lock instead of holding it throughout.
31
+ VACUUM_PAGES_PER_SLICE = 2_000
32
+ # 50k pages, ~200MB at SQLite's 4KB default. A few thousand pages a night
33
+ # would never keep up with a night's deletes on a busy app, and the file
34
+ # would go on growing with the freelist; a backlog past this one still
35
+ # drains over successive nightly runs.
36
+ VACUUM_SLICES = 25
37
+
23
38
  RAW = [ Telemetry::Execution, Telemetry::Query, Telemetry::Exception, Telemetry::CacheEvent, Telemetry::Mail,
24
39
  Telemetry::Broadcast, Telemetry::Notification, Telemetry::OutgoingRequest, Telemetry::StorageOp,
25
40
  Telemetry::ViewRender, Telemetry::Log, Telemetry::EnqueuedJob, Telemetry::Transaction,
@@ -50,7 +65,11 @@ module Railwatch
50
65
  Telemetry::IngestBatch.where(received_at: ...cutoff).delete_all
51
66
  Telemetry::Process.where(booted_at: ...cutoff).delete_all
52
67
  Telemetry::HealthSample.where(sampled_at: ...cutoff).delete_all
53
- TelemetryRecord.connection.execute("PRAGMA wal_checkpoint(#{checkpoint})") if TelemetryRecord.connection.adapter_name =~ /sqlite/i
68
+ # Before the checkpoint, not after: incremental_vacuum truncates the
69
+ # database file, and in WAL mode that truncation only reaches the file
70
+ # on disk once it is checkpointed.
71
+ TelemetryRecord.reclaim_freelist!(slice: VACUUM_PAGES_PER_SLICE, slices: VACUUM_SLICES)
72
+ TelemetryRecord.connection.execute("PRAGMA wal_checkpoint(#{checkpoint})") if TelemetryRecord.sqlite?
54
73
  end
55
74
  end
56
75
 
@@ -340,7 +340,7 @@ module Railwatch
340
340
  return if last && now - last < THROTTLE_WINDOW
341
341
 
342
342
  LAST_BROADCAST_AT[@environment.id] = now
343
- ActionCable.server.broadcast("environment_#{@environment.id}", { event: "ingested", at: now.iso8601, counts: @counts })
343
+ ActionCable.server.broadcast("railwatch:environment:#{@environment.id}", { event: "ingested", at: now.iso8601, counts: @counts })
344
344
  rescue StandardError, LoadError => e
345
345
  # A live refresh ping is not worth failing a written batch over.
346
346
  # LoadError too: a host whose production cable adapter is redis with
@@ -417,9 +417,7 @@ module Railwatch
417
417
  [ Telemetry::Process, {
418
418
  booted_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"],
419
419
  deploy: rec["deploy"], ruby_version: rec["ruby_version"], rails_version: rec["rails_version"],
420
- # Gems older than 0.1.3 send lantern_version; processes recorded
421
- # by one are still readable.
422
- railwatch_version: rec["railwatch_version"] || rec["lantern_version"], boot_seconds: rec["boot_seconds"],
420
+ railwatch_version: rec["railwatch_version"], boot_seconds: rec["boot_seconds"],
423
421
  detail: rec.slice("app", "environment", "database_adapter", "queue_adapter", "cache_store")
424
422
  } ]
425
423
  end
@@ -31,6 +31,54 @@ module Railwatch
31
31
  end
32
32
  end
33
33
 
34
+ # SQLite's auto_vacuum modes. Deleted pages only leave the file in
35
+ # :incremental, and only when PRAGMA incremental_vacuum asks for them;
36
+ # :none (the default, and what every database created before
37
+ # EnableIncrementalVacuum is in) keeps them on the freelist forever.
38
+ AUTO_VACUUM_MODES = { 0 => :none, 1 => :full, 2 => :incremental }.freeze
39
+
40
+ # Everything below reads and writes THIS database, never the host's. The
41
+ # pragmas are per-database and there is no Active Record wrapper for them,
42
+ # so they go through this class's own connection on purpose: through
43
+ # ActiveRecord::Base they would report on, and vacuum, the application's
44
+ # primary database instead.
45
+ def self.sqlite? = connection.adapter_name.match?(/sqlite/i)
46
+
47
+ def self.auto_vacuum_mode = sqlite? ? AUTO_VACUUM_MODES.fetch(connection.select_value("PRAGMA auto_vacuum").to_i, :unknown) : nil
48
+
49
+ def self.freelist_pages = sqlite? ? connection.select_value("PRAGMA freelist_count").to_i : 0
50
+
51
+ def self.page_size = sqlite? ? connection.select_value("PRAGMA page_size").to_i : 0
52
+
53
+ # Hands freelist pages back to the filesystem, a slice at a time. Each
54
+ # PRAGMA is its own implicit transaction, so the write lock is taken and
55
+ # released once per slice rather than held for the whole reclaim -- the
56
+ # same reason PruneTelemetryJob deletes in bounded batches instead of one
57
+ # statement. Returns the pages actually reclaimed, which is 0 on a
58
+ # database that is not in incremental mode: there the pragma is accepted
59
+ # and does nothing, and calling it would look like work that happened.
60
+ def self.reclaim_freelist!(slice:, slices:)
61
+ return 0 unless sqlite? && auto_vacuum_mode == :incremental
62
+
63
+ before = freelist_pages
64
+ slices.times do
65
+ break if freelist_pages.zero?
66
+
67
+ incremental_vacuum(slice.to_i)
68
+ end
69
+ before - freelist_pages
70
+ end
71
+
72
+ # Through the raw connection on purpose. incremental_vacuum frees one page
73
+ # per sqlite3_step, and Active Record's execute steps a statement with no
74
+ # result columns exactly once -- so through it this pragma frees a single
75
+ # page whatever count it is handed, which is the kind of no-op that reads
76
+ # as work done. The sqlite3 gem's own execute steps to completion. Same
77
+ # raw_connection route Ingest::Writer already takes for its inserts.
78
+ def self.incremental_vacuum(pages = nil)
79
+ connection.raw_connection.execute("PRAGMA incremental_vacuum#{"(#{pages})" if pages}")
80
+ end
81
+
34
82
  # Records arrive as the gem's wire hashes; this is the shared envelope.
35
83
  def self.envelope_columns(t)
36
84
  t.datetime :occurred_at, null: false, precision: 6
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Puts the telemetry database into incremental auto-vacuum mode, which is what
4
+ # lets PruneTelemetryJob hand the pages it frees back to the filesystem
5
+ # instead of parking them on the freelist forever. Without it the file only
6
+ # ever grows: deleting a day of telemetry frees pages for SQLite to reuse and
7
+ # returns not one byte to the disk.
8
+ #
9
+ # Why a migration, and why this version number. SQLite only lets a database
10
+ # leave auto_vacuum=none for free while it still holds no pages; after that
11
+ # the only way in is a full VACUUM, which rewrites the entire file with the
12
+ # write lock held. Declaring the pragma in config/database.yml cannot do it
13
+ # either: Rails applies its own DEFAULT_PRAGMAS before any declared ones, and
14
+ # `journal_mode = wal` writes the file header, so by the time
15
+ # `auto_vacuum = incremental` runs the database is no longer new and the
16
+ # statement is a silent no-op (measured: the mode comes out `none`).
17
+ #
18
+ # A migration is the one hook that runs both on a new install's db:prepare and
19
+ # on an existing install's upgrade, and this one is numbered below
20
+ # CreateTelemetry so that on a new database it runs while the file holds
21
+ # nothing but schema_migrations and ar_internal_metadata -- a few kilobytes,
22
+ # where the VACUUM that commits the mode is instant. Rails runs a pending
23
+ # migration whatever its version, so an existing install gets it too; there it
24
+ # does nothing at all, because converting a file that may be tens of gigabytes
25
+ # is not a decision a deploy gets to make. That one is `bin/rails
26
+ # railwatch:vacuum`, run deliberately, which says what it will cost first.
27
+ class EnableIncrementalVacuum < ActiveRecord::Migration[8.1]
28
+ # VACUUM cannot run inside a transaction.
29
+ disable_ddl_transaction!
30
+
31
+ # Rails' own bookkeeping, which is present before the first migration runs
32
+ # and so does not make a database "existing".
33
+ BOOKKEEPING = %w[schema_migrations ar_internal_metadata].freeze
34
+
35
+ def up
36
+ return say("not SQLite (#{connection.adapter_name}); auto_vacuum does not apply") unless sqlite?
37
+ return say("auto_vacuum is already incremental") if mode == "incremental"
38
+
39
+ populated = connection.tables - BOOKKEEPING
40
+ if populated.any?
41
+ return say("#{populated.size} tables already here: left in auto_vacuum=#{mode}. Run " \
42
+ "`bin/rails railwatch:vacuum` to convert this database when you can spare the lock.")
43
+ end
44
+
45
+ connection.execute("PRAGMA auto_vacuum = incremental")
46
+ connection.execute("VACUUM")
47
+ say("auto_vacuum = #{mode}")
48
+ end
49
+
50
+ # Irreversible in the useful sense: going back to `none` is another whole
51
+ # VACUUM, and nothing about the schema depends on the mode. Rolling back
52
+ # leaves it where it is rather than spending that on an undo nobody asked
53
+ # for.
54
+ def down
55
+ say("auto_vacuum left as #{sqlite? ? mode : connection.adapter_name}; use `PRAGMA auto_vacuum = none; VACUUM;` to undo it by hand")
56
+ end
57
+
58
+ private
59
+ def sqlite? = connection.adapter_name.match?(/sqlite/i)
60
+
61
+ def mode = { 0 => "none", 1 => "full", 2 => "incremental" }[connection.select_value("PRAGMA auto_vacuum").to_i]
62
+ end
data/docs/embedded.md CHANGED
@@ -165,7 +165,11 @@ c.base_controller_class = "AdminController" # requires an admin, or redirects
165
165
  ```
166
166
 
167
167
  Your controller's code runs inside the engine, whose route helpers take
168
- precedence; reach your app's with `main_app.root_path`.
168
+ precedence; reach your app's with `main_app.root_path`. The controller does
169
+ not run for Action Cable subscriptions: live updates stay closed unless
170
+ `dashboard_user` also authorizes the connection (as below). Return `nil` or
171
+ `false` for unauthorized users. The resolver names the operator on pages;
172
+ keep the controller or mount constraint as the page authorization gate.
169
173
 
170
174
  Or a routes constraint, which keeps the engine out of it entirely (for
171
175
  example with the sessions Rails' authentication generator creates):
@@ -183,7 +187,11 @@ purpose:
183
187
 
184
188
  ```ruby
185
189
  c.http_basic_auth_enabled = false
186
- c.dashboard_open = true # "something in front of the mount gates this"
190
+ # The mount constraint does not cover /cable. Authorize live updates too:
191
+ c.dashboard_user = ->(request) {
192
+ user = Session.find_by(id: request.cookie_jar.signed[:session_id])&.user
193
+ { id: user.id, name: user.name } if user&.admin?
194
+ }
187
195
  ```
188
196
 
189
197
  ### Deliberately public
@@ -201,7 +209,8 @@ With Basic off and none of `base_controller_class`, `dashboard_user` or
201
209
  forgotten one. It serves the dashboard (a routes constraint it cannot see
202
210
  is a legitimate answer) but logs a warning at every boot outside
203
211
  development, `railwatch:doctor` reports the gate as undeclared, and live
204
- updates are refused. Declaring any of the three settles it.
212
+ updates are refused. A custom base controller declares the page gate only;
213
+ live updates still require their own authorization as described below.
205
214
 
206
215
  ### Live updates and `/cable`
207
216
 
@@ -209,9 +218,11 @@ Action Cable runs on your application's own `/cable` endpoint, not under
209
218
  the engine's mount, so a routes constraint around `/railwatch` does not
210
219
  cover it and a base controller cannot reach it. The live-update channel
211
220
  therefore follows what you declared: HTTP Basic credentials are checked
212
- on the WebSocket handshake, a `dashboard_user` resolver is consulted,
213
- `dashboard_open` and `base_controller_class` are taken at their word, and
214
- an undeclared gate is refused. The channel carries an ingest ping (a
221
+ from the WebSocket handshake, a `dashboard_user` resolver is consulted,
222
+ or `dashboard_open` explicitly permits public live updates. A custom
223
+ `base_controller_class` authorizes pages only and never permits a Cable
224
+ subscription by itself. The gate is rechecked before each live update;
225
+ revocation removes the subscription and notifies the client. The channel carries an ingest ping (a
215
226
  timestamp and per-type counts) and never telemetry records.
216
227
 
217
228
  ### Naming the operator, and what an id may be
@@ -322,7 +333,7 @@ The work that keeps the dashboard current happens in two places:
322
333
  | anomaly scan | every 5 minutes | Anomaly rules, when any are enabled |
323
334
  | scheduled tasks | every 10 minutes | Missed and late scheduled tasks |
324
335
  | auto-resolve | daily | Resolves issues quiet for 14 days |
325
- | prune | daily | Deletes telemetry older than `retention_days`, then `ANALYZE` |
336
+ | prune | daily | Deletes telemetry older than `retention_days`, returns the freed pages to the filesystem when incremental auto-vacuum is on (see below), then `ANALYZE` |
326
337
 
327
338
  Because the clock lives in the web process, it keeps running when the
328
339
  job worker is down, which is exactly when "scheduled task X missed its
@@ -375,6 +386,52 @@ telemetry database grows with traffic and sampling and is pruned to
375
386
  SQLite files in `storage/`, so Litestream or a volume snapshot covers
376
387
  them.
377
388
 
389
+ ### Giving the disk back
390
+
391
+ Deleting rows does not shrink a SQLite file on its own. The pages go on
392
+ the database's freelist, where later inserts reuse them, and the file
393
+ stays whatever size it reached. So pruning alone keeps the *contents*
394
+ bounded and lets the *file* grow forever.
395
+
396
+ Railwatch creates its telemetry database in SQLite's
397
+ `auto_vacuum=incremental` mode, and the nightly prune runs `PRAGMA
398
+ incremental_vacuum` after its deletes, which hands those pages back to
399
+ the filesystem. It is bounded -- 2,000 pages a slice, 25 slices, about
400
+ 200MB a night at SQLite's 4K default page size -- so a large backlog
401
+ drains over successive nights rather than holding the write lock through
402
+ one enormous reclaim. Nothing to configure.
403
+
404
+ ```
405
+ $ bin/rails railwatch:vacuum:status
406
+ Railwatch telemetry database
407
+ file /rails/storage/production_railwatch_telemetry.sqlite3
408
+ size 1.42 GB
409
+ mode auto_vacuum=incremental
410
+ free 312 pages (1.22 MB) on the freelist
411
+
412
+ The nightly prune returns up to 195 MB a night on its own.
413
+ `bin/rails railwatch:vacuum` returns all 1.22 MB now.
414
+ ```
415
+
416
+ **Installs created before 0.3.5 report `auto_vacuum=none`, and pruning
417
+ cannot return their space.** SQLite can only set the mode on a database
418
+ that is still empty; on one that has data the only route is a full
419
+ `VACUUM`, which rewrites the entire file with the write lock held.
420
+ Railwatch will not do that to a running application behind your back, so
421
+ upgrading leaves the mode alone and the conversion is a task you run
422
+ when you can spare the lock:
423
+
424
+ ```
425
+ $ bin/rails railwatch:vacuum
426
+ ```
427
+
428
+ It prints the file, its size and its freelist first, then says how long
429
+ the `VACUUM` should take and that it needs about the file's own size in
430
+ free disk for the temporary copy. Telemetry written while it runs waits
431
+ for it and the dashboard is frozen for the duration, so pick a quiet
432
+ moment. Once converted, the nightly prune keeps up on its own and you
433
+ never need to run it again.
434
+
378
435
  ## Switching to the cloud later
379
436
 
380
437
  Set a token and drop `c.transport = :local` (or set
@@ -36,8 +36,6 @@ bin/rails generate railwatch:install \
36
36
 
37
37
  - `--prompt-token` reads without echo. `--token-stdin` is available for a
38
38
  secret-manager pipe; an already exported `RAILWATCH_TOKEN` is also detected.
39
- The legacy `--token=` flag warns because command arguments can be visible in
40
- shell history and process listings.
41
39
  - A token is written to `.env` only when Git confirms that `.env` is ignored.
42
40
  A tracked or non-ignored dotenv file is refused; use Rails credentials, a
43
41
  deployment secret manager, or add `.env` to `.gitignore` first. Token values
data/docs/source-maps.md CHANGED
@@ -48,9 +48,7 @@ New default browser issue fingerprints use the resolved original location
48
48
  when a map is already available. Custom fingerprints are preserved. Late
49
49
  uploads improve existing stack displays, Copy for AI, API issue details and
50
50
  MCP `get_issue`, while existing occurrences keep their original grouping.
51
- Missing maps or columns fall back to the raw stack. Upgrade the gem for
52
- new browser occurrences to include columns; older gem versions discarded
53
- them and cannot resolve minified locations accurately.
51
+ Missing maps or columns fall back to the raw stack.
54
52
 
55
53
  For a custom release uploader, POST the raw `.map` bytes to
56
54
  `/ingest/sourcemaps` with `Content-Type: application/octet-stream`,
@@ -13,8 +13,6 @@ module Railwatch
13
13
 
14
14
  class_option :local, type: :boolean, default: false,
15
15
  desc: "Keep telemetry in this app and serve the dashboard at /railwatch: no token, no cloud."
16
- class_option :token, type: :string,
17
- desc: "Deprecated: token in process arguments. Prefer --prompt-token, --token-stdin, or RAILWATCH_TOKEN."
18
16
  class_option :prompt_token, type: :boolean, default: false,
19
17
  desc: "Prompt for the ingest token without echoing it."
20
18
  class_option :token_stdin, type: :boolean, default: false,
@@ -167,10 +165,6 @@ module Railwatch
167
165
  return if options[:local]
168
166
 
169
167
  token = resolved_token
170
- if options[:token]
171
- say("--token exposes #{Railwatch::SecretSafety.token_preview(options[:token])} in process arguments; " \
172
- "use --prompt-token or --token-stdin next time.", :yellow)
173
- end
174
168
  vars = { TOKEN_VAR => token, URL_VAR => options[:url] }.compact
175
169
  return if vars.empty?
176
170
  return say(env_instructions(vars), :yellow) unless dotenv_app?
@@ -463,8 +457,6 @@ module Railwatch
463
457
  ask("Railwatch ingest token (input hidden):", echo: false)
464
458
  elsif options[:token_stdin]
465
459
  $stdin.gets
466
- elsif options[:token]
467
- options[:token]
468
460
  elsif !ENV[TOKEN_VAR].to_s.empty?
469
461
  ENV[TOKEN_VAR]
470
462
  end
@@ -145,9 +145,19 @@ module Railwatch
145
145
  url.start_with?("#{origin}/")
146
146
  end
147
147
 
148
+ MAX_SOURCE_BYTES = 1024 * 1024
149
+
148
150
  def source_snippet(path, line, context: 5)
149
- return nil unless path && File.readable?(path)
150
- lines = File.readlines(path, chomp: true)
151
+ return nil unless path
152
+ # String backtraces can be assigned by libraries. A lexical Rails.root
153
+ # prefix alone allows ../ and symlinks to disclose files outside it.
154
+ root = File.realpath(app_root) + File::SEPARATOR
155
+ source = File.realpath(path)
156
+ return nil unless source.start_with?(root) && File.file?(source)
157
+
158
+ data = File.binread(source, MAX_SOURCE_BYTES + 1)
159
+ return nil if data.bytesize > MAX_SOURCE_BYTES
160
+ lines = data.force_encoding(Encoding::UTF_8).scrub.lines(chomp: true)
151
161
  from = [ line - context - 1, 0 ].max
152
162
  to = [ line + context - 1, lines.size - 1 ].min
153
163
  (from..to).to_h { |i| [ i + 1, lines[i].to_s[0, 200] ] }
@@ -407,12 +407,13 @@ module Railwatch
407
407
  # engine's mount does not cover and a base controller cannot reach, so an
408
408
  # undeclared gate refuses rather than assuming.
409
409
  def dashboard_channel_allowed?(request)
410
- case dashboard_gate
411
- when :basic then http_basic_auth_ok?(request)
412
- when :resolver then !(dashboard_user.respond_to?(:call) ? dashboard_user.call(request) : dashboard_user).nil?
413
- when :open, :controller then true
414
- else false
410
+ return http_basic_auth_ok?(request) if http_basic_auth_enabled
411
+ if dashboard_user
412
+ return !!(dashboard_user.respond_to?(:call) ? dashboard_user.call(request) : dashboard_user)
415
413
  end
414
+
415
+ # A controller name is not evidence of authorization on /cable.
416
+ !!dashboard_open
416
417
  end
417
418
 
418
419
  def resolve_dashboard_user(request)
@@ -105,8 +105,8 @@ module Railwatch
105
105
  Rails.logger.warn(
106
106
  "[railwatch] the dashboard at the engine's mount has no gate this gem can see: HTTP Basic is off and " \
107
107
  "no base_controller_class, dashboard_user or dashboard_open is set. If a routes constraint or your " \
108
- "network already gates it, set `c.dashboard_open = true` to say so (it also enables live updates); " \
109
- "otherwise anyone who can reach the URL can read every query, log line and exception this app records."
108
+ "network already gates it, authorize /cable separately with `c.dashboard_user`, or set `c.dashboard_open = true` " \
109
+ "only when the network protects both endpoints. Otherwise anyone who can reach the dashboard can read every query, log line and exception this app records."
110
110
  )
111
111
  end
112
112
  end
@@ -22,7 +22,14 @@ module Railwatch
22
22
  # Not `send`: shadowing Object#send on a class makes it impossible to
23
23
  # reach the real one, and reads as a coincidence rather than a verb.
24
24
  def deliver(claim, producer_id:)
25
- result = transport_for(claim).deliver_encoded(
25
+ # Snapshot once, then verify and use those exact bytes. Configuration
26
+ # can change after Outbox#claim! checked the destination binding.
27
+ token = @config.resolved_export_token.to_s.dup.freeze
28
+ unless Digest::SHA256.hexdigest(token) == claim.token_digest
29
+ return Outcome.new(disposition: :deferred, status: 401, reason: "export credential changed after claim")
30
+ end
31
+
32
+ result = transport_for(claim, token: token).deliver_encoded(
26
33
  body: claim.body, expected_count: claim.record_count, batch_id: claim.delivery_id,
27
34
  dropped: claim.metadata.fetch("dropped", 0).to_i,
28
35
  dropped_bytes: claim.metadata.fetch("dropped_bytes", 0).to_i,
@@ -54,11 +61,11 @@ module Railwatch
54
61
  # otherwise post bytes admitted for one receiver to another one -- and,
55
62
  # if they belong to different tenants, hand a customer's telemetry to
56
63
  # somebody else while marking it delivered.
57
- def transport_for(claim)
64
+ def transport_for(claim, token:)
58
65
  key = [ claim.url, claim.token_digest ]
59
66
  @transports[key] ||= begin
60
67
  credentialed = @config.dup
61
- credentialed.token = @config.resolved_export_token
68
+ credentialed.token = token
62
69
  Transport::Http.new(credentialed, endpoint: claim.url)
63
70
  end
64
71
  end
@@ -481,19 +481,9 @@ module Railwatch
481
481
  dropped_bytes: batch.dropped_bytes + dropped_bytes, prepared: true)
482
482
  end
483
483
 
484
- # Third-party/test transports written before batch idempotency only accept
485
- # `dropped:`. Keep those working while the HTTP transport receives the
486
- # stable identity required to replay a request safely.
487
484
  def deliver(batch)
488
- parameters = @transport.method(:deliver).parameters
489
- accepts_batch_id = parameters.any? { |kind, name| kind == :keyrest || name == :batch_id }
490
- accepts_dropped_bytes = parameters.any? { |kind, name| kind == :keyrest || name == :dropped_bytes }
491
- accepts_backpressure = parameters.any? { |kind, name| kind == :keyrest || name == :backpressure_factor }
492
- keywords = { dropped: batch.dropped }
493
- keywords[:batch_id] = batch.id if accepts_batch_id
494
- keywords[:dropped_bytes] = batch.dropped_bytes if accepts_dropped_bytes
495
- keywords[:backpressure_factor] = @backpressure_factor if accepts_backpressure
496
- @transport.deliver(batch.records, **keywords)
485
+ @transport.deliver(batch.records, dropped: batch.dropped, batch_id: batch.id,
486
+ dropped_bytes: batch.dropped_bytes, backpressure_factor: @backpressure_factor)
497
487
  end
498
488
 
499
489
  def update_backpressure
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "uri"
5
6
  require "pathname"
6
7
  require "json"
@@ -10,6 +11,7 @@ module Railwatch
10
11
  # during release preparation; it never follows map URLs or HTTP redirects.
11
12
  class SourceMaps
12
13
  MAX_BYTES = 10 * 1024 * 1024
14
+ MAX_RESPONSE_BYTES = 64 * 1024
13
15
 
14
16
  def initialize(config)
15
17
  @config = config
@@ -46,7 +48,17 @@ module Railwatch
46
48
  request["X-Railwatch-Deploy"] = @config.deploy
47
49
  request["X-Railwatch-Filename"] = filename
48
50
  request.body = data
49
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30, write_timeout: 30) { |http| http.request(request) }
51
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_PEER,
52
+ open_timeout: 5, read_timeout: 30, write_timeout: 30) do |http|
53
+ http.request(request) do |result|
54
+ body = String.new(encoding: Encoding::BINARY)
55
+ result.read_body do |chunk|
56
+ raise "Source map response exceeds 64 KiB" if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
57
+ body << chunk
58
+ end
59
+ result.body = body
60
+ end
61
+ end
50
62
  unless response.is_a?(Net::HTTPSuccess)
51
63
  raise "Source map upload failed (HTTP #{response.code}) for #{filename}"
52
64
  end
@@ -15,7 +15,7 @@ module Railwatch
15
15
  class MemoryTransport
16
16
  attr_reader :batches
17
17
  def initialize = @batches = []
18
- def deliver(records, dropped: 0, batch_id: nil)
18
+ def deliver(records, dropped: 0, batch_id: nil, **)
19
19
  @batches << records
20
20
  Transport::Http::Result.new(ok: true, status: 200, accepted: records.size, rejected: 0)
21
21
  end
@@ -174,12 +174,22 @@ module Railwatch
174
174
  # explicitly so a Ruby default change cannot silently weaken ingest.
175
175
  options[:verify_mode] = OpenSSL::SSL::VERIFY_PEER if options[:use_ssl]
176
176
  Net::HTTP.start(@uri.host, @uri.port, **options) do |http|
177
- http.request(req)
177
+ http.request(req) do |response|
178
+ body = String.new(encoding: Encoding::BINARY)
179
+ response.read_body do |chunk|
180
+ if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
181
+ raise ResponseTooLarge, "ingest response is larger than #{MAX_RESPONSE_BYTES} bytes"
182
+ end
183
+ body << chunk
184
+ end
185
+ response.body = body
186
+ end
178
187
  end
179
188
  end
180
189
 
181
- # A receiver in trouble can answer with something enormous -- a proxy
182
- # error page, a stack trace. Only ever look at the first slice of it.
190
+ # Enforced while streaming, before Net::HTTP can buffer an unbounded
191
+ # proxy error page or decompressed response inside the monitored app.
192
+ class ResponseTooLarge < StandardError; end
183
193
  MAX_RESPONSE_BYTES = 64 * 1024
184
194
  # The longest delay we will take from a receiver, in either spelling.
185
195
  MAX_RETRY_AFTER = 86_400
@@ -193,8 +203,7 @@ module Railwatch
193
203
  end
194
204
  end
195
205
 
196
- # Bounds what we keep and log, not what Net::HTTP already read off the
197
- # socket -- it buffers the whole response before we ever see it.
206
+ # Keep diagnostics smaller still than the bounded response body.
198
207
  def summarize(body) = body.to_s.byteslice(0, 200).to_s.scrub
199
208
 
200
209
  # Seconds, or an HTTP date. Anything else is not a delay we can trust,