railwatch 0.3.5 → 0.3.7

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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -2
  3. data/app/channels/railwatch/environment_channel.rb +17 -4
  4. data/app/models/railwatch/ingest/batch.rb +1 -1
  5. data/app/models/railwatch/ingest/mapper.rb +1 -3
  6. data/docs/configuration.md +8 -3
  7. data/docs/embedded.md +17 -6
  8. data/docs/getting-started.md +0 -2
  9. data/docs/source-maps.md +1 -3
  10. data/lib/generators/railwatch/install/install_generator.rb +0 -8
  11. data/lib/railwatch/backtrace.rb +12 -2
  12. data/lib/railwatch/configuration.rb +6 -5
  13. data/lib/railwatch/engine.rb +2 -2
  14. data/lib/railwatch/export/client.rb +10 -3
  15. data/lib/railwatch/patches/rake_task.rb +10 -1
  16. data/lib/railwatch/patches/runner_command.rb +3 -1
  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/public/railwatch/assets/{app-layout-CdJ5Qjms.js → app-layout-B5Fcdwfz.js} +1 -1
  23. data/public/railwatch/assets/{app-wordmark-B-0yZiDb.js → app-wordmark-1SRllgHv.js} +1 -1
  24. data/public/railwatch/assets/{appearance-CU28GBf1.js → appearance-DbwahDrc.js} +1 -1
  25. data/public/railwatch/assets/{arrow-up-DY1fS0tD.js → arrow-up-DwKQcab4.js} +1 -1
  26. data/public/railwatch/assets/{auth-layout-C3FKuUJz.js → auth-layout-DXfxXAN_.js} +1 -1
  27. data/public/railwatch/assets/{badge-DKvItdlb.js → badge-CApjyWcg.js} +1 -1
  28. data/public/railwatch/assets/{braces-Bse5Gh3K.js → braces-kGSgxWnS.js} +1 -1
  29. data/public/railwatch/assets/{card-C9j_ffR9.js → card-BaJ8kNkp.js} +1 -1
  30. data/public/railwatch/assets/{chart-Cmh6JEww.js → chart-Xnrz1Nqw.js} +1 -1
  31. data/public/railwatch/assets/{chart-hover-B0SjyWFy.js → chart-hover-XYPNE2CX.js} +1 -1
  32. data/public/railwatch/assets/{chart-panel-DKNxoauI.js → chart-panel-BYk68mJJ.js} +1 -1
  33. data/public/railwatch/assets/{checkbox-BsBkRg5n.js → checkbox-Bf41R8W6.js} +1 -1
  34. data/public/railwatch/assets/{code-C879Qwgg.js → code-a-9sh36A.js} +1 -1
  35. data/public/railwatch/assets/{copy-block-DtO_3esq.js → copy-block-zykZWQym.js} +1 -1
  36. data/public/railwatch/assets/{copy-id-SeLCKsW2.js → copy-id-b6CW5ESL.js} +1 -1
  37. data/public/railwatch/assets/{cursor-load-more-OpHbe_kE.js → cursor-load-more-0cHzbTPT.js} +1 -1
  38. data/public/railwatch/assets/{data-table-Bc3ZRnpb.js → data-table-IvA7xvrK.js} +1 -1
  39. data/public/railwatch/assets/{edit-DDvf9mkq.js → edit-7lmf3k80.js} +1 -1
  40. data/public/railwatch/assets/{edit-BMpgcQhU.js → edit-DTtRV1fO.js} +1 -1
  41. data/public/railwatch/assets/{edit-CTD1jSWh.js → edit-ZKE15r-F.js} +1 -1
  42. data/public/railwatch/assets/{empty-state-CkPROZB0.js → empty-state-BFTnQ2oT.js} +1 -1
  43. data/public/railwatch/assets/{env-layout-aGQgTZRg.js → env-layout-erSDpIXg.js} +1 -1
  44. data/public/railwatch/assets/{execution-path-BX5NcXUZ.js → execution-path-Di5uUHi-.js} +1 -1
  45. data/public/railwatch/assets/{filter-bar-C0O2rJVr.js → filter-bar-Btm__gQi.js} +1 -1
  46. data/public/railwatch/assets/{flamegraph-Vj4QSAlT.js → flamegraph-C-mZdqa7.js} +1 -1
  47. data/public/railwatch/assets/{frames-CTiGwm4E.js → frames-KZBsjg6-.js} +1 -1
  48. data/public/railwatch/assets/{google-sign-in-button-BzogBGeF.js → google-sign-in-button-DJRoD72g.js} +1 -1
  49. data/public/railwatch/assets/{index-chS28dvS.js → index-9LL_C3eG.js} +1 -1
  50. data/public/railwatch/assets/{index-C__S10ze.js → index-B-bSm1jB.js} +1 -1
  51. data/public/railwatch/assets/{index-Dy7Wg880.js → index-B0VXzXYG.js} +1 -1
  52. data/public/railwatch/assets/{index-B-kuWp-m.js → index-BAkZNNEh.js} +1 -1
  53. data/public/railwatch/assets/{index-CPQKOQxV.js → index-BWSyQRZI.js} +1 -1
  54. data/public/railwatch/assets/{index-FgpFDA1Z.js → index-Bjw4sTXU.js} +1 -1
  55. data/public/railwatch/assets/{index-D9cV-IQr.js → index-Bo8hrjbs.js} +1 -1
  56. data/public/railwatch/assets/{index-BCLvKVhP.js → index-BoTOVM0v.js} +1 -1
  57. data/public/railwatch/assets/{index-y2c8xKzI.js → index-Bot7kHMA.js} +1 -1
  58. data/public/railwatch/assets/{index-DbyRgbzQ.js → index-BsrCnQqg.js} +1 -1
  59. data/public/railwatch/assets/{index-BKgAm-w2.js → index-BtSlLmkg.js} +1 -1
  60. data/public/railwatch/assets/{index-DigUOHQ8.js → index-CAzQkw-h.js} +1 -1
  61. data/public/railwatch/assets/{index-ChQ1_u8u.js → index-CBJZQgHA.js} +1 -1
  62. data/public/railwatch/assets/{index-uuKg6Zqn.js → index-CIUl9POV.js} +1 -1
  63. data/public/railwatch/assets/{index-DepurF6s.js → index-CP6jrrPT.js} +1 -1
  64. data/public/railwatch/assets/{index-Bvy021Vv.js → index-CU6yhEyt.js} +1 -1
  65. data/public/railwatch/assets/{index-B2_QluAw.js → index-CX8Br7m9.js} +1 -1
  66. data/public/railwatch/assets/{index-Dcxd_H-U.js → index-CYYmZpZu.js} +1 -1
  67. data/public/railwatch/assets/{index-DxuDLoTc.js → index-CeqMDByC.js} +1 -1
  68. data/public/railwatch/assets/{index-D3Axxkk2.js → index-Cj3_jD1E.js} +1 -1
  69. data/public/railwatch/assets/{index-D5YVcrqR.js → index-D4968-TC.js} +1 -1
  70. data/public/railwatch/assets/{index-DkgImCOg.js → index-DAEzO_vq.js} +1 -1
  71. data/public/railwatch/assets/{index-CtT4T6kp.js → index-DBTxQw77.js} +1 -1
  72. data/public/railwatch/assets/{index-CCLMaY9k.js → index-DCAho97K.js} +1 -1
  73. data/public/railwatch/assets/{index-jSEU_Yp7.js → index-DEfu_YvU.js} +1 -1
  74. data/public/railwatch/assets/{index-CY6lbwAC.js → index-DXUwhSCQ.js} +1 -1
  75. data/public/railwatch/assets/{index-Wb_U2_hz.js → index-DcItJxa5.js} +1 -1
  76. data/public/railwatch/assets/{index-CD4HIHq0.js → index-EGU__DOB.js} +1 -1
  77. data/public/railwatch/assets/{index-LaTKDe0-.js → index-HCJmFcZu.js} +1 -1
  78. data/public/railwatch/assets/{index-BRN_bXV_.js → index-P_e_cI0O.js} +1 -1
  79. data/public/railwatch/assets/{index-Ble4RoA3.js → index-QyHE9WsU.js} +1 -1
  80. data/public/railwatch/assets/{index-B69Iad2U.js → index-Z1c4nFL0.js} +1 -1
  81. data/public/railwatch/assets/{index-z2YfKBJl.js → index-kNhZPLVj.js} +1 -1
  82. data/public/railwatch/assets/{index-CxU1Jj35.js → index-uHBVO8Lh.js} +1 -1
  83. data/public/railwatch/assets/{index-Dep2XomH.js → index-wOj5j0KC.js} +1 -1
  84. data/public/railwatch/assets/{inertia-CgBdJDaE.js → inertia-Degv_G9N.js} +2 -2
  85. data/public/railwatch/assets/{input-error-DfpIawjl.js → input-error-C6SqO_iU.js} +1 -1
  86. data/public/railwatch/assets/{json-viewer-CH7nEIUs.js → json-viewer-DcQIxT86.js} +1 -1
  87. data/public/railwatch/assets/{klass-BTg1l2AO.js → klass-WgQEfWpo.js} +1 -1
  88. data/public/railwatch/assets/{label-BWgQzHCO.js → label-B_vK19tR.js} +1 -1
  89. data/public/railwatch/assets/{layout-neknbxn-.js → layout-C8q7GUlZ.js} +1 -1
  90. data/public/railwatch/assets/{live-dot-Bejk0a5o.js → live-dot-DaXEbkXo.js} +1 -1
  91. data/public/railwatch/assets/{nav-C1M8941-.js → nav-BnD5XArZ.js} +1 -1
  92. data/public/railwatch/assets/{new-BnGIYM37.js → new-BL9OqXHe.js} +1 -1
  93. data/public/railwatch/assets/{new-DGYPcz7w.js → new-BhRnQn4w.js} +1 -1
  94. data/public/railwatch/assets/{new-DElC-JCS.js → new-BqP0NhOF.js} +1 -1
  95. data/public/railwatch/assets/{new-bBwawsEg.js → new-CWP3wtwt.js} +1 -1
  96. data/public/railwatch/assets/{new-B8CzyoMu.js → new-DIjwu2jF.js} +1 -1
  97. data/public/railwatch/assets/{new-DLgCLBiW.js → new-uxMnBtoY.js} +1 -1
  98. data/public/railwatch/assets/{onboarding-CW0jUhHT.js → onboarding-C_Y3xTsH.js} +1 -1
  99. data/public/railwatch/assets/{origin-identity-bJMxeEHB.js → origin-identity-CNhOnfBa.js} +1 -1
  100. data/public/railwatch/assets/{percentile-picker-DLAnTKY3.js → percentile-picker-BujVEhP6.js} +1 -1
  101. data/public/railwatch/assets/{relative-time-BHbJh8HW.js → relative-time-6CoKr1lN.js} +1 -1
  102. data/public/railwatch/assets/{release-health-2O1GRclB.js → release-health-1Qzc_XhT.js} +1 -1
  103. data/public/railwatch/assets/{route-DSw8aIZa.js → route-xqgYjOwJ.js} +1 -1
  104. data/public/railwatch/assets/{segmented-Dg8CES94.js → segmented-CVKq2Xpa.js} +1 -1
  105. data/public/railwatch/assets/{select-CqKpvI98.js → select-CsmSXsJ4.js} +1 -1
  106. data/public/railwatch/assets/{separator-BOp2LHw3.js → separator-CrM10Oot.js} +1 -1
  107. data/public/railwatch/assets/{series-chart-COEtj3PY.js → series-chart-D33UhdG2.js} +1 -1
  108. data/public/railwatch/assets/{show-DrhEDGPn.js → show-8H9WAGav.js} +1 -1
  109. data/public/railwatch/assets/{show-dRDz8OVu.js → show-9jDeBNpc.js} +1 -1
  110. data/public/railwatch/assets/{show-C-xpDkoj.js → show-B0nHy31L.js} +1 -1
  111. data/public/railwatch/assets/{show-B7Zqjlqc.js → show-B3izvRdU.js} +1 -1
  112. data/public/railwatch/assets/{show-CGv85sRo.js → show-B7NhcWgp.js} +1 -1
  113. data/public/railwatch/assets/{show-Zr2xV4OD.js → show-BM-E2iYc.js} +1 -1
  114. data/public/railwatch/assets/{show-B40560ZT.js → show-BlNR3ZOZ.js} +1 -1
  115. data/public/railwatch/assets/{show-C9Y1To9_.js → show-CJMeX75E.js} +1 -1
  116. data/public/railwatch/assets/{show-BAxMoNvw.js → show-CMisHNAI.js} +1 -1
  117. data/public/railwatch/assets/{show-HjjzcKRq.js → show-CQ4Ze-Un.js} +1 -1
  118. data/public/railwatch/assets/{show-CPh-7VGL.js → show-CvNV23gH.js} +1 -1
  119. data/public/railwatch/assets/{show-DqtE0LI6.js → show-DMZlwzL_.js} +1 -1
  120. data/public/railwatch/assets/{show-C_9M2nLy.js → show-DUwQlohF.js} +1 -1
  121. data/public/railwatch/assets/{show-DFlP1ktM.js → show-DZwcZZrz.js} +1 -1
  122. data/public/railwatch/assets/{show-B_QVoqKE.js → show-ixK2r2f1.js} +1 -1
  123. data/public/railwatch/assets/{show-pbkvGRER.js → show-rysBbvt-.js} +1 -1
  124. data/public/railwatch/assets/{show-Bbs1i46N.js → show-zxVxhUel.js} +1 -1
  125. data/public/railwatch/assets/{sort-header-DBxQ_KFX.js → sort-header-CKj_7G3U.js} +1 -1
  126. data/public/railwatch/assets/{sparkline-cell-BbfXw3kA.js → sparkline-cell-Ckg29yVq.js} +1 -1
  127. data/public/railwatch/assets/{stat-Cd8QaTGw.js → stat-TxvoBiC8.js} +1 -1
  128. data/public/railwatch/assets/{status-badge-Ys4b0fcG.js → status-badge-5NCVJHjo.js} +1 -1
  129. data/public/railwatch/assets/{tenant-path-CQWzaNYR.js → tenant-path-B5ZRK4x3.js} +1 -1
  130. data/public/railwatch/assets/{text-link-BY8FY-CJ.js → text-link-BPllRj0F.js} +1 -1
  131. data/public/railwatch/assets/{textarea-Bq2jjOMC.js → textarea-DtD5yZp_.js} +1 -1
  132. data/public/railwatch/assets/{timeline-SxStCFO2.js → timeline-DZz5ZnHN.js} +1 -1
  133. data/public/railwatch/assets/{transition-CQ9yvfPa.js → transition-Ds6Cakuc.js} +1 -1
  134. data/public/railwatch/assets/{use-clipboard-3hTXU91g.js → use-clipboard-BLjjYH8V.js} +1 -1
  135. data/public/railwatch/assets/{use-live-LwJStJvD.js → use-live-GKze1mNC.js} +1 -1
  136. data/public/railwatch/manifest.json +1285 -1285
  137. metadata +115 -115
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8904a8f775771bd894a52f80b7d5ecf4d8a40d0dee09aaf16697e68685c5e106
4
- data.tar.gz: de27dad2204510d9e8f3d1bb3ada89901ccd467486c94fde9f0b85ff83ab50ca
3
+ metadata.gz: 56eb44eab9d0988d36c517995ad2ec8d42971ed9af60e3cf4c419c5df926007f
4
+ data.tar.gz: 20f9bfed5d8044797a1e43b523668846b2b888756ff9fef59a1f54dc136d64c1
5
5
  SHA512:
6
- metadata.gz: 5fe5b808c60a3aac92302f8669b82c539634cb655d7317e98279143c5e920f9178d15920a1e75145c9c153ad5d3bfc41ade5004c1df9f1e7c8de29ab2fc20b76
7
- data.tar.gz: 251237b9c8ee2a3d252c61eef492d89a532e3c7fef856bde91e19d4d3baa5c831ebc34a0bbbd24b2ced390f76f0ef8106c43cee2d68d6fbf25725f01b074298f
6
+ metadata.gz: 8bbd22e4636c6c3ba58dfff0242d3553db415af9b90041b524634ae7b95e65a0ff52f6584c7d6c4c61aee12274e177b308d06040508db41ee8db0af81ff1ab0d
7
+ data.tar.gz: 398eea6fd06b824600b834f5da026f68b70fba6a5bcc1ec97c73995933ca9a8e569cca0d5135f27ee413563281bd91a941eb19ccb954f0b731cd25b2446ba86c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.7 (2026-09-20)
4
+
5
+ - Stop a rake task or `rails runner` waiting on the network as it finishes.
6
+ The command patches called `Railwatch.flush` when the execution closed --
7
+ on the application's own thread, with no time limit. The engine's `at_exit`
8
+ already calls `Reporter#shutdown`, which wakes the reporter thread and joins
9
+ it for `shutdown_timeout`, so the same records were already being delivered,
10
+ already bounded, and a failed task's exception already went with them. The
11
+ flush was a second, unbounded way to do it.
12
+
13
+ Against a receiver that accepts connections and never answers, that second
14
+ way cost a full timeout ladder per process -- measured at ~6s, and ~12s when
15
+ it queued behind a delivery the reporter thread was already stuck in. An app
16
+ whose container entrypoint boots Rails eleven times before starting Puma
17
+ spent that on every boot and lost the deploy to its proxy's timeout. With
18
+ the call removed the task returns immediately and the process still leaves
19
+ within `shutdown_timeout`, carrying the same records.
20
+
21
+ Only short-lived processes were affected: web and worker processes flush on
22
+ the reporter's own thread and never blocked. `Railwatch.flush` is unchanged
23
+ and still public, for a caller who wants to wait on purpose.
24
+
25
+ - Add a process-level regression that runs a failing rake task against a
26
+ socket that accepts and never answers, and asserts the task returns without
27
+ waiting on it and the process exits inside the shutdown bound. In-process
28
+ examples cannot see this: the cost lives in a socket read and the bound in
29
+ an `at_exit` join.
30
+
31
+ ## 0.3.6 (2026-09-20)
32
+
33
+ - Harden embedded live-stream authorization and telemetry delivery. Bound
34
+ source-file access and HTTP response reads.
35
+ - Simplify prelaunch interfaces: use prompt, stdin, or environment token input;
36
+ pass the complete transport delivery metadata; use the current process-version
37
+ field. Update public install examples and remove obsolete upgrade wording.
38
+
3
39
  ## 0.3.5 (2026-09-20)
4
40
 
5
41
  - Make the embedded telemetry database give disk back. `PruneTelemetryJob`
@@ -676,8 +712,7 @@ First public release.
676
712
  - The installer now prefers a hidden prompt, stdin, or `RAILWATCH_TOKEN`, never
677
713
  prints token values, and refuses to put a token in a tracked or non-ignored
678
714
  `.env`. `railwatch:doctor` also fails when it finds a plaintext `lt_...` token
679
- in likely secret-bearing files tracked by Git. The legacy `--token=` option
680
- remains compatible but warns about shell-history and process-list exposure.
715
+ in likely secret-bearing files tracked by Git.
681
716
 
682
717
  - A forked worker profiles again. `Process._fork` now resets the profiler's
683
718
  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
@@ -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
@@ -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
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
@@ -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
@@ -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
 
@@ -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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.3.5"
4
+ VERSION = "0.3.7"
5
5
  end