railwatch 0.1.3 → 0.2.0.pre1

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 (303) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +231 -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_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
  125. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
  126. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
  127. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
  128. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
  129. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
  130. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
  131. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
  132. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
  133. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
  134. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
  135. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
  136. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
  137. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
  138. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
  139. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
  140. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
  141. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
  142. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
  143. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
  144. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
  145. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
  146. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
  147. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
  148. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
  149. data/docs/configuration.md +26 -0
  150. data/docs/embedded.md +342 -0
  151. data/docs/getting-started.md +5 -0
  152. data/docs/records.md +16 -1
  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 +51 -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/llm.rb +149 -4
  169. data/lib/railwatch/subscribers/process_info.rb +2 -1
  170. data/lib/railwatch/transport/local.rb +78 -0
  171. data/lib/railwatch/transport/socket.rb +183 -0
  172. data/lib/railwatch/version.rb +1 -1
  173. data/lib/railwatch/writer.rb +370 -0
  174. data/lib/railwatch.rb +38 -1
  175. data/lib/tasks/railwatch_tasks.rake +128 -0
  176. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  177. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  180. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  181. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  182. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  183. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  184. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  185. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  186. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  187. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  188. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  189. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  190. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  191. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  192. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  193. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  194. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  195. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  196. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  197. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  198. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  199. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  200. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  201. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  202. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  203. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  204. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  205. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  206. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  207. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  208. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  209. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  210. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  211. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  212. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  213. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  214. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  215. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  216. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  217. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  218. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  219. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  220. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  221. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  222. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  223. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  224. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  225. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  226. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  227. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  228. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  229. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  230. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  231. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  232. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  233. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  234. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  235. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  236. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  237. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  238. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  239. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  240. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  241. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  242. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  243. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  244. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  245. data/public/railwatch/assets/index-oVkururr.js +1 -0
  246. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  247. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  248. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  249. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  250. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  251. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  252. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  253. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  254. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  255. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  256. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  257. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  258. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  259. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  260. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  261. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  262. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  263. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  264. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  265. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  266. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  267. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  268. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  269. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  270. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  271. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  272. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  273. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  274. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  275. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  276. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  277. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  278. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  279. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  280. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  281. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  282. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  283. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  284. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  285. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  286. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  287. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  288. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  289. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  290. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  291. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  292. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  293. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  294. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  295. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  296. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  297. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  298. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  299. data/public/railwatch/icon.png +0 -0
  300. data/public/railwatch/icon.svg +5 -0
  301. data/public/railwatch/manifest.json +2171 -0
  302. data/public/railwatch/rails-vite.json +1 -0
  303. metadata +313 -4
data/docs/embedded.md ADDED
@@ -0,0 +1,342 @@
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
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 writer process
201
+
202
+ Puma forks one Railwatch writer from its master when `config/puma.rb`
203
+ carries the plugin (`--local` adds it):
204
+
205
+ ```ruby
206
+ plugin :railwatch if defined?(Railwatch)
207
+ ```
208
+
209
+ Every web worker keeps its reporter thread, but instead of writing
210
+ SQLite it hands each batch to the writer over a Unix socket
211
+ (`tmp/sockets/railwatch-writer.sock`, `RAILWATCH_WRITER_SOCKET`). The
212
+ socket's directory is created mode 0700 and the socket 0600, so only
213
+ the app's own user can reach it; keep it that way if you move it. Linux
214
+ caps the whole path at 108 bytes, so an app checked out deep in the
215
+ filesystem should point this at a directory of its own under
216
+ `/run/user/$UID` or `/tmp` (not a bare file in `/tmp`). Single and
217
+ cluster mode alike: a default Rails 8 app runs Puma with no workers, and
218
+ its batches come off its request threads just the same. The
219
+ writer maps the records, writes both databases, folds the rollups,
220
+ groups exceptions into issues and runs the maintenance clock below. It
221
+ is the only process that ever holds the telemetry database's write lock,
222
+ and its Ruby interpreter is its own, so none of that work is ever
223
+ interleaved with a request. Same shape as Solid Queue's
224
+ `solid_queue_mode :fork`: it exits when Puma does, and Puma restarts it
225
+ if it dies. While it is down the reporter keeps batches in memory, with
226
+ the same byte ceiling and backoff as the HTTP transport, and every
227
+ batch carries an id the writer records inside the write transaction, so
228
+ a batch delivered twice is written once.
229
+
230
+ The writer judges its own health. A single batch write that runs past
231
+ sixty seconds is a stuck writer, not a slow one (a lock that never
232
+ clears, a lost connection), and the process exits so Puma restarts it;
233
+ the batch is retained on the worker and written by the new writer. The
234
+ doctor reports both whether the socket answers and when the last batch
235
+ was actually written, since a process that is alive and a process that
236
+ is doing its job are different questions.
237
+
238
+ Whether a writer is expected decides what a missing one means. Puma
239
+ workers under the plugin expect one: a socket that is absent or not
240
+ answering is a writer that is starting or restarting, and they retain
241
+ batches and retry for as long as it takes. A process with no plugin
242
+ (`bin/rails runner`, a Solid Queue worker, a `rails server` without it,
243
+ the test suite) expects none, says so once under `RAILWATCH_DEBUG`, and
244
+ writes its batches in-process instead. That fallback is provisional: the
245
+ socket is tried again every 30 seconds, so a process that started before
246
+ the writer did hands the work back as soon as one is listening. What
247
+ changes is which process pays for the write, not whether the write
248
+ happens.
249
+
250
+ Retention while a writer is away is bounded, not infinite. A writer that
251
+ is restarting is back in seconds; one that is missing for a minute (an
252
+ unwritable socket directory, a fork that keeps failing) is treated as
253
+ absent and the worker writes its own batches again, still re-checking, so
254
+ the records are kept rather than retained to the reporter's retry cap. A
255
+ batch that does exhaust that ladder is counted as dropped and reported
256
+ through `Railwatch.on_unrecoverable`, so loss is never silent. A Puma
257
+ phased restart stops the writer and starts a fresh one once the new
258
+ workers are up.
259
+
260
+ ## Maintenance
261
+
262
+ Railwatch needs no job worker and nothing in `config/recurring.yml`.
263
+ The work that keeps the dashboard current happens in two places:
264
+
265
+ - **As each batch lands.** The writer writes the batch, folds its rows
266
+ into the hour's rollups, and groups any exceptions into issues, all
267
+ before it picks up the next batch. Counts, percentiles and the issues
268
+ list move with every batch.
269
+ - **On Railwatch's own clock.** The writer runs a `railwatch-maintenance`
270
+ thread that wakes every 30 seconds. Without a writer, every web and
271
+ worker process runs one, and one process at a time runs each task,
272
+ claimed through a lease row in the `railwatch` database, so a Puma
273
+ cluster and a Solid Queue worker on the same server do not all prune
274
+ at once.
275
+
276
+ | Task | Cadence | What it does |
277
+ | --- | --- | --- |
278
+ | drain follow-ups | every minute | Finishes the exception grouping of any batch whose process died right after the batch committed |
279
+ | release health | every minute | Hourly crash-free aggregates for the current and previous hour |
280
+ | rollup reconcile | hourly | Recomputes the previous hour's rollups from raw rows, for records that arrived after their hour closed |
281
+ | performance scan | every 5 minutes | Threshold breaches become issues |
282
+ | anomaly scan | every 5 minutes | Anomaly rules, when any are enabled |
283
+ | scheduled tasks | every 10 minutes | Missed and late scheduled tasks |
284
+ | auto-resolve | daily | Resolves issues quiet for 14 days |
285
+ | prune | daily | Deletes telemetry older than `retention_days`, then `ANALYZE` |
286
+
287
+ Because the clock lives in the web process, it keeps running when the
288
+ job worker is down, which is exactly when "scheduled task X missed its
289
+ run" needs to be raised. With Solid Queue, the issue also says which of
290
+ three things happened: the scheduler never enqueued the run, it was
291
+ enqueued but no worker is running, or a worker is alive and it is
292
+ waiting behind a backlog.
293
+
294
+ Every batch is written exactly once. The reporter gives each batch an
295
+ id before its first attempt and the write records it in the same
296
+ transaction as the rows, so a write that fails (the file locked by a
297
+ backup, say) is retried with backoff and a retry of a batch that did
298
+ commit is a no-op. `bin/rails railwatch:doctor` reports the last
299
+ tick. If you installed a pre-release that added `Railwatch::*` entries to
300
+ `config/recurring.yml`, remove them; the doctor says so too.
301
+
302
+ ## Settings
303
+
304
+ ```ruby
305
+ Railwatch.configure do |c|
306
+ c.transport = :local # RAILWATCH_TRANSPORT=local
307
+ c.issue_prefix = "SHOP" # RAILWATCH_ISSUE_PREFIX; keys like SHOP-12
308
+ c.repository_url = "https://github.com/you/shop" # RAILWATCH_REPOSITORY_URL
309
+ c.retention_days = 7 # RAILWATCH_RETENTION_DAYS
310
+ c.http_basic_auth_enabled = true # RAILWATCH_HTTP_BASIC_AUTH_ENABLED; credentials from Rails credentials or env
311
+ c.base_controller_class = "ActionController::Base" # RAILWATCH_BASE_CONTROLLER_CLASS
312
+ c.dashboard_open = false # RAILWATCH_DASHBOARD_OPEN; "yes, public, on purpose"
313
+ c.dashboard_user = ->(request) { ... }
314
+ end
315
+ ```
316
+
317
+ Every other option (sampling, redaction, ignored record types) applies
318
+ unchanged. The default samples every execution; set `c.sample` lower on
319
+ a busy app.
320
+
321
+ ## Deploys
322
+
323
+ `bin/rails railwatch:deploy` records the marker in the app's own
324
+ database instead of posting it, and the Kamal post-deploy hook does the
325
+ same when `RAILWATCH_TRANSPORT=local` is set on the deployer.
326
+
327
+ ## Storage and overhead
328
+
329
+ Telemetry is written by the reporter thread in batches, never on a
330
+ request. The cost on the request path is the same instrumentation the
331
+ cloud transport pays; the write itself adds under a millisecond at the
332
+ 99th percentile at a hundred requests per second on a single Puma. The
333
+ telemetry database grows with traffic and sampling and is pruned to
334
+ `retention_days`; the `railwatch` database stays small. Both are plain
335
+ SQLite files in `storage/`, so Litestream or a volume snapshot covers
336
+ them.
337
+
338
+ ## Switching to the cloud later
339
+
340
+ Set a token and drop `c.transport = :local` (or set
341
+ `RAILWATCH_TRANSPORT=http`). The local databases can stay; the dashboard
342
+ 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
data/docs/records.md CHANGED
@@ -512,7 +512,13 @@ not a dependency — an app without it never emits these. Requires RubyLLM
512
512
 
513
513
  The model call also appears as an `outgoing_request`, since it is an HTTP
514
514
  call like any other. The two are different grains on purpose: the
515
- `outgoing_request` is the HTTP truth, the `llm_call` is what it cost.
515
+ `outgoing_request` is the HTTP truth, the `llm_call` is what it cost. That
516
+ difference is useful: RubyLLM retries through Faraday, so one `llm_call`
517
+ with several `outgoing_request` rows against it in the same execution is a
518
+ call that was retried. Over a window, `outgoing_requests - llm_calls` to the
519
+ same provider host is the number of *extra attempts*, not a rate -- the
520
+ share of calls that were retried needs counting the calls with more than one
521
+ request against them, which the execution id supports.
516
522
 
517
523
  **Token counts and cost differ by RubyLLM version.** 1.16 reports token
518
524
  counts and no cost at all. 2.0 reports both, from its usage ledger, and
@@ -556,6 +562,15 @@ so cost is always complete.
556
562
  | `workflow_step_id` | Step identifier within the workflow (2.0+). |
557
563
  | `workflow_step_name` | Step name (2.0+). |
558
564
  | `workflow_step_parent_id` | Enclosing step, for nested steps — what reconstructs the tree (2.0+). |
565
+ | `finish_reason` | Why the model stopped: `stop`, `max_tokens`, `tool_calls`, `content_filter`, or whatever the provider spelled it. `max_tokens` means the answer was cut off -- without this a truncated extraction reads exactly like a complete one. |
566
+ | `provider_request_id` | The provider's own id for the request, read from the response headers (`request-id`, `x-request-id`, `x-amzn-requestid`). The only key that joins this record to the provider's side of it, and what a support ticket asks for. |
567
+ | `tools` | Comma-separated names of the tools the model could reach, first 50. `tool_count` says how many; retracing needs which. |
568
+ | `cost_reported` | Whether the provider priced the call itself, or the amount is an estimate from the model registry. Null on gems or operations that report no cost. |
569
+ | `attachments` | How many files the last user turn carried. Absent when it carried none. Only the last turn is measured: earlier turns were counted by the calls that sent them. |
570
+ | `attachment_types` | What they were, by category and count, e.g. `imagex2,pdf`. Categories are RubyLLM's: image, pdf, audio, video, text, document, unknown. On a document-reading call the attachments are most of the input tokens, so without this an expensive scan is indistinguishable from an expensive prompt. |
571
+ | `attachment_names` | Filenames, only when `config.capture_llm_content` is on. A filename like `ACME_invoice_88231.pdf` is business data, not metadata, so it follows the same switch as prompts. |
572
+ | `params` | JSON of the settings that produced the answer, so a surprising one can be reproduced: `temperature`, `max_output_tokens`, `tool_choice`, `tool_call_limit`, `thinking`, `caching`, `citations`, whether a `schema` was used, plus the per-operation ones (`dimensions`, `task_type`, `size`, `count`, `voice`, `format`, `language`, `pages`, `document_count`, `top_n`), `server_tools` and the provider's `server_tool_use` counters. `provider_options` is included, filtered twice: through the app's own parameter filter, and again against the credential-name matcher that catches `X-Api-Key` on a header -- an `api_key` passed per call sails straight through a password-shaped filter. For `operation: "tool"` this holds the tool result's class instead. |
573
+ | `tool_call_id` | The provider's id for a tool invocation, for joining a tool call to the assistant turn that asked for it. |
559
574
  | `prompt` | Last user turn, only when `config.capture_llm_content` is on (off by default). Capped at 4 KiB of bytes. |
560
575
  | `completion` | The reply, same condition and cap. For `operation: "tool"` these two hold the tool's arguments and result instead. |
561
576
 
@@ -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