railwatch 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +10 -0
  4. data/app/channels/railwatch/environment_channel.rb +28 -0
  5. data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
  6. data/app/controllers/railwatch/alerts_controller.rb +48 -0
  7. data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
  8. data/app/controllers/railwatch/attachments_controller.rb +22 -0
  9. data/app/controllers/railwatch/beacon_controller.rb +67 -7
  10. data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
  11. data/app/controllers/railwatch/cache_events_controller.rb +30 -0
  12. data/app/controllers/railwatch/commands_controller.rb +16 -0
  13. data/app/controllers/railwatch/comments_controller.rb +11 -0
  14. data/app/controllers/railwatch/dashboard_controller.rb +69 -0
  15. data/app/controllers/railwatch/deploys_controller.rb +34 -0
  16. data/app/controllers/railwatch/deprecations_controller.rb +54 -0
  17. data/app/controllers/railwatch/environment_scoped.rb +99 -0
  18. data/app/controllers/railwatch/exceptions_controller.rb +53 -0
  19. data/app/controllers/railwatch/executions_controller.rb +13 -0
  20. data/app/controllers/railwatch/issues_controller.rb +258 -0
  21. data/app/controllers/railwatch/jobs_controller.rb +63 -0
  22. data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
  23. data/app/controllers/railwatch/logs_controller.rb +40 -0
  24. data/app/controllers/railwatch/mails_controller.rb +19 -0
  25. data/app/controllers/railwatch/notifications_controller.rb +11 -0
  26. data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
  27. data/app/controllers/railwatch/overview_controller.rb +38 -0
  28. data/app/controllers/railwatch/people_controller.rb +25 -0
  29. data/app/controllers/railwatch/processes_controller.rb +33 -0
  30. data/app/controllers/railwatch/profiles_controller.rb +50 -0
  31. data/app/controllers/railwatch/queries_controller.rb +59 -0
  32. data/app/controllers/railwatch/releases_controller.rb +75 -0
  33. data/app/controllers/railwatch/requests_controller.rb +55 -0
  34. data/app/controllers/railwatch/saved_views_controller.rb +53 -0
  35. data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
  36. data/app/controllers/railwatch/spans_controller.rb +47 -0
  37. data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
  38. data/app/controllers/railwatch/tenants_controller.rb +44 -0
  39. data/app/controllers/railwatch/thresholds_controller.rb +40 -0
  40. data/app/controllers/railwatch/traces_controller.rb +72 -0
  41. data/app/controllers/railwatch/transactions_controller.rb +21 -0
  42. data/app/controllers/railwatch/view_renders_controller.rb +28 -0
  43. data/app/controllers/railwatch/visits_controller.rb +53 -0
  44. data/app/helpers/railwatch/assets_helper.rb +52 -0
  45. data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
  46. data/app/jobs/railwatch/application_job.rb +7 -0
  47. data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
  48. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
  49. data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
  50. data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
  51. data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
  52. data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
  53. data/app/jobs/railwatch/performance_scan_job.rb +11 -0
  54. data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
  55. data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
  56. data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
  57. data/app/jobs/railwatch/rollup_job.rb +130 -0
  58. data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
  59. data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
  60. data/app/models/railwatch/alert.rb +229 -0
  61. data/app/models/railwatch/alert_rule.rb +121 -0
  62. data/app/models/railwatch/anomaly_rule.rb +33 -0
  63. data/app/models/railwatch/application.rb +44 -0
  64. data/app/models/railwatch/application_record.rb +33 -0
  65. data/app/models/railwatch/comment.rb +36 -0
  66. data/app/models/railwatch/deploy.rb +67 -0
  67. data/app/models/railwatch/environment.rb +54 -0
  68. data/app/models/railwatch/execution_presenter.rb +185 -0
  69. data/app/models/railwatch/filter_query.rb +143 -0
  70. data/app/models/railwatch/followup_receipt.rb +27 -0
  71. data/app/models/railwatch/ingest/batch.rb +305 -0
  72. data/app/models/railwatch/ingest/mapper.rb +575 -0
  73. data/app/models/railwatch/ingest/payload.rb +96 -0
  74. data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
  75. data/app/models/railwatch/ingest/writer.rb +137 -0
  76. data/app/models/railwatch/issue.rb +277 -0
  77. data/app/models/railwatch/issue_activity.rb +23 -0
  78. data/app/models/railwatch/issue_detection_presenter.rb +309 -0
  79. data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
  80. data/app/models/railwatch/maintenance_task.rb +53 -0
  81. data/app/models/railwatch/saved_view.rb +63 -0
  82. data/app/models/railwatch/telemetry/aggregations.rb +116 -0
  83. data/app/models/railwatch/telemetry/attachment.rb +31 -0
  84. data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
  85. data/app/models/railwatch/telemetry/broadcast.rb +13 -0
  86. data/app/models/railwatch/telemetry/cache_event.rb +16 -0
  87. data/app/models/railwatch/telemetry/child.rb +53 -0
  88. data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
  89. data/app/models/railwatch/telemetry/deprecation.rb +13 -0
  90. data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
  91. data/app/models/railwatch/telemetry/exception.rb +21 -0
  92. data/app/models/railwatch/telemetry/execution.rb +71 -0
  93. data/app/models/railwatch/telemetry/health_sample.rb +72 -0
  94. data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
  95. data/app/models/railwatch/telemetry/llm_call.rb +37 -0
  96. data/app/models/railwatch/telemetry/log.rb +85 -0
  97. data/app/models/railwatch/telemetry/mail.rb +13 -0
  98. data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
  99. data/app/models/railwatch/telemetry/notification.rb +13 -0
  100. data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
  101. data/app/models/railwatch/telemetry/person.rb +50 -0
  102. data/app/models/railwatch/telemetry/process.rb +10 -0
  103. data/app/models/railwatch/telemetry/profile.rb +37 -0
  104. data/app/models/railwatch/telemetry/query.rb +50 -0
  105. data/app/models/railwatch/telemetry/query_shape.rb +45 -0
  106. data/app/models/railwatch/telemetry/release_health.rb +63 -0
  107. data/app/models/railwatch/telemetry/rollup.rb +78 -0
  108. data/app/models/railwatch/telemetry/session.rb +24 -0
  109. data/app/models/railwatch/telemetry/span.rb +19 -0
  110. data/app/models/railwatch/telemetry/storage_op.rb +13 -0
  111. data/app/models/railwatch/telemetry/tenant.rb +221 -0
  112. data/app/models/railwatch/telemetry/transaction.rb +13 -0
  113. data/app/models/railwatch/telemetry/view_render.rb +13 -0
  114. data/app/models/railwatch/telemetry/visit.rb +24 -0
  115. data/app/models/railwatch/telemetry_record.rb +57 -0
  116. data/app/models/railwatch/threshold.rb +29 -0
  117. data/app/models/railwatch/user.rb +47 -0
  118. data/app/models/railwatch/viewer.rb +13 -0
  119. data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
  120. data/config/routes.rb +59 -0
  121. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
  122. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
  123. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
  124. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +71 -0
  125. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
  126. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
  127. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
  128. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
  129. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
  130. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
  131. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
  132. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
  133. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
  134. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
  135. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
  136. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
  137. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
  138. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
  139. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
  140. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
  141. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
  142. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
  143. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
  144. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
  145. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
  146. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
  147. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
  148. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
  149. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
  150. data/docs/configuration.md +26 -0
  151. data/docs/embedded.md +352 -0
  152. data/docs/getting-started.md +5 -0
  153. data/lib/generators/railwatch/install/install_generator.rb +222 -1
  154. data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
  155. data/lib/generators/railwatch/install/templates/post-deploy +8 -0
  156. data/lib/puma/plugin/railwatch.rb +170 -0
  157. data/lib/railwatch/authentication.rb +83 -0
  158. data/lib/railwatch/configuration.rb +134 -3
  159. data/lib/railwatch/dashboard_assets.rb +45 -0
  160. data/lib/railwatch/embedded.rb +54 -0
  161. data/lib/railwatch/engine.rb +89 -1
  162. data/lib/railwatch/ingest_request_body_limit.rb +10 -0
  163. data/lib/railwatch/json_compat.rb +60 -0
  164. data/lib/railwatch/maintenance.rb +183 -0
  165. data/lib/railwatch/patches/runner_command.rb +21 -1
  166. data/lib/railwatch/record.rb +33 -8
  167. data/lib/railwatch/reporter.rb +16 -0
  168. data/lib/railwatch/subscribers/process_info.rb +2 -1
  169. data/lib/railwatch/transport/local.rb +78 -0
  170. data/lib/railwatch/transport/socket.rb +183 -0
  171. data/lib/railwatch/version.rb +1 -1
  172. data/lib/railwatch/writer.rb +370 -0
  173. data/lib/railwatch.rb +38 -1
  174. data/lib/tasks/railwatch_tasks.rake +128 -0
  175. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  176. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  177. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  180. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  181. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  182. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  183. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  184. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  185. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  186. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  187. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  188. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  189. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  190. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  191. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  192. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  193. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  194. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  195. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  196. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  197. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  198. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  199. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  200. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  201. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  202. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  203. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  204. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  205. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  206. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  207. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  208. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  209. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  210. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  211. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  212. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  213. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  214. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  215. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  216. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  217. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  218. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  219. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  220. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  221. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  222. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  223. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  224. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  225. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  226. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  227. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  228. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  229. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  230. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  231. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  232. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  233. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  234. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  235. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  236. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  237. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  238. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  239. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  240. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  241. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  242. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  243. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  244. data/public/railwatch/assets/index-oVkururr.js +1 -0
  245. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  246. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  247. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  248. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  249. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  250. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  251. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  252. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  253. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  254. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  255. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  256. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  257. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  258. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  259. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  260. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  261. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  262. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  263. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  264. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  265. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  266. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  267. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  268. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  269. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  270. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  271. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  272. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  273. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  274. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  275. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  276. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  277. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  278. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  279. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  280. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  281. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  282. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  283. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  284. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  285. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  286. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  287. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  288. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  289. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  290. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  291. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  292. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  293. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  294. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  295. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  296. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  297. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  298. data/public/railwatch/icon.png +0 -0
  299. data/public/railwatch/icon.svg +5 -0
  300. data/public/railwatch/manifest.json +2171 -0
  301. data/public/railwatch/rails-vite.json +1 -0
  302. metadata +314 -4
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Recomputes the hourly rollups for one bucket from raw rows. Idempotent:
5
+ # runs again whenever new rows land in that hour. Debounced twice: Ingest::
6
+ # Batch enqueues at most one run per bucket per minute, and Solid Queue's
7
+ # concurrency control discards an enqueue while a run for that bucket is
8
+ # already queued or running (the default :block would let a busy hour pile
9
+ # up thousands of identical jobs). RollupCatchupJob sweeps the current and
10
+ # previous hour on its recurring schedule for anything either debounce
11
+ # dropped; an embedded install instead runs this once an hour for the
12
+ # previous hour from Railwatch::Maintenance, since its batches already
13
+ # fold themselves into the current hour as they land.
14
+ class RollupJob < ApplicationJob
15
+ queue_as :rollups
16
+ limits_concurrency to: 1, key: ->(environment, bucket) { "#{environment.id}:#{bucket.to_i}" }, duration: 10.minutes, on_conflict: :discard
17
+
18
+ SOURCES = {
19
+ "request" => [ Telemetry::Execution, ->(r) { r.requests }, ->(row) { row.name } ],
20
+ "job_attempt" => [ Telemetry::Execution, ->(r) { r.jobs }, ->(row) { row.name } ],
21
+ "scheduled_task" => [ Telemetry::Execution, ->(r) { r.scheduled }, ->(row) { row.name } ],
22
+ "command" => [ Telemetry::Execution, ->(r) { r.commands }, ->(row) { row.name } ],
23
+ "channel_action" => [ Telemetry::Execution, ->(r) { r.channels }, ->(row) { row.name } ],
24
+ "query" => [ Telemetry::Query, ->(r) { r }, ->(row) { row.sql.first(255) } ],
25
+ "outgoing_request" => [ Telemetry::OutgoingRequest, ->(r) { r }, ->(row) { "#{row.method} #{row.host}" } ],
26
+ "cache_event" => [ Telemetry::CacheEvent, ->(r) { r }, ->(row) { "#{row.store} #{row.key}" } ],
27
+ "mail" => [ Telemetry::Mail, ->(r) { r }, ->(row) { row.mailer } ],
28
+ "visit" => [ Telemetry::Visit, ->(r) { r }, ->(row) { row.component } ],
29
+ "span" => [ Telemetry::Span, ->(r) { r }, ->(row) { row.name } ],
30
+ "notification" => [ Telemetry::Notification, ->(r) { r }, ->(row) { row.notifier || row.delivery_method } ],
31
+ "view_render" => [ Telemetry::ViewRender, ->(r) { r }, ->(row) { row.identifier } ],
32
+ "transaction" => [ Telemetry::Transaction, ->(r) { r }, ->(row) { "#{row.connection} · #{row.outcome}" } ],
33
+ "llm_call" => [ Telemetry::LlmCall, ->(r) { r.models }, ->(row) { "#{row.model} · #{row.operation}" } ],
34
+ "llm_tool" => [ Telemetry::LlmCall, ->(r) { r.tools }, ->(row) { row.tool_name.to_s } ]
35
+ }.freeze
36
+
37
+ # SQLite binds at most 32,766 variables per statement; a rollup row is 14.
38
+ INSERT_SLICE = 500
39
+
40
+ def perform(environment, bucket)
41
+ bucket = bucket.utc.beginning_of_hour
42
+ range = bucket...(bucket + 1.hour)
43
+ # Recomputing a busy hour is thousands of queries; recording each of
44
+ # them into Railwatch's own telemetry was 70% of everything this platform
45
+ # ingested about itself. The job record still ships with its duration.
46
+ environment.with_telemetry do
47
+ Railwatch.ignore { recompute(environment, bucket, range) }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # Every group of one type is written in one transaction: the hour's
54
+ # rollups for that type replace each other atomically, and the write lock
55
+ # is held only for the writes. Rails opens each SQLite transaction
56
+ # IMMEDIATE, so reading the hour inside it would hold every other writer
57
+ # off the tenant file for the whole read -- a busy hour is tens of
58
+ # seconds, and ingest gets five before it gives up. On 2026-09-05 one
59
+ # 37-second recompute failed every batch that arrived while it ran.
60
+ def recompute(environment, bucket, range)
61
+ SOURCES.each do |type, (klass, scope, namer)|
62
+ Railwatch.span("rollup.#{type}", bucket: bucket.iso8601) do
63
+ rows = scope.call(klass).where(occurred_at: range).select(:id, :group_hash, :duration, *(klass == Telemetry::Execution ? %i[name status outcome kind] : %i[]), *(klass == Telemetry::Query ? [ Telemetry::Query::SQL ] : []), *(klass == Telemetry::OutgoingRequest ? %i[method host status_code] : []), *(klass == Telemetry::CacheEvent ? %i[store key type] : []), *(klass == Telemetry::Mail ? %i[mailer failed] : []), *(klass == Telemetry::Visit ? %i[component status] : []), *(klass == Telemetry::Notification ? %i[notifier delivery_method failed] : []), *(klass == Telemetry::ViewRender ? %i[identifier kind] : []), *(klass == Telemetry::Transaction ? %i[outcome connection] : []), *(klass == Telemetry::Span ? %i[name status] : []), *(klass == Telemetry::LlmCall ? %i[operation model tool_name status input_tokens output_tokens cache_read_tokens cache_write_tokens cost_nanos finish_reason attachments] : []))
64
+ groups = rows.group_by(&:group_hash).except(nil)
65
+ next if groups.empty?
66
+ rows = groups.map do |group_hash, group|
67
+ Telemetry::Rollup.fresh_attributes(
68
+ record_type: type, group_hash: group_hash, name: namer.call(group.first).to_s, bucket: bucket,
69
+ durations: group.map(&:duration),
70
+ errors: group.count { |r| error?(type, r) },
71
+ client_errors: group.count { |r| type == "request" && r.status.to_i.between?(400, 499) },
72
+ extra: extra_for(type, group))
73
+ end
74
+ # One DELETE and a few INSERTs per type instead of a SELECT, a DELETE
75
+ # and an INSERT per group: a busy hour of queries is 2,000 groups,
76
+ # and this job was 20,000 of the platform's own N+1 records a week.
77
+ Telemetry::Rollup.transaction do
78
+ # The hour was read outside this transaction (above), so a batch
79
+ # that landed in between has already been folded into the row
80
+ # about to be replaced (Ingest::RollupAbsorber). A stored count
81
+ # higher than the recomputed one means exactly that: keep the
82
+ # stored row rather than overwrite it with a snapshot that
83
+ # predates the batch. The next run picks the group up.
84
+ stored = Telemetry::Rollup.where(record_type: type, group_hash: groups.keys, bucket: bucket).pluck(:group_hash, :count).to_h
85
+ rows = rows.reject { |row| stored[row[:group_hash]].to_i > row[:count] }
86
+ next if rows.empty?
87
+
88
+ Telemetry::Rollup.where(record_type: type, group_hash: rows.map { |row| row[:group_hash] }, bucket: bucket).delete_all
89
+ rows.each_slice(INSERT_SLICE) { |slice| Telemetry::Rollup.insert_all(slice) }
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ def error?(type, row)
96
+ case type
97
+ when "request" then row.status.to_i >= 500
98
+ when "job_attempt", "scheduled_task", "channel_action" then row.outcome == "failed"
99
+ when "command" then row.status.to_i != 0
100
+ when "outgoing_request" then row.status_code.to_i >= 500 || row.status_code.to_i.zero?
101
+ when "mail" then row.failed
102
+ when "visit" then row.status == "error"
103
+ when "notification" then row.failed
104
+ when "transaction" then row.outcome == "rollback"
105
+ when "span", "llm_call", "llm_tool" then row.status == "failed"
106
+ else false
107
+ end
108
+ end
109
+
110
+ def extra_for(type, group)
111
+ case type
112
+ when "cache_event"
113
+ { hits: group.count { |r| r.type == "hit" }, misses: group.count { |r| r.type == "miss" || r.type == "generate" } }
114
+ when "view_render"
115
+ { kind: group.group_by(&:kind).max_by { |_kind, rows| rows.size }&.first }
116
+ when "llm_call"
117
+ { input_tokens: group.sum { |r| r.input_tokens.to_i }, output_tokens: group.sum { |r| r.output_tokens.to_i },
118
+ cache_read_tokens: group.sum { |r| r.cache_read_tokens.to_i }, cache_write_tokens: group.sum { |r| r.cache_write_tokens.to_i },
119
+ cost_nanos: group.sum { |r| r.cost_nanos.to_i },
120
+ priced: group.count { |r| r.cost_nanos },
121
+ unpriced: group.count { |r| r.cost_nanos.nil? },
122
+ # A cut-off answer is not an error and will never show in the error
123
+ # rate, so it needs counting on its own or it stays invisible.
124
+ truncated: group.count { |r| r.finish_reason == "max_tokens" },
125
+ with_attachments: group.count { |r| r.attachments.to_i.positive? } }
126
+ else {}
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class ScheduledTaskScanJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform
8
+ [ Environment.current ].each { |env| CheckScheduledTasksJob.perform_later(env) }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Issue.record_occurrence! replaces `sample` wholesale on every breach, but the
5
+ # typed detector snapshot is durable context, not the latest reading. Capturing
6
+ # it costs a telemetry query per detector run, so re-capture only when the
7
+ # issue is newly open and otherwise carry the stored snapshot forward.
8
+ module DetectionSnapshotting
9
+ private
10
+
11
+ def carry_detection(issue, outcome)
12
+ detection = if outcome == :new || outcome == :regressed
13
+ yield
14
+ else
15
+ issue.sample_previously_was&.dig("detection")
16
+ end
17
+ issue.update!(sample: issue.sample.merge("detection" => detection)) if detection
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class Alert < ApplicationRecord
5
+ self.table_name = "railwatch_alerts"
6
+ MAX_DELIVERY_ATTEMPTS = 5
7
+ DELIVERY_LEASE = 5.minutes
8
+ ENQUEUE_LEASE = 5.minutes
9
+ RETRY_DELAYS = [ 1.minute, 5.minutes, 15.minutes, 1.hour ].freeze
10
+
11
+ # An alert is always addressed to an integration; the rule is only why it
12
+ # fired, and a "Send test" delivery has no rule behind it at all.
13
+ # No integrations in an embedded install yet: alerts are recorded, not delivered.
14
+ def integration = nil
15
+
16
+ def integration=(_)
17
+ nil
18
+ end
19
+ belongs_to :alert_rule, optional: true
20
+ belongs_to :issue, optional: true
21
+ belongs_to :summary_alert, class_name: "Alert", optional: true
22
+ has_many :summarized_alerts, class_name: "Alert", foreign_key: :summary_alert_id,
23
+ dependent: :nullify, inverse_of: :summary_alert
24
+
25
+ # `delivering` is a leased claim. A process that disappears while holding
26
+ # one is recovered by ReconcileAlertDeliveriesJob after DELIVERY_LEASE.
27
+ STATUSES = %w[pending delivering sent failed skipped collapsed summarized].freeze
28
+ TERMINAL_STATUSES = %w[sent failed skipped summarized].freeze
29
+
30
+ attr_accessor :suppress_auto_enqueue
31
+
32
+ before_validation :inherit_integration, on: :create
33
+ before_validation :assign_delivery_metadata, on: :create
34
+ after_create_commit :enqueue_initial_work, unless: :suppress_auto_enqueue
35
+
36
+ # How far back reconciliation looks. An alert older than this that is
37
+ # still pending or collapsed predates the outbox (a row an old container
38
+ # wrote, or one left over from before this shipped); delivering it now
39
+ # would page someone about something that happened hours ago.
40
+ RECONCILE_WINDOW = 1.hour
41
+
42
+ scope :due_for_delivery, ->(now = Time.current) {
43
+ where(status: "pending").where(created_at: (now - RECONCILE_WINDOW)..)
44
+ .where("next_delivery_at IS NULL OR next_delivery_at <= ?", now)
45
+ .where("delivery_enqueued_until IS NULL OR delivery_enqueued_until <= ?", now)
46
+ }
47
+ scope :with_stale_delivery_lease, ->(now = Time.current) {
48
+ where(status: "delivering").where("delivery_lease_expires_at <= ?", now)
49
+ }
50
+ scope :overdue_collapsed, ->(now = Time.current) {
51
+ where(status: "collapsed").where(created_at: (now - RECONCILE_WINDOW)..(now - AlertRule::BURST_WINDOW))
52
+ }
53
+
54
+ # Reserve a queue hand-off. If the process dies after this write and before
55
+ # perform_later, reconciliation retries once this short reservation expires.
56
+ def enqueue_delivery!(wait_until: nil)
57
+ reserved_until = nil
58
+ target = nil
59
+ with_lock do
60
+ return false unless status == "pending"
61
+
62
+ target = [ wait_until, next_delivery_at, Time.current ].compact.max
63
+ return false if delivery_enqueued_until&.future?
64
+
65
+ reserved_until = target + ENQUEUE_LEASE
66
+ update!(delivery_enqueued_until: reserved_until)
67
+ end
68
+
69
+ if target.future?
70
+ DeliverAlertJob.set(wait_until: target).perform_later(self)
71
+ else
72
+ DeliverAlertJob.perform_later(self)
73
+ end
74
+ true
75
+ rescue StandardError
76
+ Alert.where(id: id, status: "pending", delivery_enqueued_until: reserved_until)
77
+ .update_all(delivery_enqueued_until: nil, updated_at: Time.current) if reserved_until
78
+ raise
79
+ end
80
+
81
+ # Atomically turns one queued job into the only worker allowed to deliver.
82
+ # Duplicate jobs and jobs for terminal alerts return nil without I/O.
83
+ def claim_delivery!
84
+ with_lock do
85
+ return unless status == "pending"
86
+ return if next_delivery_at.present? && next_delivery_at.future?
87
+
88
+ token = SecureRandom.uuid
89
+ update!(status: "delivering", delivery_attempts: delivery_attempts + 1,
90
+ delivery_key: delivery_key.presence || SecureRandom.uuid,
91
+ delivery_lease_id: token, delivery_lease_expires_at: DELIVERY_LEASE.from_now,
92
+ delivery_enqueued_until: nil)
93
+ token
94
+ end
95
+ end
96
+
97
+ def perform_claimed_delivery!(token)
98
+ if integration.enabled?
99
+ integration.deliver(self)
100
+ finish_claim!(token, "sent")
101
+ else
102
+ finish_claim!(token, "skipped")
103
+ end
104
+ end
105
+
106
+ # Persists retry intent before another job is scheduled. A crash at the
107
+ # following queue boundary is recovered by ReconcileAlertDeliveriesJob.
108
+ def fail_claim!(token, exception)
109
+ message = "#{exception.class}: #{exception.message}".first(1000)
110
+ retry_at = nil
111
+ with_lock do
112
+ return unless owns_delivery_claim?(token)
113
+
114
+ if delivery_attempts >= MAX_DELIVERY_ATTEMPTS
115
+ update!(status: "failed", error: message, next_delivery_at: nil,
116
+ delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
117
+ else
118
+ retry_at = RETRY_DELAYS.fetch(delivery_attempts - 1).from_now
119
+ update!(status: "pending", error: message, next_delivery_at: retry_at,
120
+ delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
121
+ end
122
+ integration.update_columns(last_error: message)
123
+ end
124
+ retry_at
125
+ end
126
+
127
+ # A lease expiry is indistinguishable from a worker dying after the remote
128
+ # endpoint accepted the message but before `sent` committed. Retrying is
129
+ # required for at-least-once delivery; webhook consumers can deduplicate by
130
+ # delivery_key / Idempotency-Key.
131
+ def recover_stale_delivery!
132
+ retryable = false
133
+ with_lock do
134
+ return false unless status == "delivering" && delivery_lease_expires_at&.past?
135
+
136
+ message = error.presence || "Delivery lease expired before completion"
137
+ if delivery_attempts >= MAX_DELIVERY_ATTEMPTS
138
+ update!(status: "failed", error: message, next_delivery_at: nil,
139
+ delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
140
+ else
141
+ update!(status: "pending", error: message, next_delivery_at: Time.current,
142
+ delivery_lease_id: nil, delivery_lease_expires_at: nil, delivery_enqueued_until: nil)
143
+ retryable = true
144
+ end
145
+ end
146
+ retryable
147
+ end
148
+
149
+ # Explicit operator action: terminal failures get a fresh, bounded attempt
150
+ # budget. Sent/skipped alerts are intentionally not replayed by this path.
151
+ def retry_delivery!
152
+ with_lock do
153
+ return false unless status == "failed"
154
+
155
+ update!(status: "pending", error: nil, delivery_attempts: 0,
156
+ next_delivery_at: Time.current, delivery_enqueued_until: nil,
157
+ delivery_lease_id: nil, delivery_lease_expires_at: nil)
158
+ end
159
+ enqueue_delivery!
160
+ end
161
+
162
+ # Synchronous delivery is retained for "Send test", whose HTTP response
163
+ # must report the real destination outcome. Normal alerts use the leased job
164
+ # path above.
165
+ def deliver!
166
+ return self if TERMINAL_STATUSES.include?(status)
167
+ return update!(status: "skipped") unless integration.enabled?
168
+
169
+ integration.deliver(self)
170
+ mark_sent!
171
+ self
172
+ rescue StandardError => e
173
+ message = "#{e.class}: #{e.message}".first(1000)
174
+ update!(status: "failed", error: message)
175
+ integration.update_columns(last_error: message)
176
+ raise
177
+ end
178
+
179
+ private
180
+
181
+ def enqueue_initial_work
182
+ # An embedded install has no integration to deliver to and no delivery
183
+ # jobs to enqueue on; the alert is recorded and that is the end of it.
184
+ return if integration.nil?
185
+
186
+ if status == "pending"
187
+ enqueue_delivery!
188
+ elsif status == "collapsed" && alert_rule
189
+ SummarizeAlertBurstJob.set(wait_until: created_at + AlertRule::BURST_WINDOW).perform_later(alert_rule)
190
+ end
191
+ end
192
+
193
+ def assign_delivery_metadata
194
+ self.delivery_key ||= SecureRandom.uuid
195
+ self.next_delivery_at ||= Time.current if status.blank? || status == "pending"
196
+ end
197
+
198
+ def inherit_integration
199
+ self.integration ||= alert_rule&.integration
200
+ end
201
+
202
+ def owns_delivery_claim?(token)
203
+ status == "delivering" && delivery_lease_id == token
204
+ end
205
+
206
+ def finish_claim!(token, outcome)
207
+ with_lock do
208
+ return false unless owns_delivery_claim?(token)
209
+
210
+ attributes = { status: outcome, error: nil, next_delivery_at: nil,
211
+ delivery_lease_id: nil, delivery_lease_expires_at: nil,
212
+ delivery_enqueued_until: nil }
213
+ attributes[:sent_at] = Time.current if outcome == "sent"
214
+ update!(attributes)
215
+ if outcome == "sent"
216
+ integration.update_columns(last_delivered_at: Time.current, last_error: nil)
217
+ issue&.activities&.create!(kind: "alert", data: { event: event, integration: integration.kind })
218
+ end
219
+ true
220
+ end
221
+ end
222
+
223
+ def mark_sent!
224
+ update!(status: "sent", sent_at: Time.current, error: nil)
225
+ integration.update_columns(last_delivered_at: Time.current, last_error: nil)
226
+ issue&.activities&.create!(kind: "alert", data: { event: event, integration: integration.kind })
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class AlertRule < ApplicationRecord
5
+ self.table_name = "railwatch_alert_rules"
6
+ TITLE_PATTERN_MAX_LENGTH = 256
7
+ TITLE_PATTERN_TIMEOUT = 0.01
8
+ EVENTS = %w[new_issue regressed_issue resolved_issue ignored_issue assigned_issue threshold anomaly silent_host quota crash_free_drop].freeze
9
+
10
+ # A deploy that breaks one page opens dozens of issues in a minute, and
11
+ # each one used to be its own Slack message. These two events are the ones
12
+ # that arrive in bursts.
13
+ BURST_EVENTS = %w[new_issue regressed_issue].freeze
14
+ BURST_WINDOW = 60.seconds
15
+ # Deliver this many individually, then fold the rest of the window into one
16
+ # summary message.
17
+ BURST_LIMIT = 5
18
+
19
+ def application = Application.current
20
+ def integration = nil
21
+ has_many :alerts, dependent: :destroy
22
+
23
+ validates :event, inclusion: { in: EVENTS }
24
+ validate :valid_title_pattern
25
+
26
+ # De-duplicate: one alert per issue per event per 30 minutes.
27
+ def fire!(event:, issue: nil, payload: {})
28
+ # Serialize the duplicate/burst decision per rule. The alert is born in
29
+ # its final outbox state, so its after-commit hook can never enqueue a row
30
+ # that is about to become collapsed.
31
+ with_lock do
32
+ return if issue && alerts.where(issue: issue, event: event).where("created_at > ?", 30.minutes.ago).exists?
33
+
34
+ status = bursting?(event, issue) ? "collapsed" : "pending"
35
+ alerts.create!(event: event, issue: issue, payload: payload, status: status)
36
+ end
37
+ end
38
+
39
+ # Optional narrowing of what this rule notifies about, stored in `filters`:
40
+ # environment_ids, kinds, min_priority, title_pattern. An absent or empty
41
+ # filter matches everything; an issue-less alert (quota, silent_host) is
42
+ # only narrowed by environment, via payload[:environment_id].
43
+ def matches?(issue:, payload: {})
44
+ return false unless environment_matches?(issue, payload)
45
+ return true unless issue
46
+ kind_matches?(issue) && priority_matches?(issue) && title_matches?(issue)
47
+ end
48
+
49
+ private
50
+
51
+ def valid_title_pattern
52
+ pattern = filters["title_pattern"].to_s
53
+ return if pattern.blank?
54
+
55
+ if pattern.length > TITLE_PATTERN_MAX_LENGTH
56
+ errors.add(:filters, "title pattern is too long (maximum is #{TITLE_PATTERN_MAX_LENGTH} characters)")
57
+ return
58
+ end
59
+ return unless regular_expression_pattern?(pattern)
60
+
61
+ compile_title_pattern(pattern)
62
+ rescue RegexpError => e
63
+ errors.add(:filters, "title pattern is not a valid regular expression: #{e.message}")
64
+ end
65
+
66
+ # True once this rule has already fired BURST_LIMIT issue alerts inside the
67
+ # window. Only alerts that carry an issue are counted, so summary rows can
68
+ # never inflate the next window's tally.
69
+ def bursting?(event, issue)
70
+ return false unless BURST_EVENTS.include?(event) && issue
71
+ alerts.where(event: event, created_at: BURST_WINDOW.ago..).where.not(issue_id: nil).count >= BURST_LIMIT
72
+ end
73
+
74
+ def environment_matches?(issue, payload)
75
+ ids = Array(filters["environment_ids"]).compact_blank.map(&:to_i)
76
+ return true if ids.empty?
77
+ ids.include?((issue&.environment_id || payload[:environment_id] || payload["environment_id"]).to_i)
78
+ end
79
+
80
+ def kind_matches?(issue)
81
+ kinds = Array(filters["kinds"]).compact_blank
82
+ kinds.empty? || kinds.include?(issue.kind)
83
+ end
84
+
85
+ def priority_matches?(issue)
86
+ minimum = filters["min_priority"]
87
+ return true if minimum.blank? || Issue::PRIORITIES.exclude?(minimum)
88
+ Issue::PRIORITIES.index(issue.priority) >= Issue::PRIORITIES.index(minimum)
89
+ end
90
+
91
+ # A pattern wrapped in slashes is a regular expression, anything else is a
92
+ # case-insensitive substring.
93
+ def title_matches?(issue)
94
+ pattern = filters["title_pattern"].to_s
95
+ return true if pattern.blank?
96
+ if regular_expression_pattern?(pattern)
97
+ begin
98
+ return issue.title.match?(compile_title_pattern(pattern))
99
+ rescue Regexp::TimeoutError
100
+ # A pattern that backtracks past its budget matches nothing rather
101
+ # than stalling every alert behind it.
102
+ Rails.logger.warn("alert rule title pattern timed out alert_rule_id=#{id}")
103
+ return false
104
+ rescue RegexpError
105
+ # An unparsable regexp degrades to a substring match on its source, so
106
+ # a rule saved before this was validated does not silently go dark.
107
+ pattern = pattern[1..-2]
108
+ end
109
+ end
110
+ issue.title.downcase.include?(pattern.downcase)
111
+ end
112
+
113
+ def regular_expression_pattern?(pattern)
114
+ pattern.start_with?("/") && pattern.end_with?("/") && pattern.length > 2
115
+ end
116
+
117
+ def compile_title_pattern(pattern)
118
+ Regexp.new(pattern[1..-2], Regexp::IGNORECASE, timeout: TITLE_PATTERN_TIMEOUT)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # "Alert when p95 of GET /checkout is more than 3 standard deviations above
5
+ # its 7-day baseline for the same time of day." Evaluated by
6
+ # DetectAnomaliesJob against hourly rollups.
7
+ class AnomalyRule < ApplicationRecord
8
+ self.table_name = "railwatch_anomaly_rules"
9
+ MIN_BASELINE_DAYS = 3
10
+ MAX_BASELINE_DAYS = 30
11
+ MAX_DEVIATION = 10
12
+ MAX_WINDOW_MINUTES = 1_440
13
+ TARGET_KINDS = %w[requests jobs queries outgoing_requests scheduled_tasks].freeze
14
+ METRICS = %w[p95 avg count error_rate].freeze
15
+
16
+ def environment = Environment.current
17
+
18
+ validates :target_kind, inclusion: { in: TARGET_KINDS }
19
+ validates :metric, inclusion: { in: METRICS }
20
+ validates :target, presence: true, length: { maximum: 256 }
21
+ validates :deviation, numericality: { greater_than: 0, less_than_or_equal_to: MAX_DEVIATION }
22
+ validates :window_minutes, numericality: { only_integer: true, greater_than: 0,
23
+ less_than_or_equal_to: MAX_WINDOW_MINUTES }
24
+ validates :baseline_days, numericality: { only_integer: true, greater_than_or_equal_to: MIN_BASELINE_DAYS,
25
+ less_than_or_equal_to: MAX_BASELINE_DAYS }
26
+
27
+ scope :enabled, -> { where(enabled: true) }
28
+
29
+ def description
30
+ "#{target_kind} #{target == '*' ? 'all' : target}: #{metric} > #{deviation}σ above #{baseline_days}-day baseline"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # The one application an embedded install monitors: the host. Not a table.
5
+ # Issue keys need a prefix and a counter; the rest of what the platform's
6
+ # Application carries (account, repository links, alert rules) is either
7
+ # fixed here or empty.
8
+ class Application
9
+ ID = 1
10
+
11
+ attr_reader :id, :name, :slug
12
+
13
+ def self.current = @current ||= new
14
+ def self.find(id)
15
+ raise ActiveRecord::RecordNotFound, "Application #{id}" unless id.to_i == ID
16
+ current
17
+ end
18
+
19
+ def initialize
20
+ @id = ID
21
+ @name = Rails.application.class.module_parent_name
22
+ @slug = @name.parameterize
23
+ end
24
+
25
+ def issue_prefix = Railwatch.config.issue_prefix || @name.upcase.gsub(/[^A-Z0-9]/, "")[0, 4].presence || "APP"
26
+ def repository_url = Railwatch.config.repository_url
27
+ def default_branch = "main"
28
+ def environments = [ Environment.current ]
29
+ def alert_rules = AlertRule.where(application_id: ID)
30
+ def account = Railwatch::Embedded::Account
31
+ def issues = Issue.where(application_id: ID)
32
+
33
+ # Issue numbers are per application on the platform (APP-1, APP-2, ...),
34
+ # allocated under a row lock on the application. There is no row here, so
35
+ # the meta database's own write lock serialises it: Issue creation already
36
+ # runs inside a transaction, and SQLite allows one writer.
37
+ def next_issue_number!
38
+ (Issue.where(application_id: ID).maximum(:number) || 0) + 1
39
+ end
40
+
41
+ include GlobalID::Identification
42
+ def to_global_id(...) = GlobalID.create(self, app: "railwatch")
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Base class for what the engine owns about the monitored application besides
5
+ # telemetry: issues and their comments and activity, deploys, saved views,
6
+ # thresholds, anomaly rules, alert rules and alerts. These are authored and
7
+ # permanent where telemetry is derived and pruned, so they live in their own
8
+ # database (`railwatch` in the host's database.yml) and never in the host's
9
+ # primary.
10
+ class ApplicationRecord < ActiveRecord::Base
11
+ self.abstract_class = true
12
+ begin
13
+ connects_to database: { writing: :railwatch, reading: :railwatch }
14
+ rescue ActiveRecord::AdapterNotSpecified, LoadError
15
+ # No `railwatch` entry in this environment's database.yml, or an entry
16
+ # whose adapter gem is not in the bundle yet (LoadError). A cloud-transport
17
+ # app has none and still eager-loads this class in production, and so
18
+ # does the --local installer's own boot, before it has written the
19
+ # entry -- so loading must not raise. Using it must, though: without
20
+ # connects_to this class would inherit ActiveRecord::Base's PRIMARY
21
+ # connection, and its tables are unprefixed, so a query would read and
22
+ # a write would corrupt the host application's own tables. Every route
23
+ # into the connection goes through connection_pool, so refusing here
24
+ # fails closed for reads and writes alike.
25
+ def self.connection_pool
26
+ raise Railwatch::DatabaseNotConfigured,
27
+ "the `railwatch` database (issues, comments, saved views and deploys) is not configured for the " \
28
+ "#{Rails.env} environment; run `bin/rails generate railwatch:install --local` " \
29
+ "or add it to config/database.yml (docs/embedded.md)"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class Comment < ApplicationRecord
5
+ self.table_name = "railwatch_comments"
6
+ SOURCES = %w[railwatch linear].freeze
7
+
8
+ belongs_to :issue
9
+ def user
10
+ viewer_id && User.find_by(id: viewer_id)
11
+ end
12
+
13
+ def user=(u)
14
+ self.viewer_id = u&.id&.to_s
15
+ end
16
+
17
+ validates :body, presence: true
18
+ validates :source, inclusion: { in: SOURCES }
19
+ validates :user, presence: true, if: -> { source == "railwatch" }
20
+ validates :author_name, presence: true, if: -> { source == "linear" }
21
+
22
+ after_create :record_activity
23
+ after_create_commit :enqueue_linear_sync
24
+
25
+ private
26
+
27
+ def record_activity
28
+ issue.activities.create!(kind: "comment", user: user,
29
+ data: { comment_id: id, body: body, source: source, author_name: author_name })
30
+ end
31
+
32
+ def enqueue_linear_sync
33
+ nil unless source == "railwatch"
34
+ end
35
+ end
36
+ end