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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class Deploy < ApplicationRecord
5
+ self.table_name = "railwatch_deploys"
6
+ def environment = Environment.current
7
+
8
+ validates :deploy, presence: true, uniqueness: { scope: :environment_id }
9
+ validates :deployed_at, presence: true
10
+ # Rendered as the "Open" link on the deploy page. Same rule as
11
+ # Application#repository_url, checked by parsing: one whole http(s) URL
12
+ # with a host, so a hook (or whoever holds its token) cannot put a
13
+ # javascript:/data: link, a hostless "https://?x", or an arbitrary
14
+ # off-site destination behind a trusted button.
15
+ validate :url_is_a_web_address
16
+
17
+ scope :recent, -> { order(deployed_at: :desc) }
18
+ scope :between, ->(from, to) { where(deployed_at: from..to) }
19
+
20
+ before_validation :assign_previous_ref
21
+ before_validation :trim_commits
22
+
23
+ def short_ref
24
+ ref.to_s.first(7).presence || deploy.first(12)
25
+ end
26
+
27
+ def commits_count
28
+ commits.size
29
+ end
30
+
31
+ # The deploy immediately before this one in the same environment.
32
+ def previous_deploy
33
+ environment.deploys.where(deployed_at: ...deployed_at).recent.first
34
+ end
35
+
36
+ # The stretch of time this release was live: from its own deploy until the
37
+ # next one, or until now for the current release. Release health is summed
38
+ # over this, not over the page's window.
39
+ def window
40
+ [ deployed_at, environment.deploys.where("deployed_at > ?", deployed_at).order(:deployed_at).first&.deployed_at || Time.current ]
41
+ end
42
+
43
+ private
44
+
45
+ def url_is_a_web_address
46
+ return if url.blank?
47
+ parsed = url.match?(/\A\S+\z/) ? URI.parse(url) : nil
48
+ return if parsed.is_a?(URI::HTTP) && parsed.host.present?
49
+ errors.add(:url, "must be a single http:// or https:// URL")
50
+ rescue URI::InvalidURIError
51
+ errors.add(:url, "must be a single http:// or https:// URL")
52
+ end
53
+
54
+ def assign_previous_ref
55
+ return if previous_ref.present? || deployed_at.blank?
56
+ self.previous_ref = previous_deploy&.ref
57
+ end
58
+
59
+ # The post-deploy hook sends its most recent commits (newest first) without
60
+ # knowing where the last deploy stopped, so cut the list at that commit.
61
+ def trim_commits
62
+ return if previous_ref.blank?
63
+ index = commits.index { |c| c["sha"].to_s.start_with?(previous_ref) }
64
+ self.commits = commits.first(index) if index
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # The one environment an embedded install monitors: the host application in
5
+ # its current Rails.env. Not a table. The hosted platform's Environment is an
6
+ # account-scoped Active Record row with a token, quota and pause state; the
7
+ # telemetry, ingest and dashboard code only ever asks it for an id, a slug,
8
+ # a name, with_telemetry, and a few display attributes, so this stands in
9
+ # with exactly that surface.
10
+ class Environment
11
+ ID = 1
12
+
13
+ attr_reader :id, :name, :slug, :application_name
14
+
15
+ def self.current = @current ||= new
16
+
17
+ def self.find(id)
18
+ raise ActiveRecord::RecordNotFound, "Environment #{id}" unless id.to_i == ID
19
+ current
20
+ end
21
+
22
+ def initialize
23
+ @id = ID
24
+ @name = Rails.env.to_s
25
+ @application_name = Rails.application.class.module_parent_name
26
+ @slug = "#{@application_name.parameterize}-#{@name}"
27
+ end
28
+
29
+ def with_telemetry(&block) = TelemetryRecord.with_tenant(slug, &block)
30
+ def telemetry_exists? = true
31
+ def paused? = false
32
+ def token_prefix = "embedded"
33
+ def retention_days = 7
34
+ def last_seen_at = Railwatch::Embedded.last_seen_at
35
+ def application = Application.current
36
+ def issues = Issue.where(environment_id: ID)
37
+ def deploys = Deploy.where(environment_id: ID)
38
+ def saved_views = SavedView.where(environment_id: ID)
39
+ def thresholds = Threshold.where(environment_id: ID)
40
+ def anomaly_rules = AnomalyRule.where(environment_id: ID)
41
+ def events_this_month = 0
42
+ # The platform lets an account list the servers it expects to report so the
43
+ # dashboard can flag silent ones. One embedded install is its own server.
44
+ def expected_servers = []
45
+
46
+ # Ingest::Batch touches this in embedded mode; there is no row to update.
47
+ def update_columns(last_seen_at:) = Railwatch::Embedded.last_seen_at = last_seen_at
48
+
49
+ # GlobalID for Active Job arguments (RollupJob.perform_later(environment, bucket)).
50
+ include GlobalID::Identification
51
+ def to_global_id(...) = GlobalID.create(self, app: "railwatch")
52
+ def self.find_by_id(id) = find(id)
53
+ end
54
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Everything the execution detail page needs: the parent, its waterfall
5
+ # timeline of children, exceptions with frames, logs, and links to the
6
+ # enqueuing request or the jobs it enqueued.
7
+ class ExecutionPresenter
8
+ # The profile page renders up to 2 MiB of collapsed stacks; the execution
9
+ # page only carries an inline preview flamegraph alongside everything else
10
+ # it already ships, so it takes a smaller slice and links out when cut.
11
+ INLINE_COLLAPSED_BYTES = 512.kilobytes
12
+
13
+ # Everything an attachment row needs except `data`, which is the whole
14
+ # gzipped file and never belongs in a page prop.
15
+ ATTACHMENT_COLUMNS = %i[id name content_type bytes truncated occurred_at execution_stage].freeze
16
+
17
+ def initialize(execution, environment)
18
+ @exe = execution
19
+ @environment = environment
20
+ end
21
+
22
+ def props
23
+ @environment.with_telemetry do
24
+ {
25
+ execution: base,
26
+ timeline: timeline,
27
+ exceptions: @exe.exceptions.map { |e| exception_row(e) },
28
+ logs: @exe.logs.order(:occurred_at).limit(500).map { |l| { id: l.id, level: l.level, message: l.message, tags: l.tags, occurred_at: l.occurred_at, stage: l.execution_stage } },
29
+ queries: @exe.queries.order(:occurred_at).limit(500).with_sql.map { |q| { id: q.id, sql: q.sql, duration: q.duration_ms.round(3), source: q.source, group_hash: q.group_hash, row_count: q.row_count, stage: q.execution_stage, offset: offset(q) } },
30
+ spans: spans,
31
+ profile: profile_card,
32
+ attachments: attachment_rows.map { |a| attachment_row(a) },
33
+ enqueued_jobs: enqueued_jobs,
34
+ parent: parent,
35
+ trace: trace,
36
+ trace_url: trace_url,
37
+ issues: issues,
38
+ person: person
39
+ }
40
+ end
41
+ end
42
+
43
+ # Public so the issue page can build breadcrumbs from an occurrence's
44
+ # execution without re-implementing the per-type mapping.
45
+ def timeline_entries
46
+ timeline
47
+ end
48
+
49
+ private
50
+
51
+ def base
52
+ {
53
+ execution_id: @exe.execution_id, kind: @exe.kind, name: @exe.name, duration: @exe.duration_ms.round(3), status: @exe.status,
54
+ outcome: @exe.outcome, method: @exe.method, route: @exe.route, controller: @exe.controller, action: @exe.action,
55
+ queue: @exe.queue, attempt: @exe.attempt, queue_latency: @exe.queue_latency && (@exe.queue_latency / 1000.0).round(1),
56
+ queue_time: @exe.queue_time && (@exe.queue_time / 1000.0).round(1),
57
+ job_id: @exe.job_id, task_key: @exe.task_key, inertia_component: @exe.inertia_component,
58
+ occurred_at: @exe.occurred_at, deploy: @exe.deploy, server: @exe.server, user_ref: @exe.user_ref, tenant: @exe.app_tenant,
59
+ trace_id: @exe.trace_id, allocations: @exe.allocations, peak_memory: @exe.peak_memory, exception_preview: @exe.exception_preview,
60
+ profiled: profile.present?,
61
+ stages: @exe.stages.transform_values { |v| (v / 1000.0).round(3) }, counters: @exe.counters, detail: @exe.detail
62
+ }
63
+ end
64
+
65
+ def offset(row)
66
+ ((row.occurred_at - @exe.occurred_at) * 1000.0).round(3)
67
+ end
68
+
69
+ # Rich waterfall entries for the Timeline component: unlike the model's
70
+ # generic `timeline_label`, this keeps type-specific fields (sql, status,
71
+ # subject, level...) so the frontend can render a real hover/detail panel
72
+ # instead of a truncated string.
73
+ def timeline
74
+ entries = []
75
+ @exe.queries.with_sql.each { |q| entries << { type: "query", id: q.id, offset: offset(q), duration: q.duration_ms, stage: q.execution_stage,
76
+ label: q.sql.to_s.first(120), sql: q.sql, source: q.source, detail: { "rows" => q.row_count, "connection" => q.connection } } }
77
+ @exe.cache_events.each { |c| entries << { type: "cache_event", id: c.id, offset: offset(c), duration: c.duration_ms, stage: c.execution_stage,
78
+ label: "#{c.type} #{c.key}".first(120), source: nil, detail: { "key" => c.key, "op" => c.type, "store" => c.store, "hits" => c.hits, "ttl" => c.ttl } } }
79
+ @exe.outgoing_requests.each { |o| entries << { type: "outgoing_request", id: o.id, offset: offset(o), duration: o.duration_ms, stage: o.execution_stage,
80
+ label: o.url.to_s.first(120), source: o.source, detail: { "method" => o.method, "url" => o.url, "status" => o.status_code, "host" => o.host, "error" => o.error, "body" => o.response_body } } }
81
+ @exe.mails.each { |m| entries << { type: "mail", id: m.id, offset: offset(m), duration: m.duration_ms, stage: m.execution_stage,
82
+ label: m.mailer.to_s.first(120), source: nil, detail: { "subject" => m.subject, "mailer" => m.mailer, "to" => m.to } } }
83
+ @exe.logs.each { |l| entries << { type: "log", id: l.id, offset: offset(l), duration: l.duration_ms, stage: l.execution_stage,
84
+ label: l.message.to_s.first(120), source: l.source, detail: { "level" => l.level, "message" => l.message.to_s.first(500) } } }
85
+ @exe.exceptions.each { |e| entries << { type: "exception", id: e.id, offset: offset(e), duration: e.duration_ms, stage: e.execution_stage,
86
+ label: "#{e.class_name}: #{e.message.to_s.first(100)}", source: (e.file ? "#{e.file}:#{e.line}" : nil), detail: { "class" => e.class_name, "message" => e.message.to_s.first(300), "handled" => e.handled } } }
87
+ @exe.broadcasts.each { |b| entries << { type: "broadcast", id: b.id, offset: offset(b), duration: b.duration_ms, stage: b.execution_stage,
88
+ label: b.channel.to_s.first(120), source: nil, detail: { "channel" => b.channel, "action" => b.action, "stream" => b.stream } } }
89
+ @exe.storage_ops.each { |s| entries << { type: "storage_op", id: s.id, offset: offset(s), duration: s.duration_ms, stage: s.execution_stage,
90
+ label: "#{s.op} #{s.key}".first(120), source: nil, detail: { "op" => s.op, "key" => s.key, "service" => s.service } } }
91
+ @exe.view_renders.each { |v| entries << { type: "view_render", id: v.id, offset: offset(v), duration: v.duration_ms, stage: v.execution_stage,
92
+ label: v.identifier.to_s.first(120), source: nil, detail: { "count" => v.count, "cache_hits" => v.cache_hits } } }
93
+ @exe.enqueued_jobs.each { |j| entries << { type: "enqueued_job", id: j.id, offset: offset(j), duration: j.duration_ms, stage: j.execution_stage,
94
+ label: j.name.to_s.first(120), source: nil, detail: { "queue" => j.queue, "job_id" => j.job_id } } }
95
+ @exe.spans.each { |s| entries << { type: "span", id: s.id, offset: offset(s), duration: s.duration_ms, stage: s.execution_stage,
96
+ label: s.name.to_s.first(120), source: nil, detail: { "status" => s.status }.merge(s.payload) } }
97
+ @exe.transactions.each { |t| entries << { type: "transaction", id: t.id, offset: offset(t), duration: t.duration_ms, stage: t.execution_stage,
98
+ label: t.outcome.to_s.first(120), source: nil, detail: { "outcome" => t.outcome, "connection" => t.connection } } }
99
+ @exe.llm_calls.each { |c| entries << { type: "llm_call", id: c.id, offset: offset(c), duration: c.duration_ms, stage: c.execution_stage,
100
+ label: c.timeline_label.first(120), source: nil, detail: { "operation" => c.operation, "provider" => c.provider, "model" => c.model,
101
+ "tool" => c.tool_name, "input_tokens" => c.input_tokens, "output_tokens" => c.output_tokens, "cost" => c.cost,
102
+ "status" => c.status, "error" => c.error, "workflow" => c.workflow_name, "step" => c.workflow_step_name } } }
103
+ attachment_rows.each { |a| entries << { type: "attachment", id: a.id, offset: offset(a), duration: nil, stage: a.execution_stage,
104
+ label: a.name.to_s.first(120), source: nil, detail: { "name" => a.name, "content_type" => a.content_type, "bytes" => a.bytes, "truncated" => a.truncated } } }
105
+ entries.each { |e| e[:duration] = e[:duration]&.round(3) }
106
+ entries.sort_by { |e| e[:offset] }
107
+ end
108
+
109
+ def exception_row(e)
110
+ { id: e.id, class_name: e.class_name, message: e.message, handled: e.handled, severity: e.severity, source: e.source,
111
+ file: e.file, line: e.line, frames: e.frames, cause: e.cause, locals: e.locals, context: e.context, occurred_at: e.occurred_at, group_hash: e.group_hash }
112
+ end
113
+
114
+ def spans
115
+ @exe.spans.order(:occurred_at).limit(200).map do |s|
116
+ { id: s.id, name: s.name, duration: s.duration_ms&.round(3), status: s.status, attributes: s.payload,
117
+ offset: offset(s), stage: s.execution_stage, occurred_at: s.occurred_at }
118
+ end
119
+ end
120
+
121
+ # The execution's profile, if one was shipped. Looked up by execution_id
122
+ # rather than through the denormalised executions.profile_id so a profile
123
+ # that arrived in a later batch than its execution still shows up.
124
+ def profile
125
+ return @profile if defined?(@profile)
126
+ @profile = @exe.profiles.recent.first
127
+ end
128
+
129
+ def profile_card
130
+ row = profile or return nil
131
+ collapsed, truncated = row.collapsed_capped(INLINE_COLLAPSED_BYTES)
132
+ { id: row.id, profiler: row.profiler, mode: row.mode, interval: row.interval, duration: row.duration_ms.round(2),
133
+ samples: row.samples, stacks_bytes: row.stacks_bytes, collapsed: collapsed, truncated: truncated }
134
+ rescue Telemetry::BoundedGzip::Error
135
+ nil
136
+ end
137
+
138
+ def attachment_rows
139
+ @attachment_rows ||= @exe.attachments.order(:occurred_at).limit(50).select(*ATTACHMENT_COLUMNS).to_a
140
+ end
141
+
142
+ def attachment_row(a)
143
+ { id: a.id, name: a.name, content_type: a.content_type, bytes: a.bytes, truncated: a.truncated,
144
+ viewable: a.viewable?, occurred_at: a.occurred_at }
145
+ end
146
+
147
+ def enqueued_jobs
148
+ @exe.enqueued_jobs.map do |j|
149
+ attempt = Telemetry::Execution.jobs.find_by(job_id: j.job_id)
150
+ { id: j.id, name: j.name, queue: j.queue, job_id: j.job_id, scheduled_at: j.scheduled_at, offset: offset(j),
151
+ attempt_execution_id: attempt&.execution_id, attempt_outcome: attempt&.outcome }
152
+ end
153
+ end
154
+
155
+ def parent
156
+ return nil unless @exe.kind == "job_attempt" && @exe.trace_id
157
+ p = Telemetry::Execution.where(trace_id: @exe.trace_id).where.not(execution_id: @exe.execution_id).order(:occurred_at).first
158
+ p && { execution_id: p.execution_id, kind: p.kind, name: p.name }
159
+ end
160
+
161
+ def trace
162
+ @trace ||= Telemetry::Execution.where(trace_id: @exe.trace_id).order(:occurred_at).limit(50)
163
+ .map { |e| { execution_id: e.execution_id, kind: e.kind, name: e.name, duration: e.duration_ms.round(1), status: e.status, outcome: e.outcome,
164
+ occurred_at: e.occurred_at, parent_id: e.parent_id, server: e.server, current: e.execution_id == @exe.execution_id } }
165
+ end
166
+
167
+ # Only worth linking out to the distributed trace page when this execution
168
+ # is not the whole trace.
169
+ def trace_url
170
+ return nil unless @exe.trace_id && trace.size > 1
171
+ Railwatch::Engine.routes.url_helpers.application_environment_trace_path(@environment.application, @environment, @exe.trace_id)
172
+ end
173
+
174
+ def issues
175
+ groups = @exe.exceptions.map(&:group_hash).uniq
176
+ @environment.issues.where(group_hash: groups).map { |i| { id: i.id, key: i.key, title: i.title, status: i.status, group_hash: i.group_hash } }
177
+ end
178
+
179
+ def person
180
+ return nil unless @exe.user_ref
181
+ p = Telemetry::Person.find_by(ref: @exe.user_ref) or return nil
182
+ { ref: p.ref, name: p.display_name, email: p.email }
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Parses and applies the "key:value key2:value2 free text" grammar shared by
5
+ # telemetry pages, REST endpoints, and MCP tools.
6
+ module FilterQuery
7
+ TOKEN = /\A([a-z_]+):(\S+)\z/
8
+ EXECUTION_KINDS = %w[request job scheduled_task command].freeze
9
+ LOG_LEVELS = %w[debug info warn error fatal event unknown].freeze
10
+ HANDLED_VALUES = %w[true false].freeze
11
+
12
+ COMMON_FIELDS = %w[after before user tenant deploy kind].freeze
13
+ RESOURCE_FIELDS = {
14
+ jobs: %w[class job_id outcome queue status],
15
+ exceptions: %w[source class handled severity status],
16
+ logs: %w[source level status],
17
+ queries: %w[source adapter connection role]
18
+ }.freeze
19
+
20
+ def self.parse(query)
21
+ text = []
22
+ fields = {}
23
+ query.to_s.split(/\s+/).each do |word|
24
+ if (m = TOKEN.match(word))
25
+ fields[m[1]] = m[2]
26
+ elsif word.present?
27
+ text << word
28
+ end
29
+ end
30
+ { text: text.join(" "), fields: fields }
31
+ end
32
+
33
+ # "5xx" -> 500..599, "404" -> 404..404, anything else -> nil
34
+ def self.status_range(value)
35
+ case value.to_s
36
+ when /\A([2-5])xx\z/i then ($1.to_i * 100)..($1.to_i * 100 + 99)
37
+ when /\A\d+\z/ then value.to_i..value.to_i
38
+ end
39
+ end
40
+
41
+ def self.fields_for(resource)
42
+ COMMON_FIELDS + RESOURCE_FIELDS.fetch(resource.to_sym)
43
+ end
44
+
45
+ def self.cursor_context(environment_id:, resource:, query:, window:)
46
+ Digest::SHA256.hexdigest([ environment_id, resource, query.to_s, window ].to_json)
47
+ end
48
+
49
+ # Applies the same telemetry grammar for the web UI, REST API, and MCP.
50
+ # The supplied window remains the outer bound; after:/before: may only
51
+ # narrow it. Unknown fields are inert, and every value is bound through
52
+ # Active Record rather than interpolated into SQL.
53
+ def self.apply(scope, resource:, query:, from:, to:, except: [])
54
+ resource = resource.to_sym
55
+ parsed = parse(query)
56
+ fields = parsed[:fields].except(*Array(except).map(&:to_s))
57
+ scope = scope.where(occurred_at: bounded_time_range(fields, from, to))
58
+ scope = apply_common(scope, fields)
59
+
60
+ case resource
61
+ when :jobs then apply_jobs(scope, fields, parsed[:text])
62
+ when :exceptions then apply_exceptions(scope, fields, parsed[:text])
63
+ when :logs then apply_logs(scope, fields, parsed[:text])
64
+ when :queries then apply_queries(scope, fields, parsed[:text])
65
+ else raise ArgumentError, "unsupported telemetry resource: #{resource}"
66
+ end
67
+ end
68
+
69
+ def self.bounded_time_range(fields, from, to)
70
+ after = parse_time(fields["after"])
71
+ before = parse_time(fields["before"])
72
+ lower = [ from, after ].compact.max
73
+ upper = [ to, before ].compact.min
74
+ lower <= upper ? lower..upper : (lower...lower)
75
+ end
76
+ private_class_method :bounded_time_range
77
+
78
+ def self.parse_time(value)
79
+ return if value.blank?
80
+ Time.zone.parse(value.to_s)
81
+ rescue ArgumentError, TypeError
82
+ nil
83
+ end
84
+ private_class_method :parse_time
85
+
86
+ def self.apply_common(scope, fields)
87
+ scope = scope.where(user_ref: fields["user"]) if fields["user"].present?
88
+ scope = scope.where(app_tenant: fields["tenant"]) if fields["tenant"].present?
89
+ scope = scope.where(deploy: fields["deploy"]) if fields["deploy"].present?
90
+ if fields["source"].present?
91
+ if EXECUTION_KINDS.include?(fields["source"].to_s) && scope.klass.column_names.include?("execution_source")
92
+ scope = scope.where(execution_source: fields["source"])
93
+ elsif scope.klass.column_names.include?("source")
94
+ pattern = "#{scope.klass.sanitize_sql_like(fields["source"])}%"
95
+ scope = scope.where(scope.klass.arel_table[:source].matches(pattern))
96
+ end
97
+ end
98
+ scope = scope.where(execution_source: fields["kind"]) if EXECUTION_KINDS.include?(fields["kind"].to_s) && scope.klass.column_names.include?("execution_source")
99
+ scope
100
+ end
101
+ private_class_method :apply_common
102
+
103
+ def self.apply_jobs(scope, fields, text)
104
+ outcome = fields["outcome"].presence || fields["status"].presence
105
+ scope = scope.where(queue: fields["queue"]) if fields["queue"].present?
106
+ scope = scope.where(outcome: outcome) if outcome
107
+ scope = scope.where(name: fields["class"]) if fields["class"].present?
108
+ scope = scope.where(job_id: fields["job_id"]) if fields["job_id"].present?
109
+ return scope unless text.present?
110
+ pattern = "%#{Telemetry::Execution.sanitize_sql_like(text)}%"
111
+ scope.where("name LIKE :pattern OR exception_preview LIKE :pattern", pattern: pattern)
112
+ end
113
+ private_class_method :apply_jobs
114
+
115
+ def self.apply_exceptions(scope, fields, text)
116
+ handled = fields["handled"]
117
+ handled ||= { "handled" => "true", "unhandled" => "false" }[fields["status"]]
118
+ scope = scope.where(class_name: fields["class"]) if fields["class"].present?
119
+ scope = scope.where(handled: handled == "true") if HANDLED_VALUES.include?(handled.to_s)
120
+ scope = scope.where(severity: fields["severity"]) if fields["severity"].present?
121
+ return scope unless text.present?
122
+ pattern = "%#{Telemetry::Exception.sanitize_sql_like(text)}%"
123
+ scope.where("message LIKE :pattern OR class_name LIKE :pattern", pattern: pattern)
124
+ end
125
+ private_class_method :apply_exceptions
126
+
127
+ def self.apply_logs(scope, fields, text)
128
+ level = fields["level"].presence || fields["status"].presence
129
+ scope = scope.where(level: level) if LOG_LEVELS.include?(level.to_s)
130
+ text.present? ? scope.fts(text) : scope
131
+ end
132
+ private_class_method :apply_logs
133
+
134
+ def self.apply_queries(scope, fields, text)
135
+ %w[adapter connection role].each do |field|
136
+ scope = scope.where(field => fields[field]) if fields[field].present?
137
+ end
138
+ return scope unless text.present?
139
+ scope.matching(text)
140
+ end
141
+ private_class_method :apply_queries
142
+ end
143
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # One row per (batch, exception group) whose occurrences have been counted
5
+ # onto an issue. Lives beside the issues, in the same database and the same
6
+ # transaction as the count, so "did this batch's exceptions already reach
7
+ # this issue" has exactly one answer however many times the follow-up runs.
8
+ class FollowupReceipt < ApplicationRecord
9
+ self.table_name = "railwatch_followup_receipts"
10
+
11
+ # Receipts outlive their batch's ledger row by this much; a replay after
12
+ # that is one the ledger no longer knows about either.
13
+ RETENTION = 30.days
14
+
15
+ # True when this call is the first for the pair: the caller's work is
16
+ # owed. False when a receipt already exists: the work was done, skip it.
17
+ # Must run inside a transaction on this database with the work itself.
18
+ def self.claim!(batch_id:, group_hash:)
19
+ insert({ batch_id: batch_id, group_hash: group_hash, created_at: Time.current },
20
+ unique_by: %i[batch_id group_hash]).rows.any?
21
+ end
22
+
23
+ def self.prune!(now: Time.current)
24
+ where(created_at: ...(now - RETENTION)).delete_all
25
+ end
26
+ end
27
+ end