railwatch 0.1.3 → 0.2.0.pre1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +231 -0
  3. data/README.md +10 -0
  4. data/app/channels/railwatch/environment_channel.rb +28 -0
  5. data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
  6. data/app/controllers/railwatch/alerts_controller.rb +48 -0
  7. data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
  8. data/app/controllers/railwatch/attachments_controller.rb +22 -0
  9. data/app/controllers/railwatch/beacon_controller.rb +67 -7
  10. data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
  11. data/app/controllers/railwatch/cache_events_controller.rb +30 -0
  12. data/app/controllers/railwatch/commands_controller.rb +16 -0
  13. data/app/controllers/railwatch/comments_controller.rb +11 -0
  14. data/app/controllers/railwatch/dashboard_controller.rb +69 -0
  15. data/app/controllers/railwatch/deploys_controller.rb +34 -0
  16. data/app/controllers/railwatch/deprecations_controller.rb +54 -0
  17. data/app/controllers/railwatch/environment_scoped.rb +99 -0
  18. data/app/controllers/railwatch/exceptions_controller.rb +53 -0
  19. data/app/controllers/railwatch/executions_controller.rb +13 -0
  20. data/app/controllers/railwatch/issues_controller.rb +258 -0
  21. data/app/controllers/railwatch/jobs_controller.rb +63 -0
  22. data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
  23. data/app/controllers/railwatch/logs_controller.rb +40 -0
  24. data/app/controllers/railwatch/mails_controller.rb +19 -0
  25. data/app/controllers/railwatch/notifications_controller.rb +11 -0
  26. data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
  27. data/app/controllers/railwatch/overview_controller.rb +38 -0
  28. data/app/controllers/railwatch/people_controller.rb +25 -0
  29. data/app/controllers/railwatch/processes_controller.rb +33 -0
  30. data/app/controllers/railwatch/profiles_controller.rb +50 -0
  31. data/app/controllers/railwatch/queries_controller.rb +59 -0
  32. data/app/controllers/railwatch/releases_controller.rb +75 -0
  33. data/app/controllers/railwatch/requests_controller.rb +55 -0
  34. data/app/controllers/railwatch/saved_views_controller.rb +53 -0
  35. data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
  36. data/app/controllers/railwatch/spans_controller.rb +47 -0
  37. data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
  38. data/app/controllers/railwatch/tenants_controller.rb +44 -0
  39. data/app/controllers/railwatch/thresholds_controller.rb +40 -0
  40. data/app/controllers/railwatch/traces_controller.rb +72 -0
  41. data/app/controllers/railwatch/transactions_controller.rb +21 -0
  42. data/app/controllers/railwatch/view_renders_controller.rb +28 -0
  43. data/app/controllers/railwatch/visits_controller.rb +53 -0
  44. data/app/helpers/railwatch/assets_helper.rb +52 -0
  45. data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
  46. data/app/jobs/railwatch/application_job.rb +7 -0
  47. data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
  48. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
  49. data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
  50. data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
  51. data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
  52. data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
  53. data/app/jobs/railwatch/performance_scan_job.rb +11 -0
  54. data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
  55. data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
  56. data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
  57. data/app/jobs/railwatch/rollup_job.rb +130 -0
  58. data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
  59. data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
  60. data/app/models/railwatch/alert.rb +229 -0
  61. data/app/models/railwatch/alert_rule.rb +121 -0
  62. data/app/models/railwatch/anomaly_rule.rb +33 -0
  63. data/app/models/railwatch/application.rb +44 -0
  64. data/app/models/railwatch/application_record.rb +33 -0
  65. data/app/models/railwatch/comment.rb +36 -0
  66. data/app/models/railwatch/deploy.rb +67 -0
  67. data/app/models/railwatch/environment.rb +54 -0
  68. data/app/models/railwatch/execution_presenter.rb +185 -0
  69. data/app/models/railwatch/filter_query.rb +143 -0
  70. data/app/models/railwatch/followup_receipt.rb +27 -0
  71. data/app/models/railwatch/ingest/batch.rb +305 -0
  72. data/app/models/railwatch/ingest/mapper.rb +575 -0
  73. data/app/models/railwatch/ingest/payload.rb +96 -0
  74. data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
  75. data/app/models/railwatch/ingest/writer.rb +137 -0
  76. data/app/models/railwatch/issue.rb +277 -0
  77. data/app/models/railwatch/issue_activity.rb +23 -0
  78. data/app/models/railwatch/issue_detection_presenter.rb +309 -0
  79. data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
  80. data/app/models/railwatch/maintenance_task.rb +53 -0
  81. data/app/models/railwatch/saved_view.rb +63 -0
  82. data/app/models/railwatch/telemetry/aggregations.rb +116 -0
  83. data/app/models/railwatch/telemetry/attachment.rb +31 -0
  84. data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
  85. data/app/models/railwatch/telemetry/broadcast.rb +13 -0
  86. data/app/models/railwatch/telemetry/cache_event.rb +16 -0
  87. data/app/models/railwatch/telemetry/child.rb +53 -0
  88. data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
  89. data/app/models/railwatch/telemetry/deprecation.rb +13 -0
  90. data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
  91. data/app/models/railwatch/telemetry/exception.rb +21 -0
  92. data/app/models/railwatch/telemetry/execution.rb +71 -0
  93. data/app/models/railwatch/telemetry/health_sample.rb +72 -0
  94. data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
  95. data/app/models/railwatch/telemetry/llm_call.rb +37 -0
  96. data/app/models/railwatch/telemetry/log.rb +85 -0
  97. data/app/models/railwatch/telemetry/mail.rb +13 -0
  98. data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
  99. data/app/models/railwatch/telemetry/notification.rb +13 -0
  100. data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
  101. data/app/models/railwatch/telemetry/person.rb +50 -0
  102. data/app/models/railwatch/telemetry/process.rb +10 -0
  103. data/app/models/railwatch/telemetry/profile.rb +37 -0
  104. data/app/models/railwatch/telemetry/query.rb +50 -0
  105. data/app/models/railwatch/telemetry/query_shape.rb +45 -0
  106. data/app/models/railwatch/telemetry/release_health.rb +63 -0
  107. data/app/models/railwatch/telemetry/rollup.rb +78 -0
  108. data/app/models/railwatch/telemetry/session.rb +24 -0
  109. data/app/models/railwatch/telemetry/span.rb +19 -0
  110. data/app/models/railwatch/telemetry/storage_op.rb +13 -0
  111. data/app/models/railwatch/telemetry/tenant.rb +221 -0
  112. data/app/models/railwatch/telemetry/transaction.rb +13 -0
  113. data/app/models/railwatch/telemetry/view_render.rb +13 -0
  114. data/app/models/railwatch/telemetry/visit.rb +24 -0
  115. data/app/models/railwatch/telemetry_record.rb +57 -0
  116. data/app/models/railwatch/threshold.rb +29 -0
  117. data/app/models/railwatch/user.rb +47 -0
  118. data/app/models/railwatch/viewer.rb +13 -0
  119. data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
  120. data/config/routes.rb +59 -0
  121. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
  122. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
  123. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
  124. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
  125. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
  126. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
  127. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
  128. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
  129. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
  130. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
  131. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
  132. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
  133. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
  134. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
  135. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
  136. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
  137. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
  138. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
  139. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
  140. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
  141. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
  142. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
  143. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
  144. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
  145. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
  146. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
  147. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
  148. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
  149. data/docs/configuration.md +26 -0
  150. data/docs/embedded.md +342 -0
  151. data/docs/getting-started.md +5 -0
  152. data/docs/records.md +16 -1
  153. data/lib/generators/railwatch/install/install_generator.rb +222 -1
  154. data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
  155. data/lib/generators/railwatch/install/templates/post-deploy +8 -0
  156. data/lib/puma/plugin/railwatch.rb +170 -0
  157. data/lib/railwatch/authentication.rb +83 -0
  158. data/lib/railwatch/configuration.rb +134 -3
  159. data/lib/railwatch/dashboard_assets.rb +45 -0
  160. data/lib/railwatch/embedded.rb +51 -0
  161. data/lib/railwatch/engine.rb +89 -1
  162. data/lib/railwatch/ingest_request_body_limit.rb +10 -0
  163. data/lib/railwatch/json_compat.rb +60 -0
  164. data/lib/railwatch/maintenance.rb +183 -0
  165. data/lib/railwatch/patches/runner_command.rb +21 -1
  166. data/lib/railwatch/record.rb +33 -8
  167. data/lib/railwatch/reporter.rb +16 -0
  168. data/lib/railwatch/subscribers/llm.rb +149 -4
  169. data/lib/railwatch/subscribers/process_info.rb +2 -1
  170. data/lib/railwatch/transport/local.rb +78 -0
  171. data/lib/railwatch/transport/socket.rb +183 -0
  172. data/lib/railwatch/version.rb +1 -1
  173. data/lib/railwatch/writer.rb +370 -0
  174. data/lib/railwatch.rb +38 -1
  175. data/lib/tasks/railwatch_tasks.rake +128 -0
  176. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  177. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  180. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  181. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  182. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  183. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  184. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  185. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  186. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  187. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  188. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  189. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  190. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  191. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  192. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  193. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  194. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  195. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  196. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  197. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  198. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  199. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  200. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  201. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  202. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  203. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  204. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  205. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  206. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  207. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  208. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  209. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  210. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  211. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  212. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  213. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  214. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  215. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  216. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  217. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  218. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  219. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  220. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  221. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  222. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  223. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  224. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  225. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  226. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  227. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  228. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  229. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  230. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  231. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  232. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  233. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  234. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  235. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  236. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  237. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  238. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  239. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  240. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  241. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  242. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  243. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  244. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  245. data/public/railwatch/assets/index-oVkururr.js +1 -0
  246. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  247. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  248. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  249. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  250. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  251. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  252. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  253. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  254. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  255. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  256. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  257. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  258. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  259. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  260. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  261. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  262. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  263. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  264. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  265. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  266. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  267. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  268. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  269. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  270. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  271. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  272. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  273. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  274. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  275. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  276. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  277. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  278. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  279. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  280. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  281. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  282. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  283. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  284. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  285. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  286. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  287. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  288. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  289. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  290. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  291. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  292. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  293. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  294. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  295. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  296. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  297. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  298. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  299. data/public/railwatch/icon.png +0 -0
  300. data/public/railwatch/icon.svg +5 -0
  301. data/public/railwatch/manifest.json +2171 -0
  302. data/public/railwatch/rails-vite.json +1 -0
  303. metadata +313 -4
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class SpansController < DashboardController
5
+ def index
6
+ spans = grouped("span", limit: 200, order: params[:sort], dir: params[:dir])
7
+ spans = apply_filters(spans, FilterQuery.parse(params[:q]).fetch(:fields))
8
+ render inertia: { spans: spans, series: series("span"), summary: summary_with_delta("span"),
9
+ sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s }
10
+ end
11
+
12
+ def show
13
+ group_hash = params[:id]
14
+ rows = telemetry { Telemetry::Span.where(group_hash: group_hash).between(*window_range).recent.limit(200).to_a }
15
+ render inertia: {
16
+ group_hash: group_hash,
17
+ name: rows.first&.name || telemetry { Telemetry::Rollup.for_type("span").where(group_hash: group_hash).pick(:name) },
18
+ summary: summary_with_delta("span", group_hash: group_hash), series: series("span", group_hash: group_hash),
19
+ facets: attribute_facets(rows), samples: rows.first(50).map { |s| span_row(s) }
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ # Rollup rows for "span" carry the span name and a failure count, so
26
+ # status:failed is "this group failed at least once in the window" rather
27
+ # than a per-span lookup.
28
+ def apply_filters(spans, fields)
29
+ spans = spans.select { |s| s[:name].to_s.include?(fields["name"]) } if fields["name"].present?
30
+ spans = spans.select { |s| s[:errors].positive? } if fields["status"] == "failed"
31
+ spans
32
+ end
33
+
34
+ # Which attribute keys these spans carry, and the most common values for
35
+ # each -- the span equivalent of the query page's "called from" breakdown.
36
+ def attribute_facets(rows)
37
+ rows.flat_map { |r| r.payload.to_a }.group_by(&:first).map { |key, pairs|
38
+ { key: key, count: pairs.size, values: pairs.map { |_k, value| value.to_s }.tally.sort_by { |_v, c| -c }.first(5) }
39
+ }.sort_by { |facet| -facet[:count] }.first(10)
40
+ end
41
+
42
+ def span_row(s)
43
+ { id: s.id, name: s.name, duration: s.duration_ms.round(3), status: s.status, attributes: s.payload,
44
+ occurred_at: s.occurred_at, execution_id: s.execution_id, execution_preview: s.execution_preview }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class StorageOpsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ ops = telemetry do
8
+ Telemetry::StorageOp.between(from, to).group(:service, :op).pluck(:service, :op, Arel.sql("COUNT(*)"), Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"))
9
+ .map { |s, o, c, avg, mx| { service: s, op: o, count: c, avg: (avg.to_f / 1000.0).round(1), max: (mx.to_f / 1000.0).round(1) } }
10
+ end
11
+ recent = telemetry { Telemetry::StorageOp.between(from, to).recent.limit(100).map { |r| { id: r.id, service: r.service, op: r.op, key: r.key, duration: r.duration_ms.round(1), occurred_at: r.occurred_at, execution_id: r.execution_id, execution_preview: r.execution_preview } } }
12
+ render inertia: { ops: ops, recent: recent }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class TenantsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ tenants, summary = telemetry do
8
+ rows = Telemetry::Tenant.index(from, to, q: params[:q], sort: params[:sort], dir: params[:dir])
9
+ [ rows, Telemetry::Tenant.overview(rows, from, to) ]
10
+ end
11
+ render inertia: { tenants: tenants, summary: summary,
12
+ sort: params[:sort] || "requests", dir: params[:dir] || "desc", q: params[:q].to_s }
13
+ end
14
+
15
+ # :id is the tenant string. Rails reads a dot in a path segment as a format
16
+ # separator, so tenants like "acme.co" arrive percent-encoded (see the
17
+ # tenants index page's tenantPath).
18
+ def show
19
+ tenant = params[:id]
20
+ from, to = window_range
21
+ data = telemetry do
22
+ { tenant: tenant, summary: { current: Telemetry::Tenant.summary(tenant, from, to),
23
+ previous: Telemetry::Tenant.summary(tenant, *previous_window_range) },
24
+ series: Telemetry::Tenant.series(tenant, from, to), routes: Telemetry::Tenant.routes(tenant, from, to),
25
+ jobs: Telemetry::Tenant.job_classes(tenant, from, to), exceptions: Telemetry::Tenant.exceptions(tenant, from, to),
26
+ people: Telemetry::Tenant.people(tenant), recent_requests: Telemetry::Tenant.recent_requests(tenant, from, to) }
27
+ end
28
+ issues = environment.issues.where(group_hash: data[:exceptions].map { |e| e[:group_hash] }.uniq).index_by(&:group_hash)
29
+ render inertia: data.merge(exceptions: data[:exceptions].map { |e| with_issue(e, issues) }, links: links(tenant))
30
+ end
31
+
32
+ private
33
+
34
+ def with_issue(exception, issues)
35
+ issue = issues[exception[:group_hash]]
36
+ exception.merge(issue_id: issue&.id, issue_key: issue&.key)
37
+ end
38
+
39
+ def links(tenant)
40
+ { logs: application_environment_logs_path(application, environment, window: window_key, q: "tenant:#{tenant}"),
41
+ people: application_environment_people_path(application, environment, window: window_key) }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class ThresholdsController < DashboardController
5
+ def index
6
+ render inertia: { thresholds: environment.thresholds.order(:target_kind, :target).map { |t| t.slice(:id, :target_kind, :target, :metric, :limit, :window_minutes).merge(description: t.description) },
7
+ routes: telemetry { Telemetry::Rollup.for_type("request").where("bucket > ?", 7.days.ago).distinct.pluck(:name).sort },
8
+ jobs: telemetry { Telemetry::Rollup.for_type("job_attempt").where("bucket > ?", 7.days.ago).distinct.pluck(:name).sort },
9
+ kinds: Threshold::TARGET_KINDS, metrics: Threshold::METRICS,
10
+ anomaly_rules: environment.anomaly_rules.order(:target_kind, :target).map { |r| r.slice(:id, :target_kind, :target, :metric, :deviation, :window_minutes, :baseline_days, :enabled, :last_fired_at).merge(description: r.description) },
11
+ anomaly_target_kinds: AnomalyRule::TARGET_KINDS, anomaly_metrics: AnomalyRule::METRICS }
12
+ end
13
+
14
+ def create
15
+ threshold = environment.thresholds.new(threshold_params)
16
+ if threshold.save
17
+ redirect_to application_environment_thresholds_path(application, environment), notice: "Threshold added"
18
+ else
19
+ redirect_to application_environment_thresholds_path(application, environment), inertia: { errors: threshold.errors }
20
+ end
21
+ end
22
+
23
+ def update
24
+ threshold = environment.thresholds.find(params[:id])
25
+ threshold.update(threshold_params)
26
+ redirect_to application_environment_thresholds_path(application, environment), notice: "Threshold updated"
27
+ end
28
+
29
+ def destroy
30
+ environment.thresholds.find(params[:id]).destroy
31
+ redirect_to application_environment_thresholds_path(application, environment), notice: "Threshold removed"
32
+ end
33
+
34
+ private
35
+
36
+ def threshold_params
37
+ params.permit(:target_kind, :target, :metric, :limit, :window_minutes)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One distributed trace: every execution that shares a trace_id, nested by
4
+ # the parent_id the gem propagates (the enqueuing execution's id for jobs,
5
+ # the W3C traceparent parent-id for HTTP hops).
6
+ module Railwatch
7
+ class TracesController < DashboardController
8
+ LIMIT = 200
9
+
10
+ def show
11
+ trace_id = params[:trace_id]
12
+ executions, outgoing = telemetry do
13
+ rows = Telemetry::Execution.where(trace_id: trace_id).order(:occurred_at).limit(LIMIT).to_a
14
+ [ rows, Telemetry::OutgoingRequest.where(execution_id: rows.map(&:execution_id)).order(:occurred_at).group_by(&:execution_id) ]
15
+ end
16
+ raise ActiveRecord::RecordNotFound if executions.empty?
17
+
18
+ @start = executions.first.occurred_at
19
+ @outgoing = outgoing
20
+ render inertia: { trace_id: trace_id, roots: tree(executions),
21
+ span_count: executions.size + outgoing.values.sum(&:size),
22
+ services: executions.filter_map(&:server).uniq, duration: total_duration(executions) }
23
+ end
24
+
25
+ private
26
+
27
+ # An execution's parent is the one whose execution_id matches its
28
+ # parent_id outright (a job enqueued by that execution) or whose id
29
+ # matches once dashes are dropped and it is cut to the 16 hex chars a
30
+ # W3C traceparent carries (an incoming HTTP hop). Anything whose parent
31
+ # is missing -- the trace's entry point, or a hop whose caller was not
32
+ # reported -- is a root.
33
+ def tree(executions)
34
+ by_id = executions.index_by(&:execution_id)
35
+ by_short_id = executions.index_by { |e| e.execution_id.to_s.delete("-").first(16) }
36
+ children = Hash.new { |hash, key| hash[key] = [] }
37
+ roots = []
38
+ executions.each do |exe|
39
+ parent = exe.parent_id.presence && (by_id[exe.parent_id] || by_short_id[exe.parent_id])
40
+ parent = nil if parent && parent.execution_id == exe.execution_id
41
+ parent ? children[parent.execution_id] << exe : roots << exe
42
+ end
43
+ roots.map { |exe| node(exe, children) }
44
+ end
45
+
46
+ def node(exe, children)
47
+ { execution: execution_row(exe), children: children[exe.execution_id].map { |child| node(child, children) } }
48
+ end
49
+
50
+ def execution_row(exe)
51
+ { execution_id: exe.execution_id, kind: exe.kind, name: exe.name, status: exe.status, outcome: exe.outcome,
52
+ parent_id: exe.parent_id, server: exe.server, occurred_at: exe.occurred_at,
53
+ duration: exe.duration_ms.round(2), offset: offset(exe.occurred_at),
54
+ outgoing: @outgoing.fetch(exe.execution_id, []).map { |o| outgoing_row(o) } }
55
+ end
56
+
57
+ def outgoing_row(o)
58
+ { id: o.id, method: o.method, url: o.url.to_s.first(200), status_code: o.status_code,
59
+ duration: o.duration_ms&.round(2), offset: offset(o.occurred_at) }
60
+ end
61
+
62
+ def offset(time)
63
+ ((time - @start) * 1000.0).round(2)
64
+ end
65
+
66
+ # Wall-clock span of the whole trace: the last thing to finish, minus the
67
+ # first thing to start.
68
+ def total_duration(executions)
69
+ executions.map { |e| offset(e.occurred_at) + e.duration_ms }.max.round(2)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class TransactionsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ rows = grouped("transaction", limit: 200, order: params[:sort], dir: params[:dir])
8
+ recent = telemetry { Telemetry::Transaction.between(from, to).recent.limit(50).map { |t| transaction_row(t) } }
9
+ render inertia: { transactions: rows, series: series("transaction"), recent: recent,
10
+ sort: params[:sort] || "count", dir: params[:dir] || "desc" }
11
+ end
12
+
13
+ private
14
+
15
+ def transaction_row(t)
16
+ { id: t.id, outcome: t.outcome, connection: t.connection, duration: t.duration_ms.round(3), statement_count: t.statement_count, occurred_at: t.occurred_at,
17
+ execution_id: t.execution_id, execution_source: t.execution_source, execution_preview: t.execution_preview,
18
+ group_hash: t.group_hash }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class ViewRendersController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ fields = FilterQuery.parse(params[:q]).fetch(:fields)
8
+ rows = grouped("view_render", limit: 200, order: params[:sort], dir: params[:dir])
9
+ rows = rows.select { |r| r[:name].include?(fields["template"]) } if fields["template"].present?
10
+ kinds = telemetry do
11
+ Telemetry::Rollup.for_type("view_render").between(from, to).to_a.group_by(&:group_hash)
12
+ .transform_values { |group_rows| group_rows.max_by(&:bucket).extra["kind"] }
13
+ end
14
+ rows.each { |r| r[:kind] = kinds[r[:group_hash]] }
15
+ slowest = telemetry { Telemetry::ViewRender.between(from, to).order(duration: :desc).limit(30).map { |v| render_row(v) } }
16
+ render inertia: { renders: rows, series: series("view_render"), slowest: slowest,
17
+ sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s }
18
+ end
19
+
20
+ private
21
+
22
+ def render_row(v)
23
+ { id: v.id, identifier: v.identifier, kind: v.kind, layout: v.layout, duration: v.duration_ms.round(3),
24
+ occurred_at: v.occurred_at, execution_id: v.execution_id, execution_source: v.execution_source,
25
+ execution_preview: v.execution_preview, group_hash: v.group_hash }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class VisitsController < DashboardController
5
+ # Vitals come from raw visit rows (rollups only carry the visit duration),
6
+ # so cap how many we read for a p75.
7
+ MAX_VITAL_SAMPLES = 50_000
8
+
9
+ def index
10
+ fields = FilterQuery.parse(params[:q]).fetch(:fields)
11
+ rows, vitals, component_vitals = telemetry do
12
+ scope = Telemetry::Visit.between(*window_range)
13
+ recent = fields["component"].present? ? scope.where(component: fields["component"]) : scope
14
+ [ recent.recent.limit(200).to_a, web_vitals(scope), component_web_vitals(scope) ]
15
+ end
16
+ components = grouped("visit", limit: 100, order: params[:sort], dir: params[:dir])
17
+ .map { |row| row.merge(component_vitals.transform_values { |p75s| p75s[row[:name]] }) }
18
+ render inertia: { components: components, series: series("visit"), vitals: vitals,
19
+ sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s,
20
+ recent: rows.map { |v| { id: v.id, component: v.component, url: v.url, method: v.method, duration: v.duration_ms.round(1), status: v.status, partial: v.partial, only: v.only, props_bytes: v.props_bytes, occurred_at: v.occurred_at, user_ref: v.user_ref, lcp: v.lcp, cls: v.cls, inp: v.inp, ttfb: v.ttfb } } }
21
+ end
22
+
23
+ private
24
+
25
+ # p75 of each web vital in the window, with its rating and sample count.
26
+ # Metrics nobody reported are left out entirely.
27
+ def web_vitals(scope)
28
+ Telemetry::Visit::VITALS.keys.filter_map { |metric|
29
+ values = samples(scope, metric).pluck(metric)
30
+ next if values.empty?
31
+ value = p75(values)
32
+ [ metric, { value: value, rating: Telemetry::Visit.rating(metric, value), samples: values.size } ]
33
+ }.to_h
34
+ end
35
+
36
+ # { lcp: { "widgets/index" => 2100 }, inp: { ... } } for the components table.
37
+ def component_web_vitals(scope)
38
+ %i[lcp inp].index_with do |metric|
39
+ samples(scope, metric).pluck(:component, metric).group_by(&:first)
40
+ .transform_values { |pairs| p75(pairs.map(&:last)) }
41
+ end
42
+ end
43
+
44
+ def samples(scope, metric)
45
+ scope.where.not(metric => nil).recent.limit(MAX_VITAL_SAMPLES)
46
+ end
47
+
48
+ def p75(values)
49
+ sorted = values.sort
50
+ sorted[[ (sorted.size * 0.75).ceil - 1, 0 ].max].round(3)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Emits the dashboard's script and stylesheet tags from the manifest Vite
5
+ # wrote at gem build time. The gem ships the built bundle under
6
+ # public/railwatch, so a host app needs no Node toolchain and no asset
7
+ # pipeline integration: the engine serves those files itself and this
8
+ # helper does the little that rails_vite's vite_tags would have done.
9
+ module AssetsHelper
10
+ MANIFEST = Railwatch::Engine.root.join("public/railwatch/manifest.json")
11
+ ASSET_PREFIX = "/railwatch/assets"
12
+
13
+ def railwatch_asset_tags(nonce: nil)
14
+ manifest = railwatch_manifest
15
+ tags = []
16
+ preloaded = Set.new
17
+ %w[app/frontend/entrypoints/application.css app/frontend/entrypoints/inertia.tsx].each do |entry|
18
+ info = manifest.fetch(entry)
19
+ Array(info["imports"]).each do |key|
20
+ file = manifest.fetch(key)["file"]
21
+ next unless preloaded.add?(file)
22
+ tags << tag.link(rel: "modulepreload", href: "#{ASSET_PREFIX}/#{file}", nonce: nonce)
23
+ end
24
+ tags << if info["file"].end_with?(".css")
25
+ tag.link(rel: "stylesheet", href: "#{ASSET_PREFIX}/#{info['file']}", nonce: nonce)
26
+ else
27
+ tag.script(src: "#{ASSET_PREFIX}/#{info['file']}", type: "module", nonce: nonce)
28
+ end
29
+ Array(info["css"]).each { |css| tags << tag.link(rel: "stylesheet", href: "#{ASSET_PREFIX}/#{css}", nonce: nonce) }
30
+ end
31
+ safe_join(tags, "\n")
32
+ end
33
+
34
+ def railwatch_font_preloads
35
+ safe_join(railwatch_manifest.select { |k, _| k.end_with?(".woff2") && k.include?("Roboto") }.map { |_, v|
36
+ tag.link(rel: "preload", href: "#{ASSET_PREFIX}/#{v['file']}", as: "font", type: "font/woff2", crossorigin: "anonymous")
37
+ }, "\n")
38
+ end
39
+
40
+ # The bundle digest doubles as the Inertia asset version: a gem upgrade
41
+ # changes it and every open tab does a full reload on its next visit.
42
+ def self.digest
43
+ @digest ||= Digest::SHA256.file(MANIFEST).hexdigest[0, 16]
44
+ end
45
+
46
+ private
47
+
48
+ def railwatch_manifest
49
+ @railwatch_manifest ||= JSON.parse(File.read(MANIFEST))
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class AnomalyScanJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform
8
+ DetectAnomaliesJob.perform_later(Environment.current) if AnomalyRule.where(enabled: true).exists?
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # The engine's jobs run on whatever Active Job adapter the host configured.
5
+ class ApplicationJob < ActiveJob::Base
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Auto-resolves open issues that have gone quiet for a configured number of
5
+ # days (nil = off). Resolved with no deploy attached, so any recurrence is
6
+ # treated as a regression rather than a duplicate. Runs daily (recurring.yml).
7
+ class AutoResolveIssuesJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ def perform
11
+ days = Railwatch::Embedded::Account.auto_resolve_after_days
12
+ return unless days
13
+
14
+ Issue.open.where("last_seen_at < ?", days.days.ago).find_each do |issue|
15
+ issue.resolve!(deploy: nil)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Flags scheduled tasks that have not run when expected. Uses the schedule
5
+ # string the gem sends (Solid Queue's cron / natural language) parsed with
6
+ # fugit, which Solid Queue already depends on.
7
+ class CheckScheduledTasksJob < ApplicationJob
8
+ include DetectionSnapshotting
9
+
10
+ queue_as :default
11
+
12
+ GRACE = 5.minutes
13
+ # A deploy restarts the worker, and Solid Queue skips (does not replay) the
14
+ # recurring ticks that fell inside the restart window. A tick missed within
15
+ # this long after a deploy is the deploy, not the scheduler.
16
+ DEPLOY_GRACE = 15.minutes
17
+
18
+ def perform(environment)
19
+ require "fugit"
20
+ tasks = environment.with_telemetry do
21
+ last_runs = Telemetry::Execution.scheduled.where("occurred_at > ?", 30.days.ago).group(:task_key).maximum(:occurred_at)
22
+ schedules = Telemetry::Execution.latest_schedules(last_runs.keys)
23
+ last_runs.map { |key, last| [ key, last, schedules[key] ] }
24
+ end
25
+ last_deploy_at = environment.deploys.maximum(:deployed_at)
26
+ configured = environment.with_telemetry { Telemetry::HealthSample.recurring_task_keys }
27
+ open_missed = environment.issues.open.where("group_hash LIKE 'missed:%'").index_by(&:group_hash)
28
+ tasks.each do |key, last_run, schedule|
29
+ next if schedule.blank?
30
+ # A task taken out of config/recurring.yml stops running on purpose.
31
+ # Its history would otherwise read as a miss every window for 30 days
32
+ # (reconcile_ingest_batches was live for four minutes and flagged 62
33
+ # times). The gem ships the schedule Solid Queue is actually running
34
+ # on every health sample; a key absent from the newest manifest is
35
+ # removed, not missed. No manifest yet means an older gem: judge from
36
+ # history alone, as before.
37
+ if configured && !configured.include?(key)
38
+ resolve_missed!(open_missed["missed:#{key}"], "Auto-resolved: #{key} is no longer a configured recurring task.")
39
+ next
40
+ end
41
+ cron = Fugit.parse(schedule) or next
42
+ expected = cron.next_time(last_run).to_t
43
+ if expected > Time.current - GRACE
44
+ # The task is back on schedule; a missed-run issue for it heals on its own.
45
+ resolve_missed!(open_missed["missed:#{key}"], "Auto-resolved: #{key} ran again on schedule.")
46
+ next
47
+ end
48
+ next if last_deploy_at && expected.between?(last_deploy_at - DEPLOY_GRACE, last_deploy_at + DEPLOY_GRACE)
49
+ diagnosis = diagnose(key, expected)
50
+ issue, outcome = Issue.record_occurrence!(
51
+ environment: environment, group_hash: "missed:#{key}", kind: "performance",
52
+ title: "Scheduled task #{key} missed its run (expected #{expected.utc.iso8601})#{diagnosis[:title_suffix]}",
53
+ culprit: key, occurred_at: Time.current, deploy: nil, user_ref: nil,
54
+ sample: { task_key: key, schedule: schedule, last_run: last_run.iso8601, expected: expected.iso8601 }.merge(diagnosis[:sample]))
55
+ next unless outcome == :new || outcome == :regressed
56
+
57
+ # Only on a new or regressed issue: the detectors re-run every few minutes
58
+ # and capturing a snapshot on every breached window would query telemetry
59
+ # for an issue nobody is newly looking at.
60
+ latest = environment.with_telemetry { Telemetry::Execution.scheduled.where(task_key: key).recent.first }
61
+ issue.update!(sample: issue.sample.merge("detection" => IssueDetectionSnapshot.capture(
62
+ environment: environment, kind: "performance", telemetry_type: "scheduled_task",
63
+ telemetry_group_hash: latest.group_hash, target: key, metric: "missed", from: expected, to: Time.current,
64
+ representative_from: last_run,
65
+ rule: { type: "schedule", task_key: key, schedule: schedule,
66
+ last_run: last_run.iso8601, expected: expected.iso8601 }
67
+ )))
68
+ payload = { issue_key: issue.key, title: issue.title, environment: environment.name }
69
+ environment.application.alert_rules.where(event: "threshold").find_each do |rule|
70
+ next unless rule.matches?(issue: issue, payload: payload)
71
+ rule.fire!(event: "threshold", issue: issue, payload: payload)
72
+ end
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # Where the run went, from Solid Queue's own ledger when it is in this
79
+ # process. A RecurringExecution row is written by the scheduler the moment
80
+ # it enqueues a run, so its presence without a scheduled_task execution
81
+ # means "enqueued, never performed" (no worker, or a wedged one); its
82
+ # absence means the scheduler itself never fired. The hosted platform, and
83
+ # any adapter other than Solid Queue, only ever sees the absence of the
84
+ # run and reports it as before.
85
+ def diagnose(key, expected)
86
+ return NO_DIAGNOSIS unless defined?(::SolidQueue::RecurringExecution)
87
+
88
+ enqueued_at = Railwatch.ignore { ::SolidQueue::RecurringExecution.where(task_key: key).where("run_at >= ?", expected - 1.minute).maximum(:run_at) }
89
+ workers = Railwatch.ignore { ::SolidQueue::Process.where(kind: "Worker").count }
90
+ if enqueued_at
91
+ { title_suffix: workers.zero? ? ": enqueued, but no Solid Queue worker is running" : ": enqueued, not yet performed",
92
+ sample: { enqueued_at: enqueued_at.iso8601, workers: workers, cause: workers.zero? ? "no_worker" : "backlog" } }
93
+ else
94
+ { title_suffix: ": the scheduler never enqueued it",
95
+ sample: { enqueued_at: nil, workers: workers, cause: "scheduler" } }
96
+ end
97
+ rescue StandardError
98
+ NO_DIAGNOSIS
99
+ end
100
+
101
+ NO_DIAGNOSIS = { title_suffix: "", sample: {} }.freeze
102
+
103
+ # Sentry Crons marked a monitor OK again on the next successful check-in;
104
+ # the same here: once the task has run inside its window again, the open
105
+ # missed-run issue resolves with an activity row, so it does not sit open
106
+ # until someone notices it is stale.
107
+ def resolve_missed!(issue, body)
108
+ return unless issue
109
+ issue.resolve!
110
+ issue.activities.create!(kind: "comment", data: { body: body })
111
+ end
112
+ end
113
+ end