railwatch 0.1.4 → 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 (301) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +205 -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/lib/generators/railwatch/install/install_generator.rb +222 -1
  153. data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
  154. data/lib/generators/railwatch/install/templates/post-deploy +8 -0
  155. data/lib/puma/plugin/railwatch.rb +170 -0
  156. data/lib/railwatch/authentication.rb +83 -0
  157. data/lib/railwatch/configuration.rb +134 -3
  158. data/lib/railwatch/dashboard_assets.rb +45 -0
  159. data/lib/railwatch/embedded.rb +51 -0
  160. data/lib/railwatch/engine.rb +89 -1
  161. data/lib/railwatch/ingest_request_body_limit.rb +10 -0
  162. data/lib/railwatch/json_compat.rb +60 -0
  163. data/lib/railwatch/maintenance.rb +183 -0
  164. data/lib/railwatch/patches/runner_command.rb +21 -1
  165. data/lib/railwatch/record.rb +33 -8
  166. data/lib/railwatch/reporter.rb +16 -0
  167. data/lib/railwatch/subscribers/process_info.rb +2 -1
  168. data/lib/railwatch/transport/local.rb +78 -0
  169. data/lib/railwatch/transport/socket.rb +183 -0
  170. data/lib/railwatch/version.rb +1 -1
  171. data/lib/railwatch/writer.rb +370 -0
  172. data/lib/railwatch.rb +38 -1
  173. data/lib/tasks/railwatch_tasks.rake +128 -0
  174. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  175. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  176. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  177. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  180. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  181. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  182. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  183. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  184. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  185. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  186. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  187. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  188. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  189. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  190. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  191. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  192. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  193. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  194. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  195. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  196. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  197. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  198. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  199. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  200. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  201. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  202. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  203. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  204. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  205. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  206. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  207. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  208. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  209. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  210. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  211. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  212. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  213. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  214. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  215. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  216. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  217. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  218. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  219. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  220. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  221. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  222. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  223. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  224. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  225. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  226. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  227. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  228. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  229. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  230. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  231. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  232. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  233. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  234. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  235. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  236. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  237. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  238. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  239. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  240. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  241. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  242. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  243. data/public/railwatch/assets/index-oVkururr.js +1 -0
  244. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  245. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  246. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  247. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  248. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  249. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  250. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  251. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  252. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  253. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  254. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  255. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  256. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  257. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  258. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  259. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  260. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  261. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  262. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  263. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  264. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  265. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  266. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  267. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  268. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  269. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  270. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  271. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  272. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  273. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  274. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  275. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  276. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  277. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  278. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  279. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  280. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  281. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  282. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  283. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  284. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  285. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  286. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  287. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  288. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  289. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  290. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  291. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  292. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  293. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  294. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  295. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  296. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  297. data/public/railwatch/icon.png +0 -0
  298. data/public/railwatch/icon.svg +5 -0
  299. data/public/railwatch/manifest.json +2171 -0
  300. data/public/railwatch/rails-vite.json +1 -0
  301. metadata +313 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b78b98999447f64098f912cdfdcbb6202c9331ac4e07988fb350080d084f79c
4
- data.tar.gz: e230f37a0a728c381fc4b84849bc0db762ad14e2b9c37aa5802850b97cba7271
3
+ metadata.gz: 9500835353b9f428ff09ae908f9cbc360c44f0ea01740139738ec8c06ce73eb2
4
+ data.tar.gz: 7b4e2b48a881ef09243af6d2f51865609bd5615e55d27d9fca784f3e2740de7b
5
5
  SHA512:
6
- metadata.gz: 27c7659440a7e1592b99d10ad2a409c711a2b5a44cafee9a4707ff8672e0952fcf6819ad5b98c96f4e9d25ae6e1c5b86193ebb70a74b52904f8aee808977c72d
7
- data.tar.gz: c1101b7933f5703f4dfd7469b07bf9ac3f39cfaee7bd2a9d451e30126921c33a0326c5976d8605e7dce39f13b10b8d65fd8052007051775bc9e46f0b5cbb6368
6
+ metadata.gz: 0d38ab1fced44ae3168de8f7864b858732ad07a26a4a9a96d82b74891d3b20144b9cc19a703743a127a2d270552ba422e9d51c1c9f8e768077c5a12364f613c8
7
+ data.tar.gz: 0e4ad6b17f76856cc2f160c64c992ed8405e50ba9485563c6e3b40495fdb11c1da756d8aa0317adec1d78b93f4b3dc6783de196c33c42d58dbbc606e3491310f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,210 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Embedded mode: `bin/rails generate railwatch:install --local` keeps
6
+ every record in two SQLite databases the app owns (`railwatch` for
7
+ issues, comments, saved views, thresholds and deploys;
8
+ `railwatch_telemetry` for what the app reports) and serves the whole
9
+ Railwatch dashboard at `/railwatch`, from a bundle shipped inside the
10
+ gem. No token, no Node, no asset pipeline. `c.transport = :local`
11
+ (`RAILWATCH_TRANSPORT=local`) switches the reporter to write batches
12
+ in-process; everything else about sampling, redaction and buffering is
13
+ unchanged. The installer adds the databases (migrated from the gem's own
14
+ migration history, so a gem update is followed by `db:prepare` and
15
+ nothing else) and an
16
+ initializer with `issue_prefix`, `repository_url`, `retention_days`
17
+ and a `dashboard_user` resolver. `railwatch:doctor`, `railwatch:status`
18
+ and `railwatch:deploy` understand the mode. See docs/embedded.md.
19
+ - Embedded mode needs no job worker. Exceptions are grouped into issues
20
+ as each batch lands, and release health, threshold and anomaly scans,
21
+ missed scheduled tasks, auto-resolve and pruning run from
22
+ `Railwatch::Maintenance`, a clocked thread in every web and worker
23
+ process (one process per server runs each task, leased through the new
24
+ `railwatch_maintenance_tasks` table). Nothing goes through Active Job,
25
+ so the gem never writes the host's queue adapter, and a dead worker
26
+ cannot hide its own missed runs. The installer no longer edits
27
+ `config/recurring.yml`; remove any `Railwatch::*` entries a pre-release
28
+ added there.
29
+ - Embedded ingest folds each batch into the hourly rollups as it lands
30
+ (`Ingest::RollupAbsorber`), so the dashboard's counts and percentiles
31
+ move with every batch; the hosted platform's per-batch RollupJob
32
+ recompute is not enqueued in embedded mode and the minute-long
33
+ aggregate cache is bypassed there.
34
+ - Embedded mode gets a writer process. `plugin :railwatch` in
35
+ `config/puma.rb` (added by `--local`) forks one `Railwatch::Writer`
36
+ from the Puma master, the way Solid Queue's in-Puma mode does. Web
37
+ workers hand their batches to it over a Unix socket
38
+ (`Transport::Socket`) instead of writing SQLite on their own reporter
39
+ thread, so mapping, the write lock, rollups, issue grouping and the
40
+ maintenance clock all run in a process whose interpreter no request
41
+ shares. The writer is restarted by Puma if it dies and stops with it;
42
+ a process with no writer (a runner, a Solid Queue worker, a server
43
+ without the plugin) writes in-process from its first miss. The doctor
44
+ reports whether the writer is listening. Measured
45
+ on a two-worker Puma host under five minutes of open-loop load with
46
+ every record sampled: all-paths p95 817 ms with the writer against
47
+ 938 ms writing on the worker threads and 716 ms with Railwatch off, and
48
+ each worker's reporter thread fell from 7 s of CPU per minute of load
49
+ to 2 s over the whole run.
50
+ - Hardening from an adversarial review of the writer: a batch's
51
+ exceptions are counted onto an issue exactly once however many times
52
+ its follow-ups run (a `railwatch_followup_receipts` row per batch and
53
+ group, committed with the count); the wedge guard tracks each write by
54
+ invocation so a retry of the same batch cannot hide a stuck original;
55
+ the writer reads and writes under deadlines, bounds inflation and the
56
+ accept queue, binds inside a 0700 directory as a 0600 socket, and
57
+ refuses to take over a socket another writer is answering on; Puma
58
+ phased restarts restart the writer rather than losing it; workers
59
+ under the plugin retain batches while the writer is away, and any
60
+ other process falls back to in-process writing on the first miss
61
+ (including a stale socket file); a maintenance lease is released only
62
+ by the token that claimed it and a failed task is retried next tick;
63
+ maintenance failures report through `on_unrecoverable`, never
64
+ `Rails.error`; pruning is bounded per run. The plugin is cluster-mode
65
+ only.
66
+ - Embedded ingest keeps a delivery ledger. The reporter's batch id is
67
+ stored on the `ingest_batches` row inside the batch's own transaction,
68
+ so a batch the reporter retries after a failure is written once, and
69
+ a write that fails is now retried with the same backoff the HTTP
70
+ transport gets instead of being dropped. The exception grouping a
71
+ batch owes is recorded on that row as well and cleared when done; a
72
+ process that dies in between leaves it for `Railwatch::Maintenance` to
73
+ finish on its next tick, so an exception can no longer be stored
74
+ without ever becoming an issue.
75
+ - A missed scheduled task says why, when Solid Queue is the adapter:
76
+ the scheduler never enqueued the run, it was enqueued but no worker
77
+ is running, or it is enqueued and waiting behind a backlog. Read from
78
+ Solid Queue's own `recurring_executions` and `processes` tables.
79
+ - `rails runner script/x.rb` is a deployed script even when the whole
80
+ application is checked out under a scratch directory such as `/tmp`;
81
+ only a scratch path inside the app still counts as interactive.
82
+ - `RollupJob` no longer overwrites a rollup that a batch folded into
83
+ between its read and its write: a stored count higher than the
84
+ recomputed one is kept, and the next run picks the group up.
85
+ - `PruneTelemetryJob` takes `checkpoint:`; the embedded clock prunes with
86
+ a PASSIVE WAL checkpoint so it never blocks the app's own readers.
87
+ - The affected-user count on an issue is recomputed at most once every
88
+ five minutes per issue (and always for a new one) instead of on every
89
+ batch that touched the group.
90
+ - The gem now depends on `inertia_rails` and `tdigest` for the dashboard.
91
+ - Embedded install is one command and boots in production. `bin/rails
92
+ generate railwatch:install --local` now creates and migrates both
93
+ databases itself (no separate `db:prepare`), fills in the production
94
+ `database:` paths Rails 8.1's template leaves commented out. The two
95
+ entries name `adapter: sqlite3` themselves instead of inheriting the
96
+ app's default, so embedded mode works on a PostgreSQL or MySQL app (the
97
+ generator adds `gem "sqlite3"` there and asks for a `bundle install`
98
+ first) and needs no `&default` anchor to exist. The gem
99
+ depends on `json < 3` for now: Rails 8.1 cannot decode with json 3
100
+ (rails/rails#58784), which broke this gem's own SQLite migrations on a
101
+ fresh Ruby 3.4.10, and a dependency is what makes `bundle add
102
+ railwatch` resolve past it. The engine loads Active Job itself
103
+ and treats Action Cable as optional, so an app from `rails new
104
+ --minimal` boots with it.
105
+ - The engine's models never fall back to the host's primary database. Both
106
+ abstract bases rescue a missing `database.yml` entry so a cloud-transport
107
+ app still boots and eager-loads them, but using one then raises
108
+ `Railwatch::DatabaseNotConfigured` instead of inheriting
109
+ `ActiveRecord::Base`'s connection -- where the unprefixed telemetry
110
+ tables (`sessions`, `visits`, `people`, `notifications`) are the
111
+ application's own.
112
+ - The in-process fallback is provisional: a process that found no writer
113
+ re-checks the socket every 30 seconds instead of writing its own batches
114
+ for the rest of its life, and a process that expects a writer stops
115
+ retaining and writes its own after a minute without one, rather than
116
+ holding batches until the retry cap drops them. A batch abandoned after the retry cap is now
117
+ reported through `on_unrecoverable` rather than only under
118
+ `RAILWATCH_DEBUG`, and `railwatch:doctor` no longer calls a configured
119
+ but absent writer a pass.
120
+ - The generated Puma line is `plugin :railwatch`, unconditional:
121
+ `bundle exec puma` evaluates `config/puma.rb` before it loads the app,
122
+ so the old `if defined?(Railwatch)` guard meant a writer was never
123
+ started there. The plugin itself now decides whether to run after boot.
124
+ - Embedded mode is verified on PostgreSQL and MySQL hosts, not only
125
+ SQLite ones. The telemetry databases are SQLite files whatever the
126
+ application runs on, so the install is `generate`, `bundle install`
127
+ (for the sqlite3 gem the generator adds), then `db:prepare`. On both,
128
+ the app's own databases keep every table they had and the server gains
129
+ no Railwatch table at all.
130
+ - Releasing is gated on the thing that silently breaks it. `gem build`
131
+ globs its file list, so a missing or half-built dashboard produces a
132
+ gem that installs cleanly and serves a blank page; `rake
133
+ package:assert_dashboard` refuses to publish one, and the release
134
+ workflow runs it between building the bundle and building the gem. The
135
+ publish job also installs the bundle it was already calling `bundle
136
+ exec` against, which it had never done. Built `.gem` files are no
137
+ longer tracked in git.
138
+ - The browser beacon is bounded the way a hosted product bounds a public
139
+ ingest endpoint, since one cannot hold a credential the page does not
140
+ already give away: an origin allowlist (`beacon_allowed_origins`,
141
+ same-origin by default, the analogue of Sentry's allowed domains), the
142
+ per-client rate limit, and a new ceiling for the endpoint as a whole
143
+ (`beacon_global_rate_limit`, 6,000/minute) so a rotating address cannot
144
+ multiply past the first. Both limits fail closed on a cache store that
145
+ cannot count, where the limiter used to fail open.
146
+ - How the dashboard is gated is now something the app states rather than
147
+ something the gem guesses. `Configuration#dashboard_gate` names it:
148
+ HTTP Basic, a `base_controller_class`, a `dashboard_user` resolver that
149
+ can refuse, or `dashboard_open = true` for a dashboard that is public on
150
+ purpose (a private network, a VPN, a routes constraint the gem cannot
151
+ see). With Basic off and none of them set the gate is undeclared: the
152
+ dashboard still serves, because a constraint is a legitimate answer, but
153
+ the app logs a warning at every boot outside development, the doctor
154
+ reports it, and live updates are refused. The live channel follows the
155
+ declared gate, which matters because Action Cable runs on the host's own
156
+ `/cable` endpoint that no routes constraint around the mount covers.
157
+ - Railwatch no longer pins `json` for the host. The Rails 8.1 and json 3
158
+ incompatibility (rails/rails#58784) is the application's own, and a
159
+ gemspec dependency would constrain every bundle for it. The gem detects
160
+ the pair by asking it to decode rather than by comparing version
161
+ numbers, so a patched Rails or a backport is judged correctly and the
162
+ warning goes quiet by itself when Rails ships the fix. The installer
163
+ offers the pin in the app's Gemfile, the doctor reports it, and the app
164
+ logs it once at boot.
165
+ - Both database bases also survive an entry whose adapter gem is not in
166
+ the bundle yet (a `LoadError` rather than `AdapterNotSpecified`), which
167
+ is the state a PostgreSQL app is in between the installer adding
168
+ `gem "sqlite3"` and the `bundle install` that follows.
169
+ - The embedded dashboard authenticates the way Mission Control Jobs
170
+ does: HTTP Basic is on and closed by default, so with no credentials
171
+ every dashboard page answers 401 (with a note saying what to run), the
172
+ live channel refuses the subscription, and the doctor reports it. The
173
+ beacon endpoint and the dashboard's own static assets stay public, as
174
+ they must be. `bin/rails
175
+ railwatch:authentication:configure` writes
176
+ `railwatch.http_basic_auth_user/_password` to the environment's Rails
177
+ credentials; `RAILWATCH_HTTP_BASIC_AUTH_USER/_PASSWORD` or the
178
+ initializer do the same. A host with its own admin auth sets
179
+ `c.http_basic_auth_enabled = false` and either
180
+ `c.base_controller_class` (the dashboard controllers inherit from it)
181
+ or a routes constraint around the mount. Before this a production
182
+ install served every query and log line to anyone who found the URL.
183
+ - The Puma plugin forks the writer in single mode too (the default for a
184
+ Rails 8 app). It stops the writer from `at_exit`, after Puma's run loop
185
+ has returned: Puma's SIGTERM trap fires `after_stopped` BEFORE it drains
186
+ in-flight requests, so stopping there took the writer away from requests
187
+ that were still running and lost their records. The live-update
188
+ broadcast after a batch rescues a `LoadError` as well: a host whose
189
+ production `cable.yml` names redis without the gem (Rails 8.1's
190
+ non-Docker template) used to take the writer down on every batch.
191
+ - Puma workers under the plugin actually use the writer. The socket
192
+ transport captured "is a writer expected" when it was built, and
193
+ `rails server` builds the reporter (app boot) before Puma evaluates
194
+ `config/puma.rb` (where the plugin sets the flag), so every worker
195
+ inherited a transport that expected no writer, missed the socket once
196
+ during the writer's startup, and wrote its own batches in-process for
197
+ the rest of its life. The flag is now read at delivery time. On the
198
+ dogfood host this is the difference between a reporter thread at 3.6 ms
199
+ of CPU per request in each worker and one at 0.2 ms, with the writer
200
+ process doing the 2.8 ms.
201
+ - The per-record memory estimate on the request thread
202
+ (`Record.buffered_bytes`, run once for every query, cache event and log
203
+ line an execution buffers) walks a record in one loop instead of one
204
+ method call per value: 6.2 to 2.7 us for a query record, about 120 us
205
+ off a 20-query request. Same numbers for every record shape, including
206
+ at the depth bound and on a self-referential one.
207
+
3
208
  ## 0.1.4 (2026-09-15)
4
209
 
5
210
  - `llm_call` records what the call carried and how it was configured, not
data/README.md CHANGED
@@ -15,6 +15,14 @@ bin/rails generate railwatch:install --prompt-token # 2. hidden token input plu
15
15
  bin/rails railwatch:doctor # 3. check every piece is wired up after restart
16
16
  ```
17
17
 
18
+ Or keep everything inside your app, with the full dashboard at
19
+ `/railwatch` and no token ([Embedded mode](docs/embedded.md)):
20
+
21
+ ```sh
22
+ bundle add railwatch
23
+ bin/rails generate railwatch:install --local
24
+ ```
25
+
18
26
  Getting the token, the generator's flags, and deploying with Kamal,
19
27
  Docker, Heroku, or Render are covered in
20
28
  [Getting started](docs/getting-started.md). For a self-hosted deployment
@@ -76,6 +84,8 @@ expect { get "/widgets" }.not_to have_railwatch_n_plus_one
76
84
  option by option and call site by call site.
77
85
  - [Coming from Laravel Nightwatch](docs/replacing-nightwatch.md) — the
78
86
  record-type mapping and the sampling model, for Laravel people.
87
+ - [Embedded mode](docs/embedded.md) — the whole dashboard inside your
88
+ app, telemetry in your own SQLite files, no cloud.
79
89
  - [Self-hosting](docs/self-hosting.md) — pointing the gem at your own
80
90
  Railwatch Cloud.
81
91
  - [Troubleshooting](docs/troubleshooting.md) — every failure mode, paired
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Live dashboard updates: Ingest::Batch broadcasts "ingested" on
5
+ # environment_<id> after each write (throttled to one per 2 s), and the
6
+ # page reloads its props. Under HTTP Basic the browser sends the same
7
+ # Authorization header on the WebSocket handshake, so the channel checks
8
+ # the same credentials as the pages.
9
+ #
10
+ # With Basic off, Configuration#dashboard_channel_allowed? asks whichever
11
+ # gate the host declared: a dashboard_user resolver can refuse this
12
+ # request, a named base controller or an explicit dashboard_open is taken
13
+ # at its word, and an undeclared gate refuses. That last case matters
14
+ # because Action Cable runs on the host's own /cable endpoint: a routes
15
+ # constraint around the engine's mount does not cover it and a base
16
+ # controller cannot reach it, so "something in front of /railwatch" is not
17
+ # evidence about this. What a subscriber would see is the ingest ping (a
18
+ # timestamp and per-type counts), never telemetry records.
19
+ class EnvironmentChannel < ActionCable::Channel::Base
20
+ def subscribed
21
+ if params[:id].to_i == Environment::ID && Railwatch.config.dashboard_channel_allowed?(connection.request)
22
+ stream_from "environment_#{Environment::ID}"
23
+ else
24
+ reject
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Builds identity props from the current environment's telemetry database.
4
+ # Callers must already be inside `telemetry { }`; this keeps Person lookups
5
+ # batched and prevents a reference from ever being resolved in another
6
+ # environment's tenant database.
7
+ module Railwatch
8
+ module TelemetryIdentity
9
+ private
10
+
11
+ def origin_people(rows)
12
+ refs = rows.filter_map(&:user_ref).uniq
13
+ refs.empty? ? {} : Telemetry::Person.where(ref: refs).index_by(&:ref)
14
+ end
15
+
16
+ def origin_identity(record, people)
17
+ person = people[record.user_ref]
18
+ {
19
+ user_ref: record.user_ref,
20
+ tenant: record.app_tenant,
21
+ person: person && { ref: person.ref, name: person.display_name }
22
+ }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fired-alert history. Reachable two ways: account-wide at /alerts (every
4
+ # environment) and, nested under an environment, scoped to that
5
+ # environment's issues. AlertRule (and so Alert) belongs to an application
6
+ # rather than an environment, so the env-scoped view filters through the
7
+ # alert's issue instead -- app-level alerts with no issue (e.g. quota) only
8
+ # show up in the account-wide view.
9
+ module Railwatch
10
+ class AlertsController < DashboardController
11
+ # One environment in an embedded install, so the account-wide and the
12
+ # environment-scoped listing are the same page; the base class already
13
+ # set the environment and shares its props.
14
+
15
+ def index
16
+ scope = account_alerts
17
+ scope = scope.joins(:issue).where(railwatch_issues: { environment_id: environment.id }) if params[:environment_id]
18
+ fields = FilterQuery.parse(params[:q]).fetch(:fields)
19
+ scope = scope.where(event: fields["event"]) if AlertRule::EVENTS.include?(fields["event"].to_s)
20
+ scope = scope.where(status: fields["status"]) if Alert::STATUSES.include?(fields["status"].to_s)
21
+ alerts = scope.order(created_at: :desc).limit(200)
22
+ render inertia: { alerts: alerts.map { |a| row(a) }, q: params[:q].to_s, statuses: Alert::STATUSES }
23
+ end
24
+
25
+ def retry
26
+ alert = account_alerts.find(params[:id])
27
+ if alert.retry_delivery!
28
+ redirect_back fallback_location: alerts_path, notice: "Retrying alert"
29
+ else
30
+ redirect_back fallback_location: alerts_path, alert: "Only failed alerts can be retried"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Scoped through the integration rather than the rule's application: a
37
+ # "Send test" alert has no rule, and its destination is what owns it.
38
+ def account_alerts
39
+ Alert.all
40
+ end
41
+
42
+ def row(a)
43
+ { id: a.id, event: a.event, status: a.status, sent_at: a.sent_at, error: a.error, payload: a.payload, created_at: a.created_at,
44
+ integration: { kind: a.integration.kind, name: a.integration.name },
45
+ issue: a.issue && { id: a.issue.id, key: a.issue.key, title: a.issue.title } }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class AnomalyRulesController < DashboardController
5
+ def create
6
+ rule = environment.anomaly_rules.new(anomaly_rule_params)
7
+ if rule.save
8
+ redirect_to application_environment_thresholds_path(application, environment), notice: "Anomaly rule added"
9
+ else
10
+ redirect_to application_environment_thresholds_path(application, environment), inertia: { errors: rule.errors }
11
+ end
12
+ end
13
+
14
+ def update
15
+ rule = environment.anomaly_rules.find(params[:id])
16
+ rule.update(anomaly_rule_params)
17
+ redirect_to application_environment_thresholds_path(application, environment), notice: "Anomaly rule updated"
18
+ end
19
+
20
+ def destroy
21
+ environment.anomaly_rules.find(params[:id]).destroy
22
+ redirect_to application_environment_thresholds_path(application, environment), notice: "Anomaly rule removed"
23
+ end
24
+
25
+ private
26
+
27
+ def anomaly_rule_params
28
+ params.permit(:target_kind, :target, :metric, :deviation, :window_minutes, :baseline_days, :enabled)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Serves the bytes an app attached to an execution or exception. The row
4
+ # only exists in its own environment's telemetry database, so an id from
5
+ # another environment 404s here.
6
+ module Railwatch
7
+ class AttachmentsController < DashboardController
8
+ def show
9
+ attachment = telemetry { Telemetry::Attachment.find(params[:id]) }
10
+ # Rendered as text/plain even for text/html or SVG: these bytes came
11
+ # from a monitored app and must never execute on our origin.
12
+ if params[:view] == "1" && attachment.viewable?
13
+ send_data attachment.body, type: "text/plain; charset=utf-8", disposition: "inline"
14
+ else
15
+ send_data attachment.body, type: attachment.content_type.presence || "application/octet-stream",
16
+ filename: attachment.name, disposition: "attachment"
17
+ end
18
+ rescue Telemetry::BoundedGzip::Error
19
+ head :unprocessable_content
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module Railwatch
4
6
  # Receives Inertia visit timings and JavaScript errors from the browser
5
7
  # client (app/frontend/lib/railwatch.ts). Mounted at /railwatch/beacon.
@@ -22,7 +24,7 @@ module Railwatch
22
24
 
23
25
  RATE_LIMIT_WINDOW = 60 # seconds
24
26
 
25
- before_action :limit_payload, :throttle, if: -> { Railwatch.config.beacon_enabled }
27
+ before_action :limit_payload, :verify_origin, :throttle, if: -> { Railwatch.config.beacon_enabled }
26
28
 
27
29
  def create
28
30
  return head :no_content unless Railwatch.config.beacon_enabled
@@ -65,17 +67,75 @@ module Railwatch
65
67
  # The beacon takes no credential and keeps every browser error it is
66
68
  # sent, so a client that is not the page -- a script, a bored visitor
67
69
  # with curl -- could otherwise fill the app's quota with junk issues.
68
- # Same shape as Rails' rate_limit (a counter per client IP in the app's
69
- # cache store), read from config at request time so an initializer can
70
- # raise or disable it. A store that cannot count (NullStore) fails open:
71
- # the beacon keeps working, just unthrottled.
70
+ # Two ceilings, both counters in the app's cache store in the shape of
71
+ # Rails' own rate_limit: one per client IP, and one for the endpoint as a
72
+ # whole so a rotating address cannot multiply its way past the first.
73
+ # Read from config per request, so an initializer can raise or remove
74
+ # either one.
75
+ # Same idea as Sentry's allowed domains: a public ingest endpoint cannot
76
+ # authenticate its caller (the credential would be in the page), so it
77
+ # refuses anything that says it came from somewhere else. Not a security
78
+ # boundary -- an Origin header is trivially forged outside a browser --
79
+ # but it stops another site's page from spending this app's quota.
80
+ # Missing Origin and Referer pass, which is how Rails' own forgery origin
81
+ # check behaves.
82
+ def verify_origin
83
+ origin = request.origin || referer_origin
84
+ return if origin.nil? || origin == request.base_url
85
+ return if Railwatch.config.beacon_allowed_origins.any? { |allowed| origin_matches?(origin, allowed) }
86
+
87
+ Railwatch.debug { "refused a beacon from #{origin} (this app is #{request.base_url})" }
88
+ head :forbidden
89
+ end
90
+
91
+ def referer_origin
92
+ referer = request.referer.presence or return nil
93
+ uri = URI.parse(referer)
94
+ return nil unless uri.scheme && uri.host
95
+
96
+ port = ":#{uri.port}" if uri.port && uri.port != uri.default_port
97
+ "#{uri.scheme}://#{uri.host}#{port}"
98
+ rescue URI::InvalidURIError
99
+ nil
100
+ end
101
+
102
+ # An entry is either a full origin ("https://app.example.com") or a bare
103
+ # host, which matches either scheme.
104
+ def origin_matches?(origin, allowed)
105
+ return true if origin == allowed
106
+
107
+ allowed.include?("://") ? false : [ "https://#{allowed}", "http://#{allowed}" ].include?(origin)
108
+ end
109
+
72
110
  def throttle
73
111
  limit = Railwatch.config.beacon_rate_limit.to_i
112
+ global = Railwatch.config.beacon_global_rate_limit.to_i
113
+ return refuse_beacon if global.positive? && over?("railwatch:beacon:all", global)
74
114
  return unless limit.positive?
75
115
 
76
- count = cache_store.increment("railwatch:beacon:#{request.remote_ip}", 1, expires_in: RATE_LIMIT_WINDOW)
77
- return unless count && count > limit
116
+ # A store that cannot count (NullStore, a cache that is down, one whose
117
+ # increment is unsupported) returns nil. Refuse the beacon then rather
118
+ # than serving an unauthenticated, unlimited write endpoint: the
119
+ # browser client retries on the next flush, and an app that genuinely
120
+ # wants no limit sets beacon_rate_limit to 0.
121
+ refuse_beacon if over?("railwatch:beacon:#{request.remote_ip}", limit)
122
+ end
123
+
124
+ # True when this key has passed its ceiling for the window, and also when
125
+ # the store cannot count at all: a store that returns nil (NullStore, a
126
+ # cache that is down, one without increment) would otherwise leave an
127
+ # unauthenticated endpoint with no ceiling on it. An app that genuinely
128
+ # wants no limit sets the limit to 0, or turns the beacon off.
129
+ def over?(key, limit)
130
+ count = cache_store.increment(key, 1, expires_in: RATE_LIMIT_WINDOW)
131
+ if count.nil?
132
+ Railwatch.debug { "beacon rate limiting is unavailable (#{cache_store.class}); refusing the beacon" }
133
+ return true
134
+ end
135
+ count > limit
136
+ end
78
137
 
138
+ def refuse_beacon
79
139
  response.set_header("Retry-After", RATE_LIMIT_WINDOW.to_s)
80
140
  head :too_many_requests
81
141
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class BroadcastsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ streams = telemetry do
8
+ Telemetry::Broadcast.between(from, to).group(:group_hash, :kind).pluck(:group_hash, :kind, Arel.sql("MAX(COALESCE(stream, channel))"), Arel.sql("COUNT(*)"), Arel.sql("SUM(bytes)"), Arel.sql("AVG(duration)"))
9
+ .map { |g, k, name, c, b, d| { group_hash: g, kind: k, name: name, count: c, bytes: b.to_i, avg: (d.to_f / 1000.0).round(3) } }.sort_by { |r| -r[:count] }.first(200)
10
+ end
11
+ recent = telemetry { Telemetry::Broadcast.between(from, to).recent.limit(100).map { |b| { id: b.id, kind: b.kind, stream: b.stream, channel: b.channel, action: b.action, bytes: b.bytes, failed: b.failed, duration: b.duration_ms.round(3), occurred_at: b.occurred_at, execution_id: b.execution_id, execution_preview: b.execution_preview } } }
12
+ render inertia: { streams: streams, recent: recent }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class CacheEventsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ fields = FilterQuery.parse(params[:q]).fetch(:fields)
8
+ keys = telemetry do
9
+ scope = Telemetry::Rollup.for_type("cache_event").between(from, to)
10
+ if fields["store"].present?
11
+ # Rollup rows have no store column; find the matching group_hashes
12
+ # from the raw cache_events for this window and filter by those.
13
+ hashes = Telemetry::CacheEvent.between(from, to).where(store: fields["store"]).distinct.pluck(:group_hash)
14
+ scope = scope.where(group_hash: hashes)
15
+ end
16
+ rows = scope.to_a
17
+ rows.group_by(&:group_hash).map do |group_hash, group_rows|
18
+ count = group_rows.sum(&:count)
19
+ hits = group_rows.sum { |r| r.extra["hits"].to_i }
20
+ misses = group_rows.sum { |r| r.extra["misses"].to_i }
21
+ duration_sum = group_rows.sum(&:duration_sum)
22
+ { group_hash: group_hash, key: group_rows.max_by(&:bucket).name, count: count, hits: hits, misses: misses,
23
+ hit_rate: (hits + misses).zero? ? nil : (hits * 100.0 / (hits + misses)).round(1),
24
+ avg: count.zero? ? 0 : (duration_sum / count / 1000.0).round(3), sparkline: Telemetry::Aggregations.sparkline(group_rows, from, to) }
25
+ end.sort_by { |r| -r[:count] }.first(200)
26
+ end
27
+ render inertia: { keys: keys, series: series("cache_event"), q: params[:q].to_s }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class CommandsController < DashboardController
5
+ def index
6
+ rows = telemetry { Telemetry::Execution.commands.between(*window_range).recent.limit(200).to_a }
7
+ render inertia: { commands: grouped("command", limit: 100),
8
+ runs: rows.map { |r| { execution_id: r.execution_id, name: r.name, exit_code: r.status, duration: r.duration_ms.round(1), occurred_at: r.occurred_at, server: r.server, exception_preview: r.exception_preview } } }
9
+ end
10
+
11
+ def show
12
+ exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
13
+ render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class CommentsController < DashboardController
5
+ def create
6
+ issue = Issue.all.find(params[:issue_id])
7
+ issue.comments.create!(user: Viewer.user, body: params.require(:body))
8
+ redirect_to issue_path(issue), notice: "Comment added"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Base for every dashboard page: the prebuilt Inertia bundle, the shared
5
+ # props its layouts read, and per-controller Inertia config so a host that
6
+ # also uses Inertia keeps its own.
7
+ #
8
+ # Inherits from Railwatch.config.base_controller_class (default
9
+ # ActionController::Base) so a host can put its own admin gate in front of
10
+ # every page the way Mission Control Jobs allows: point it at a controller
11
+ # whose before_action requires an admin, and turn HTTP Basic off.
12
+ class DashboardController < Railwatch.config.base_controller_class.constantize
13
+ include Railwatch::AssetsHelper
14
+ helper Railwatch::AssetsHelper
15
+ include Railwatch::EnvironmentScoped
16
+
17
+ layout "railwatch/dashboard"
18
+ # The bundle's pages are named after the platform's controllers
19
+ # (overview/show, requests/index); strip the engine namespace so
20
+ # default_render finds them.
21
+ inertia_config version: -> { Railwatch::AssetsHelper.digest }, layout: "railwatch/dashboard",
22
+ use_script_element_for_initial_page: true, always_include_errors_hash: true, default_render: true,
23
+ component_path_resolver: ->(path:, action:) { "#{path.delete_prefix('railwatch/')}/#{action}" }
24
+ rescue_from Telemetry::CursorPage::InvalidCursor do |exception|
25
+ render plain: exception.message, status: :unprocessable_content
26
+ end
27
+
28
+ # HTTP Basic, on and closed by default (Configuration#http_basic_auth_*).
29
+ # Runs before anything reads telemetry; a host that authenticates in its
30
+ # base controller or a routes constraint turns it off.
31
+ before_action :authenticate_by_http_basic
32
+ before_action { Viewer.user = Railwatch.config.resolve_dashboard_user(request) }
33
+
34
+ inertia_share auth: -> { { user: Viewer.user.as_json, session: { id: "embedded", recently_authenticated: true } } },
35
+ account: -> { Railwatch::Embedded::Account.as_json },
36
+ accounts: -> { [ { id: 1, name: Railwatch::Embedded::Account.name } ] },
37
+ applications: -> { [ { id: 1, name: Application.current.name, slug: Application.current.slug,
38
+ issue_prefix: Application.current.issue_prefix,
39
+ environments: [ { id: 1, name: Environment.current.name, slug: Environment.current.slug,
40
+ last_seen_at: Environment.current.last_seen_at, paused: false } ] } ] },
41
+ flash: -> { { alert: flash.alert, warning: flash[:warning], notice: flash.notice } },
42
+ google_oauth: false,
43
+ embedded: true
44
+
45
+ private
46
+
47
+ def authenticate_by_http_basic
48
+ config = Railwatch.config
49
+ return unless config.http_basic_auth_enabled
50
+
51
+ if config.http_basic_auth_configured?
52
+ http_basic_authenticate_or_request_with(name: config.http_basic_auth_user, password: config.http_basic_auth_password,
53
+ realm: "Railwatch")
54
+ else
55
+ # Closed, not open: same as Mission Control with no credentials. The
56
+ # challenge header is still sent so a client or a monitor sees the
57
+ # scheme, and the body says what to do, since a bare 401 looks like a
58
+ # broken install rather than an unconfigured one.
59
+ response.set_header("WWW-Authenticate", %(Basic realm="Railwatch"))
60
+ render plain: "Railwatch: HTTP Basic authentication is on and no credentials are configured, so the " \
61
+ "dashboard is closed. Run `bin/rails railwatch:authentication:configure` (writes " \
62
+ "railwatch.http_basic_auth_user/_password to Rails credentials), or set " \
63
+ "RAILWATCH_HTTP_BASIC_AUTH_USER and _PASSWORD, or turn Basic off " \
64
+ "(c.http_basic_auth_enabled = false) once your own auth gates the mount. See docs/embedded.md.",
65
+ status: :unauthorized
66
+ end
67
+ end
68
+ end
69
+ end