railwatch 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +6 -2
  3. data/CHANGELOG.md +133 -0
  4. data/README.md +21 -13
  5. data/app/controllers/railwatch/dashboard_controller.rb +1 -0
  6. data/app/controllers/railwatch/requests_controller.rb +1 -19
  7. data/app/models/railwatch/application_record.rb +2 -2
  8. data/app/models/railwatch/execution_presenter.rb +1 -1
  9. data/app/models/railwatch/filter_query.rb +20 -0
  10. data/app/models/railwatch/saved_view.rb +1 -1
  11. data/app/models/railwatch/telemetry_record.rb +2 -2
  12. data/docs/configuration.md +29 -13
  13. data/docs/embedded.md +32 -12
  14. data/docs/faq.md +6 -5
  15. data/docs/getting-started.md +9 -5
  16. data/docs/troubleshooting.md +10 -6
  17. data/lib/generators/railwatch/install/install_generator.rb +31 -16
  18. data/lib/generators/railwatch/install/templates/initializer.rb.tt +4 -4
  19. data/lib/puma/plugin/railwatch.rb +48 -3
  20. data/lib/railwatch/configuration.rb +16 -1
  21. data/lib/railwatch/engine.rb +14 -3
  22. data/lib/railwatch/reporter.rb +52 -16
  23. data/lib/railwatch/transport/http.rb +4 -9
  24. data/lib/railwatch/version.rb +1 -1
  25. data/lib/railwatch.rb +35 -1
  26. data/lib/tasks/railwatch_tasks.rake +11 -4
  27. data/llms.txt +8 -4
  28. data/public/railwatch/assets/{app-layout-DDyQa72H.js → app-layout-2xAKndVD.js} +1 -1
  29. data/public/railwatch/assets/{app-wordmark-o9CODKP0.js → app-wordmark-Bt6XqURo.js} +1 -1
  30. data/public/railwatch/assets/{appearance-BwuCXabr.js → appearance-DyCfeh9o.js} +1 -1
  31. data/public/railwatch/assets/{arrow-up-C6PxDiY3.js → arrow-up-EmePsTKP.js} +1 -1
  32. data/public/railwatch/assets/{auth-layout-BRt8MGFD.js → auth-layout-BuwogWMZ.js} +1 -1
  33. data/public/railwatch/assets/{badge-CAxXV8za.js → badge-Dv7I-ebP.js} +1 -1
  34. data/public/railwatch/assets/{braces-DgomTCNf.js → braces-iZ7MEfYP.js} +1 -1
  35. data/public/railwatch/assets/{card-cAtqCxWl.js → card-DqdEEKcJ.js} +1 -1
  36. data/public/railwatch/assets/{chart-BBeBkkNa.js → chart-C4k7Ub9W.js} +1 -1
  37. data/public/railwatch/assets/{chart-hover-B1M9jc0y.js → chart-hover-2yZG3w2u.js} +1 -1
  38. data/public/railwatch/assets/{chart-panel-DUQTz_C8.js → chart-panel-CONkZcn9.js} +1 -1
  39. data/public/railwatch/assets/{checkbox-CmhMHWZO.js → checkbox-DKbThbxr.js} +1 -1
  40. data/public/railwatch/assets/{code-DESvxyTj.js → code-CYahYQHZ.js} +1 -1
  41. data/public/railwatch/assets/{copy-block-BkSU5832.js → copy-block-CbJum9s8.js} +1 -1
  42. data/public/railwatch/assets/{copy-id-D03GhN9F.js → copy-id-JmvLfkmx.js} +1 -1
  43. data/public/railwatch/assets/{cursor-load-more-CRyuMeQb.js → cursor-load-more-C1ylbqqL.js} +1 -1
  44. data/public/railwatch/assets/{data-table-BIlt7Rtm.js → data-table-CVKR2wkq.js} +1 -1
  45. data/public/railwatch/assets/{edit-DJ0D0wHN.js → edit-BXF5oNYA.js} +1 -1
  46. data/public/railwatch/assets/{edit-O0NSBWxo.js → edit-BwSEVrTG.js} +1 -1
  47. data/public/railwatch/assets/{edit-Bb6MKoe4.js → edit-D5BRDwLT.js} +1 -1
  48. data/public/railwatch/assets/{empty-state-C38il627.js → empty-state-Bn48Gplm.js} +1 -1
  49. data/public/railwatch/assets/{env-layout-REF7OM4q.js → env-layout-CKLL8Ds7.js} +1 -1
  50. data/public/railwatch/assets/{execution-path-FYLq1TwC.js → execution-path-DWovhgLi.js} +1 -1
  51. data/public/railwatch/assets/{filter-bar-CYog9Alp.js → filter-bar-xcbJC93z.js} +1 -1
  52. data/public/railwatch/assets/{flamegraph-DSs69foN.js → flamegraph-BNbNIuKT.js} +1 -1
  53. data/public/railwatch/assets/{frames-BUi2J5Mk.js → frames-Bk39xfJG.js} +1 -1
  54. data/public/railwatch/assets/{google-sign-in-button-BQiIKFdd.js → google-sign-in-button-B5as2ps1.js} +1 -1
  55. data/public/railwatch/assets/{index-QpTtwFwu.js → index-Atp1FTgX.js} +1 -1
  56. data/public/railwatch/assets/{index-C-PmdhXA.js → index-B-FXAo1w.js} +1 -1
  57. data/public/railwatch/assets/{index-DqTFTP8p.js → index-B0xgzHuN.js} +1 -1
  58. data/public/railwatch/assets/{index-so4lRrRq.js → index-BDVShWMs.js} +1 -1
  59. data/public/railwatch/assets/{index-C_upSl_k.js → index-BRvRkfob.js} +1 -1
  60. data/public/railwatch/assets/{index-CsoN51vW.js → index-BVWapDeB.js} +1 -1
  61. data/public/railwatch/assets/{index-CrZ3vHDL.js → index-Bd4YpOIO.js} +1 -1
  62. data/public/railwatch/assets/{index-BaR1U9An.js → index-BeQw69GM.js} +1 -1
  63. data/public/railwatch/assets/{index-Dh4IRLFI.js → index-BfitxVBv.js} +1 -1
  64. data/public/railwatch/assets/{index-umIAl-pL.js → index-C-EwH4lO.js} +1 -1
  65. data/public/railwatch/assets/{index-CICUIFHL.js → index-C8JIdb3_.js} +1 -1
  66. data/public/railwatch/assets/{index-CFFpnzIS.js → index-CBFCtJAR.js} +1 -1
  67. data/public/railwatch/assets/{index-tpz-OGUP.js → index-CDXlITyG.js} +1 -1
  68. data/public/railwatch/assets/{index-DW2CBbxU.js → index-COd9lT84.js} +1 -1
  69. data/public/railwatch/assets/{index-FhUaPPab.js → index-C_hQmVTP.js} +1 -1
  70. data/public/railwatch/assets/{index-JdCVBrw8.js → index-CcennT28.js} +1 -1
  71. data/public/railwatch/assets/{index-DrcKVG2f.js → index-CiaeS-n7.js} +1 -1
  72. data/public/railwatch/assets/{index-BoUBioBP.js → index-CrA7jBTK.js} +1 -1
  73. data/public/railwatch/assets/{index-C3A_9imx.js → index-D0mKj3Hg.js} +1 -1
  74. data/public/railwatch/assets/{index-BiiyMcA0.js → index-DV5MdmpY.js} +1 -1
  75. data/public/railwatch/assets/{index-CiPo4Gob.js → index-DaE-1xhx.js} +1 -1
  76. data/public/railwatch/assets/{index-CpkI015n.js → index-DdVg9LKX.js} +1 -1
  77. data/public/railwatch/assets/{index-DvjY3dPD.js → index-DeLtMWou.js} +1 -1
  78. data/public/railwatch/assets/{index-CFRLPs4J.js → index-DiQ4mqT_.js} +1 -1
  79. data/public/railwatch/assets/{index-DtHmuB9Q.js → index-LKgpCzoR.js} +1 -1
  80. data/public/railwatch/assets/{index-BeOh2t_S.js → index-S0NVaLj9.js} +1 -1
  81. data/public/railwatch/assets/{index-C7OtLq_3.js → index-Vj93BTR5.js} +1 -1
  82. data/public/railwatch/assets/{index-ZOGOB8SA.js → index-XjXcV-EO.js} +1 -1
  83. data/public/railwatch/assets/{index-ZSZg9rtq.js → index-Zkt4W_CM.js} +1 -1
  84. data/public/railwatch/assets/{index-8-hnAhOD.js → index-aPCZX-pW.js} +1 -1
  85. data/public/railwatch/assets/{index-DDI_Zx5V.js → index-cTzBycCg.js} +1 -1
  86. data/public/railwatch/assets/{index-sTYvcbkh.js → index-dpG-19ZN.js} +1 -1
  87. data/public/railwatch/assets/{index-r0tSIplE.js → index-gCvuyG1l.js} +1 -1
  88. data/public/railwatch/assets/{index-DSvlZVWG.js → index-o1xqGvLo.js} +1 -1
  89. data/public/railwatch/assets/{index-CGs4m_fa.js → index-w_skgMmK.js} +1 -1
  90. data/public/railwatch/assets/{inertia-DLew8ZNx.js → inertia-CqnzqPVD.js} +2 -2
  91. data/public/railwatch/assets/{input-error-cvM6_Jht.js → input-error-ByT28UdP.js} +1 -1
  92. data/public/railwatch/assets/{json-viewer-D922McGi.js → json-viewer-Bnu9RsJL.js} +1 -1
  93. data/public/railwatch/assets/{klass-CrwICqN8.js → klass-nLiBV-Az.js} +1 -1
  94. data/public/railwatch/assets/{label-GWl7I6sf.js → label-1nSzpSB6.js} +1 -1
  95. data/public/railwatch/assets/{layout-0ZAnD3zl.js → layout-D7lE1Doo.js} +1 -1
  96. data/public/railwatch/assets/{live-dot-D1n_BreY.js → live-dot-UdzOfzJb.js} +1 -1
  97. data/public/railwatch/assets/{nav-DPxr1NNC.js → nav-BRCgp2w3.js} +1 -1
  98. data/public/railwatch/assets/{new-DHAHDrN7.js → new-C60G72DR.js} +1 -1
  99. data/public/railwatch/assets/{new-D-ZzUK9a.js → new-COyDKOOb.js} +1 -1
  100. data/public/railwatch/assets/{new-DEVkYv-z.js → new-Cd9zPNJ8.js} +1 -1
  101. data/public/railwatch/assets/{new-Dz4lZf1L.js → new-DMJGKjmH.js} +1 -1
  102. data/public/railwatch/assets/{new-GMrRFurX.js → new-Omvf-c9s.js} +1 -1
  103. data/public/railwatch/assets/{new-Cdl6pqST.js → new-vjRzERL3.js} +1 -1
  104. data/public/railwatch/assets/onboarding-gUjJQc-6.js +1 -0
  105. data/public/railwatch/assets/{origin-identity-Bk9yHWZ1.js → origin-identity-M1YUPYS0.js} +1 -1
  106. data/public/railwatch/assets/{percentile-picker-DfSx9yJO.js → percentile-picker-BxIVblBw.js} +1 -1
  107. data/public/railwatch/assets/{relative-time-CjIjb8Lg.js → relative-time-DQ4kjowZ.js} +1 -1
  108. data/public/railwatch/assets/{release-health-4b3tivEf.js → release-health-Cb1tMOOO.js} +1 -1
  109. data/public/railwatch/assets/{route-C_5BUtHK.js → route-BJkoEm7r.js} +1 -1
  110. data/public/railwatch/assets/{segmented-BgbT3wZa.js → segmented-C4oWE73o.js} +1 -1
  111. data/public/railwatch/assets/{select-DmunxCKE.js → select-ocdQJdQo.js} +1 -1
  112. data/public/railwatch/assets/{separator-BXzEdZ_8.js → separator-Ct2UcZ2x.js} +1 -1
  113. data/public/railwatch/assets/{series-chart-Xf49v9cv.js → series-chart-D6upsrSn.js} +1 -1
  114. data/public/railwatch/assets/{show-DlRVS18-.js → show-B4bwI9x2.js} +1 -1
  115. data/public/railwatch/assets/{show-DnR1Dnjd.js → show-BAjfddEn.js} +1 -1
  116. data/public/railwatch/assets/{show-mU38uGTg.js → show-BLJOyLb_.js} +1 -1
  117. data/public/railwatch/assets/{show-DYskfl3-.js → show-BoVyMQS6.js} +1 -1
  118. data/public/railwatch/assets/{show-DXs4deaC.js → show-Bsu1rvnK.js} +1 -1
  119. data/public/railwatch/assets/{show-Dn-GwFZL.js → show-C6OiJ6Hr.js} +1 -1
  120. data/public/railwatch/assets/{show-CAl7xcex.js → show-C8TkpEgm.js} +1 -1
  121. data/public/railwatch/assets/{show-BLpWUHWD.js → show-CBFBiBY2.js} +1 -1
  122. data/public/railwatch/assets/{show-Y74rM0VT.js → show-CEhPVMMT.js} +1 -1
  123. data/public/railwatch/assets/{show-SHwZjXb7.js → show-CV-lG2b6.js} +1 -1
  124. data/public/railwatch/assets/{show-Dily73Xk.js → show-CX_pab9S.js} +1 -1
  125. data/public/railwatch/assets/{show-DcpTFiLi.js → show-D6lgzL6n.js} +1 -1
  126. data/public/railwatch/assets/{show-vQ4bndYD.js → show-DDVsv-uS.js} +1 -1
  127. data/public/railwatch/assets/{show-SvLOcPrx.js → show-Dwiq-8hA.js} +1 -1
  128. data/public/railwatch/assets/{show-B2zLAW83.js → show-eKazflRI.js} +1 -1
  129. data/public/railwatch/assets/{show-DSP9Cq_C.js → show-gtwKsTsl.js} +1 -1
  130. data/public/railwatch/assets/{show-DI8IhNUH.js → show-yEOrk6Pt.js} +1 -1
  131. data/public/railwatch/assets/{sort-header-Dcq9bzmo.js → sort-header-CPM6fhXv.js} +1 -1
  132. data/public/railwatch/assets/{sparkline-cell-BON3qQUB.js → sparkline-cell-CXCxXPWt.js} +1 -1
  133. data/public/railwatch/assets/{stat-DFEyFxkO.js → stat-BNEfE8X1.js} +1 -1
  134. data/public/railwatch/assets/{status-badge-BaUKP7Yo.js → status-badge-D0aylrM5.js} +1 -1
  135. data/public/railwatch/assets/{tenant-path-DPZPc985.js → tenant-path-C4aM_cO0.js} +1 -1
  136. data/public/railwatch/assets/{text-link-BO77t9Xk.js → text-link-CP4lqUi6.js} +1 -1
  137. data/public/railwatch/assets/{textarea-DTqrCiV0.js → textarea-DzJo2ds4.js} +1 -1
  138. data/public/railwatch/assets/{timeline-D5rJ0es2.js → timeline-BNcJ9PMc.js} +1 -1
  139. data/public/railwatch/assets/{transition-DMIrZVth.js → transition-BX2M1iaq.js} +1 -1
  140. data/public/railwatch/assets/{use-clipboard-ByoUGQqA.js → use-clipboard-C1ApSwzA.js} +1 -1
  141. data/public/railwatch/assets/{use-live-D7xKz2ma.js → use-live-DWwLg1Nj.js} +1 -1
  142. data/public/railwatch/manifest.json +1283 -1283
  143. metadata +116 -116
  144. data/public/railwatch/assets/onboarding-D1vwaHYT.js +0 -1
  145. /data/db/railwatch_telemetry_migrate/{20260919000000_create_export_queue.rb → 20260919000100_create_export_queue.rb} +0 -0
@@ -9,10 +9,11 @@ module Railwatch
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
11
  desc "Creates config/initializers/railwatch.rb, a Kamal post-deploy hook, the browser client, and wires the test helpers. " \
12
- "With --local, also the two SQLite databases the in-app dashboard needs."
12
+ "By default telemetry stays in this app, in two SQLite databases, with the dashboard at /railwatch. " \
13
+ "--cloud (or any token or URL option) sends it to Railwatch Cloud instead."
13
14
 
14
- class_option :local, type: :boolean, default: false,
15
- desc: "Keep telemetry in this app and serve the dashboard at /railwatch: no token, no cloud."
15
+ class_option :cloud, type: :boolean, default: false,
16
+ desc: "Send telemetry to Railwatch Cloud instead of keeping it in this app. Implied by --prompt-token, --token-stdin, --url and --kamal-secrets."
16
17
  class_option :prompt_token, type: :boolean, default: false,
17
18
  desc: "Prompt for the ingest token without echoing it."
18
19
  class_option :token_stdin, type: :boolean, default: false,
@@ -67,9 +68,9 @@ module Railwatch
67
68
  # database is, so an app on PostgreSQL or MySQL needs the adapter gem
68
69
  # added before those files can be created.
69
70
  def ensure_sqlite3_gem
70
- return unless options[:local]
71
+ return unless local?
71
72
  return if Gem.loaded_specs.key?("sqlite3")
72
- return say("--local needs the sqlite3 gem for its two databases; add `gem \"sqlite3\"` and re-run.", :yellow) unless File.exist?("Gemfile")
73
+ return say("Embedded mode needs the sqlite3 gem for its two databases; add `gem \"sqlite3\"` and re-run.", :yellow) unless File.exist?("Gemfile")
73
74
 
74
75
  contents = File.read("Gemfile")
75
76
  unless contents.match?(/^\s*gem ["']sqlite3["']/)
@@ -89,9 +90,9 @@ module Railwatch
89
90
  # creates the tables now and migrates them after every gem update.
90
91
  # Nothing is copied into the app.
91
92
  def configure_local_databases
92
- return unless options[:local]
93
+ return unless local?
93
94
 
94
- return say("--local: no config/database.yml found; add railwatch and railwatch_telemetry databases yourself (docs/embedded.md).", :yellow) unless File.exist?("config/database.yml")
95
+ return say("No config/database.yml found; add railwatch and railwatch_telemetry databases yourself (docs/embedded.md).", :yellow) unless File.exist?("config/database.yml")
95
96
 
96
97
  contents = File.read("config/database.yml")
97
98
  updated = self.class.database_yml_with_railwatch(contents)
@@ -103,8 +104,8 @@ module Railwatch
103
104
  # The writer process: one per Puma master, forked by the gem's Puma
104
105
  # plugin, so batches are mapped and written outside the web workers.
105
106
  def configure_local_writer
106
- return unless options[:local]
107
- return say("--local: no config/puma.rb found; add `plugin :railwatch` to your Puma config yourself (docs/embedded.md).", :yellow) unless File.exist?("config/puma.rb")
107
+ return unless local?
108
+ return say("No config/puma.rb found; add `plugin :railwatch` to your Puma config yourself (docs/embedded.md).", :yellow) unless File.exist?("config/puma.rb")
108
109
 
109
110
  contents = File.read("config/puma.rb")
110
111
  updated = self.class.puma_rb_with_railwatch(contents)
@@ -166,7 +167,7 @@ module Railwatch
166
167
  # A token lands in .env only when Git confirms the file is ignored.
167
168
  # URLs are not secret and can still be written to a tracked dotenv file.
168
169
  def write_env
169
- return if options[:local]
170
+ return if local?
170
171
 
171
172
  token = resolved_token
172
173
  vars = { TOKEN_VAR => token, URL_VAR => options[:url] }.compact
@@ -219,7 +220,7 @@ module Railwatch
219
220
  # the same prepare a deploy runs, for both databases only. The host's
220
221
  # own databases are not touched, and a schema file is never written.
221
222
  def prepare_local_databases
222
- return unless options[:local]
223
+ return unless local?
223
224
  return unless File.exist?("config/database.yml")
224
225
  return if @needs_bundle
225
226
  return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
@@ -242,21 +243,25 @@ module Railwatch
242
243
  end
243
244
 
244
245
  def show_next_steps
245
- if options[:local]
246
+ if local?
246
247
  say <<~STEPS, :green
247
248
 
248
249
  Next steps
249
- 1. Set the dashboard's HTTP Basic credentials (it is closed until
250
- you do): bin/rails railwatch:authentication:configure
250
+ 1. Restart the app and open /railwatch. In development it is open.
251
+ 2. Before production, give it a password (it is closed there until
252
+ you do): RAILS_ENV=production bin/rails railwatch:authentication:configure
251
253
  Using your own admin auth instead? See docs/embedded.md,
252
254
  Authentication (base_controller_class or a routes constraint).
253
- 2. Restart the app and open /railwatch.
254
255
  3. #{@prepared ? "Nothing else to run. Both databases were created just now and" : "Create the two databases: bin/rails db:prepare\n Then"}
255
256
  `bin/rails db:prepare` (which a deploy already runs) migrates
256
257
  them after every gem update. With `plugin :railwatch` in
257
258
  config/puma.rb Puma forks one Railwatch writer process that
258
259
  writes every batch and runs the maintenance clock, so no web
259
260
  process ever holds the telemetry database. No job worker.
261
+ 4. Optional: mirror to Railwatch Cloud for alerts that still
262
+ arrive when this app is down, MCP for your AI assistant, and
263
+ every app in one place. Set RAILWATCH_TOKEN and
264
+ c.export_enabled = true (docs/embedded.md).
260
265
  STEPS
261
266
  return
262
267
  end
@@ -287,7 +292,7 @@ module Railwatch
287
292
  # This process read its configuration before the initializer was
288
293
  # written, so in local mode the doctor would report an http transport
289
294
  # with no token. The databases it would check were prepared above.
290
- return if options[:local]
295
+ return if local?
291
296
  return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
292
297
 
293
298
  say "\nbin/rails railwatch:doctor", :green
@@ -455,6 +460,16 @@ module Railwatch
455
460
 
456
461
  private
457
462
 
463
+ # Embedded unless the invocation asks for the cloud: --cloud itself, or
464
+ # an option that only means something there. A RAILWATCH_TOKEN already
465
+ # in the environment is not asking -- it is picked up when one of these
466
+ # is given, never used to choose the mode.
467
+ CLOUD_OPTIONS = %i[cloud prompt_token token_stdin url kamal_secrets].freeze
468
+
469
+ def local?
470
+ CLOUD_OPTIONS.none? { |name| options[name] }
471
+ end
472
+
458
473
  def resolved_token
459
474
  @resolved_token ||= begin
460
475
  value = if options[:prompt_token]
@@ -3,7 +3,7 @@
3
3
  # Railwatch: first-class monitoring for Rails. Every option here can also be
4
4
  # set by the RAILWATCH_* env var named in the comment.
5
5
  Railwatch.configure do |c|
6
- <% if options[:local] -%>
6
+ <% if local? -%>
7
7
  # Telemetry stays in this app's own railwatch_telemetry database and the
8
8
  # dashboard is served at /railwatch. No token, no cloud. Put the mount
9
9
  # behind your own authentication; this only names who is looking.
@@ -13,9 +13,9 @@ Railwatch.configure do |c|
13
13
  # c.repository_url = "https://github.com/you/app" # RAILWATCH_REPOSITORY_URL; source links from stack traces
14
14
  # c.retention_days = 7 # RAILWATCH_RETENTION_DAYS; PruneTelemetryJob keeps this much
15
15
  # Access. The dashboard shows every query, log line and exception this app
16
- # records, so pick one of these. Out of the box it is HTTP Basic, on and
17
- # closed until credentials exist:
18
- # bin/rails railwatch:authentication:configure (writes Rails credentials)
16
+ # records, so pick one of these. Out of the box it is HTTP Basic: open in
17
+ # development, closed everywhere else until credentials exist:
18
+ # RAILS_ENV=production bin/rails railwatch:authentication:configure
19
19
  # or RAILWATCH_HTTP_BASIC_AUTH_USER / _PASSWORD.
20
20
  #
21
21
  # Using your own admin auth instead? Turn Basic off and say which, so the
@@ -21,6 +21,11 @@ Puma::Plugin.create do
21
21
  attr_reader :log_writer, :writer_pid
22
22
 
23
23
  POLL = 2
24
+ # How often a stopping writer is checked for, and how long a KILL is given
25
+ # to take before the pid is abandoned (a process stuck in disk I/O cannot
26
+ # die until the I/O returns, and Puma's exit should not wait for that).
27
+ REAP_POLL = 0.05
28
+ KILL_REAP = 1
24
29
 
25
30
  def start(launcher)
26
31
  @log_writer = launcher.log_writer
@@ -151,19 +156,59 @@ Puma::Plugin.create do
151
156
  log "Railwatch writer shutdown failed (#{e.class}: #{e.message})"
152
157
  end
153
158
 
154
- # TERM closes the writer's listener and it drains what it is holding; the
155
- # wait also reaps it, so a cluster master never leaves a zombie behind.
159
+ # TERM closes the writer's listener; it finishes what it is holding and
160
+ # exits on its own. Waited for with a deadline, not Process.wait, which
161
+ # has none: a writer wedged in a SQLite write or on a full disk would hold
162
+ # Puma's exit open for as long as it stayed wedged. KILL past the deadline.
163
+ # Reaped either way, so a cluster master never leaves a zombie behind.
156
164
  def stop_writer
157
165
  return unless @writer_pid
158
166
 
159
167
  Process.kill(:TERM, @writer_pid)
160
- Process.wait(@writer_pid)
168
+ return if reaped_within?(stop_timeout)
169
+
170
+ log "Railwatch writer (pid #{@writer_pid}) did not exit within #{stop_timeout}s of TERM; killing it"
171
+ Process.kill(:KILL, @writer_pid)
172
+ log "Railwatch writer (pid #{@writer_pid}) did not exit on KILL; leaving it" unless reaped_within?(KILL_REAP)
161
173
  rescue Errno::ECHILD, Errno::ESRCH
162
174
  nil
163
175
  ensure
164
176
  @writer_pid = nil
165
177
  end
166
178
 
179
+ # shutdown_timeout: the same allowance this process gives its own
180
+ # reporter, and deliberately NOT the writer's full theoretical exit time
181
+ # (a sequential SHUTDOWN_DRAIN join per worker thread, its maintenance
182
+ # join, then its own reporter shutdown -- 13s at the defaults). Waiting
183
+ # that long would buy nothing: a writer killed mid-batch loses no data,
184
+ # because the transaction rolls back and the worker retries the batch by
185
+ # id against the next writer (Writer#serve says so). Exit time spent
186
+ # waiting for the drain is spent for nothing. An idle writer is gone in
187
+ # well under a second either way.
188
+ #
189
+ # This runs from at_exit, inside the container's TERM-to-KILL grace. Under
190
+ # Kamal that is Docker's 10s default for a proxied role that has not set
191
+ # `stop_timeout`, or whatever `stop_timeout` says when it has; an app that
192
+ # wants the writer given longer can raise RAILWATCH_SHUTDOWN_TIMEOUT to
193
+ # match its grace, and this bound rises with it.
194
+ def stop_timeout
195
+ [ ::Railwatch.config.shutdown_timeout.to_f, 0.0 ].max
196
+ end
197
+
198
+ # Non-blocking waits on a short poll; Process.wait has no timeout and a
199
+ # child that never exits would hold it forever. ECHILD (the cluster's
200
+ # wait2(-1) reaped it first) propagates to stop_writer, which reads it as
201
+ # gone.
202
+ def reaped_within?(seconds)
203
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
204
+ loop do
205
+ return true if Process.waitpid(@writer_pid, Process::WNOHANG)
206
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
207
+
208
+ sleep REAP_POLL
209
+ end
210
+ end
211
+
167
212
  def log(message)
168
213
  log_writer.log(message)
169
214
  end
@@ -81,7 +81,7 @@ module Railwatch
81
81
  :max_view_renders_per_execution, :ignored_cache_key_prefixes,
82
82
  :beacon_enabled, :beacon_rate_limit, :beacon_global_rate_limit, :beacon_allowed_origins,
83
83
  :debug, :capture_default_vendor_commands,
84
- :capture_default_vendor_cache_keys, :on_unrecoverable,
84
+ :capture_default_vendor_cache_keys, :on_unrecoverable, :warn_on_data_loss,
85
85
  :capture_framework_events,
86
86
  :tail_sample_slow_ms, :failure_context, :propagate_traces, :trace_propagation_hosts,
87
87
  :health_interval, :capture_query_explain, :explain_threshold_ms,
@@ -195,6 +195,12 @@ module Railwatch
195
195
  @capture_default_vendor_cache_keys = env_bool("RAILWATCH_CAPTURE_DEFAULT_VENDOR_CACHE_KEYS", false)
196
196
  @capture_framework_events = env_bool("RAILWATCH_CAPTURE_FRAMEWORK_EVENTS", false)
197
197
  @on_unrecoverable = nil
198
+ # Off, because a gem printing into an application's own output is the
199
+ # gem changing that application's behaviour, and this one stays
200
+ # additive. Turn it on and a batch lost for good says so in one stderr
201
+ # line; leave it off and the loss shows behind RAILWATCH_DEBUG, or
202
+ # wherever on_unrecoverable routes it.
203
+ @warn_on_data_loss = env_bool("RAILWATCH_WARN_ON_DATA_LOSS", true)
198
204
  @beacon_enabled = env_bool("RAILWATCH_BEACON", true)
199
205
  # The beacon is unauthenticated and forces Railwatch.keep! for browser
200
206
  # errors, so without a ceiling anyone can spend an app's event quota
@@ -372,12 +378,21 @@ module Railwatch
372
378
  http_basic_auth_user.to_s.strip != "" && http_basic_auth_password.to_s.strip != ""
373
379
  end
374
380
 
381
+ # Development with Basic on and nothing configured is open, so a first
382
+ # run is `rails g railwatch:install` and a page, not a password step
383
+ # first. Rails already shows full error pages there. Every other
384
+ # environment stays closed until credentials exist.
385
+ def http_basic_auth_waived?
386
+ http_basic_auth_enabled && !http_basic_auth_configured? && defined?(Rails) && Rails.env.development?
387
+ end
388
+
375
389
  # Whether the request carries the configured HTTP Basic credentials.
376
390
  # False when Basic is on and nothing is configured (closed), true when
377
391
  # Basic is off (the host's base controller or routes constraint is the
378
392
  # gate then). Shared by the dashboard controller and the live channel.
379
393
  def http_basic_auth_ok?(request)
380
394
  return true unless http_basic_auth_enabled
395
+ return true if http_basic_auth_waived?
381
396
  return false unless http_basic_auth_configured?
382
397
 
383
398
  ActionController::HttpAuthentication::Basic.authenticate(request) do |user, password|
@@ -91,16 +91,27 @@ module Railwatch
91
91
  config.http_basic_auth_password ||= app.credentials.dig(:railwatch, :http_basic_auth_password)
92
92
  end
93
93
 
94
- # Two things worth one line in the log at boot, because both are
94
+ # Three things worth one line in the log at boot, because all are
95
95
  # invisible until something is already wrong: a Rails/json pair that
96
- # cannot decode, and an embedded dashboard with nothing declared in
97
- # front of it.
96
+ # cannot decode, an embedded dashboard with nothing declared in front of
97
+ # it, and one that is closed to everyone because Basic has no
98
+ # credentials outside development.
98
99
  initializer "railwatch.warnings", after: :load_config_initializers do
99
100
  config.after_initialize do
100
101
  next unless Railwatch.enabled?
101
102
 
102
103
  Rails.logger.warn("[railwatch] #{Railwatch::JsonCompat.advice}") if Railwatch::JsonCompat.broken?
103
104
 
105
+ if Railwatch.config.local? && Railwatch.config.dashboard_gate == :basic &&
106
+ !Railwatch.config.http_basic_auth_configured? && !Rails.env.local?
107
+ Rails.logger.warn(
108
+ "[railwatch] the dashboard is closed: HTTP Basic is on and no credentials are configured for " \
109
+ "#{Rails.env}, so every request to it is 401. Run `RAILS_ENV=#{Rails.env} bin/rails " \
110
+ "railwatch:authentication:configure`, or gate it with your own auth and set " \
111
+ "`c.http_basic_auth_enabled = false` (docs/embedded.md)."
112
+ )
113
+ end
114
+
104
115
  if Railwatch.config.local? && Railwatch.config.dashboard_gate == :undeclared && !Rails.env.local?
105
116
  Rails.logger.warn(
106
117
  "[railwatch] the dashboard at the engine's mount has no gate this gem can see: HTTP Basic is off and " \
@@ -100,10 +100,17 @@ module Railwatch
100
100
 
101
101
  def flush
102
102
  ensure_process!
103
- @flush_mutex.synchronize do
103
+ deferred = nil
104
+ result = @flush_mutex.synchronize do
105
+ @deferred_notifications = []
104
106
  update_backpressure
105
107
  deliver_buffer
108
+ ensure
109
+ deferred = @deferred_notifications
110
+ @deferred_notifications = nil
106
111
  end
112
+ deferred&.each { |error| Railwatch.notify_unrecoverable(error) }
113
+ result
107
114
  end
108
115
 
109
116
  def ensure_thread
@@ -278,6 +285,19 @@ module Railwatch
278
285
  end
279
286
  end
280
287
 
288
+ # Everything reachable from deliver_buffer runs inside @flush_mutex, so
289
+ # the loss it reports cannot be handed to the application there. Ruby's
290
+ # Mutex is not reentrant: the documented callback is Rails.error.report,
291
+ # whose subscriber records the error as an exception and can end up asking
292
+ # this same reporter to flush -- "ThreadError: deadlock; recursive
293
+ # locking", rescued by notify_unrecoverable and so a callback cut off
294
+ # halfway, reporting nothing. Collected here and dispatched by flush once
295
+ # the lock is released. retain already does this for @mutex; @flush_mutex
296
+ # is the outer one it still sat inside.
297
+ def defer_notification(error)
298
+ @deferred_notifications ? @deferred_notifications << error : Railwatch.notify_unrecoverable(error)
299
+ end
300
+
281
301
  def deliver_buffer
282
302
  # A 401 was reported once, when the transport first saw it; after
283
303
  # that the token is wrong until the process restarts, and repeating
@@ -337,7 +357,7 @@ module Railwatch
337
357
  rescue StandardError => e
338
358
  result = Transport::Http::Result.new(ok: false, error: "#{e.class}: #{e.message}")
339
359
  batch&.records&.any? ? retain(batch, result) : delivery_succeeded
340
- Railwatch.notify_unrecoverable(e)
360
+ defer_notification(e)
341
361
  result
342
362
  ensure
343
363
  in_flight(0, 0, 0, 0)
@@ -350,7 +370,7 @@ module Railwatch
350
370
  end
351
371
 
352
372
  def retain(batch, result)
353
- @mutex.synchronize do
373
+ gave_up = @mutex.synchronize do
354
374
  @in_flight_records = 0
355
375
  @in_flight_dropped = 0
356
376
  @in_flight_bytes = 0
@@ -362,17 +382,7 @@ module Railwatch
362
382
  @retry_attempt = 0
363
383
  @retry_at = nil
364
384
  Railwatch.debug { "gave up on a batch of #{batch.records.size} records after #{MAX_RETRY_ATTEMPTS} retries (#{result.error || result.status}); dropped and counted" }
365
- # Losing a batch is not a debug-level event: with an ingest (or an
366
- # embedded writer) that never comes back this is the only place the
367
- # loss is ever reported, and the dropped counter it leaves behind
368
- # rides on the NEXT successful delivery, which may never happen.
369
- Railwatch.notify_unrecoverable(
370
- DeliveryError.new("Railwatch dropped #{batch.records.size} records after #{MAX_RETRY_ATTEMPTS} failed delivery attempts: " \
371
- "#{result.error || result.status}",
372
- status: result.status, records: batch.records.size, bytes: batch.bytes,
373
- dropped: batch.dropped, dropped_bytes: batch.dropped_bytes)
374
- )
375
- next
385
+ next true
376
386
  end
377
387
  @retry_batch = batch
378
388
  delay = retry_delay(@retry_attempt)
@@ -382,7 +392,32 @@ module Railwatch
382
392
  "retained #{batch.records.size} records after retryable delivery failure " \
383
393
  "(#{result.error || result.status}); retry #{@retry_attempt} in #{delay.round(3)}s"
384
394
  end
395
+ false
385
396
  end
397
+ return unless gave_up
398
+
399
+ # Losing a batch is not a debug-level event: with an ingest (or an
400
+ # embedded writer) that never comes back this is the only place the
401
+ # loss is ever reported, and the dropped counter it leaves behind
402
+ # rides on the NEXT successful delivery, which may never happen.
403
+ #
404
+ # Reported here, after the lock is released, never inside it. The
405
+ # callback is the app's: the documented one is Rails.error.report,
406
+ # whose subscriber records the error as an exception and so writes
407
+ # straight back into this reporter -- which needs @mutex to arm the
408
+ # thread or ask for a flush, and under the lock that was
409
+ # "ThreadError: deadlock; recursive locking" and a callback cut off
410
+ # halfway. A slow callback under the lock was worse: shutdown's own
411
+ # @mutex.synchronize and every request thread's write_now sat behind
412
+ # it for as long as it took. notify_unsent and delivery_rejected
413
+ # already call out unlocked; this was the one that did not.
414
+ defer_notification(
415
+ DeliveryError.new("Railwatch dropped #{batch.records.size} #{batch.records.size == 1 ? "record" : "records"} " \
416
+ "after #{MAX_RETRY_ATTEMPTS + 1} failed delivery attempts: " \
417
+ "#{result.error || result.status}",
418
+ status: result.status, records: batch.records.size, bytes: batch.bytes,
419
+ dropped: batch.dropped, dropped_bytes: batch.dropped_bytes)
420
+ )
386
421
  end
387
422
 
388
423
  def discard_unauthorized
@@ -406,7 +441,7 @@ module Railwatch
406
441
  def delivery_rejected(batch, result)
407
442
  delivery_succeeded
408
443
  detail = result.error.to_s.empty? ? "HTTP #{result.status}" : result.error
409
- Railwatch.notify_unrecoverable(
444
+ defer_notification(
410
445
  DeliveryError.new("Railwatch ingest permanently rejected #{batch.records.size} records: #{detail}",
411
446
  status: result.status, records: batch.records.size, bytes: batch.bytes,
412
447
  dropped: batch.dropped, dropped_bytes: batch.dropped_bytes)
@@ -550,7 +585,8 @@ module Railwatch
550
585
  return unless should_notify
551
586
 
552
587
  Railwatch.notify_unrecoverable(
553
- DeliveryError.new("Railwatch #{reason} with #{records} unsent records retained in memory (#{bytes} bytes)",
588
+ DeliveryError.new("Railwatch #{reason} with #{records} unsent #{records == 1 ? "record" : "records"} " \
589
+ "retained in memory (#{bytes} bytes)",
554
590
  records: records, bytes: bytes, dropped: dropped, dropped_bytes: dropped_bytes)
555
591
  )
556
592
  end
@@ -8,9 +8,10 @@ require "json"
8
8
 
9
9
  module Railwatch
10
10
  module Transport
11
- # POSTs gzip NDJSON batches to the platform. Each call retries one raised
12
- # error or 5xx response, then returns a classified, non-raising result;
13
- # Reporter owns retention and backoff between calls. A 401 marks the
11
+ # POSTs gzip NDJSON batches to the platform. Each call makes exactly one
12
+ # attempt and returns a classified, non-raising result; Reporter owns
13
+ # retention, the retry ladder, and backoff between calls, so a retry here
14
+ # would multiply into its schedule rather than add to it. A 401 marks the
14
15
  # transport unauthorized so no further requests are made.
15
16
  class Http
16
17
  # The request headers a delivery carries besides the body. The receiver
@@ -87,17 +88,11 @@ module Railwatch
87
88
  dropped += over_cap
88
89
  dropped_bytes += over_cap_bytes
89
90
  end
90
- attempt = 0
91
91
  begin
92
- attempt += 1
93
92
  result = parse(post(body, dropped, dropped_bytes, backpressure_factor, batch_id), expected_count: sent)
94
- if attempt < 2 && (500..599).cover?(result.status)
95
- result = parse(post(body, dropped, dropped_bytes, backpressure_factor, batch_id), expected_count: sent)
96
- end
97
93
  apply_status_policy(result)
98
94
  result
99
95
  rescue StandardError => e
100
- retry if attempt < 2
101
96
  Result.new(ok: false, error: "#{e.class}: #{e.message}")
102
97
  end
103
98
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/railwatch.rb CHANGED
@@ -343,10 +343,32 @@ module Railwatch
343
343
 
344
344
  # Called (rescued) whenever the gem itself rescues an internal exception:
345
345
  # a subscriber block raising, or delivery failing after its retry.
346
- # Falls back to the debug log when no callback is registered.
346
+ #
347
+ # With no callback registered, an internal error goes to the debug log:
348
+ # the gem recovered, and a subscriber that raises once is noise in
349
+ # someone else's cron output. A DeliveryError is different. It is raised
350
+ # only when records are already gone -- a batch dropped after the retry
351
+ # ladder, one the receiver permanently refused, or the ones still unsent
352
+ # when at_exit's bounded shutdown ran out of time -- and since 0.3.7 that
353
+ # shutdown is the only delivery a rake task or runner gets. Behind
354
+ # RAILWATCH_DEBUG that loss was invisible: a cron job whose exception
355
+ # never reached the platform looked exactly like one that had nothing to
356
+ # say.
357
+ #
358
+ # It is still not printed by default. A monitoring gem writing into
359
+ # someone else's cron output is the gem changing their application's
360
+ # behaviour, which is not a trade this one makes to report on itself: it
361
+ # stays additive and out of the way. Ask for it with warn_on_data_loss and
362
+ # it becomes one stderr line, or register on_unrecoverable and route it
363
+ # wherever the app already sends such things. Kernel#warn, not
364
+ # Rails.logger, for the same reason as #debug: it must never become a log
365
+ # record about itself.
347
366
  def notify_unrecoverable(error)
348
367
  if config.on_unrecoverable
349
368
  config.on_unrecoverable.call(error)
369
+ elsif error.is_a?(Reporter::DeliveryError) && config.warn_on_data_loss
370
+ warn("[railwatch] #{error.message}. Register Railwatch.on_unrecoverable to route this elsewhere, " \
371
+ "or set warn_on_data_loss = false to silence it.")
350
372
  else
351
373
  debug { "unrecoverable internal error: #{error.class}: #{error.message}" }
352
374
  end
@@ -599,6 +621,18 @@ module Railwatch
599
621
  File.expand_path("../db/#{database}_migrate", __dir__)
600
622
  end
601
623
 
624
+ # Where links the models build (a trace from an execution page, a saved
625
+ # view's page) resolve. The engine's own routes serve the embedded
626
+ # dashboard; a host that mounts these pages on its own routes, as
627
+ # Railwatch Cloud does, points this at them.
628
+ def self.url_helpers
629
+ @url_helpers || Engine.routes.url_helpers
630
+ end
631
+
632
+ def self.url_helpers=(helpers)
633
+ @url_helpers = helpers
634
+ end
635
+
602
636
  # The one browser client, relative to the gem root. The dashboard bundle
603
637
  # is built from it, the install generator copies it, the gem ships it (the
604
638
  # only app/frontend file it does), and a host with its own frontend build
@@ -235,7 +235,7 @@ namespace :railwatch do
235
235
  %w[railwatch railwatch_telemetry].each do |name|
236
236
  configured = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: name)
237
237
  check.call(!configured.nil?, "#{name} database",
238
- configured ? configured.database : "not in config/database.yml (bin/rails generate railwatch:install --local)",
238
+ configured ? configured.database : "not in config/database.yml (bin/rails generate railwatch:install)",
239
239
  fatal: true)
240
240
  end
241
241
  { "railwatch_telemetry" => Railwatch::TelemetryRecord, "railwatch" => Railwatch::ApplicationRecord }.each do |name, base|
@@ -326,12 +326,19 @@ namespace :railwatch do
326
326
  # HTTP Basic is on and closed until credentials exist; a deploy that
327
327
  # forgot gets a 401, not a public page, and this says so first.
328
328
  gate = config.dashboard_gate
329
- check.call(gate != :undeclared && !(gate == :basic && !config.http_basic_auth_configured?), "dashboard access",
329
+ check.call(gate != :undeclared && !(gate == :basic && !config.http_basic_auth_configured? && !config.http_basic_auth_waived?),
330
+ "dashboard access",
330
331
  case gate
331
332
  when :basic
332
- config.http_basic_auth_configured? ? "HTTP Basic, user #{config.http_basic_auth_user}" :
333
+ if config.http_basic_auth_configured?
334
+ "HTTP Basic, user #{config.http_basic_auth_user}"
335
+ elsif config.http_basic_auth_waived?
336
+ "open in development. Production stays closed until you run " \
337
+ "`RAILS_ENV=production bin/rails railwatch:authentication:configure`"
338
+ else
333
339
  "closed: HTTP Basic is on with no credentials, so every dashboard request is 401. " \
334
- "Run `bin/rails railwatch:authentication:configure`"
340
+ "Run `bin/rails railwatch:authentication:configure`"
341
+ end
335
342
  when :controller then "your own: c.base_controller_class = #{config.base_controller_class}"
336
343
  when :resolver then "your own: c.dashboard_user decides, and live updates follow it"
337
344
  when :open then "deliberately open: anyone who can reach the mount can read it (c.dashboard_open)"
data/llms.txt CHANGED
@@ -4,12 +4,16 @@
4
4
  > requests, jobs, scheduled tasks, rake/runner commands, database queries and
5
5
  > N+1s, exceptions, cache, mail, notifications, broadcasts, outgoing HTTP,
6
6
  > Active Storage, view renders, and logs — links every one of them into a
7
- > single execution tree by `execution_id`/`trace_id`, and ships them to
8
- > Railwatch Cloud from a background thread. It replaces a separate APM and a
7
+ > single execution tree by `execution_id`/`trace_id`, and writes them from a
8
+ > background thread into the app's own SQLite files (embedded, the default) or
9
+ > to Railwatch Cloud. It replaces a separate APM and a
9
10
  > separate error tracker with one gem and one configuration block, costs
10
11
  > under a millisecond of CPU per request, and never writes to the
11
12
  > application's own database. Install is `bundle add railwatch` followed by
12
- > `bin/rails generate railwatch:install`, which writes the initializer, mounts
13
+ > `bin/rails generate railwatch:install`, which by default keeps everything in
14
+ > the app (two SQLite databases, the dashboard at `/railwatch`); with
15
+ > `--cloud` or a token option it ships to Railwatch Cloud instead. It writes
16
+ > the initializer, mounts
13
17
  > the beacon engine, adds a Kamal post-deploy hook and the Inertia browser
14
18
  > client where the app has them, wires the test matchers, and then runs
15
19
  > `bin/rails railwatch:doctor` to print a ✓/✗ line for every piece.
@@ -31,7 +35,7 @@
31
35
 
32
36
  ## For coding agents
33
37
 
34
- - [AGENTS.md](AGENTS.md): how to install and use Railwatch from inside a Rails app — the facade methods, the spec matchers, `railwatch:doctor`, and the MCP hookup. Duplicated verbatim as [CLAUDE.md](CLAUDE.md).
38
+ - [AGENTS.md](AGENTS.md): how to install and use Railwatch from inside a Rails app — the facade methods, the spec matchers, `railwatch:doctor`, and the MCP hookup.
35
39
 
36
40
  ## Optional
37
41