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
@@ -1,11 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The dashboard controllers are Inertia controllers; a host that does not use
4
+ # Inertia itself never requires the gem, so the engine does.
5
+ require "inertia_rails"
6
+ # The engine's jobs (grouping, rollups, scans) are Active Job classes even
7
+ # though embedded mode calls their perform directly, so a host built with
8
+ # `rails new --minimal` (no Active Job) still needs the framework loaded.
9
+ # Part of the rails gem this one depends on, so nothing new is pulled in.
10
+ require "active_job/railtie"
11
+
3
12
  module Railwatch
13
+ # isolate_namespace would give every model a railwatch_ prefix. Only the
14
+ # engine's own records (issues, comments, deploys: Railwatch::ApplicationRecord)
15
+ # carry it; the telemetry tables are the hosted platform's schema and stay
16
+ # unprefixed. Defined before isolate_namespace runs, which only adds a
17
+ # prefix when none is set.
18
+ def self.table_name_prefix = ""
19
+
4
20
  class Engine < ::Rails::Engine
5
21
  isolate_namespace Railwatch
6
22
 
7
23
  config.railwatch = ActiveSupport::OrderedOptions.new
8
24
 
25
+ # The dashboard bundle is built into the gem at release time; serve it
26
+ # from here so a host app needs no asset pipeline integration at all.
27
+ # Where it goes in the stack is DashboardAssets.install!'s decision.
28
+ initializer "railwatch.dashboard_assets" do |app|
29
+ Railwatch::DashboardAssets.install!(app, root: root.join("public/railwatch").to_s)
30
+ end
31
+
32
+ # Action Cable is optional: live dashboard updates need it, nothing else
33
+ # does. A host without it (`rails new --minimal`, --skip-action-cable)
34
+ # must not eager-load the engine's channel, which inherits from a class
35
+ # that does not exist there.
36
+ initializer "railwatch.channels", before: :setup_main_autoloader do
37
+ Rails.autoloaders.main.ignore(root.join("app/channels")) unless defined?(::ActionCable)
38
+ end
39
+
40
+ # The dashboard bundle subscribes to "EnvironmentChannel" by name; Action
41
+ # Cable constantizes that, so the engine's channel needs the bare name.
42
+ # Only defined when the host has not got one of its own.
43
+ initializer "railwatch.live_channel" do
44
+ next unless defined?(::ActionCable)
45
+
46
+ # Action Cable constantizes the subscription's channel name at
47
+ # subscribe time; the alias only has to exist by then, and it must
48
+ # not clobber a host channel of the same name.
49
+ config.to_prepare do
50
+ Object.const_set(:EnvironmentChannel, Railwatch::EnvironmentChannel) unless Object.const_defined?(:EnvironmentChannel)
51
+ end
52
+ end
53
+
9
54
  # The request middleware goes first so wall time includes every other
10
55
  # middleware, exactly like Nightwatch's GlobalMiddleware.
11
56
  initializer "railwatch.middleware", before: :load_config_initializers do |app|
@@ -18,8 +63,39 @@ module Railwatch
18
63
  Railwatch::Console.silence!
19
64
  end
20
65
 
66
+ # Same source Mission Control Jobs reads: railwatch.http_basic_auth_user
67
+ # and _password in Rails credentials, which `bin/rails
68
+ # railwatch:authentication:configure` writes. An initializer or env var
69
+ # that already set them wins.
70
+ initializer "railwatch.http_basic_auth", after: :load_config_initializers do |app|
71
+ config = Railwatch.config
72
+ config.http_basic_auth_user ||= app.credentials.dig(:railwatch, :http_basic_auth_user)
73
+ config.http_basic_auth_password ||= app.credentials.dig(:railwatch, :http_basic_auth_password)
74
+ end
75
+
76
+ # Two things worth one line in the log at boot, because both are
77
+ # invisible until something is already wrong: a Rails/json pair that
78
+ # cannot decode, and an embedded dashboard with nothing declared in
79
+ # front of it.
80
+ initializer "railwatch.warnings", after: :load_config_initializers do
81
+ config.after_initialize do
82
+ next unless Railwatch.enabled?
83
+
84
+ Rails.logger.warn("[railwatch] #{Railwatch::JsonCompat.advice}") if Railwatch::JsonCompat.broken?
85
+
86
+ if Railwatch.config.local? && Railwatch.config.dashboard_gate == :undeclared && !Rails.env.local?
87
+ Rails.logger.warn(
88
+ "[railwatch] the dashboard at the engine's mount has no gate this gem can see: HTTP Basic is off and " \
89
+ "no base_controller_class, dashboard_user or dashboard_open is set. If a routes constraint or your " \
90
+ "network already gates it, set `c.dashboard_open = true` to say so (it also enables live updates); " \
91
+ "otherwise anyone who can reach the URL can read every query, log line and exception this app records."
92
+ )
93
+ end
94
+ end
95
+ end
96
+
21
97
  initializer "railwatch.transport_security", after: :load_config_initializers do
22
- next if Railwatch.config.ingest_url_allowed?
98
+ next if Railwatch.config.local? || Railwatch.config.ingest_url_allowed?
23
99
 
24
100
  Rails.logger.warn(
25
101
  "Railwatch will not send telemetry to #{Railwatch.config.ingest_url}: plain HTTP is allowed only for loopback " \
@@ -129,6 +205,17 @@ module Railwatch
129
205
  at_exit { Railwatch::Sessions.stop! }
130
206
  end
131
207
 
208
+ # The embedded install's maintenance clock (release health, scans,
209
+ # pruning). Same shape and ordering rationale as "railwatch.health":
210
+ # stopped before the reporter's final flush. Maintenance.start! is a
211
+ # no-op unless the transport is local.
212
+ initializer "railwatch.maintenance" do
213
+ next unless Railwatch.enabled?
214
+
215
+ Railwatch::Maintenance.start!
216
+ at_exit { Railwatch::Maintenance.stop! }
217
+ end
218
+
132
219
  # lib/tasks/railwatch_tasks.rake is picked up by Rails::Engine's default
133
220
  # lib/tasks convention; the rake_tasks block above only installs the
134
221
  # Rake::Task patch.
@@ -138,6 +225,7 @@ end
138
225
  require "railwatch/console"
139
226
  require "railwatch/health"
140
227
  require "railwatch/sessions"
228
+ require "railwatch/maintenance"
141
229
  require "railwatch/middleware/request"
142
230
  require "railwatch/job_tracing"
143
231
  require "railwatch/controller_helpers"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The hosted platform bounds ingest request bodies with Rack middleware. In
4
+ # an embedded install nothing arrives over HTTP, but Ingest::Payload and
5
+ # Telemetry::Attachment still size their limits from this constant.
6
+ module Railwatch
7
+ module IngestRequestBodyLimit
8
+ MAX_BYTES = 32 * 1024 * 1024
9
+ end
10
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/json"
4
+
5
+ module Railwatch
6
+ # Rails 8.1, up to and including 8.1.3.1, calls `JSON.parse` with a
7
+ # positional options hash. json 3 made those options keyword arguments
8
+ # (rails/rails#58784), so on that pair every signed cookie, every JSON
9
+ # column, and this gem's own SQLite migrations raise ArgumentError. Ruby
10
+ # 3.4.10 ships json 3.0.2 as a default gem, so a fresh machine meets it
11
+ # without choosing to. The fix is merged on Rails' 8-1-stable branch and
12
+ # unreleased as of 8.1.3.1.
13
+ #
14
+ # Detected by behaviour, not by version numbers: the pair is asked to
15
+ # decode a document once. That way the check is right about combinations
16
+ # nobody has enumerated (a patched Rails, a backport, a future json that
17
+ # restores the old signature), and it goes quiet by itself the day the
18
+ # host upgrades, with no release of this gem required.
19
+ #
20
+ # Railwatch does not pin `json` for the host. The breakage is the host
21
+ # application's either way -- its sessions are already failing -- so the
22
+ # gem reports it and the installer offers the pin, rather than quietly
23
+ # constraining everybody's bundle for a bug that is not ours and is on its
24
+ # way out.
25
+ module JsonCompat
26
+ PIN = %(gem "json", "< 3")
27
+ ISSUE = "rails/rails#58784"
28
+
29
+ module_function
30
+
31
+ def broken?
32
+ return @broken unless @broken.nil?
33
+
34
+ @broken = begin
35
+ ActiveSupport::JSON.decode("{}")
36
+ false
37
+ rescue ArgumentError
38
+ true
39
+ rescue StandardError
40
+ # Anything else is not this bug.
41
+ false
42
+ end
43
+ end
44
+
45
+ # One line, usable from the installer, the doctor and a boot log.
46
+ def advice
47
+ "json #{json_version} cannot be decoded by Rails #{rails_version} (#{ISSUE}): signed cookies, JSON " \
48
+ "columns and Railwatch's own migrations all raise ArgumentError on this pair. Add #{PIN} to your " \
49
+ "Gemfile until Rails ships the fix, then remove it."
50
+ end
51
+
52
+ def json_version = defined?(::JSON::VERSION) ? ::JSON::VERSION : "unknown"
53
+ def rails_version = defined?(::Rails) && ::Rails.respond_to?(:version) ? ::Rails.version : "unknown"
54
+
55
+ # Test hook.
56
+ def reset!
57
+ @broken = nil
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TASKS is built from duration literals at load time; the engine has these
4
+ # loaded already, but a writer process required ahead of the app must not
5
+ # depend on that.
6
+ require "active_support/core_ext/numeric/time"
7
+
8
+ module Railwatch
9
+ # The embedded install's own clock. One background thread per web/worker
10
+ # process wakes every TICK seconds and runs whichever maintenance task is
11
+ # due: release-health rollups, threshold and anomaly scans, missed
12
+ # scheduled tasks, auto-resolve, pruning. Every process runs the clock;
13
+ # one process at a time runs a given task, claimed through a lease row in
14
+ # the railwatch database (MaintenanceTask), so a Puma cluster and a Solid
15
+ # Queue worker on the same host do not all prune at once.
16
+ #
17
+ # Same thread shape as Railwatch::Health: parked on a ConditionVariable,
18
+ # started from the engine, re-armed in every forked child. Nothing here
19
+ # goes through Active Job -- the bodies call the job classes' perform
20
+ # directly -- so an embedded install needs no worker, no recurring.yml,
21
+ # and never writes the host's queue adapter.
22
+ #
23
+ # A task must never be visible to the app it is maintaining: each body runs
24
+ # inside Railwatch.ignore and rescues everything.
25
+ module Maintenance
26
+ ROLES = %w[web worker writer].freeze
27
+ TICK = 30
28
+ FOLLOWUP_BATCHES_PER_TICK = 200
29
+
30
+ # name => [interval, lease, body]. The lease is how long a claim is held
31
+ # by a process that never releases it (crashed mid-task); it is a ceiling
32
+ # on the work, not an estimate of it.
33
+ TASKS = {
34
+ # A batch's exceptions are grouped into issues right after it commits;
35
+ # a process that dies in between leaves the work recorded on the
36
+ # batch's ledger row. Bounded per tick so a long outage drains over a
37
+ # few ticks rather than one long one.
38
+ "drain_followups" => [ 1.minute, 5.minutes, lambda { |env|
39
+ env.with_telemetry do
40
+ Telemetry::IngestBatch.with_pending_followups.limit(FOLLOWUP_BATCHES_PER_TICK).each do |batch|
41
+ batch.drain_followups!(env)
42
+ end
43
+ end
44
+ } ],
45
+ "release_health" => [ 1.minute, 5.minutes, lambda { |env|
46
+ now = Time.current
47
+ [ now.beginning_of_hour, (now - 1.hour).beginning_of_hour ].each do |bucket|
48
+ ReleaseHealthRollupJob.new.perform(env, bucket)
49
+ end
50
+ } ],
51
+ # Embedded batches fold themselves into the current hour as they land
52
+ # (Ingest::RollupAbsorber), so the reconciler only ever needs to catch
53
+ # rows that arrived after their hour closed.
54
+ "rollup_reconcile" => [ 1.hour, 10.minutes, lambda { |env|
55
+ RollupJob.new.perform(env, (Time.current - 1.hour).beginning_of_hour)
56
+ } ],
57
+ "performance_scan" => [ 5.minutes, 10.minutes, lambda { |env|
58
+ DetectPerformanceIssuesJob.new.perform(env)
59
+ } ],
60
+ "anomaly_scan" => [ 5.minutes, 10.minutes, lambda { |env|
61
+ DetectAnomaliesJob.new.perform(env) if AnomalyRule.where(enabled: true).exists?
62
+ } ],
63
+ "scheduled_tasks" => [ 10.minutes, 10.minutes, lambda { |env|
64
+ CheckScheduledTasksJob.new.perform(env)
65
+ } ],
66
+ "auto_resolve" => [ 24.hours, 10.minutes, lambda { |_env|
67
+ AutoResolveIssuesJob.new.perform
68
+ } ],
69
+ # PASSIVE, not TRUNCATE: this runs inside a Puma worker, and a
70
+ # truncating checkpoint blocks every reader and writer on the file.
71
+ "prune" => [ 24.hours, 60.minutes, lambda { |env|
72
+ PruneTelemetryJob.new.perform(env, checkpoint: "PASSIVE")
73
+ OptimizeTelemetryJob.new.perform(env)
74
+ FollowupReceipt.prune!
75
+ } ]
76
+ }.freeze
77
+
78
+ @mutex = Mutex.new
79
+ @wakeup = ConditionVariable.new
80
+ @thread = nil
81
+ @pid = nil
82
+ @stopping = false
83
+
84
+ module_function
85
+
86
+ # Idempotent; Railwatch.restart_after_fork! calls it again in every forked
87
+ # child.
88
+ def start!
89
+ return unless Railwatch.enabled? && Railwatch.config.local?
90
+ return if defined?(Rails) && Rails.env.test?
91
+ return unless ROLES.include?(Subscribers::ProcessInfo.role)
92
+ # With a writer process listening, it is the one clock. The lease table
93
+ # would keep two clocks honest, but there is no reason to run a second.
94
+ return if !Writer.running? && Writer.listening?
95
+ return if @thread&.alive? && @pid == Process.pid
96
+
97
+ @mutex.synchronize do
98
+ return if @thread&.alive? && @pid == Process.pid
99
+
100
+ @pid = Process.pid
101
+ @stopping = false
102
+ @thread = Thread.new { run }
103
+ @thread.name = "railwatch-maintenance"
104
+ @thread.abort_on_exception = false
105
+ @thread.report_on_exception = false
106
+ end
107
+ end
108
+
109
+ # A forked child inherits a dead thread and may inherit a mutex held by a
110
+ # vanished parent thread; every primitive is replaced before start!.
111
+ def restart_after_fork!
112
+ @mutex = Mutex.new
113
+ @wakeup = ConditionVariable.new
114
+ @thread = nil
115
+ @pid = nil
116
+ @stopping = false
117
+ start!
118
+ end
119
+
120
+ def stop!
121
+ return unless @thread
122
+
123
+ @stopping = true
124
+ @mutex.synchronize { @wakeup.signal }
125
+ @thread.join(1)
126
+ @thread = nil
127
+ end
128
+
129
+ def run
130
+ until @stopping
131
+ @mutex.synchronize { @wakeup.wait(@mutex, TICK) unless @stopping }
132
+ tick unless @stopping
133
+ end
134
+ end
135
+
136
+ # Runs every task that is due and unclaimed. Returns the names it ran.
137
+ # Public so a spec, or an operator in a console, can drive the clock by
138
+ # hand. One failing task is reported and does not stop the others.
139
+ #
140
+ # A web worker's clock starts at boot, before the Puma plugin has forked
141
+ # the writer, so the start-time check in start! cannot see it. Checked
142
+ # again on every tick: once a writer is listening this process's clock
143
+ # stands down and stays down (the lease table would keep both honest,
144
+ # but there is no reason to run a second one).
145
+ def tick(now: Time.current)
146
+ return [] if !Writer.running? && Writer.listening?
147
+
148
+ ran = []
149
+ Rails.application.executor.wrap do
150
+ env = Environment.current
151
+ TASKS.each do |name, (every, lease, body)|
152
+ token = MaintenanceTask.claim(name, every: every, lease: lease, owner: owner, now: now) or next
153
+
154
+ succeeded = false
155
+ begin
156
+ Railwatch.ignore { body.call(env) }
157
+ succeeded = true
158
+ ran << name
159
+ rescue StandardError => e
160
+ # Not Rails.error: this thread has no execution for Railwatch.ignore
161
+ # to pause, so a report there would be captured by Railwatch's own
162
+ # subscriber and opened as an application issue about Railwatch.
163
+ Railwatch.debug { "maintenance #{name} failed: #{e.class}: #{e.message}" }
164
+ Railwatch.notify_unrecoverable(TaskError.new("maintenance task #{name} failed: #{e.class}: #{e.message}"))
165
+ ensure
166
+ MaintenanceTask.release(name, token: token, ran_at: now, succeeded: succeeded)
167
+ end
168
+ end
169
+ end
170
+ ran
171
+ rescue StandardError => e
172
+ # The lease table not being migrated yet is the usual way to get here.
173
+ Railwatch.debug { "maintenance tick failed: #{e.class}: #{e.message}" }
174
+ ran
175
+ end
176
+
177
+ def owner
178
+ "#{Railwatch.config.server}:#{Process.pid}"
179
+ end
180
+
181
+ class TaskError < StandardError; end
182
+ end
183
+ end
@@ -107,13 +107,33 @@ module Railwatch
107
107
  # Expanded so a relative path is judged by where it actually resolves.
108
108
  # An argument File.expand_path refuses (a "~nobody/x.rb") is matched
109
109
  # as-is rather than assumed interactive: when in doubt, report.
110
+ #
111
+ # A scratch directory that contains the application itself is not a
112
+ # scratch directory for the application's own files: an app checked out
113
+ # under /tmp (a CI runner, a throwaway worktree) must not have its
114
+ # script/ classified as a shell session because of where the checkout
115
+ # lives. A scratch path inside the app (an operator's own
116
+ # `Rails.root/tmp/`) is left alone by this rule and still matches.
110
117
  def self.scratch?(argument)
111
118
  path = begin
112
119
  File.expand_path(argument)
113
120
  rescue StandardError
114
121
  argument
115
122
  end
116
- Railwatch.config.interactive_runner_paths.any? { |directory| path.start_with?(directory) }
123
+ root = application_root
124
+ Railwatch.config.interactive_runner_paths.any? do |directory|
125
+ next false if root && "#{root}/".start_with?(directory) && path.start_with?("#{root}/")
126
+
127
+ path.start_with?(directory)
128
+ end
129
+ end
130
+
131
+ def self.application_root
132
+ return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
133
+
134
+ Rails.root.to_s
135
+ rescue StandardError
136
+ nil
117
137
  end
118
138
  end
119
139
  end
@@ -54,32 +54,57 @@ module Railwatch
54
54
  # Counting stops as soon as `limit` is exceeded: past that the only fact
55
55
  # the caller uses is "too big", so there is no reason to keep walking.
56
56
  def buffered_bytes(value, limit:, depth: 0)
57
- return limit + 1 if depth > MAX_SIZING_DEPTH
57
+ bytes = weigh(value, limit, depth)
58
+ bytes > limit ? limit + 1 : bytes
59
+ end
58
60
 
59
- bytes = case value
61
+ # The walk itself. Positional arguments, and scalars weighed inline in
62
+ # the container loops rather than through a call per value: a record is
63
+ # thirty-odd scalars under one Hash, so the common case is one call and
64
+ # one loop, and only a nested header or payload hash recurses. Halves the
65
+ # per-record cost against the one-method-per-value version (measured:
66
+ # 6.2 to 2.8 us for a query record).
67
+ def weigh(value, limit, depth)
68
+ case value
60
69
  when String then 40 + value.bytesize
61
70
  when Hash then hash_bytes(value, limit, depth)
62
71
  when Array then array_bytes(value, limit, depth)
63
- when Symbol then 16
64
72
  else 16
65
73
  end
66
- bytes > limit ? limit + 1 : bytes
67
74
  end
68
75
 
69
76
  def hash_bytes(hash, limit, depth)
77
+ # Children are weighed inline below, so the bound is checked for them
78
+ # here: a container whose contents would sit past MAX_SIZING_DEPTH is
79
+ # over the limit by definition, which is what a per-value depth check
80
+ # produced before the contents were inlined.
81
+ return limit + 1 if depth > MAX_SIZING_DEPTH || (depth == MAX_SIZING_DEPTH && !hash.empty?)
82
+
70
83
  bytes = 80 + (hash.size * 40)
71
- hash.each do |key, value|
72
- bytes += buffered_bytes(key, limit: limit, depth: depth + 1)
73
- bytes += buffered_bytes(value, limit: limit, depth: depth + 1)
84
+ hash.each_pair do |key, value|
85
+ bytes += key.is_a?(Symbol) ? 16 : weigh(key, limit, depth + 1)
86
+ bytes += case value
87
+ when String then 40 + value.bytesize
88
+ when Hash then hash_bytes(value, limit, depth + 1)
89
+ when Array then array_bytes(value, limit, depth + 1)
90
+ else 16
91
+ end
74
92
  break if bytes > limit
75
93
  end
76
94
  bytes
77
95
  end
78
96
 
79
97
  def array_bytes(array, limit, depth)
98
+ return limit + 1 if depth > MAX_SIZING_DEPTH || (depth == MAX_SIZING_DEPTH && !array.empty?)
99
+
80
100
  bytes = 40 + (array.size * 8)
81
101
  array.each do |value|
82
- bytes += buffered_bytes(value, limit: limit, depth: depth + 1)
102
+ bytes += case value
103
+ when String then 40 + value.bytesize
104
+ when Hash then hash_bytes(value, limit, depth + 1)
105
+ when Array then array_bytes(value, limit, depth + 1)
106
+ else 16
107
+ end
83
108
  break if bytes > limit
84
109
  end
85
110
  bytes
@@ -187,6 +187,12 @@ module Railwatch
187
187
  end
188
188
 
189
189
  def forked_transport
190
+ # A child may be a different kind of process from its parent: the
191
+ # writer forked from a Puma master must write SQLite itself, not hand
192
+ # batches back to the socket it is about to serve.
193
+ reselected = Railwatch.local_transport if @config.local?
194
+ return reselected if reselected && reselected.class != @transport.class
195
+
190
196
  transport = @transport.dup
191
197
  transport.reset_after_fork! if transport.respond_to?(:reset_after_fork!)
192
198
  transport
@@ -327,6 +333,16 @@ module Railwatch
327
333
  @retry_attempt = 0
328
334
  @retry_at = nil
329
335
  Railwatch.debug { "gave up on a batch of #{batch.records.size} records after #{MAX_RETRY_ATTEMPTS} retries (#{result.error || result.status}); dropped and counted" }
336
+ # Losing a batch is not a debug-level event: with an ingest (or an
337
+ # embedded writer) that never comes back this is the only place the
338
+ # loss is ever reported, and the dropped counter it leaves behind
339
+ # rides on the NEXT successful delivery, which may never happen.
340
+ Railwatch.notify_unrecoverable(
341
+ DeliveryError.new("Railwatch dropped #{batch.records.size} records after #{MAX_RETRY_ATTEMPTS} failed delivery attempts: " \
342
+ "#{result.error || result.status}",
343
+ status: result.status, records: batch.records.size, bytes: batch.bytes,
344
+ dropped: batch.dropped, dropped_bytes: batch.dropped_bytes)
345
+ )
330
346
  next
331
347
  end
332
348
  @retry_batch = batch