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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # A tenant of the monitored application, as seen in its telemetry. Not an
6
+ # Active Record table: `app_tenant` is a column on every telemetry row (the
7
+ # gem fills it from Railwatch::Context.current_tenant, which multi-tenant
8
+ # Rails apps get from ActiveRecord::Tenanted / TenantRecord.current_tenant),
9
+ # so a "tenant" only exists as a GROUP BY over the raw tables.
10
+ #
11
+ # Telemetry::Rollup -- what every other page's numbers come from -- is keyed
12
+ # by record type and group_hash only and has no tenant dimension, so all of
13
+ # this is computed from raw rows in the window. Every method must run inside
14
+ # environment.with_telemetry { }.
15
+ class Tenant
16
+ MAX_TENANTS = 200
17
+ # A p95 costs one ordered query per tenant (SQLite has no percentile
18
+ # aggregate), so only the busiest tenants get one; the rest report 0.
19
+ P95_TENANTS = 50
20
+ SPARKLINE_BUCKETS = 12
21
+ SERIES_BUCKETS = 48
22
+ SORTS = { "requests" => :requests, "errors" => :errors, "p95" => :p95, "users" => :users }.freeze
23
+
24
+ ERRORS_SQL = Arel.sql("SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END)")
25
+ CLIENT_ERRORS_SQL = Arel.sql("SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END)")
26
+ FAILED_SQL = Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)")
27
+ COUNT_SQL = Arel.sql("COUNT(*)")
28
+ USERS_SQL = Arel.sql("COUNT(DISTINCT user_ref)")
29
+
30
+ # One aggregate row per tenant seen in the window, filtered by `q`
31
+ # (a substring of the tenant name), sorted, and capped at MAX_TENANTS.
32
+ def self.index(from, to, q: nil, sort: nil, dir: nil)
33
+ rows = Hash.new { |h, tenant| h[tenant] = blank_row(tenant) }
34
+ absorb_requests(rows, from, to, q)
35
+ absorb_jobs(rows, from, to, q)
36
+ absorb_counts(rows, filtered(Telemetry::Exception.between(from, to), q), :exceptions)
37
+ absorb_counts(rows, filtered(Telemetry::Log.between(from, to), q), :logs)
38
+ absorb_last_seen(rows, from, to, q)
39
+ absorb_sparklines(rows, from, to, q)
40
+ absorb_p95s(rows.values.max_by(P95_TENANTS) { |r| r[:requests] }, from, to)
41
+ sorted(rows.values, sort, dir)
42
+ end
43
+
44
+ # Headline numbers for the index page, computed from the rows it shows
45
+ # (so the top-tenant share is a share of the listed tenants' requests)
46
+ # plus the share of requests in the window that carry no tenant at all.
47
+ def self.overview(rows, from, to)
48
+ requests = Telemetry::Execution.requests.between(from, to)
49
+ total = requests.count
50
+ untagged = requests.where(app_tenant: nil).count
51
+ tagged = rows.sum { |r| r[:requests] }
52
+ top = rows.max_by { |r| r[:requests] }
53
+ {
54
+ tenants: rows.size, top_tenant: top && top[:tenant],
55
+ top_share: tagged.zero? ? 0.0 : (top[:requests] * 100.0 / tagged).round(1),
56
+ with_errors: rows.count { |r| r[:errors].positive? },
57
+ untagged_share: total.zero? ? 0.0 : (untagged * 100.0 / total).round(1)
58
+ }
59
+ end
60
+
61
+ # Window totals for one tenant. Durations in milliseconds, like the rest
62
+ # of the tenant props.
63
+ def self.summary(tenant, from, to)
64
+ requests = Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant)
65
+ count, errors, users = requests.pick(COUNT_SQL, ERRORS_SQL, USERS_SQL)
66
+ jobs, failed_jobs = Telemetry::Execution.jobs.between(from, to).where(app_tenant: tenant).pick(COUNT_SQL, FAILED_SQL)
67
+ {
68
+ requests: count, errors: errors.to_i, p95: p95_ms(requests, count), jobs: jobs, failed_jobs: failed_jobs.to_i,
69
+ exceptions: Telemetry::Exception.between(from, to).where(app_tenant: tenant).count,
70
+ users: users, logs: Telemetry::Log.between(from, to).where(app_tenant: tenant).count
71
+ }
72
+ end
73
+
74
+ # Request volume and duration bucketed over the window, SeriesPoint-shaped
75
+ # so the same charts render it. Percentiles are approximations: these
76
+ # buckets come from raw rows, without the per-bucket t-digest
77
+ # Telemetry::Rollup keeps, so p50 is the bucket's average and p95/p99 its
78
+ # max.
79
+ def self.series(tenant, from, to)
80
+ width = bucket_width(from, to, SERIES_BUCKETS)
81
+ bucket = bucket_sql(from, width)
82
+ Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).group(bucket)
83
+ .pluck(bucket, COUNT_SQL, ERRORS_SQL, CLIENT_ERRORS_SQL, Arel.sql("SUM(duration)"), Arel.sql("MAX(duration)"))
84
+ .map { |index, count, errors, client_errors, sum, max|
85
+ avg = ms(sum / count)
86
+ { t: bucket_at(from, width, index, SERIES_BUCKETS).iso8601, count: count, errors: errors, client_errors: client_errors,
87
+ avg: avg, p50: avg, p95: ms(max), p99: ms(max) }
88
+ }.sort_by { |point| point[:t] }
89
+ end
90
+
91
+ def self.routes(tenant, from, to, limit: 20)
92
+ grouped_executions(Telemetry::Execution.requests, tenant, from, to, ERRORS_SQL, :errors, limit)
93
+ end
94
+
95
+ def self.job_classes(tenant, from, to, limit: 10)
96
+ grouped_executions(Telemetry::Execution.jobs, tenant, from, to, FAILED_SQL, :failed, limit)
97
+ end
98
+
99
+ def self.exceptions(tenant, from, to, limit: 20)
100
+ Telemetry::Exception.between(from, to).where(app_tenant: tenant).recent.limit(limit).map do |e|
101
+ { id: e.id, class_name: e.class_name, message: e.message.first(500), occurred_at: e.occurred_at,
102
+ execution_id: e.execution_id, group_hash: e.group_hash }
103
+ end
104
+ end
105
+
106
+ def self.people(tenant, limit: 20)
107
+ Telemetry::Person.where(app_tenant: tenant).recent.limit(limit).map do |person|
108
+ { ref: person.ref, name: person.display_name, email: person.email, last_seen_at: person.last_seen_at }
109
+ end
110
+ end
111
+
112
+ # Same field shape as RequestsController#execution_row, for the tenant
113
+ # page's "Recent requests" table.
114
+ def self.recent_requests(tenant, from, to, limit: 50)
115
+ Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).recent.limit(limit).map do |r|
116
+ { execution_id: r.execution_id, name: r.name, status: r.status, duration: r.duration_ms.round(2), occurred_at: r.occurred_at,
117
+ user_ref: r.user_ref, tenant: r.app_tenant, exception_preview: r.exception_preview, inertia_component: r.inertia_component,
118
+ queries: r.counters["queries"], deploy: r.deploy }
119
+ end
120
+ end
121
+
122
+ # -- Aggregation steps -----------------------------------------------------
123
+
124
+ def self.absorb_requests(rows, from, to, q)
125
+ filtered(Telemetry::Execution.requests.between(from, to), q).group(:app_tenant)
126
+ .pluck(:app_tenant, COUNT_SQL, ERRORS_SQL, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"), USERS_SQL)
127
+ .each do |tenant, count, errors, avg, max, users|
128
+ rows[tenant].merge!(requests: count, errors: errors, avg: ms(avg), max: ms(max), users: users)
129
+ end
130
+ end
131
+
132
+ def self.absorb_jobs(rows, from, to, q)
133
+ filtered(Telemetry::Execution.jobs.between(from, to), q).group(:app_tenant)
134
+ .pluck(:app_tenant, COUNT_SQL, FAILED_SQL)
135
+ .each { |tenant, count, failed| rows[tenant].merge!(jobs: count, failed_jobs: failed) }
136
+ end
137
+
138
+ def self.absorb_counts(rows, scope, key)
139
+ scope.group(:app_tenant).count.each { |tenant, count| rows[tenant][key] = count }
140
+ end
141
+
142
+ def self.absorb_last_seen(rows, from, to, q)
143
+ filtered(Telemetry::Execution.between(from, to), q).group(:app_tenant).maximum(:occurred_at)
144
+ .each { |tenant, at| rows[tenant][:last_seen_at] = at }
145
+ end
146
+
147
+ def self.absorb_sparklines(rows, from, to, q)
148
+ width = bucket_width(from, to, SPARKLINE_BUCKETS)
149
+ counts = filtered(Telemetry::Execution.requests.between(from, to), q).group(:app_tenant, bucket_sql(from, width)).count
150
+ counts.each do |(tenant, index), count|
151
+ rows[tenant][:sparkline][[ index.to_i, SPARKLINE_BUCKETS - 1 ].min] += count
152
+ end
153
+ end
154
+
155
+ def self.absorb_p95s(rows, from, to)
156
+ rows.each do |row|
157
+ scope = Telemetry::Execution.requests.between(from, to).where(app_tenant: row[:tenant])
158
+ row[:p95] = p95_ms(scope, row[:requests])
159
+ end
160
+ end
161
+
162
+ # -- Helpers ---------------------------------------------------------------
163
+
164
+ def self.blank_row(tenant)
165
+ { tenant: tenant, requests: 0, errors: 0, avg: 0.0, max: 0.0, p95: 0.0, jobs: 0, failed_jobs: 0,
166
+ exceptions: 0, logs: 0, users: 0, last_seen_at: nil, sparkline: Array.new(SPARKLINE_BUCKETS, 0) }
167
+ end
168
+
169
+ # Tenant-tagged rows only: a NULL app_tenant never matches LIKE either.
170
+ def self.filtered(scope, q)
171
+ return scope.where.not(app_tenant: nil) if q.blank?
172
+ scope.where("app_tenant LIKE ?", "%#{TelemetryRecord.sanitize_sql_like(q.to_s)}%")
173
+ end
174
+
175
+ def self.sorted(rows, sort, dir)
176
+ key = SORTS.fetch(sort.to_s, :requests)
177
+ sign = dir.to_s == "asc" ? 1 : -1
178
+ rows.sort_by { |row| row[key] * sign }.first(MAX_TENANTS)
179
+ end
180
+
181
+ # The 95th percentile as the duration `count * 5%` rows from the slowest
182
+ # end -- one indexed query, no percentile function needed.
183
+ def self.p95_ms(scope, count)
184
+ return 0.0 if count.to_i.zero?
185
+ ms(scope.order(duration: :desc).offset((count * 0.05).floor).limit(1).pluck(:duration).first)
186
+ end
187
+
188
+ def self.grouped_executions(scope, tenant, from, to, error_sql, error_key, limit)
189
+ scope.between(from, to).where(app_tenant: tenant).group(:group_hash, :name)
190
+ .pluck(:group_hash, :name, COUNT_SQL, error_sql, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"))
191
+ .map { |group_hash, name, count, errors, avg, max|
192
+ { group_hash: group_hash, name: name, count: count, error_key => errors, avg: ms(avg), max: ms(max) }
193
+ }.sort_by { |row| -row[:count] }.first(limit)
194
+ end
195
+
196
+ # Microseconds on the wire, milliseconds in every prop.
197
+ def self.ms(duration)
198
+ duration ? (duration / 1000.0).round(2) : 0.0
199
+ end
200
+
201
+ def self.bucket_width(from, to, buckets)
202
+ [ (to - from) / buckets, 1.0 ].max
203
+ end
204
+
205
+ # Bucket index per row: epoch seconds since `from` over the bucket width.
206
+ # Windows are an hour or more, so second resolution is plenty.
207
+ # Both interpolations are coerced to numbers first (`from` is a Time, `width`
208
+ # a Float from bucket_width), so the fragment can only ever contain two
209
+ # numeric literals -- never a caller-supplied string.
210
+ def self.bucket_sql(from, width)
211
+ Arel.sql("CAST((strftime('%s', occurred_at) - #{Integer(from.to_i)}) / #{Float(width)} AS INTEGER)")
212
+ end
213
+
214
+ # A row landing exactly on `to` indexes one bucket past the end; clamp it
215
+ # into the last one.
216
+ def self.bucket_at(from, width, index, buckets)
217
+ from + ([ index.to_i, buckets - 1 ].min * width)
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Transaction < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ outcome.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class ViewRender < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ identifier.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Visit < TelemetryRecord
6
+ include Child
7
+
8
+ # Core Web Vitals reported by the browser client, with Google's
9
+ # [good, poor] thresholds: at or below good is "good", above poor is
10
+ # "poor". lcp/inp/ttfb are milliseconds, cls is unitless.
11
+ VITALS = { lcp: [ 2500, 4000 ], cls: [ 0.1, 0.25 ], inp: [ 200, 500 ], ttfb: [ 800, 1800 ] }.freeze
12
+
13
+ def self.rating(metric, value)
14
+ good, poor = VITALS.fetch(metric)
15
+ return "good" if value <= good
16
+ (value > poor) ? "poor" : "needs-improvement"
17
+ end
18
+
19
+ def timeline_label
20
+ component.to_s.first(120)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Base class for everything the engine stores about the host application.
5
+ # The hosted platform keeps one SQLite file per monitored environment through
6
+ # activerecord-tenanted; an embedded install monitors exactly one application,
7
+ # so this is a plain second database (`railwatch_telemetry` in the host's
8
+ # database.yml), separate from the host's own primary and from the engine's
9
+ # meta tables. Every telemetry query still runs inside
10
+ # Environment#with_telemetry so the code path matches the platform's.
11
+ class TelemetryRecord < ActiveRecord::Base
12
+ self.abstract_class = true
13
+ begin
14
+ connects_to database: { writing: :railwatch_telemetry, reading: :railwatch_telemetry }
15
+ rescue ActiveRecord::AdapterNotSpecified, LoadError
16
+ # No `railwatch_telemetry` entry in this environment's database.yml, or an entry
17
+ # whose adapter gem is not in the bundle yet (LoadError). A cloud-transport
18
+ # app has none and still eager-loads this class in production, and so
19
+ # does the --local installer's own boot, before it has written the
20
+ # entry -- so loading must not raise. Using it must, though: without
21
+ # connects_to this class would inherit ActiveRecord::Base's PRIMARY
22
+ # connection, and its tables are unprefixed, so a query would read and
23
+ # a write would corrupt the host application's own tables. Every route
24
+ # into the connection goes through connection_pool, so refusing here
25
+ # fails closed for reads and writes alike.
26
+ def self.connection_pool
27
+ raise Railwatch::DatabaseNotConfigured,
28
+ "the `railwatch_telemetry` database (everything the app reports) is not configured for the " \
29
+ "#{Rails.env} environment; run `bin/rails generate railwatch:install --local` " \
30
+ "or add it to config/database.yml (docs/embedded.md)"
31
+ end
32
+ end
33
+
34
+ # Records arrive as the gem's wire hashes; this is the shared envelope.
35
+ def self.envelope_columns(t)
36
+ t.datetime :occurred_at, null: false, precision: 6
37
+ t.string :deploy, limit: 128
38
+ t.string :server, limit: 255
39
+ t.string :group_hash, limit: 32
40
+ t.string :trace_id, limit: 36
41
+ t.string :execution_source, limit: 20
42
+ t.string :execution_id, limit: 36
43
+ t.string :execution_preview, limit: 255
44
+ t.string :execution_stage, limit: 32
45
+ t.string :user_ref, limit: 255
46
+ t.string :app_tenant, limit: 255
47
+ end
48
+
49
+ # Platform parity: callers wrap telemetry work in with_tenant. There is one
50
+ # tenant here, so it is just the block.
51
+ def self.with_tenant(_slug)
52
+ yield
53
+ end
54
+
55
+ def self.tenant_exist?(_slug) = true
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # A performance rule: "requests to * must keep p95 under 2000 ms over 5
5
+ # minutes". Evaluated by DetectPerformanceIssuesJob against rollups.
6
+ class Threshold < ApplicationRecord
7
+ self.table_name = "railwatch_thresholds"
8
+ MAX_LIMIT = 1_000_000_000
9
+ MAX_WINDOW_MINUTES = 1_440
10
+ TARGET_KINDS = %w[requests jobs commands queries scheduled_tasks outgoing_requests].freeze
11
+ METRICS = %w[p95 max avg error_rate failure_rate missed].freeze
12
+
13
+ def environment = Environment.current
14
+
15
+ validates :target_kind, inclusion: { in: TARGET_KINDS }
16
+ validates :metric, inclusion: { in: METRICS }
17
+ validates :limit, numericality: { greater_than: 0, less_than_or_equal_to: MAX_LIMIT }
18
+ validates :window_minutes, numericality: { only_integer: true, greater_than: 0,
19
+ less_than_or_equal_to: MAX_WINDOW_MINUTES }
20
+ validates :target, presence: true, length: { maximum: 256 }
21
+ validates :limit, numericality: { less_than_or_equal_to: 100 }, if: -> { metric&.end_with?("rate") }
22
+ validates :target, uniqueness: { scope: %i[environment_id target_kind metric] }
23
+
24
+ def description
25
+ unit = metric.end_with?("rate") ? "%" : "ms"
26
+ "#{target_kind} #{target == '*' ? 'all' : target}: #{metric} over #{limit}#{unit} in #{window_minutes}m"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Who is looking at the dashboard. The platform has accounts and memberships;
5
+ # an embedded install sits behind the host's own authentication, so this is
6
+ # whatever the host says it is (Railwatch.config.dashboard_user), defaulting
7
+ # to a single anonymous operator. Comments, saved views and issue activity
8
+ # record this id.
9
+ class User
10
+ ID = 1
11
+
12
+ attr_reader :id, :name, :email
13
+
14
+ def self.current = Viewer.user
15
+ def self.find(id) = find_by(id: id) || raise(ActiveRecord::RecordNotFound, "User #{id}")
16
+
17
+ # A host's dashboard_user resolver hands back its own ids, and those ids
18
+ # are what comments, saved views and issue activity store. Resolving only
19
+ # ID meant a comment written by user 7 had no author, and assigning an
20
+ # issue to them raised RecordNotFound. There is no user table to look
21
+ # anyone up in, so: the viewer when it is them, the anonymous operator
22
+ # for the default id, and a bare identity carrying the stored id
23
+ # otherwise, which is everything that is actually known about them.
24
+ def self.find_by(id:)
25
+ return nil if id.nil? || id.to_s.empty?
26
+ viewer = Viewer.user
27
+ return viewer if viewer && viewer.id.to_s == id.to_s
28
+ return default if id.to_i == ID
29
+
30
+ new(id: id, name: "Operator", email: nil)
31
+ end
32
+
33
+ def self.default = @default ||= new(id: ID, name: "Operator", email: nil)
34
+
35
+ def initialize(id:, name:, email: nil)
36
+ @id = id
37
+ @name = name
38
+ @email = email
39
+ end
40
+
41
+ def as_json(*) = { id: id, name: name, email: email, provider: nil, verified: true, editor: "vscode", editor_root: nil }
42
+ def accounts = [ Railwatch::Embedded::Account ]
43
+ def ==(other) = other.is_a?(User) && other.id == id
44
+ alias eql? ==
45
+ def hash = id.hash
46
+ end
47
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Who is looking at the dashboard, request-scoped. The platform's code
5
+ # reads Current.user; the gem's own Railwatch::Current is the running
6
+ # execution, so this has a different name. Set from the host's
7
+ # dashboard_user resolver on every dashboard request.
8
+ class Viewer < ActiveSupport::CurrentAttributes
9
+ attribute :user, :environment, :account
10
+
11
+ def account = Railwatch::Embedded::Account
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title inertia><%= content_for(:title) || "Railwatch" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="application-name" content="Railwatch">
7
+ <meta name="railwatch-mount" content="<%= request.script_name %>">
8
+ <link rel="icon" href="/railwatch/icon.svg" type="image/svg+xml">
9
+ <link rel="apple-touch-icon" href="/railwatch/icon.png">
10
+ <%= csrf_meta_tags %>
11
+ <%= csp_meta_tag %>
12
+ <script nonce="<%= content_security_policy_nonce %>">
13
+ document.documentElement.classList.toggle(
14
+ "dark",
15
+ localStorage.appearance === "dark" ||
16
+ (!("appearance" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
17
+ );
18
+ </script>
19
+ <%= railwatch_font_preloads %>
20
+ <%= railwatch_asset_tags(nonce: content_security_policy_nonce) %>
21
+ </head>
22
+ <body>
23
+ <%= yield %>
24
+ </body>
25
+ </html>
data/config/routes.rb CHANGED
@@ -2,4 +2,63 @@
2
2
 
3
3
  Railwatch::Engine.routes.draw do
4
4
  post "beacon", to: "beacon#create"
5
+
6
+ # The embedded dashboard. Same URL shape as the hosted platform so the
7
+ # prebuilt bundle's route helpers resolve unchanged once js-routes knows
8
+ # the mount point; a singleton install always answers for app 1, env 1.
9
+ root to: redirect { |_p, req| "#{req.script_name}/apps/1/envs/1" }
10
+ get "dashboard", to: redirect { |_p, req| "#{req.script_name}/apps/1/envs/1" }
11
+ get "docs", to: redirect("https://railwatch.rebulk.com/docs")
12
+
13
+ scope "apps/:application_id", as: :application do
14
+ get "/", to: redirect { |p, req| "#{req.script_name}/apps/#{p[:application_id]}/envs/1" }
15
+ scope "envs/:environment_id", as: :environment do
16
+ get "/", to: "overview#show", as: :overview
17
+ resources :requests, only: [ :index, :show ] do
18
+ get "routes/:group_hash", to: "requests#route", on: :collection, as: :route
19
+ end
20
+ resources :jobs, only: [ :index, :show ] do
21
+ get "classes/:group_hash", to: "jobs#klass", on: :collection, as: :klass
22
+ end
23
+ resources :scheduled_tasks, only: [ :index, :show ]
24
+ resources :commands, only: [ :index, :show ]
25
+ resources :executions, only: [ :show ]
26
+ resources :queries, only: [ :index, :show ]
27
+ resources :spans, only: [ :index, :show ]
28
+ resources :profiles, only: [ :index, :show ]
29
+ resources :attachments, only: [ :show ]
30
+ get "traces/:trace_id", to: "traces#show", as: :trace
31
+ resources :view_renders, path: "views", only: [ :index ]
32
+ resources :transactions, only: [ :index ]
33
+ resources :deprecations, only: [ :index ]
34
+ resources :exceptions, only: [ :index ]
35
+ resources :cache_events, path: "cache", only: [ :index ]
36
+ resources :mails, path: "mail", only: [ :index ]
37
+ resources :notifications, only: [ :index ]
38
+ resources :broadcasts, only: [ :index ]
39
+ resources :outgoing_requests, only: [ :index ]
40
+ resources :llm_calls, path: "llm", only: [ :index ]
41
+ resources :storage_ops, path: "storage", only: [ :index ]
42
+ resources :logs, only: [ :index ]
43
+ resources :people, path: "users", only: [ :index, :show ]
44
+ resources :deploys, only: [ :index, :show ]
45
+ resources :releases, only: [ :index, :show ]
46
+ resources :visits, only: [ :index ]
47
+ resources :processes, only: [ :index ]
48
+ resources :tenants, only: [ :index, :show ]
49
+ resources :saved_views, only: [ :create, :update, :destroy ]
50
+ resources :thresholds, only: [ :index, :create, :update, :destroy ]
51
+ resources :anomaly_rules, only: [ :create, :update, :destroy ]
52
+ resources :alerts, only: [ :index ]
53
+ end
54
+ end
55
+
56
+ resources :issues, only: [ :index, :show, :update ] do
57
+ resources :comments, only: [ :create ]
58
+ post :bulk, on: :collection
59
+ get :merge_candidates, on: :member
60
+ end
61
+ resources :alerts, only: [ :index ] do
62
+ post :retry, on: :member
63
+ end
5
64
  end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The engine's own record of the monitored application: issues and what
4
+ # people did about them, saved views, thresholds, anomaly and alert rules,
5
+ # deploy markers. Authored and permanent where telemetry is derived and
6
+ # pruned, so it lives in the host's `railwatch` database, never its primary.
7
+ #
8
+ # Nothing here references the host's tables. `viewer_id` on comments,
9
+ # activity and saved views is whatever the host's dashboard_user resolver
10
+ # returned as `id`, stored as an integer and never joined; `assignee_id`
11
+ # on issues is the same. application_id and environment_id are always 1 in
12
+ # an embedded install and exist so the dashboard's URLs and the hosted
13
+ # platform's code stay identical.
14
+ class CreateRailwatchTables < ActiveRecord::Migration[8.1]
15
+ def change
16
+ create_table :railwatch_issues do |t|
17
+ t.integer :application_id, null: false
18
+ t.integer :environment_id, null: false
19
+ t.integer :number, null: false
20
+ t.string :group_hash, limit: 32, null: false
21
+ t.string :kind, default: "exception", null: false
22
+ t.string :title, null: false
23
+ t.string :culprit
24
+ t.string :source, limit: 128
25
+ t.string :status, default: "open", null: false
26
+ t.string :priority, default: "normal", null: false
27
+ t.integer :assignee_id
28
+ t.integer :merged_into_id
29
+ t.bigint :occurrences, default: 0, null: false
30
+ t.integer :affected_users, default: 0, null: false
31
+ t.datetime :first_seen_at, null: false
32
+ t.datetime :last_seen_at, null: false
33
+ t.datetime :resolved_at
34
+ t.string :resolved_in_deploy, limit: 128
35
+ t.datetime :regressed_at
36
+ t.json :sample, default: {}, null: false
37
+ t.timestamps
38
+ end
39
+ add_index :railwatch_issues, [ :application_id, :number ], unique: true
40
+ add_index :railwatch_issues, [ :environment_id, :group_hash ], unique: true
41
+ add_index :railwatch_issues, [ :environment_id, :status, :last_seen_at ]
42
+ add_index :railwatch_issues, :merged_into_id
43
+
44
+ create_table :railwatch_comments do |t|
45
+ t.integer :issue_id, null: false
46
+ t.integer :viewer_id
47
+ t.string :author_name
48
+ t.text :body, null: false
49
+ t.string :source, default: "railwatch", null: false
50
+ t.string :external_id
51
+ t.datetime :external_updated_at
52
+ t.timestamps
53
+ end
54
+ add_index :railwatch_comments, :issue_id
55
+
56
+ create_table :railwatch_issue_activities do |t|
57
+ t.integer :issue_id, null: false
58
+ t.integer :viewer_id
59
+ t.string :kind, null: false
60
+ t.json :data, default: {}, null: false
61
+ t.datetime :created_at, null: false
62
+ end
63
+ add_index :railwatch_issue_activities, [ :issue_id, :created_at ]
64
+
65
+ create_table :railwatch_deploys do |t|
66
+ t.integer :environment_id, null: false
67
+ t.string :deploy, limit: 128, null: false
68
+ t.string :ref, limit: 128
69
+ t.string :previous_ref, limit: 128
70
+ t.string :name
71
+ t.string :url
72
+ t.string :server
73
+ t.datetime :deployed_at, null: false
74
+ t.json :commits, default: [], null: false
75
+ t.json :detail, default: {}, null: false
76
+ t.timestamps
77
+ end
78
+ add_index :railwatch_deploys, [ :environment_id, :deploy ], unique: true
79
+ add_index :railwatch_deploys, [ :environment_id, :deployed_at ]
80
+
81
+ create_table :railwatch_saved_views do |t|
82
+ t.integer :environment_id, null: false
83
+ t.integer :viewer_id, null: false
84
+ t.string :name, null: false
85
+ t.string :page, null: false
86
+ t.string :query
87
+ t.string :window, limit: 8
88
+ t.json :params, default: {}, null: false
89
+ t.boolean :pinned, default: true, null: false
90
+ t.boolean :shared, default: true, null: false
91
+ t.timestamps
92
+ end
93
+ add_index :railwatch_saved_views, [ :environment_id, :page ]
94
+
95
+ create_table :railwatch_thresholds do |t|
96
+ t.integer :environment_id, null: false
97
+ t.string :target_kind, null: false
98
+ t.string :target, default: "*", null: false
99
+ t.string :metric, default: "p95", null: false
100
+ t.float :limit, null: false
101
+ t.integer :window_minutes, default: 5, null: false
102
+ t.timestamps
103
+ end
104
+ add_index :railwatch_thresholds, [ :environment_id, :target_kind, :target, :metric ], unique: true, name: "index_railwatch_thresholds_unique"
105
+
106
+ create_table :railwatch_anomaly_rules do |t|
107
+ t.integer :environment_id, null: false
108
+ t.string :target_kind, null: false
109
+ t.string :target, default: "*", null: false
110
+ t.string :metric, null: false
111
+ t.float :deviation, default: 3.0, null: false
112
+ t.integer :baseline_days, default: 7, null: false
113
+ t.integer :window_minutes, default: 15, null: false
114
+ t.boolean :enabled, default: true, null: false
115
+ t.datetime :last_fired_at
116
+ t.timestamps
117
+ end
118
+ add_index :railwatch_anomaly_rules, :environment_id
119
+
120
+ create_table :railwatch_alert_rules do |t|
121
+ t.integer :application_id, null: false
122
+ t.string :event, null: false
123
+ t.json :filters, default: {}, null: false
124
+ t.timestamps
125
+ end
126
+ add_index :railwatch_alert_rules, :application_id
127
+
128
+ create_table :railwatch_alerts do |t|
129
+ t.integer :alert_rule_id
130
+ t.integer :issue_id
131
+ t.integer :summary_alert_id
132
+ t.string :event, null: false
133
+ t.string :status, default: "pending", null: false
134
+ t.json :payload, default: {}, null: false
135
+ t.text :error
136
+ t.datetime :sent_at
137
+ t.string :burst_key
138
+ t.string :delivery_key
139
+ t.integer :delivery_attempts, default: 0, null: false
140
+ t.datetime :next_delivery_at
141
+ t.datetime :delivery_enqueued_until
142
+ t.string :delivery_lease_id
143
+ t.datetime :delivery_lease_expires_at
144
+ t.timestamps
145
+ end
146
+ add_index :railwatch_alerts, :burst_key, unique: true
147
+ add_index :railwatch_alerts, :delivery_key, unique: true
148
+ add_index :railwatch_alerts, [ :issue_id, :event, :created_at ]
149
+ add_index :railwatch_alerts, [ :status, :created_at, :alert_rule_id ], name: "index_railwatch_alerts_for_burst_reconciliation"
150
+ end
151
+ end