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,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Ingest
5
+ # Folds one batch's rows into the hourly rollups as they land, so a count
6
+ # or a percentile on the dashboard moves with every batch instead of
7
+ # waiting for RollupJob to recompute the hour from raw rows. Same
8
+ # classification as RollupJob (which stays as the reconciler for anything
9
+ # this missed), applied to the mapped row hashes before they are inserted
10
+ # rather than to Active Record rows read back afterwards.
11
+ #
12
+ # One SELECT for the groups this batch touches, the merge in Ruby, one
13
+ # upsert: the read and the write both stay small whatever the hour holds,
14
+ # so this never grows into the 100 ms recompute that RollupJob is on a
15
+ # busy hour. It runs inside the batch's transaction.
16
+ class RollupAbsorber
17
+ # record_type => [table class, row filter, group name]. Executions carry
18
+ # their kind on the row; LLM calls split into models and tools.
19
+ SOURCES = {
20
+ "request" => [ Telemetry::Execution, ->(r) { r[:kind] == "request" }, ->(r) { r[:name] } ],
21
+ "job_attempt" => [ Telemetry::Execution, ->(r) { r[:kind] == "job_attempt" }, ->(r) { r[:name] } ],
22
+ "scheduled_task" => [ Telemetry::Execution, ->(r) { r[:kind] == "scheduled_task" }, ->(r) { r[:name] } ],
23
+ "command" => [ Telemetry::Execution, ->(r) { r[:kind] == "command" }, ->(r) { r[:name] } ],
24
+ "channel_action" => [ Telemetry::Execution, ->(r) { r[:kind] == "channel_action" }, ->(r) { r[:name] } ],
25
+ "query" => [ Telemetry::Query, ->(_r) { true }, ->(r) { r[:sql].to_s } ],
26
+ "outgoing_request" => [ Telemetry::OutgoingRequest, ->(_r) { true }, ->(r) { "#{r[:method]} #{r[:host]}" } ],
27
+ "cache_event" => [ Telemetry::CacheEvent, ->(_r) { true }, ->(r) { "#{r[:store]} #{r[:key]}" } ],
28
+ "mail" => [ Telemetry::Mail, ->(_r) { true }, ->(r) { r[:mailer] } ],
29
+ "visit" => [ Telemetry::Visit, ->(_r) { true }, ->(r) { r[:component] } ],
30
+ "span" => [ Telemetry::Span, ->(_r) { true }, ->(r) { r[:name] } ],
31
+ "notification" => [ Telemetry::Notification, ->(_r) { true }, ->(r) { r[:notifier] || r[:delivery_method] } ],
32
+ "view_render" => [ Telemetry::ViewRender, ->(_r) { true }, ->(r) { r[:identifier] } ],
33
+ "transaction" => [ Telemetry::Transaction, ->(_r) { true }, ->(r) { "#{r[:connection]} · #{r[:outcome]}" } ],
34
+ "llm_call" => [ Telemetry::LlmCall, ->(r) { r[:operation] != Telemetry::LlmCall::TOOL }, ->(r) { "#{r[:model]} · #{r[:operation]}" } ],
35
+ "llm_tool" => [ Telemetry::LlmCall, ->(r) { r[:operation] == Telemetry::LlmCall::TOOL }, ->(r) { r[:tool_name].to_s } ]
36
+ }.freeze
37
+
38
+ # SQLite binds at most 32,766 variables per statement; a rollup row is 14.
39
+ UPSERT_SLICE = 500
40
+
41
+ def initialize(rows_by_class, query_shapes: {})
42
+ @rows_by_class = rows_by_class
43
+ # Writer files a query's text on its shape and blanks the row, so the
44
+ # group's name has to come from the shape when the row is empty.
45
+ @query_shapes = query_shapes
46
+ end
47
+
48
+ # Groups every rolled-up row by (type, group, hour), merges each group
49
+ # into its existing rollup row if there is one, and writes them back.
50
+ def absorb!
51
+ pending = collect
52
+ return 0 if pending.empty?
53
+
54
+ existing = load_existing(pending.keys)
55
+ rows = pending.map { |key, group| merged_row(key, group, existing[key]) }
56
+ rows.each_slice(UPSERT_SLICE) do |slice|
57
+ Telemetry::Rollup.upsert_all(slice, unique_by: %i[record_type group_hash bucket], record_timestamps: false)
58
+ end
59
+ rows.size
60
+ end
61
+
62
+ private
63
+
64
+ # { [type, group_hash, bucket] => { name:, durations:, errors:, client_errors:, extra: } }
65
+ def collect
66
+ pending = {}
67
+ SOURCES.each do |type, (klass, filter, namer)|
68
+ @rows_by_class.fetch(klass, []).each do |row|
69
+ next unless filter.call(row)
70
+ group_hash = row[:group_hash]
71
+ duration = row[:duration]
72
+ next if group_hash.nil? || duration.nil?
73
+
74
+ bucket = bucket_for(row[:occurred_at])
75
+ next unless bucket
76
+
77
+ entry = pending[[ type, group_hash, bucket ]] ||= { name: nil, durations: [], errors: 0, client_errors: 0, extra: Hash.new(0) }
78
+ entry[:name] ||= name_for(type, row, namer)
79
+ entry[:durations] << duration.to_i
80
+ entry[:errors] += 1 if error?(type, row)
81
+ entry[:client_errors] += 1 if type == "request" && row[:status].to_i.between?(400, 499)
82
+ add_extra(type, row, entry[:extra])
83
+ end
84
+ end
85
+ pending
86
+ end
87
+
88
+ def load_existing(keys)
89
+ found = {}
90
+ keys.group_by { |type, _group, bucket| [ type, bucket ] }.each do |(type, bucket), group_keys|
91
+ Telemetry::Rollup.where(record_type: type, bucket: bucket, group_hash: group_keys.map { |k| k[1] })
92
+ .each { |row| found[[ type, row.group_hash, bucket ]] = row }
93
+ end
94
+ found
95
+ end
96
+
97
+ def merged_row((type, group_hash, bucket), group, existing)
98
+ digest = existing&.digest ? TDigest::TDigest.from_bytes(existing.digest) : TDigest::TDigest.new(0.01)
99
+ group[:durations].each { |d| digest.push(d) }
100
+ digest.compress!
101
+ extra = existing ? existing.extra.merge(group[:extra]) { |_k, a, b| a.is_a?(Numeric) && b.is_a?(Numeric) ? a + b : b } : group[:extra].to_h
102
+ {
103
+ record_type: type, group_hash: group_hash, bucket: bucket,
104
+ name: (group[:name].presence || existing&.name || "").to_s.first(255),
105
+ count: existing&.count.to_i + group[:durations].size,
106
+ error_count: existing&.error_count.to_i + group[:errors],
107
+ client_error_count: existing&.client_error_count.to_i + group[:client_errors],
108
+ duration_sum: existing&.duration_sum.to_i + group[:durations].sum,
109
+ duration_max: [ existing&.duration_max.to_i, group[:durations].max ].max,
110
+ p50: digest.percentile(0.5).to_i, p95: digest.percentile(0.95).to_i, p99: digest.percentile(0.99).to_i,
111
+ digest: digest.as_small_bytes, extra: extra
112
+ }
113
+ end
114
+
115
+ # Mapper writes occurred_at as a UTC string; the hour is its prefix.
116
+ def bucket_for(occurred_at)
117
+ return nil if occurred_at.nil?
118
+
119
+ @buckets ||= {}
120
+ @buckets[occurred_at.to_s[0, 13]] ||= Time.zone.parse(occurred_at.to_s)&.utc&.beginning_of_hour
121
+ end
122
+
123
+ def name_for(type, row, namer)
124
+ if type == "query"
125
+ text = row[:sql].to_s
126
+ text = @query_shapes[row[:group_hash]].to_s if text.empty?
127
+ text.first(255)
128
+ else
129
+ namer.call(row).to_s
130
+ end
131
+ end
132
+
133
+ def error?(type, row)
134
+ case type
135
+ when "request" then row[:status].to_i >= 500
136
+ when "job_attempt", "scheduled_task", "channel_action" then row[:outcome] == "failed"
137
+ when "command" then row[:status].to_i != 0
138
+ when "outgoing_request" then row[:status_code].to_i >= 500 || row[:status_code].to_i.zero?
139
+ when "mail", "notification" then row[:failed] ? true : false
140
+ when "visit" then row[:status] == "error"
141
+ when "transaction" then row[:outcome] == "rollback"
142
+ when "span", "llm_call", "llm_tool" then row[:status] == "failed"
143
+ else false
144
+ end
145
+ end
146
+
147
+ def add_extra(type, row, extra)
148
+ case type
149
+ when "cache_event"
150
+ extra["hits"] += 1 if row[:type] == "hit"
151
+ extra["misses"] += 1 if row[:type] == "miss" || row[:type] == "generate"
152
+ when "view_render"
153
+ # RollupJob stores the hour's commonest kind; a batch cannot know
154
+ # that, so the latest seen wins. Same shape, close enough.
155
+ extra["kind"] = row[:kind] if row[:kind]
156
+ when "llm_call"
157
+ extra["input_tokens"] += row[:input_tokens].to_i
158
+ extra["output_tokens"] += row[:output_tokens].to_i
159
+ extra["cache_read_tokens"] += row[:cache_read_tokens].to_i
160
+ extra["cache_write_tokens"] += row[:cache_write_tokens].to_i
161
+ extra["cost_nanos"] += row[:cost_nanos].to_i
162
+ extra["priced"] += 1 if row[:cost_nanos]
163
+ extra["unpriced"] += 1 if row[:cost_nanos].nil?
164
+ extra["truncated"] += 1 if row[:finish_reason] == "max_tokens"
165
+ extra["with_attachments"] += 1 if row[:attachments].to_i.positive?
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Ingest
5
+ # Inserts mapped rows (klass => [row_hash, ...], from Ingest::Mapper.row_for)
6
+ # into the current tenant's telemetry database inside one transaction.
7
+ #
8
+ # On SQLite, binds straight into a prepared statement per (table, column
9
+ # set) instead of going through Active Record's insert machinery — this is
10
+ # the fast path the platform is tuned for. On any other adapter (Postgres
11
+ # stays supported per SCOPE.md), falls back to Active Record's insert_all.
12
+ class Writer
13
+ # Classes whose new rowids we need after the insert: exception ids go
14
+ # back to the caller for grouping, log ids into the full-text index.
15
+ ID_CLASSES = [ Telemetry::Exception, Telemetry::Log ].freeze
16
+
17
+ def initialize(rows_by_class)
18
+ @rows_by_class = rows_by_class
19
+ end
20
+
21
+ # group_hash => statement, for the queries this batch filed on their
22
+ # shape; RollupAbsorber names a query group from it.
23
+ attr_reader :query_shapes
24
+
25
+ def write!
26
+ exception_ids = []
27
+ TelemetryRecord.transaction do
28
+ tables = with_query_shapes(@rows_by_class)
29
+ connection = TelemetryRecord.connection
30
+ exception_ids = connection.adapter_name == "SQLite" ? write_sqlite(connection, tables) : write_generic(tables)
31
+ end
32
+ exception_ids
33
+ end
34
+
35
+ private
36
+
37
+ # A query's group is the gem's digest of its connection name and its
38
+ # normalized statement, which is the text the gem sends unless it is
39
+ # configured to capture values. A row whose text digests to its group
40
+ # therefore carries the group's statement: it is filed once in
41
+ # query_shapes and the row stores "". Any other text (captured values,
42
+ # a statement the gem truncated, an older gem's raw text) stays on the
43
+ # row. One MD5 per row; the shapes ride the same INSERT OR IGNORE as
44
+ # every table, so a group already on file costs nothing.
45
+ def with_query_shapes(rows_by_class)
46
+ shapes = {}
47
+ rows_by_class.fetch(Telemetry::Query, []).each do |row|
48
+ group_hash = row[:group_hash]
49
+ next unless group_hash && row[:sql].present? && Railwatch::Record.group_hash(row[:connection], row[:sql]) == group_hash
50
+
51
+ shapes[group_hash] ||= { group_hash: group_hash, sql: row[:sql] }
52
+ row[:sql] = ""
53
+ end
54
+ @query_shapes = shapes.transform_values { |shape| shape[:sql] }
55
+ shapes.empty? ? rows_by_class : rows_by_class.merge(Telemetry::QueryShape => shapes.values)
56
+ end
57
+
58
+ def write_sqlite(connection, tables)
59
+ raw = connection.raw_connection
60
+ ids = Hash.new { |h, k| h[k] = [] }
61
+ tables.each do |klass, rows|
62
+ next if rows.empty?
63
+ table = connection.quote_table_name(klass.table_name)
64
+ rows.group_by(&:keys).each do |columns, group_rows|
65
+ ids[klass].concat(insert_group(raw, connection, table, columns, group_rows, klass))
66
+ end
67
+ end
68
+ index_logs(raw, ids[Telemetry::Log])
69
+ ids[Telemetry::Exception]
70
+ end
71
+
72
+ def insert_group(raw, connection, table, columns, rows, klass)
73
+ column_sql = columns.map { |c| connection.quote_column_name(c.to_s) }.join(",")
74
+ placeholders = ([ "?" ] * columns.size).join(",")
75
+ # The gem re-sends a batch after a transport timeout, so the same
76
+ # execution can arrive twice; the unique index on execution_id turns
77
+ # the duplicate into a no-op instead of aborting the whole batch.
78
+ stmt = raw.prepare("INSERT OR IGNORE INTO #{table} (#{column_sql}) VALUES (#{placeholders})")
79
+ ids = []
80
+ begin
81
+ rows.each do |row|
82
+ stmt.bind_params(*columns.map { |c| row[c] })
83
+ stmt.step
84
+ ids << raw.last_insert_row_id if ID_CLASSES.include?(klass) && raw.changes.positive?
85
+ stmt.reset!
86
+ end
87
+ ensure
88
+ stmt.close
89
+ end
90
+ ids
91
+ end
92
+
93
+ # logs_fts is an external-content FTS5 table declared without triggers
94
+ # (the schema dumper cannot carry them), so every inserted message has to
95
+ # be copied into the index by hand, inside the same transaction as the
96
+ # rows themselves. Only SQLite has the index; write_generic does nothing.
97
+ def index_logs(raw, ids)
98
+ # A tenant database migrated before the index existed, or one restored
99
+ # from a backup that predates it, must not fail every batch that carries
100
+ # a log line; telemetry:fts:rebuild creates the index later.
101
+ return if ids.empty? || !Telemetry::Log.fts_available?
102
+
103
+ ids.each_slice(500) do |slice|
104
+ placeholders = ([ "?" ] * slice.size).join(",")
105
+ raw.execute("INSERT INTO logs_fts(rowid, message) SELECT id, message FROM logs WHERE id IN (#{placeholders})", slice)
106
+ end
107
+ end
108
+
109
+ def write_generic(tables)
110
+ exception_ids = []
111
+ tables.each do |klass, rows|
112
+ next if rows.empty?
113
+ result = klass.insert_all(restore_json(klass, rows), record_timestamps: false,
114
+ unique_by: (:execution_id if klass == Telemetry::Execution),
115
+ returning: klass == Telemetry::Exception ? [ :id ] : false)
116
+ exception_ids.concat(result.rows.flatten) if klass == Telemetry::Exception
117
+ end
118
+ exception_ids
119
+ end
120
+
121
+ # insert_all round-trips every value through its column's type (cast
122
+ # then serialize) before quoting it, which is correct for booleans
123
+ # (0/1 casts back to false/true) and timestamps (the formatted string
124
+ # parses back to a Time) but not for a JSON column: its mutable type
125
+ # re-encodes a String as-is instead of parsing it, which would double-
126
+ # encode our pre-serialized JSON. Only this fallback needs the fix —
127
+ # the SQLite fast path writes the encoded string straight through.
128
+ def restore_json(klass, rows)
129
+ json_columns = Ingest::Mapper.columns_for(klass).select { |_, (type, _)| type == :json }.keys.map(&:to_sym)
130
+ return rows if json_columns.empty?
131
+ rows.map do |row|
132
+ row.dup.tap { |r| json_columns.each { |c| r[c] = JSON.parse(r[c]) if r[c].is_a?(String) } }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # A grouped exception or performance problem with a lifecycle. Lives in the
5
+ # primary database so it keeps its sequential id after telemetry is pruned.
6
+ class Issue < ApplicationRecord
7
+ self.table_name = "railwatch_issues"
8
+ class InvalidMerge < ArgumentError; end
9
+
10
+ KINDS = %w[exception performance anomaly].freeze
11
+ STATUSES = %w[open resolved ignored merged].freeze
12
+ PRIORITIES = %w[low normal high urgent].freeze
13
+ # How many recent occurrences a split by message looks at.
14
+ SPLIT_SAMPLE = 200
15
+ MERGE_CHAIN_LIMIT = 10
16
+
17
+ def application = Application.current
18
+ def environment = Environment.current
19
+ # assignee is a plain id on an embedded install (User is not a table).
20
+ def assignee
21
+ assignee_id && User.find_by(id: assignee_id)
22
+ end
23
+
24
+ def assignee=(user)
25
+ self.assignee_id = user&.id&.to_s
26
+ end
27
+ belongs_to :merged_into, class_name: "Issue", optional: true
28
+ has_many :merged_issues, class_name: "Issue", foreign_key: :merged_into_id, inverse_of: :merged_into, dependent: :nullify
29
+ has_many :comments, dependent: :destroy
30
+ has_many :alerts, dependent: :nullify
31
+ has_many :activities, class_name: "IssueActivity", dependent: :destroy
32
+
33
+ validates :kind, inclusion: { in: KINDS }
34
+ validates :status, inclusion: { in: STATUSES }
35
+ validates :priority, inclusion: { in: PRIORITIES }
36
+ validates :group_hash, presence: true, uniqueness: { scope: :environment_id }
37
+ validates :title, presence: true
38
+
39
+ before_validation :assign_number, on: :create
40
+ after_create :record_created_activity
41
+ after_update :record_priority_activity
42
+ after_update :record_assignee_activity
43
+
44
+ scope :open, -> { where(status: "open") }
45
+ scope :recent, -> { order(last_seen_at: :desc) }
46
+
47
+ def key
48
+ "#{application.issue_prefix}-#{number}"
49
+ end
50
+
51
+ def open? = status == "open"
52
+ def resolved? = status == "resolved"
53
+ def merged? = status == "merged"
54
+
55
+ def resolve!(deploy: nil)
56
+ record_status_change! { update!(status: "resolved", resolved_at: Time.current, resolved_in_deploy: deploy) }
57
+ end
58
+
59
+ def ignore!
60
+ record_status_change! { update!(status: "ignored") }
61
+ end
62
+
63
+ def reopen!
64
+ record_status_change! { update!(status: "open", resolved_at: nil, resolved_in_deploy: nil) }
65
+ end
66
+
67
+ # Merges this issue into `target`: moves comments over, folds occurrence
68
+ # and affected-user counts into the target, widens its first/last seen
69
+ # range, and marks this issue "merged". Future occurrences for this
70
+ # issue's group_hash are then credited to the target (see
71
+ # .record_occurrence!). Same environment only.
72
+ def merge_into!(target)
73
+ raise InvalidMerge, "both issues must be persisted before merging" unless persisted? && target&.persisted?
74
+
75
+ transaction do
76
+ # Every merge locks the same pair in the same order. Besides preventing
77
+ # reciprocal requests from creating a cycle, lock! reloads both rows so
78
+ # folding counts cannot overwrite a merge committed from a stale object.
79
+ [ self, target ].sort_by(&:id).each(&:lock!)
80
+ validate_merge_target!(target)
81
+
82
+ comments.update_all(issue_id: target.id)
83
+ target.update!(occurrences: target.occurrences + occurrences, affected_users: [ target.affected_users, affected_users ].max,
84
+ first_seen_at: [ target.first_seen_at, first_seen_at ].min, last_seen_at: [ target.last_seen_at, last_seen_at ].max)
85
+ update!(status: "merged", merged_into_id: target.id)
86
+ activities.create!(kind: "merge", user: Viewer.user, data: { target_id: target.id, target_key: target.key })
87
+ target.activities.create!(kind: "absorbed", user: Viewer.user, data: { source_id: id, source_key: key, occurrences: occurrences })
88
+ end
89
+ end
90
+
91
+ # Gives the merged-in events back: occurrences counted on the target since
92
+ # the merge stay there (they were credited under the target's own group),
93
+ # only the count that came along at merge time moves back. Chained merges
94
+ # must be unwound from the live end so an inner source cannot be restored
95
+ # while its count is still included farther up the chain.
96
+ def unmerge!
97
+ transaction do
98
+ target_id = self.class.where(id: id).pick(:merged_into_id)
99
+ raise InvalidMerge, "cannot unmerge an issue that is not merged" unless target_id
100
+
101
+ target = self.class.find(target_id)
102
+ [ self, target ].sort_by(&:id).each(&:lock!)
103
+ unless merged? && merged_into_id == target.id
104
+ raise InvalidMerge, "issue merge changed; reload and try again"
105
+ end
106
+ if target.merged? || target.merged_into_id.present?
107
+ raise InvalidMerge, "cannot unmerge this issue yet; unmerge #{target.key} first (top-down order)"
108
+ end
109
+
110
+ target.update!(occurrences: [ target.occurrences - occurrences, 0 ].max)
111
+ update!(status: "open", merged_into_id: nil)
112
+ activities.create!(kind: "unmerge", user: Viewer.user, data: { target_id: target.id, target_key: target.key })
113
+ end
114
+ end
115
+
116
+ # Splits this issue by the raw message of its recent occurrences: one new
117
+ # issue per distinct message, each taking that message's count, seen range
118
+ # and sample, with this issue giving those occurrences up. Nothing moves in
119
+ # the telemetry database -- the rows are still grouped under the hash the
120
+ # gem sent -- so this separates what has already been seen; to split future
121
+ # occurrences too, give the error a real fingerprint (Railwatch.fingerprint).
122
+ # Returns the issues it created: [] when every recent occurrence carries
123
+ # the same message, or when they have all been split off already.
124
+ def split!(by: "message")
125
+ return [] unless by == "message"
126
+
127
+ rows = environment.with_telemetry { Telemetry::Exception.where(group_hash: group_hash).recent.limit(SPLIT_SAMPLE).to_a }
128
+ groups = rows.group_by { |row| row.message.to_s }
129
+ return [] if groups.size < 2
130
+
131
+ transaction do
132
+ pending = groups.reject { |message, _| environment.issues.exists?(group_hash: self.class.split_group_hash(group_hash, message)) }
133
+ created = pending.map { |message, message_rows| split_off(message, message_rows) }
134
+ next [] if created.empty?
135
+
136
+ moved = created.sum(&:occurrences)
137
+ update!(occurrences: [ occurrences - moved, 0 ].max)
138
+ activities.create!(kind: "split", user: Viewer.user, data: { by: by, occurrences: moved, into: created.map(&:key) })
139
+ created
140
+ end
141
+ end
142
+
143
+ # Mirrors the gem's Record.group_hash (MD5, 32 hex chars) but joins on a
144
+ # separator the gem never sends, so an issue split here can never collide
145
+ # with one the gem itself would group.
146
+ def self.split_group_hash(group_hash, message)
147
+ Digest::MD5.hexdigest([ group_hash, message ].join("\x1f"))[0, 32]
148
+ end
149
+
150
+ # Called by the grouper for every new occurrence. Returns :new, :regressed,
151
+ # or :seen so the caller can decide whether to alert.
152
+ def self.record_occurrence!(environment:, group_hash:, kind:, title:, culprit:, occurred_at:, deploy:, user_ref:, sample:, source: nil)
153
+ issue = find_or_initialize_by(environment_id: environment.id, group_hash: group_hash)
154
+ return record_occurrence_on!(issue, environment:, kind:, title:, culprit:, occurred_at:, deploy:, sample:, source:) if issue.new_record?
155
+
156
+ visited = {}
157
+ 0.upto(MERGE_CHAIN_LIMIT) do |depth|
158
+ raise InvalidMerge, "cycle detected in issue merge chain" if visited[issue.id]
159
+ visited[issue.id] = true
160
+
161
+ next_id = nil
162
+ result = nil
163
+ # Lock only the row currently being inspected. If it has merged, release
164
+ # that lock before following the pointer; this avoids taking chain locks
165
+ # in an order that can deadlock with merge_into!.
166
+ issue.with_lock do
167
+ if issue.merged? || issue.merged_into_id.present?
168
+ next_id = issue.merged_into_id
169
+ raise InvalidMerge, "merged issue has no merge target" unless next_id
170
+ else
171
+ result = record_occurrence_on!(issue, environment:, kind:, title:, culprit:, occurred_at:, deploy:, sample:, source:)
172
+ end
173
+ end
174
+ return result if result
175
+ raise InvalidMerge, "issue merge chain exceeds #{MERGE_CHAIN_LIMIT} links" if depth == MERGE_CHAIN_LIMIT
176
+
177
+ issue = find_by(id: next_id)
178
+ raise InvalidMerge, "issue merge target no longer exists" unless issue
179
+ end
180
+ end
181
+
182
+ # Routes an issue lifecycle event through every alert rule subscribed to it
183
+ # whose filters match. Public so detectors (DetectAnomaliesJob) can route
184
+ # their own events through the same path.
185
+ def fire_alerts!(event, extra = {})
186
+ return unless event
187
+ application.alert_rules.where(event: event).find_each do |rule|
188
+ payload = { issue_key: key, title: title, environment: environment.name, actor: Viewer.user&.name }.merge(extra)
189
+ next unless rule.matches?(issue: self, payload: payload)
190
+ rule.fire!(event: event, issue: self, payload: payload)
191
+ end
192
+ end
193
+
194
+ # During the rollout the legacy one-workspace columns may already contain
195
+ # a link. Materialize it once so old rows enter the per-workspace model
196
+ # without losing synchronization.
197
+ def linear_links_with_legacy = []
198
+
199
+ private
200
+
201
+ def self.record_occurrence_on!(issue, environment:, kind:, title:, culprit:, occurred_at:, deploy:, sample:, source:)
202
+ outcome =
203
+ if issue.new_record?
204
+ issue.assign_attributes(application_id: environment.application.id, kind: kind, title: title, culprit: culprit,
205
+ first_seen_at: occurred_at)
206
+ :new
207
+ elsif issue.resolved? && (issue.resolved_in_deploy.nil? || deploy.to_s != issue.resolved_in_deploy)
208
+ issue.assign_attributes(status: "open", regressed_at: occurred_at, resolved_at: nil, resolved_in_deploy: nil)
209
+ :regressed
210
+ else
211
+ :seen
212
+ end
213
+ issue.occurrences += 1
214
+ issue.last_seen_at = occurred_at if issue.last_seen_at.nil? || occurred_at > issue.last_seen_at
215
+ issue.sample = sample
216
+ # Where the latest occurrence was raised: "browser" for a JavaScript
217
+ # error the beacon reported, the Rails source for everything else.
218
+ issue.source = source
219
+ issue.title = title if outcome == :new
220
+ issue.save!
221
+ issue.activities.create!(kind: "regressed", data: { at: occurred_at }) if outcome == :regressed
222
+ [ issue, outcome ]
223
+ end
224
+ private_class_method :record_occurrence_on!
225
+
226
+ def validate_merge_target!(target)
227
+ raise InvalidMerge, "can only merge issues in the same environment" unless target.environment_id == environment_id
228
+ raise InvalidMerge, "cannot merge an issue into itself" if target.id == id
229
+ raise InvalidMerge, "cannot merge an issue that is already merged" if merged? || merged_into_id.present?
230
+ raise InvalidMerge, "merge target must be open" unless target.open?
231
+ raise InvalidMerge, "merge target must not itself be merged" if target.merged_into_id.present?
232
+ end
233
+
234
+ def split_off(message, rows)
235
+ latest = rows.max_by(&:occurred_at)
236
+ issue = environment.issues.create!(application_id: application.id, kind: kind, culprit: culprit, source: source,
237
+ group_hash: self.class.split_group_hash(group_hash, message),
238
+ title: "#{latest.class_name}: #{message.first(200)}", occurrences: rows.size,
239
+ first_seen_at: rows.min_by(&:occurred_at).occurred_at, last_seen_at: latest.occurred_at,
240
+ sample: { exception_id: latest.id, handled: latest.handled, execution_id: latest.execution_id,
241
+ execution_preview: latest.execution_preview, deploy: latest.deploy,
242
+ fingerprint: latest.fingerprint, fingerprint_source: latest.fingerprint_source })
243
+ issue.activities.create!(kind: "split", user: Viewer.user, data: { from_id: id, from_key: key, occurrences: rows.size })
244
+ issue
245
+ end
246
+
247
+ def record_status_change!
248
+ from = status
249
+ yield
250
+ return if status == from
251
+ activities.create!(kind: "status", user: Viewer.user, data: { from: from, to: status })
252
+ fire_alerts!({ "resolved" => "resolved_issue", "ignored" => "ignored_issue", "open" => "regressed_issue" }[status])
253
+ end
254
+
255
+ def record_created_activity
256
+ activities.create!(kind: "created", user: Viewer.user)
257
+ end
258
+
259
+ def record_priority_activity
260
+ return unless saved_change_to_priority?
261
+ from, to = saved_change_to_priority
262
+ activities.create!(kind: "priority", user: Viewer.user, data: { from: from, to: to })
263
+ end
264
+
265
+ def record_assignee_activity
266
+ return unless saved_change_to_assignee_id?
267
+ from, to = saved_change_to_assignee_id
268
+ activities.create!(kind: "assignee", user: Viewer.user, data: { from_id: from, to_id: to })
269
+ fire_alerts!("assigned_issue", assignee: assignee&.name, assignee_id: to) if to
270
+ end
271
+
272
+
273
+ def assign_number
274
+ self.number ||= application.next_issue_number!
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # One entry in an issue's timeline: a comment or a system event (status
5
+ # change, priority change, reassignment, merge/unmerge, split, regression,
6
+ # alert sent). `data` holds kind-specific details the frontend renders into a
7
+ # sentence.
8
+ class IssueActivity < ApplicationRecord
9
+ self.table_name = "railwatch_issue_activities"
10
+ KINDS = %w[created status priority assignee comment merge absorbed unmerge split regressed alert agent].freeze
11
+
12
+ belongs_to :issue
13
+ def user
14
+ viewer_id && User.find_by(id: viewer_id)
15
+ end
16
+
17
+ def user=(u)
18
+ self.viewer_id = u&.id&.to_s
19
+ end
20
+
21
+ validates :kind, inclusion: { in: KINDS }
22
+ end
23
+ end