railwatch 0.1.4 → 0.2.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 (302) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +10 -0
  4. data/app/channels/railwatch/environment_channel.rb +28 -0
  5. data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
  6. data/app/controllers/railwatch/alerts_controller.rb +48 -0
  7. data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
  8. data/app/controllers/railwatch/attachments_controller.rb +22 -0
  9. data/app/controllers/railwatch/beacon_controller.rb +67 -7
  10. data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
  11. data/app/controllers/railwatch/cache_events_controller.rb +30 -0
  12. data/app/controllers/railwatch/commands_controller.rb +16 -0
  13. data/app/controllers/railwatch/comments_controller.rb +11 -0
  14. data/app/controllers/railwatch/dashboard_controller.rb +69 -0
  15. data/app/controllers/railwatch/deploys_controller.rb +34 -0
  16. data/app/controllers/railwatch/deprecations_controller.rb +54 -0
  17. data/app/controllers/railwatch/environment_scoped.rb +99 -0
  18. data/app/controllers/railwatch/exceptions_controller.rb +53 -0
  19. data/app/controllers/railwatch/executions_controller.rb +13 -0
  20. data/app/controllers/railwatch/issues_controller.rb +258 -0
  21. data/app/controllers/railwatch/jobs_controller.rb +63 -0
  22. data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
  23. data/app/controllers/railwatch/logs_controller.rb +40 -0
  24. data/app/controllers/railwatch/mails_controller.rb +19 -0
  25. data/app/controllers/railwatch/notifications_controller.rb +11 -0
  26. data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
  27. data/app/controllers/railwatch/overview_controller.rb +38 -0
  28. data/app/controllers/railwatch/people_controller.rb +25 -0
  29. data/app/controllers/railwatch/processes_controller.rb +33 -0
  30. data/app/controllers/railwatch/profiles_controller.rb +50 -0
  31. data/app/controllers/railwatch/queries_controller.rb +59 -0
  32. data/app/controllers/railwatch/releases_controller.rb +75 -0
  33. data/app/controllers/railwatch/requests_controller.rb +55 -0
  34. data/app/controllers/railwatch/saved_views_controller.rb +53 -0
  35. data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
  36. data/app/controllers/railwatch/spans_controller.rb +47 -0
  37. data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
  38. data/app/controllers/railwatch/tenants_controller.rb +44 -0
  39. data/app/controllers/railwatch/thresholds_controller.rb +40 -0
  40. data/app/controllers/railwatch/traces_controller.rb +72 -0
  41. data/app/controllers/railwatch/transactions_controller.rb +21 -0
  42. data/app/controllers/railwatch/view_renders_controller.rb +28 -0
  43. data/app/controllers/railwatch/visits_controller.rb +53 -0
  44. data/app/helpers/railwatch/assets_helper.rb +52 -0
  45. data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
  46. data/app/jobs/railwatch/application_job.rb +7 -0
  47. data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
  48. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
  49. data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
  50. data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
  51. data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
  52. data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
  53. data/app/jobs/railwatch/performance_scan_job.rb +11 -0
  54. data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
  55. data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
  56. data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
  57. data/app/jobs/railwatch/rollup_job.rb +130 -0
  58. data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
  59. data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
  60. data/app/models/railwatch/alert.rb +229 -0
  61. data/app/models/railwatch/alert_rule.rb +121 -0
  62. data/app/models/railwatch/anomaly_rule.rb +33 -0
  63. data/app/models/railwatch/application.rb +44 -0
  64. data/app/models/railwatch/application_record.rb +33 -0
  65. data/app/models/railwatch/comment.rb +36 -0
  66. data/app/models/railwatch/deploy.rb +67 -0
  67. data/app/models/railwatch/environment.rb +54 -0
  68. data/app/models/railwatch/execution_presenter.rb +185 -0
  69. data/app/models/railwatch/filter_query.rb +143 -0
  70. data/app/models/railwatch/followup_receipt.rb +27 -0
  71. data/app/models/railwatch/ingest/batch.rb +305 -0
  72. data/app/models/railwatch/ingest/mapper.rb +575 -0
  73. data/app/models/railwatch/ingest/payload.rb +96 -0
  74. data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
  75. data/app/models/railwatch/ingest/writer.rb +137 -0
  76. data/app/models/railwatch/issue.rb +277 -0
  77. data/app/models/railwatch/issue_activity.rb +23 -0
  78. data/app/models/railwatch/issue_detection_presenter.rb +309 -0
  79. data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
  80. data/app/models/railwatch/maintenance_task.rb +53 -0
  81. data/app/models/railwatch/saved_view.rb +63 -0
  82. data/app/models/railwatch/telemetry/aggregations.rb +116 -0
  83. data/app/models/railwatch/telemetry/attachment.rb +31 -0
  84. data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
  85. data/app/models/railwatch/telemetry/broadcast.rb +13 -0
  86. data/app/models/railwatch/telemetry/cache_event.rb +16 -0
  87. data/app/models/railwatch/telemetry/child.rb +53 -0
  88. data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
  89. data/app/models/railwatch/telemetry/deprecation.rb +13 -0
  90. data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
  91. data/app/models/railwatch/telemetry/exception.rb +21 -0
  92. data/app/models/railwatch/telemetry/execution.rb +71 -0
  93. data/app/models/railwatch/telemetry/health_sample.rb +72 -0
  94. data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
  95. data/app/models/railwatch/telemetry/llm_call.rb +37 -0
  96. data/app/models/railwatch/telemetry/log.rb +85 -0
  97. data/app/models/railwatch/telemetry/mail.rb +13 -0
  98. data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
  99. data/app/models/railwatch/telemetry/notification.rb +13 -0
  100. data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
  101. data/app/models/railwatch/telemetry/person.rb +50 -0
  102. data/app/models/railwatch/telemetry/process.rb +10 -0
  103. data/app/models/railwatch/telemetry/profile.rb +37 -0
  104. data/app/models/railwatch/telemetry/query.rb +50 -0
  105. data/app/models/railwatch/telemetry/query_shape.rb +45 -0
  106. data/app/models/railwatch/telemetry/release_health.rb +63 -0
  107. data/app/models/railwatch/telemetry/rollup.rb +78 -0
  108. data/app/models/railwatch/telemetry/session.rb +24 -0
  109. data/app/models/railwatch/telemetry/span.rb +19 -0
  110. data/app/models/railwatch/telemetry/storage_op.rb +13 -0
  111. data/app/models/railwatch/telemetry/tenant.rb +221 -0
  112. data/app/models/railwatch/telemetry/transaction.rb +13 -0
  113. data/app/models/railwatch/telemetry/view_render.rb +13 -0
  114. data/app/models/railwatch/telemetry/visit.rb +24 -0
  115. data/app/models/railwatch/telemetry_record.rb +57 -0
  116. data/app/models/railwatch/threshold.rb +29 -0
  117. data/app/models/railwatch/user.rb +47 -0
  118. data/app/models/railwatch/viewer.rb +13 -0
  119. data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
  120. data/config/routes.rb +59 -0
  121. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
  122. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
  123. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
  124. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +71 -0
  125. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
  126. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
  127. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
  128. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
  129. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
  130. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
  131. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
  132. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
  133. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
  134. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
  135. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
  136. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
  137. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
  138. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
  139. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
  140. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
  141. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
  142. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
  143. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
  144. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
  145. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
  146. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
  147. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
  148. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
  149. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
  150. data/docs/configuration.md +26 -0
  151. data/docs/embedded.md +352 -0
  152. data/docs/getting-started.md +5 -0
  153. data/lib/generators/railwatch/install/install_generator.rb +222 -1
  154. data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
  155. data/lib/generators/railwatch/install/templates/post-deploy +8 -0
  156. data/lib/puma/plugin/railwatch.rb +170 -0
  157. data/lib/railwatch/authentication.rb +83 -0
  158. data/lib/railwatch/configuration.rb +134 -3
  159. data/lib/railwatch/dashboard_assets.rb +45 -0
  160. data/lib/railwatch/embedded.rb +54 -0
  161. data/lib/railwatch/engine.rb +89 -1
  162. data/lib/railwatch/ingest_request_body_limit.rb +10 -0
  163. data/lib/railwatch/json_compat.rb +60 -0
  164. data/lib/railwatch/maintenance.rb +183 -0
  165. data/lib/railwatch/patches/runner_command.rb +21 -1
  166. data/lib/railwatch/record.rb +33 -8
  167. data/lib/railwatch/reporter.rb +16 -0
  168. data/lib/railwatch/subscribers/process_info.rb +2 -1
  169. data/lib/railwatch/transport/local.rb +78 -0
  170. data/lib/railwatch/transport/socket.rb +183 -0
  171. data/lib/railwatch/version.rb +1 -1
  172. data/lib/railwatch/writer.rb +370 -0
  173. data/lib/railwatch.rb +38 -1
  174. data/lib/tasks/railwatch_tasks.rake +128 -0
  175. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  176. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  177. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  180. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  181. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  182. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  183. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  184. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  185. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  186. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  187. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  188. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  189. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  190. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  191. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  192. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  193. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  194. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  195. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  196. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  197. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  198. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  199. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  200. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  201. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  202. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  203. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  204. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  205. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  206. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  207. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  208. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  209. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  210. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  211. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  212. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  213. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  214. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  215. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  216. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  217. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  218. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  219. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  220. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  221. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  222. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  223. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  224. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  225. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  226. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  227. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  228. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  229. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  230. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  231. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  232. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  233. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  234. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  235. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  236. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  237. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  238. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  239. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  240. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  241. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  242. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  243. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  244. data/public/railwatch/assets/index-oVkururr.js +1 -0
  245. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  246. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  247. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  248. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  249. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  250. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  251. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  252. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  253. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  254. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  255. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  256. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  257. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  258. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  259. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  260. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  261. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  262. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  263. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  264. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  265. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  266. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  267. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  268. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  269. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  270. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  271. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  272. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  273. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  274. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  275. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  276. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  277. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  278. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  279. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  280. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  281. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  282. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  283. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  284. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  285. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  286. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  287. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  288. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  289. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  290. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  291. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  292. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  293. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  294. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  295. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  296. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  297. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  298. data/public/railwatch/icon.png +0 -0
  299. data/public/railwatch/icon.svg +5 -0
  300. data/public/railwatch/manifest.json +2171 -0
  301. data/public/railwatch/rails-vite.json +1 -0
  302. metadata +314 -4
data/docs/embedded.md ADDED
@@ -0,0 +1,352 @@
1
+ # Embedded mode: the dashboard inside your app
2
+
3
+ Railwatch can keep every record in your own application and serve the
4
+ full dashboard at `/railwatch`, with no token and no cloud. The gem's
5
+ reporter, buffer and sampling are the same; the only difference is
6
+ where a batch ends up. In embedded mode it is written straight into a
7
+ SQLite database your app owns, and the dashboard reads it back from
8
+ there.
9
+
10
+ Use it when one server runs the app. Telemetry lands in a file next to
11
+ your other SQLite databases, so several servers would each see only
12
+ their own slice. For more than one server, or for a team that wants one
13
+ place for many apps, point the gem at Railwatch Cloud instead
14
+ ([Getting started](getting-started.md)); the two are switchable with
15
+ one setting.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ bundle add railwatch
21
+ bin/rails generate railwatch:install --local
22
+ ```
23
+
24
+ Restart the app and open `/railwatch`. Then `bin/rails railwatch:doctor`
25
+ checks the wiring. The generator creates and migrates both databases
26
+ itself; `bin/rails db:prepare`, which a deploy already runs, migrates
27
+ them after every gem update.
28
+
29
+ The engine needs Active Job (its grouping and scan jobs are Active Job
30
+ classes even though embedded mode calls them directly) and loads it
31
+ itself. Action Cable is optional: with it the dashboard updates live,
32
+ without it (`rails new --minimal`) the pages refresh on navigation.
33
+
34
+ ## Your application's own database
35
+
36
+ Embedded mode does not care what your application runs on. The two
37
+ databases it adds are SQLite files either way, so the generated entries
38
+ name `adapter: sqlite3` themselves rather than inheriting your default
39
+ block, and they need no `&default` anchor to exist.
40
+
41
+ On a PostgreSQL or MySQL app that means the install is two commands
42
+ rather than one, because SQLite's adapter gem will not be in your bundle:
43
+
44
+ ```sh
45
+ bin/rails generate railwatch:install --local # adds gem "sqlite3", writes the config
46
+ bundle install
47
+ bin/rails db:prepare # creates the two SQLite files
48
+ ```
49
+
50
+ Verified end to end on both. On a PostgreSQL app and on a MySQL app, the
51
+ application's own four databases stay where they were, Railwatch's two are
52
+ files under `storage/`, and neither server gains a single Railwatch table.
53
+
54
+ What `--local` writes, on top of the usual install:
55
+
56
+ - `config/initializers/railwatch.rb` with `c.transport = :local` and the
57
+ dashboard's own paths excluded from request capture.
58
+ - Two databases in every environment of `config/database.yml`:
59
+ `railwatch` (issues, comments, saved views, thresholds, deploys:
60
+ small, permanent) and `railwatch_telemetry` (everything the app
61
+ reports: written continuously, pruned nightly). A flat
62
+ `development:` entry is nested under `primary:` first, since named
63
+ databases need that form. Each entry's `migrations_paths` points into
64
+ the gem, so `db:prepare` builds the tables from the gem's own
65
+ migrations and nothing is copied into `db/`.
66
+ - `mount Railwatch::Engine, at: "/railwatch"`, as always.
67
+
68
+ Nothing touches your primary database.
69
+
70
+ ## Upgrading
71
+
72
+ The engine's tables migrate the way Active Storage's do: the migrations
73
+ live in the gem and each database's `migrations_paths` points at them.
74
+ After `bundle update railwatch`, run `bin/rails db:prepare` (or
75
+ `db:migrate`) and whatever is new applies; a deploy that already runs
76
+ one of those needs nothing extra. `bin/rails railwatch:doctor` reports
77
+ pending migrations for both databases. Tables in the `railwatch` file
78
+ are prefixed `railwatch_`; the telemetry file's tables are the hosted
79
+ platform's schema and are unprefixed.
80
+
81
+ ## What you get
82
+
83
+ Every page of Railwatch Cloud: requests, jobs, scheduled tasks,
84
+ commands, queries, spans, exceptions, logs, cache, mail, notifications,
85
+ broadcasts, outgoing requests, LLM calls, storage, views, transactions,
86
+ deprecations, processes, releases, deploys, users, tenants, visits,
87
+ thresholds; issues with comments, assignment, merge and split; saved
88
+ views; alert rules and the alert log. Live updates arrive over Action
89
+ Cable when the app has it. The dashboard is a prebuilt bundle shipped
90
+ inside the gem, so the app needs no Node, no Vite and no asset pipeline
91
+ integration.
92
+
93
+ Not in embedded mode: accounts and members (the operator is whoever your
94
+ app lets through), integrations (alerts are recorded, not delivered),
95
+ and the MCP server.
96
+
97
+ ## Authentication
98
+
99
+ The dashboard shows every query, log line and exception your app
100
+ produced, so it works the way Mission Control Jobs does: **HTTP Basic
101
+ authentication is on and closed by default**. With no credentials
102
+ configured every dashboard request is 401, and `railwatch:doctor` says
103
+ so. Set them with
104
+
105
+ ```sh
106
+ bin/rails railwatch:authentication:configure
107
+ RAILS_ENV=production bin/rails railwatch:authentication:configure
108
+ ```
109
+
110
+ which writes them to that environment's Rails credentials:
111
+
112
+ ```yml
113
+ railwatch:
114
+ http_basic_auth_user: ops
115
+ http_basic_auth_password: secret
116
+ ```
117
+
118
+ `RAILWATCH_HTTP_BASIC_AUTH_USER` and `RAILWATCH_HTTP_BASIC_AUTH_PASSWORD`,
119
+ or `c.http_basic_auth_user =` / `c.http_basic_auth_password =` in the
120
+ initializer, do the same. The live-update channel checks the same
121
+ credentials (the browser sends them on the WebSocket handshake).
122
+
123
+ ### Your own authentication
124
+
125
+ Two ways, both from Mission Control's playbook. Either lets an admin of
126
+ your app in with no second password. Turn Basic off when you use one,
127
+ or both gates apply.
128
+
129
+ A base controller. Every dashboard controller inherits from it, so its
130
+ `before_action` runs first:
131
+
132
+ ```ruby
133
+ c.http_basic_auth_enabled = false
134
+ c.base_controller_class = "AdminController" # requires an admin, or redirects to sign-in
135
+ ```
136
+
137
+ Your controller's code runs inside the engine, whose route helpers take
138
+ precedence; reach your app's with `main_app.root_path`.
139
+
140
+ Or a routes constraint, which keeps the engine out of it entirely (for
141
+ example with the sessions Rails' authentication generator creates):
142
+
143
+ ```ruby
144
+ # config/routes.rb
145
+ constraints ->(request) { Session.find_by(id: request.cookie_jar.signed[:session_id])&.user&.admin? } do
146
+ mount Railwatch::Engine, at: "/railwatch"
147
+ end
148
+ ```
149
+
150
+ Requests that fail the constraint never reach the engine. A constraint is
151
+ invisible to the gem, though, so say that the engine's own gate is off on
152
+ purpose:
153
+
154
+ ```ruby
155
+ c.http_basic_auth_enabled = false
156
+ c.dashboard_open = true # "something in front of the mount gates this"
157
+ ```
158
+
159
+ ### Deliberately public
160
+
161
+ A dashboard on a private network or behind a VPN can be open, and saying
162
+ so is a setting rather than an omission:
163
+
164
+ ```ruby
165
+ c.http_basic_auth_enabled = false
166
+ c.dashboard_open = true
167
+ ```
168
+
169
+ With Basic off and none of `base_controller_class`, `dashboard_user` or
170
+ `dashboard_open` set, the gem cannot tell a deliberate choice from a
171
+ forgotten one. It serves the dashboard (a routes constraint it cannot see
172
+ is a legitimate answer) but logs a warning at every boot outside
173
+ development, `railwatch:doctor` reports the gate as undeclared, and live
174
+ updates are refused. Declaring any of the three settles it.
175
+
176
+ ### Live updates and `/cable`
177
+
178
+ Action Cable runs on your application's own `/cable` endpoint, not under
179
+ the engine's mount, so a routes constraint around `/railwatch` does not
180
+ cover it and a base controller cannot reach it. The live-update channel
181
+ therefore follows what you declared: HTTP Basic credentials are checked
182
+ on the WebSocket handshake, a `dashboard_user` resolver is consulted,
183
+ `dashboard_open` and `base_controller_class` are taken at their word, and
184
+ an undeclared gate is refused. The channel carries an ingest ping (a
185
+ timestamp and per-type counts) and never telemetry records.
186
+
187
+ ### Naming the operator, and what an id may be
188
+
189
+ Comments, saved views and issue activity record who did them. Give the
190
+ initializer a resolver and the dashboard shows that person instead of a
191
+ single "Operator":
192
+
193
+ ```ruby
194
+ c.dashboard_user = ->(request) do
195
+ user = User.find_by(id: request.session[:user_id])
196
+ user && { id: user.id, name: user.name, email: user.email }
197
+ end
198
+ ```
199
+
200
+ The `id` may be anything your application already uses: an integer, a
201
+ UUID, a ULID, an email. It is stored as an opaque string and handed back
202
+ to you; nothing joins on it and nothing parses it, because the engine has
203
+ no user table to check it against. Comments, saved views, issue activity
204
+ and assignment all key off whatever you return, so a person keeps their
205
+ own views and their name on their own comments however you identify them.
206
+
207
+ Returning `nil` refuses the request, which is what makes this an
208
+ authorisation rule as well as a label.
209
+
210
+ ## The writer process
211
+
212
+ Puma forks one Railwatch writer from its master when `config/puma.rb`
213
+ carries the plugin (`--local` adds it):
214
+
215
+ ```ruby
216
+ plugin :railwatch if defined?(Railwatch)
217
+ ```
218
+
219
+ Every web worker keeps its reporter thread, but instead of writing
220
+ SQLite it hands each batch to the writer over a Unix socket
221
+ (`tmp/sockets/railwatch-writer.sock`, `RAILWATCH_WRITER_SOCKET`). The
222
+ socket's directory is created mode 0700 and the socket 0600, so only
223
+ the app's own user can reach it; keep it that way if you move it. Linux
224
+ caps the whole path at 108 bytes, so an app checked out deep in the
225
+ filesystem should point this at a directory of its own under
226
+ `/run/user/$UID` or `/tmp` (not a bare file in `/tmp`). Single and
227
+ cluster mode alike: a default Rails 8 app runs Puma with no workers, and
228
+ its batches come off its request threads just the same. The
229
+ writer maps the records, writes both databases, folds the rollups,
230
+ groups exceptions into issues and runs the maintenance clock below. It
231
+ is the only process that ever holds the telemetry database's write lock,
232
+ and its Ruby interpreter is its own, so none of that work is ever
233
+ interleaved with a request. Same shape as Solid Queue's
234
+ `solid_queue_mode :fork`: it exits when Puma does, and Puma restarts it
235
+ if it dies. While it is down the reporter keeps batches in memory, with
236
+ the same byte ceiling and backoff as the HTTP transport, and every
237
+ batch carries an id the writer records inside the write transaction, so
238
+ a batch delivered twice is written once.
239
+
240
+ The writer judges its own health. A single batch write that runs past
241
+ sixty seconds is a stuck writer, not a slow one (a lock that never
242
+ clears, a lost connection), and the process exits so Puma restarts it;
243
+ the batch is retained on the worker and written by the new writer. The
244
+ doctor reports both whether the socket answers and when the last batch
245
+ was actually written, since a process that is alive and a process that
246
+ is doing its job are different questions.
247
+
248
+ Whether a writer is expected decides what a missing one means. Puma
249
+ workers under the plugin expect one: a socket that is absent or not
250
+ answering is a writer that is starting or restarting, and they retain
251
+ batches and retry for as long as it takes. A process with no plugin
252
+ (`bin/rails runner`, a Solid Queue worker, a `rails server` without it,
253
+ the test suite) expects none, says so once under `RAILWATCH_DEBUG`, and
254
+ writes its batches in-process instead. That fallback is provisional: the
255
+ socket is tried again every 30 seconds, so a process that started before
256
+ the writer did hands the work back as soon as one is listening. What
257
+ changes is which process pays for the write, not whether the write
258
+ happens.
259
+
260
+ Retention while a writer is away is bounded, not infinite. A writer that
261
+ is restarting is back in seconds; one that is missing for a minute (an
262
+ unwritable socket directory, a fork that keeps failing) is treated as
263
+ absent and the worker writes its own batches again, still re-checking, so
264
+ the records are kept rather than retained to the reporter's retry cap. A
265
+ batch that does exhaust that ladder is counted as dropped and reported
266
+ through `Railwatch.on_unrecoverable`, so loss is never silent. A Puma
267
+ phased restart stops the writer and starts a fresh one once the new
268
+ workers are up.
269
+
270
+ ## Maintenance
271
+
272
+ Railwatch needs no job worker and nothing in `config/recurring.yml`.
273
+ The work that keeps the dashboard current happens in two places:
274
+
275
+ - **As each batch lands.** The writer writes the batch, folds its rows
276
+ into the hour's rollups, and groups any exceptions into issues, all
277
+ before it picks up the next batch. Counts, percentiles and the issues
278
+ list move with every batch.
279
+ - **On Railwatch's own clock.** The writer runs a `railwatch-maintenance`
280
+ thread that wakes every 30 seconds. Without a writer, every web and
281
+ worker process runs one, and one process at a time runs each task,
282
+ claimed through a lease row in the `railwatch` database, so a Puma
283
+ cluster and a Solid Queue worker on the same server do not all prune
284
+ at once.
285
+
286
+ | Task | Cadence | What it does |
287
+ | --- | --- | --- |
288
+ | drain follow-ups | every minute | Finishes the exception grouping of any batch whose process died right after the batch committed |
289
+ | release health | every minute | Hourly crash-free aggregates for the current and previous hour |
290
+ | rollup reconcile | hourly | Recomputes the previous hour's rollups from raw rows, for records that arrived after their hour closed |
291
+ | performance scan | every 5 minutes | Threshold breaches become issues |
292
+ | anomaly scan | every 5 minutes | Anomaly rules, when any are enabled |
293
+ | scheduled tasks | every 10 minutes | Missed and late scheduled tasks |
294
+ | auto-resolve | daily | Resolves issues quiet for 14 days |
295
+ | prune | daily | Deletes telemetry older than `retention_days`, then `ANALYZE` |
296
+
297
+ Because the clock lives in the web process, it keeps running when the
298
+ job worker is down, which is exactly when "scheduled task X missed its
299
+ run" needs to be raised. With Solid Queue, the issue also says which of
300
+ three things happened: the scheduler never enqueued the run, it was
301
+ enqueued but no worker is running, or a worker is alive and it is
302
+ waiting behind a backlog.
303
+
304
+ Every batch is written exactly once. The reporter gives each batch an
305
+ id before its first attempt and the write records it in the same
306
+ transaction as the rows, so a write that fails (the file locked by a
307
+ backup, say) is retried with backoff and a retry of a batch that did
308
+ commit is a no-op. `bin/rails railwatch:doctor` reports the last
309
+ tick. If you installed a pre-release that added `Railwatch::*` entries to
310
+ `config/recurring.yml`, remove them; the doctor says so too.
311
+
312
+ ## Settings
313
+
314
+ ```ruby
315
+ Railwatch.configure do |c|
316
+ c.transport = :local # RAILWATCH_TRANSPORT=local
317
+ c.issue_prefix = "SHOP" # RAILWATCH_ISSUE_PREFIX; keys like SHOP-12
318
+ c.repository_url = "https://github.com/you/shop" # RAILWATCH_REPOSITORY_URL
319
+ c.retention_days = 7 # RAILWATCH_RETENTION_DAYS
320
+ c.http_basic_auth_enabled = true # RAILWATCH_HTTP_BASIC_AUTH_ENABLED; credentials from Rails credentials or env
321
+ c.base_controller_class = "ActionController::Base" # RAILWATCH_BASE_CONTROLLER_CLASS
322
+ c.dashboard_open = false # RAILWATCH_DASHBOARD_OPEN; "yes, public, on purpose"
323
+ c.dashboard_user = ->(request) { ... }
324
+ end
325
+ ```
326
+
327
+ Every other option (sampling, redaction, ignored record types) applies
328
+ unchanged. The default samples every execution; set `c.sample` lower on
329
+ a busy app.
330
+
331
+ ## Deploys
332
+
333
+ `bin/rails railwatch:deploy` records the marker in the app's own
334
+ database instead of posting it, and the Kamal post-deploy hook does the
335
+ same when `RAILWATCH_TRANSPORT=local` is set on the deployer.
336
+
337
+ ## Storage and overhead
338
+
339
+ Telemetry is written by the reporter thread in batches, never on a
340
+ request. The cost on the request path is the same instrumentation the
341
+ cloud transport pays; the write itself adds under a millisecond at the
342
+ 99th percentile at a hundred requests per second on a single Puma. The
343
+ telemetry database grows with traffic and sampling and is pruned to
344
+ `retention_days`; the `railwatch` database stays small. Both are plain
345
+ SQLite files in `storage/`, so Litestream or a volume snapshot covers
346
+ them.
347
+
348
+ ## Switching to the cloud later
349
+
350
+ Set a token and drop `c.transport = :local` (or set
351
+ `RAILWATCH_TRANSPORT=http`). The local databases can stay; the dashboard
352
+ at `/railwatch` keeps reading what is there until it is pruned.
@@ -4,6 +4,11 @@ Five minutes from `bundle add` to a request on the dashboard, on a
4
4
  Rails 8 app. Everything below is the gem's own generator and rake tasks;
5
5
  nothing else has to be wired by hand.
6
6
 
7
+ Running one server and want the dashboard inside the app itself, with
8
+ no token and no cloud? That is `bin/rails generate railwatch:install
9
+ --local`; see [Embedded mode](embedded.md). The rest of this page is
10
+ the cloud install.
11
+
7
12
  ## 1. Add the gem
8
13
 
9
14
  ```sh
@@ -8,8 +8,11 @@ module Railwatch
8
8
  class InstallGenerator < Rails::Generators::Base
9
9
  source_root File.expand_path("templates", __dir__)
10
10
 
11
- desc "Creates config/initializers/railwatch.rb, a Kamal post-deploy hook, the browser client, and wires the test helpers."
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
13
 
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."
13
16
  class_option :token, type: :string,
14
17
  desc: "Deprecated: token in process arguments. Prefer --prompt-token, --token-stdin, or RAILWATCH_TOKEN."
15
18
  class_option :prompt_token, type: :boolean, default: false,
@@ -43,6 +46,75 @@ module Railwatch
43
46
  template "initializer.rb", "config/initializers/railwatch.rb"
44
47
  end
45
48
 
49
+ # Not a gemspec dependency: the breakage is the host application's
50
+ # either way (its own sessions are failing), so this offers the pin in
51
+ # the app's Gemfile, where the app can drop it the day Rails ships the
52
+ # fix, rather than constraining every bundle that installs this gem.
53
+ def pin_json_when_it_cannot_decode
54
+ return unless Railwatch::JsonCompat.broken?
55
+ return say("#{Railwatch::JsonCompat.advice} (no Gemfile here to add it to)", :yellow) unless File.exist?("Gemfile")
56
+
57
+ contents = File.read("Gemfile")
58
+ return say_status(:identical, "Gemfile (json pin)", :blue) if contents.match?(/^\s*gem ["']json["']/)
59
+
60
+ append_to_file "Gemfile", "#{contents.end_with?("\n") ? "" : "\n"}\n" \
61
+ "# #{Railwatch::JsonCompat::ISSUE}: Rails cannot decode with json 3 yet. Remove when it can.\n" \
62
+ "#{Railwatch::JsonCompat::PIN}\n"
63
+ @needs_bundle = true
64
+ say "#{Railwatch::JsonCompat.advice} Added the pin to your Gemfile; run `bundle install`, then " \
65
+ "`bin/rails db:prepare`.", :yellow
66
+ end
67
+
68
+ # Embedded mode stores telemetry in SQLite files whatever the app's own
69
+ # database is, so an app on PostgreSQL or MySQL needs the adapter gem
70
+ # added before those files can be created.
71
+ def ensure_sqlite3_gem
72
+ return unless options[:local]
73
+ return if Gem.loaded_specs.key?("sqlite3")
74
+ return say("--local needs the sqlite3 gem for its two databases; add `gem \"sqlite3\"` and re-run.", :yellow) unless File.exist?("Gemfile")
75
+
76
+ contents = File.read("Gemfile")
77
+ unless contents.match?(/^\s*gem ["']sqlite3["']/)
78
+ append_to_file "Gemfile", %(#{contents.end_with?("\n") ? "" : "\n"}\n# Railwatch (embedded) keeps its telemetry in two SQLite files of its own.\ngem "sqlite3"\n)
79
+ end
80
+ @needs_bundle = true
81
+ say "Added `gem \"sqlite3\"` to the Gemfile: Railwatch's two databases are SQLite files whatever this app's " \
82
+ "own database is. Run `bundle install`, then `bin/rails db:prepare` to create them.", :yellow
83
+ end
84
+
85
+
86
+ # Two databases of its own, never the app's primary: `railwatch` for
87
+ # what people author (issues, comments, saved views, thresholds) and
88
+ # `railwatch_telemetry` for what the app reports, which is written
89
+ # continuously and pruned. Their migrations live in the gem; the
90
+ # database entries point migrations_paths at them, so db:prepare
91
+ # creates the tables now and migrates them after every gem update.
92
+ # Nothing is copied into the app.
93
+ def configure_local_databases
94
+ return unless options[:local]
95
+
96
+ 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")
97
+
98
+ contents = File.read("config/database.yml")
99
+ updated = self.class.database_yml_with_railwatch(contents)
100
+ return say_status(:identical, "config/database.yml", :blue) if updated == contents
101
+
102
+ create_file "config/database.yml", updated, force: true
103
+ end
104
+
105
+ # The writer process: one per Puma master, forked by the gem's Puma
106
+ # plugin, so batches are mapped and written outside the web workers.
107
+ def configure_local_writer
108
+ return unless options[:local]
109
+ 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")
110
+
111
+ contents = File.read("config/puma.rb")
112
+ updated = self.class.puma_rb_with_railwatch(contents)
113
+ return say_status(:identical, "config/puma.rb", :blue) if updated == contents
114
+
115
+ create_file "config/puma.rb", updated, force: true
116
+ end
117
+
46
118
  def create_kamal_hook
47
119
  return unless File.exist?("config/deploy.yml")
48
120
  template "post-deploy", ".kamal/hooks/post-deploy"
@@ -92,6 +164,8 @@ module Railwatch
92
164
  # A token lands in .env only when Git confirms the file is ignored.
93
165
  # URLs are not secret and can still be written to a tracked dotenv file.
94
166
  def write_env
167
+ return if options[:local]
168
+
95
169
  token = resolved_token
96
170
  if options[:token]
97
171
  say("--token exposes #{Railwatch::SecretSafety.token_preview(options[:token])} in process arguments; " \
@@ -142,7 +216,53 @@ module Railwatch
142
216
  create_file "config/deploy.yml", updated, force: true
143
217
  end
144
218
 
219
+ # The two databases exist and are migrated when the generator returns:
220
+ # config/database.yml was just rewritten, so this re-reads it and runs
221
+ # the same prepare a deploy runs, for both databases only. The host's
222
+ # own databases are not touched, and a schema file is never written.
223
+ def prepare_local_databases
224
+ return unless options[:local]
225
+ return unless File.exist?("config/database.yml")
226
+ return if @needs_bundle
227
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
228
+
229
+ say "\nbin/rails db:prepare (railwatch, railwatch_telemetry)", :green
230
+ require "active_record"
231
+ ActiveRecord::Base.configurations = Rails.application.config.database_configuration
232
+ %w[railwatch railwatch_telemetry].each do |name|
233
+ db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: name)
234
+ next say(" #{name}: not in config/database.yml for #{Rails.env}", :yellow) unless db_config
235
+
236
+ ActiveRecord::Tasks::DatabaseTasks.with_temporary_pool_for_each(env: Rails.env, name: name) do
237
+ ActiveRecord::Tasks::DatabaseTasks.migrate
238
+ end
239
+ say_status :prepared, "#{name} (#{db_config.database})", :green
240
+ end
241
+ @prepared = true
242
+ rescue StandardError => e
243
+ say "Could not prepare the Railwatch databases here (#{e.class}: #{e.message}). Run `bin/rails db:prepare` yourself.", :yellow
244
+ end
245
+
145
246
  def show_next_steps
247
+ if options[:local]
248
+ say <<~STEPS, :green
249
+
250
+ Next steps
251
+ 1. Set the dashboard's HTTP Basic credentials (it is closed until
252
+ you do): bin/rails railwatch:authentication:configure
253
+ Using your own admin auth instead? See docs/embedded.md,
254
+ Authentication (base_controller_class or a routes constraint).
255
+ 2. Restart the app and open /railwatch.
256
+ 3. #{@prepared ? "Nothing else to run. Both databases were created just now and" : "Create the two databases: bin/rails db:prepare\n Then"}
257
+ `bin/rails db:prepare` (which a deploy already runs) migrates
258
+ them after every gem update. With `plugin :railwatch` in
259
+ config/puma.rb Puma forks one Railwatch writer process that
260
+ writes every batch and runs the maintenance clock, so no web
261
+ process ever holds the telemetry database. No job worker.
262
+ STEPS
263
+ return
264
+ end
265
+
146
266
  say <<~STEPS, :green
147
267
 
148
268
  Next steps
@@ -166,6 +286,10 @@ module Railwatch
166
286
  # note below says so rather than letting a ✗ look like a broken install.
167
287
  def run_doctor
168
288
  return unless options[:doctor]
289
+ # This process read its configuration before the initializer was
290
+ # written, so in local mode the doctor would report an http transport
291
+ # with no token. The databases it would check were prepared above.
292
+ return if options[:local]
169
293
  return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
170
294
 
171
295
  say "\nbin/rails railwatch:doctor", :green
@@ -199,6 +323,103 @@ module Railwatch
199
323
  insert_lines(lines, block_end(lines, secret_start), " - #{name}\n")
200
324
  end
201
325
 
326
+ # Spelled out rather than `<<: *default`, on purpose. The host's default
327
+ # block carries ITS adapter: on a PostgreSQL or MySQL app inheriting it
328
+ # would ask that server for a database called
329
+ # "storage/production_railwatch.sqlite3". These two are always SQLite
330
+ # files the app owns, whatever the app's own database is, so they name
331
+ # their adapter themselves -- which also means the file needs no
332
+ # `&default` anchor to exist at all.
333
+ RAILWATCH_DATABASES = <<~YAML
334
+ railwatch:
335
+ adapter: sqlite3
336
+ database: storage/%<env>s_railwatch.sqlite3
337
+ pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %%>
338
+ timeout: 5000
339
+ migrations_paths: <%%= Railwatch.migrations_path(:railwatch) %%>
340
+ schema_dump: false
341
+ railwatch_telemetry:
342
+ adapter: sqlite3
343
+ database: storage/%<env>s_railwatch_telemetry.sqlite3
344
+ pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %%>
345
+ timeout: 5000
346
+ migrations_paths: <%%= Railwatch.migrations_path(:railwatch_telemetry) %%>
347
+ schema_dump: false
348
+ pragmas:
349
+ journal_mode: wal
350
+ synchronous: normal
351
+ mmap_size: 134217728
352
+ cache_size: -65536
353
+ temp_store: memory
354
+ YAML
355
+
356
+ # Adds the railwatch and railwatch_telemetry databases to every
357
+ # environment in config/database.yml. A flat environment
358
+ # (`development:` straight to `<<: *default`) becomes a `primary:`
359
+ # entry first, since named databases need the nested form. Text
360
+ # insertion rather than a YAML round trip, for the same reason as
361
+ # deploy_yml_with_secret: the comments are most of the file.
362
+ def self.database_yml_with_railwatch(contents)
363
+ lines = contents.lines
364
+ # Rails 8.1's non-Docker template leaves every production `database:`
365
+ # commented out ("path/to/persistent/storage/..."), so db:prepare
366
+ # cannot run in production at all until the host fills them in. The
367
+ # storage/ paths are what the Docker template writes and what
368
+ # config/deploy.yml mounts; use them.
369
+ lines = lines.map do |line|
370
+ line.sub(%r{\A(\s+)# database: path/to/persistent/storage/(\S+)$}, '\1database: storage/\2')
371
+ end
372
+ environments_in(lines).each do |env|
373
+ start = lines.index { |line| line.match?(/\A#{env}:\s*(#.*)?$/) }
374
+ next unless start
375
+
376
+ stop = block_end(lines, start)
377
+ block = lines[(start + 1)...stop]
378
+ next if block.any? { |line| line.match?(/\A\s+railwatch_telemetry:\s*$/) }
379
+
380
+ nested = block.any? { |line| line.match?(/\A [a-z_]+:\s*$/) }
381
+ unless nested
382
+ lines[(start + 1)...stop] = block.map { |line| line.strip.empty? ? line : " #{line}" }
383
+ lines.insert(start + 1, " primary:\n")
384
+ stop += 1
385
+ end
386
+ entries = format(RAILWATCH_DATABASES, env: env).lines.map { |line| " #{line}" }
387
+ lines = insert_lines(lines, stop, entries.join).lines
388
+ end
389
+ lines.join
390
+ end
391
+
392
+ # Unconditional: `bundle exec puma` evaluates this file before it loads
393
+ # the Rack app, so `if defined?(Railwatch)` would be false there and the
394
+ # writer would never start. The plugin itself is inert when Railwatch is
395
+ # absent, off, or not in embedded mode.
396
+ PUMA_PLUGIN_LINES = <<~RUBY
397
+
398
+ # Railwatch (embedded): fork one writer process from Puma so telemetry
399
+ # is mapped and written outside the processes serving requests.
400
+ plugin :railwatch
401
+ RUBY
402
+
403
+ # Appends the plugin line once. Puma's config is plain Ruby evaluated top
404
+ # to bottom, so the end of the file is always a valid place for it.
405
+ def self.puma_rb_with_railwatch(contents)
406
+ return contents if contents.include?("plugin :railwatch")
407
+
408
+ "#{contents.sub(/\n*\z/, "\n")}#{PUMA_PLUGIN_LINES}"
409
+ end
410
+
411
+ # Every environment the file defines, not a fixed three: an app with a
412
+ # `staging` (or `review`, or `qa`) environment needs the databases
413
+ # there too, and hardcoding names silently left it without them. A
414
+ # top-level key with a block under it, minus YAML's own anchors.
415
+ NON_ENVIRONMENT_KEYS = %w[default shared].freeze
416
+
417
+ def self.environments_in(lines)
418
+ lines.filter_map { |line| line[/\A([a-z_][a-z0-9_]*):\s*(?:&\S+\s*)?(?:#.*)?$/, 1] }
419
+ .reject { |name| NON_ENVIRONMENT_KEYS.include?(name) }
420
+ .uniq
421
+ end
422
+
202
423
  # Index of the first line after the block opened at `start`: the next
203
424
  # line indented no more deeply than it, ignoring blanks and comments,
204
425
  # then backed up over any trailing blank lines so an insertion lands
@@ -3,7 +3,46 @@
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] -%>
7
+ # Telemetry stays in this app's own railwatch_telemetry database and the
8
+ # dashboard is served at /railwatch. No token, no cloud. Put the mount
9
+ # behind your own authentication; this only names who is looking.
10
+ c.transport = :local # RAILWATCH_TRANSPORT
11
+ c.ignored_request_paths += ["/railwatch", %r{\A/railwatch/}]
12
+ # c.issue_prefix = "APP" # RAILWATCH_ISSUE_PREFIX; issue keys like APP-12
13
+ # c.repository_url = "https://github.com/you/app" # RAILWATCH_REPOSITORY_URL; source links from stack traces
14
+ # c.retention_days = 7 # RAILWATCH_RETENTION_DAYS; PruneTelemetryJob keeps this much
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)
19
+ # or RAILWATCH_HTTP_BASIC_AUTH_USER / _PASSWORD.
20
+ #
21
+ # Using your own admin auth instead? Turn Basic off and say which, so the
22
+ # gem knows the mount is covered and live updates can follow your rule:
23
+ # c.http_basic_auth_enabled = false # RAILWATCH_HTTP_BASIC_AUTH_ENABLED
24
+ # c.base_controller_class = "AdminController" # every dashboard page inherits it
25
+ # c.dashboard_user = ->(request) { ... } # or decide per request, below
26
+ #
27
+ # Genuinely public (a private network, a VPN, a constraint the gem cannot
28
+ # see)? Say so on purpose. Nothing else makes the dashboard public, and
29
+ # without one of the above the app logs a warning at every boot:
30
+ # c.dashboard_open = false # RAILWATCH_DASHBOARD_OPEN
31
+ # Who the dashboard names on comments and saved views. Returning nil also
32
+ # refuses the request, so this doubles as your authorisation rule:
33
+ # c.dashboard_user = ->(request) { user = Current.user; user&.admin? && { id: user.id, name: user.name, email: user.email } }
34
+
35
+ # The browser beacon (Inertia visit timings and JavaScript errors) is a
36
+ # public endpoint, like every browser telemetry endpoint: it is bounded by
37
+ # an origin allowlist, a per-client rate limit and a ceiling for the whole
38
+ # endpoint, not by a credential.
39
+ # c.beacon_enabled = false # RAILWATCH_BEACON
40
+ # c.beacon_allowed_origins = ["https://app.example.com"] # beyond this app's own
41
+ # c.beacon_rate_limit = 120 # per client IP per minute
42
+ # c.beacon_global_rate_limit = 6_000 # for the endpoint, per minute
43
+ <% else -%>
6
44
  # c.token = ENV["RAILWATCH_TOKEN"] # RAILWATCH_TOKEN (required)
45
+ <% end -%>
7
46
  # c.ingest_url = "https://railwatch.rebulk.com" # RAILWATCH_INGEST_URL
8
47
  # c.deploy = "release-name" # RAILWATCH_DEPLOY; platform/Git auto-detected
9
48
  # c.detect_deploy = false # RAILWATCH_DETECT_DEPLOY; default true