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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class LlmCallsController < DashboardController
5
+ NANOS_PER_DOLLAR = 1_000_000_000.0
6
+
7
+ def index
8
+ from, to = window_range
9
+ fields = FilterQuery.parse(params[:q]).fetch(:fields)
10
+ models = telemetry { model_rows(from, to) }
11
+ tools = telemetry { tool_rows(from, to) }
12
+ rows = telemetry { recent_rows(fields) }
13
+ render inertia: { models: models, tools: tools, recent: rows,
14
+ totals: totals(models, tools),
15
+ # Two record types over one table, the way Execution
16
+ # feeds request/job_attempt/... Waiting on a model is
17
+ # seconds of somebody else's compute; a tool call is
18
+ # your own code in milliseconds. One p95 over both
19
+ # describes neither.
20
+ series: series("llm_call"), tool_series: series("llm_tool"),
21
+ q: params[:q].to_s }
22
+ end
23
+
24
+ private
25
+
26
+ # One row per provider/model/operation group, from rollups: tokens and
27
+ # spend come out of Rollup#extra, which sums them on merge, so a 30-day
28
+ # window costs the same few rows as an hour.
29
+ def model_rows(from, to)
30
+ Telemetry::Rollup.for_type("llm_call").between(from, to).to_a
31
+ .group_by(&:group_hash).map { |group_hash, group_rows| model_row(group_hash, group_rows, from, to) }
32
+ .sort_by { |r| [ -(r[:cost] || 0), -r[:count] ] }.first(200)
33
+ end
34
+
35
+ # Tools have no tokens and no price, so they rank by how much time they
36
+ # actually cost the request: slowest first.
37
+ def tool_rows(from, to)
38
+ Telemetry::Rollup.for_type("llm_tool").between(from, to).to_a
39
+ .group_by(&:group_hash).map do |group_hash, group_rows|
40
+ summary = Telemetry::Rollup.summarize(group_rows)
41
+ { group_hash: group_hash, name: group_rows.max_by(&:bucket).name,
42
+ count: group_rows.sum(&:count), errors: group_rows.sum(&:error_count),
43
+ avg: (summary[:avg] / 1000.0).round(2), p95: (summary[:p95] / 1000.0).round(2),
44
+ max: (summary[:max] / 1000.0).round(2),
45
+ sparkline: Telemetry::Aggregations.sparkline(group_rows, from, to) }
46
+ end.sort_by { |r| -r[:p95] }.first(200)
47
+ end
48
+
49
+ def model_row(group_hash, group_rows, from, to)
50
+ count = group_rows.sum(&:count)
51
+ summary = Telemetry::Rollup.summarize(group_rows)
52
+ nanos = sum_extra(group_rows, "cost_nanos")
53
+ priced = sum_extra(group_rows, "priced")
54
+ unpriced = sum_extra(group_rows, "unpriced")
55
+ {
56
+ group_hash: group_hash, name: group_rows.max_by(&:bucket).name, count: count,
57
+ errors: group_rows.sum(&:error_count),
58
+ input_tokens: sum_extra(group_rows, "input_tokens"), output_tokens: sum_extra(group_rows, "output_tokens"),
59
+ cache_read_tokens: sum_extra(group_rows, "cache_read_tokens"),
60
+ cache_write_tokens: sum_extra(group_rows, "cache_write_tokens"),
61
+ # nil, not 0, when nothing in the group was priced: a table that shows
62
+ # $0.00 for an unpriced model is lying about the bill. `unpriced`
63
+ # carries how many calls the figure leaves out when it is partial.
64
+ cost: priced.zero? ? nil : nanos / NANOS_PER_DOLLAR, unpriced: unpriced,
65
+ # Neither of these is an error, so neither shows in the error rate.
66
+ truncated: sum_extra(group_rows, "truncated"),
67
+ with_attachments: sum_extra(group_rows, "with_attachments"),
68
+ avg: (summary[:avg] / 1000.0).round(2), p95: (summary[:p95] / 1000.0).round(2),
69
+ sparkline: Telemetry::Aggregations.sparkline(group_rows, from, to)
70
+ }
71
+ end
72
+
73
+ def sum_extra(rows, key)
74
+ rows.sum { |r| r.extra[key].to_i }
75
+ end
76
+
77
+ # `count` is model calls only. Tool calls are counted separately rather
78
+ # than folded in: they cost no tokens and no money, so adding them to the
79
+ # headline number inflates it with work that never reached a provider.
80
+ def totals(models, tools)
81
+ priced = models.reject { |m| m[:cost].nil? }
82
+ {
83
+ count: models.sum { |m| m[:count] }, errors: models.sum { |m| m[:errors] },
84
+ tool_count: tools.sum { |t| t[:count] }, tool_errors: tools.sum { |t| t[:errors] },
85
+ input_tokens: models.sum { |m| m[:input_tokens] }, output_tokens: models.sum { |m| m[:output_tokens] },
86
+ cost: priced.empty? ? nil : priced.sum { |m| m[:cost] }, unpriced: models.sum { |m| m[:unpriced] }
87
+ }
88
+ end
89
+
90
+ def recent_rows(fields)
91
+ scope = Telemetry::LlmCall.between(*window_range)
92
+ scope = scope.where("model LIKE ?", "%#{Telemetry::LlmCall.sanitize_sql_like(fields['model'])}%") if fields["model"].present?
93
+ scope = scope.where(provider: fields["provider"]) if fields["provider"].present?
94
+ scope = scope.where(operation: fields["operation"]) if fields["operation"].present?
95
+ scope = scope.where(workflow_id: fields["workflow"]) if fields["workflow"].present?
96
+ scope = scope.where(finish_reason: fields["finish"]) if fields["finish"].present?
97
+ # "attachments:image" is the question a document-heavy app actually asks.
98
+ if fields["attachments"].present?
99
+ scope = if fields["attachments"] == "any"
100
+ scope.where.not(attachments: nil)
101
+ else
102
+ scope.where("attachment_types LIKE ?", "%#{Telemetry::LlmCall.sanitize_sql_like(fields['attachments'])}%")
103
+ end
104
+ end
105
+ scope.recent.limit(200).map { |r| row_props(r) }
106
+ end
107
+
108
+ def row_props(call)
109
+ { id: call.id, operation: call.operation, provider: call.provider, model: call.model,
110
+ response_model: call.response_model, tool_name: call.tool_name, duration: call.duration_ms.round(1),
111
+ status: call.status, error: call.error, streaming: call.streaming, message_count: call.message_count,
112
+ tool_count: call.tool_count, input_tokens: call.input_tokens, output_tokens: call.output_tokens,
113
+ cache_read_tokens: call.cache_read_tokens, cache_write_tokens: call.cache_write_tokens,
114
+ thinking_tokens: call.thinking_tokens, cost: call.cost, cost_reported: call.cost_reported,
115
+ workflow_id: call.workflow_id, workflow_name: call.workflow_name,
116
+ workflow_step_name: call.workflow_step_name,
117
+ finish_reason: call.finish_reason, provider_request_id: call.provider_request_id,
118
+ tools: call.tools, attachments: call.attachments, attachment_types: call.attachment_types,
119
+ attachment_names: call.attachment_names, tool_call_id: call.tool_call_id, params: call.params,
120
+ prompt: call.prompt, completion: call.completion, occurred_at: call.occurred_at,
121
+ execution_id: call.execution_id, execution_preview: call.execution_preview }
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class LogsController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ parsed = FilterQuery.parse(params[:q])
8
+ page_data = nil
9
+ page = lambda do
10
+ page_data ||= telemetry do
11
+ filtered = FilterQuery.apply(Telemetry::Log.all, resource: :logs, query: params[:q], from: from, to: to)
12
+ rows, meta = Telemetry::CursorPage.call(filtered, cursor: params[:cursor], limit: params[:limit],
13
+ context: telemetry_cursor_context(:logs))
14
+ [ rows.map { |l| log_row(l) }, meta, Telemetry::Log.fts_snippets(rows.map(&:id), parsed[:text]) ]
15
+ end
16
+ end
17
+ render inertia: {
18
+ logs: InertiaRails.merge { page.call[0] }, pagination: -> { page.call[1] },
19
+ counts: -> { level_counts(from, to) }, highlights: InertiaRails.deep_merge { page.call[2] }, q: params[:q].to_s
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ # The level facet counts the same rows the search does, except for the
26
+ # selected level itself, so switching level never disagrees with the table.
27
+ def level_counts(from, to)
28
+ telemetry do
29
+ FilterQuery.apply(Telemetry::Log.all, resource: :logs, query: params[:q], from: from, to: to,
30
+ except: %w[level status]).group(:level).count
31
+ end
32
+ end
33
+
34
+ def log_row(log)
35
+ { id: log.id, level: log.level, message: log.message.first(2000), tags: log.tags, occurred_at: log.occurred_at,
36
+ execution_id: log.execution_id, execution_source: log.execution_source, execution_preview: log.execution_preview,
37
+ source: log.source, tenant: log.app_tenant, user_ref: log.user_ref, context: log.context }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class MailsController < DashboardController
5
+ def index
6
+ fields = FilterQuery.parse(params[:q]).fetch(:fields)
7
+ rows = telemetry do
8
+ scope = Telemetry::Mail.between(*window_range)
9
+ scope = scope.where(mailer: fields["mailer"]) if fields["mailer"].present?
10
+ # Mail rows have no "kind" column; delivery_method (smtp/test/deliver_later
11
+ # etc.) is the closest fit for a kind: filter token.
12
+ scope = scope.where(delivery_method: fields["kind"]) if fields["kind"].present?
13
+ scope.recent.limit(200).to_a
14
+ end
15
+ render inertia: { mailers: grouped("mail", limit: 100), series: series("mail"), q: params[:q].to_s,
16
+ recent: rows.map { |m| { id: m.id, mailer: m.mailer, subject: m.subject, to: m.to, cc: m.cc, bcc: m.bcc, attachments: m.attachments, delivery_method: m.delivery_method, duration: m.duration_ms.round(1), failed: m.failed, occurred_at: m.occurred_at, execution_id: m.execution_id, execution_preview: m.execution_preview } } }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class NotificationsController < DashboardController
5
+ def index
6
+ rows = telemetry { Telemetry::Notification.between(*window_range).recent.limit(200).to_a }
7
+ render inertia: { groups: grouped("notification", limit: 100),
8
+ recent: rows.map { |n| { id: n.id, notifier: n.notifier, delivery_method: n.delivery_method, duration: n.duration_ms.round(1), failed: n.failed, occurred_at: n.occurred_at, execution_id: n.execution_id, execution_preview: n.execution_preview } } }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class OutgoingRequestsController < DashboardController
5
+ def index
6
+ fields = FilterQuery.parse(params[:q]).fetch(:fields)
7
+ rows = telemetry do
8
+ scope = Telemetry::OutgoingRequest.between(*window_range)
9
+ scope = scope.where("host LIKE ?", "%#{Telemetry::OutgoingRequest.sanitize_sql_like(fields['host'])}%") if fields["host"].present?
10
+ if (range = FilterQuery.status_range(fields["status"]))
11
+ scope = scope.where(status_code: range)
12
+ end
13
+ scope.recent.limit(200).to_a
14
+ end
15
+ render inertia: { hosts: grouped("outgoing_request", limit: 100), series: series("outgoing_request"), q: params[:q].to_s,
16
+ recent: rows.map { |r| { id: r.id, host: r.host, method: r.method, url: r.url, status_code: r.status_code, duration: r.duration_ms.round(1), request_size: r.request_size, response_size: r.response_size, error: r.error, source: r.source, occurred_at: r.occurred_at, execution_id: r.execution_id, execution_preview: r.execution_preview, response_body: r.response_body } } }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class OverviewController < DashboardController
5
+ def show
6
+ requests = grouped("request", limit: 8, order: :p95)
7
+ jobs = grouped("job_attempt", limit: 8)
8
+ render inertia: {
9
+ totals: { requests: summary_with_delta("request"), jobs: summary_with_delta("job_attempt") },
10
+ request_series: series("request"),
11
+ job_series: series("job_attempt"),
12
+ slow_routes: requests,
13
+ top_jobs: jobs,
14
+ issues: environment.issues.open.recent.limit(8).map { |i| issue_row(i) },
15
+ deploys: deploys_in_window,
16
+ release_health: release_health,
17
+ processes: telemetry { Telemetry::Process.recent.limit(20).group_by(&:server).transform_values { |ps| ps.first.slice(:role, :deploy, :ruby_version, :rails_version, :railwatch_version, :booted_at) } }
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ # Crash-free sessions for the release that is live now, over the page's
24
+ # window. nil (and the stat is not rendered) until the environment reports
25
+ # sessions at all.
26
+ def release_health
27
+ deploy = environment.deploys.recent.first or return nil
28
+ health = telemetry { Telemetry::ReleaseHealth.for_deploy(deploy.deploy, *window_range) }
29
+ return nil if health[:crash_free_sessions].nil?
30
+ health.slice(:sessions, :crash_free_sessions).merge(deploy: deploy.deploy, ref: deploy.short_ref)
31
+ end
32
+
33
+ def issue_row(i)
34
+ { id: i.id, key: i.key, title: i.title, kind: i.kind, status: i.status, priority: i.priority, occurrences: i.occurrences,
35
+ affected_users: i.affected_users, last_seen_at: i.last_seen_at, culprit: i.culprit }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class PeopleController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ active = telemetry do
8
+ Telemetry::Execution.requests.between(from, to).where.not(user_ref: nil).group(:user_ref)
9
+ .pluck(:user_ref, Arel.sql("COUNT(*)"), Arel.sql("SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END)"), Arel.sql("MAX(occurred_at)"))
10
+ .map { |ref, c, e, last| { ref: ref, requests: c, errors: e, last_seen_at: last } }.sort_by { |r| -r[:requests] }.first(200)
11
+ end
12
+ people = telemetry { Telemetry::Person.where(ref: active.map { |a| a[:ref] }).index_by(&:ref) }
13
+ render inertia: { people: active.map { |a| a.merge(name: people[a[:ref]]&.display_name, email: people[a[:ref]]&.email, tenant: people[a[:ref]]&.app_tenant) } }
14
+ end
15
+
16
+ def show
17
+ ref = params[:id]
18
+ person = telemetry { Telemetry::Person.find_by(ref: ref) }
19
+ executions = telemetry { Telemetry::Execution.where(user_ref: ref).between(*window_range).recent.limit(100).map { |r| { execution_id: r.execution_id, kind: r.kind, name: r.name, status: r.status, outcome: r.outcome, duration: r.duration_ms.round(1), occurred_at: r.occurred_at, exception_preview: r.exception_preview } } }
20
+ exception_groups = telemetry { Telemetry::Exception.where(user_ref: ref).between(*window_range).group(:group_hash).count }
21
+ issues = environment.issues.where(group_hash: exception_groups.keys).map { |i| { id: i.id, key: i.key, title: i.title, status: i.status, count: exception_groups[i.group_hash] } }
22
+ render inertia: { person: person && { person_ref: person.ref, name: person.display_name, email: person.email, tenant: person.app_tenant, first_seen_at: person.first_seen_at, last_seen_at: person.last_seen_at }, person_ref: ref, executions: executions, issues: issues }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class ProcessesController < DashboardController
5
+ def index
6
+ live_since = Telemetry::HealthSample::LIVE_WINDOW.ago
7
+ samples, series, queues, live_servers, processes = telemetry do
8
+ live = Telemetry::HealthSample.live(live_since).to_a
9
+ [ live, Telemetry::HealthSample.series(*window_range), Telemetry::HealthSample.queue_depths(live),
10
+ live.map(&:server) | Telemetry::Execution.servers_since(live_since),
11
+ Telemetry::Process.recent.limit(200).to_a ]
12
+ end
13
+ render inertia: { samples: samples.map { |s| sample_row(s) }, series: series, queues: queues,
14
+ silent_servers: environment.expected_servers - live_servers.compact,
15
+ processes: processes.map { |p| process_row(p) } }
16
+ end
17
+
18
+ private
19
+
20
+ def sample_row(s)
21
+ { id: s.id, server: s.server, role: s.role, pid: s.pid, deploy: s.deploy, sampled_at: s.sampled_at,
22
+ threads_busy: s.threads_busy, threads_max: s.threads_max, backlog: s.backlog,
23
+ pool_busy: s.pool_busy, pool_size: s.pool_size, pool_waiting: s.pool_waiting,
24
+ queue_depth: s.queue_depth, queue_latency: s.queue_latency && (s.queue_latency / 1000.0).round(1), memory: s.memory }
25
+ end
26
+
27
+ def process_row(p)
28
+ { id: p.id, booted_at: p.booted_at, pid: p.pid, role: p.role, server: p.server, deploy: p.deploy,
29
+ ruby_version: p.ruby_version, rails_version: p.rails_version, railwatch_version: p.railwatch_version,
30
+ boot_seconds: p.boot_seconds, detail: p.detail }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class ProfilesController < DashboardController
5
+ # Profiles are never rolled up (they are opt-in and sampled, so a rollup
6
+ # row would be noise), which means every number on these pages comes from
7
+ # the raw table. This caps how many rows the grouping pass reads; the
8
+ # StatStrip totals still come from SQL aggregates over the whole window.
9
+ SCAN_LIMIT = 5_000
10
+
11
+ def index
12
+ from, to = window_range
13
+ rows, count, avg_samples, profiled, executions = telemetry do
14
+ scope = Telemetry::Profile.between(from, to)
15
+ [ scope.recent.limit(SCAN_LIMIT).select(:id, :group_hash, :execution_preview, :duration, :samples, :occurred_at).to_a,
16
+ scope.count, scope.average(:samples), scope.distinct.count(:execution_id), Telemetry::Execution.between(from, to).count ]
17
+ end
18
+ render inertia: { profiles: groups(rows),
19
+ summary: { profiles: count, executions: executions, profiled: profiled, avg_samples: avg_samples.to_f.round(0) } }
20
+ end
21
+
22
+ def show
23
+ profile = telemetry { Telemetry::Profile.find(params[:id]) }
24
+ collapsed, truncated = profile.collapsed_capped
25
+ render inertia: { profile: profile_row(profile), collapsed: collapsed, truncated: truncated }
26
+ rescue Telemetry::BoundedGzip::Error
27
+ head :unprocessable_content
28
+ end
29
+
30
+ private
31
+
32
+ # Rows arrive newest-first, so the head of each group is the profile the
33
+ # page links to.
34
+ def groups(rows)
35
+ rows.group_by(&:group_hash).map { |group_hash, group|
36
+ newest = group.first
37
+ { group_hash: group_hash, name: newest.execution_preview, profile_id: newest.id, count: group.size,
38
+ avg_duration: (group.sum { |row| row.duration.to_i } / group.size.to_f / 1000.0).round(2),
39
+ max_samples: group.filter_map(&:samples).max, last_seen_at: newest.occurred_at }
40
+ }.sort_by { |g| -g[:count] }.first(100)
41
+ end
42
+
43
+ def profile_row(row)
44
+ { id: row.id, profiler: row.profiler, mode: row.mode, interval: row.interval, duration: row.duration_ms.round(2),
45
+ samples: row.samples, stacks_bytes: row.stacks_bytes, group_hash: row.group_hash, execution_id: row.execution_id,
46
+ execution_preview: row.execution_preview, execution_source: row.execution_source, occurred_at: row.occurred_at,
47
+ deploy: row.deploy, server: row.server }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class QueriesController < DashboardController
5
+ def index
6
+ from, to = window_range
7
+ page_data = nil
8
+ page = lambda do
9
+ page_data ||= telemetry do
10
+ scope = FilterQuery.apply(Telemetry::Query.with_sql, resource: :queries, query: params[:q], from: from, to: to)
11
+ rows, meta = Telemetry::CursorPage.call(scope, cursor: params[:cursor], limit: params[:limit], order: :slowest,
12
+ context: telemetry_cursor_context(:queries))
13
+ [ rows.map { |q| query_row(q) }, meta ]
14
+ end
15
+ end
16
+ render inertia: {
17
+ queries: -> { grouped("query", limit: 200, order: params[:sort], dir: params[:dir]) },
18
+ n_plus_ones: -> { n_plus_ones(from, to) }, slowest: InertiaRails.merge { page.call.first },
19
+ pagination: -> { page.call.last }, q: params[:q].to_s, summary: -> { summary_with_delta("query") },
20
+ sort: params[:sort] || "count", dir: params[:dir] || "desc"
21
+ }
22
+ end
23
+
24
+ def show
25
+ group_hash = params[:id]
26
+ rows = telemetry { Telemetry::Query.where(group_hash: group_hash).between(*window_range).recent.limit(100).with_sql.to_a }
27
+ summary = telemetry { Telemetry::Rollup.summarize(Telemetry::Rollup.for_type("query").where(group_hash: group_hash).between(*window_range)) }
28
+ sources = rows.filter_map(&:source).tally.sort_by { |_s, c| -c }.first(10)
29
+ callers = rows.map(&:execution_preview).compact.tally.sort_by { |_s, c| -c }.first(10)
30
+ render inertia: { group_hash: group_hash, sql: rows.first&.sql, summary: summary, series: series("query", group_hash: group_hash),
31
+ sources: sources, callers: callers, roles: rows.filter_map(&:role).tally.sort_by { |_r, c| -c },
32
+ explain: explain_sample(group_hash), samples: rows.first(50).map { |q| query_row(q) } }
33
+ end
34
+
35
+ private
36
+
37
+ def n_plus_ones(from, to)
38
+ telemetry do
39
+ Telemetry::NPlusOne.between(from, to).group(:group_hash).pluck(:group_hash, Arel.sql("COUNT(*)"), Arel.sql("MAX(sql)"), Arel.sql("MAX(source)"), Arel.sql("MAX(count)"))
40
+ .map { |g, c, sql, src, mx| { group_hash: g, occurrences: c, sql: sql, source: src, max_count: mx, suggestion: Telemetry::NPlusOne.new(sql: sql, source: src).suggestion } }
41
+ .sort_by { |r| -r[:occurrences] }.first(50)
42
+ end
43
+ end
44
+
45
+ # The newest sample in the group that the gem attached a query plan to.
46
+ # Only slow queries get one (RAILWATCH_CAPTURE_QUERY_EXPLAIN), so this is
47
+ # usually a different row than the newest sample overall.
48
+ def explain_sample(group_hash)
49
+ q = telemetry { Telemetry::Query.where(group_hash: group_hash).between(*window_range).where.not(explain: nil).recent.with_sql.first }
50
+ return nil if q.nil?
51
+ { plan: q.explain, occurred_at: q.occurred_at, duration: q.duration_ms.round(3), execution_id: q.execution_id }
52
+ end
53
+
54
+ def query_row(q)
55
+ { id: q.id, sql: q.sql.first(300), duration: q.duration_ms.round(3), occurred_at: q.occurred_at, execution_id: q.execution_id,
56
+ execution_preview: q.execution_preview, source: q.source, connection: q.connection, row_count: q.row_count, group_hash: q.group_hash }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sentry-style release health: sessions per deploy, and the crash-free rates
4
+ # read off them. The release is the deploy string the gem stamps on every
5
+ # record it ships, so `show`'s :id is that string rather than a Deploy id.
6
+ module Railwatch
7
+ class ReleasesController < DashboardController
8
+ SESSIONS_SHOWN = 50
9
+ RELEASES_SHOWN = 50
10
+ # SQLite reads the deploy back out of an issue's sample payload, which is
11
+ # the release its latest occurrence came from.
12
+ SAMPLE_DEPLOY = "json_extract(sample, '$.deploy') = ?"
13
+
14
+ def index
15
+ from, to = window_range
16
+ deploys = environment.deploys.between(from, to).recent.limit(RELEASES_SHOWN).to_a
17
+ new_issues = new_issue_counts(deploys, to)
18
+ summary, series, adoption, health = telemetry do
19
+ [ Telemetry::ReleaseHealth.for_deploy(nil, from, to),
20
+ Telemetry::ReleaseHealth.series(nil, from, to),
21
+ Telemetry::ReleaseHealth.adoption(from, to),
22
+ deploys.to_h { |d| [ d.deploy, Telemetry::ReleaseHealth.for_deploy(d.deploy, from, to) ] } ]
23
+ end
24
+ releases = deploys.map do |d|
25
+ health.fetch(d.deploy).merge(deploy: d.deploy, ref: d.short_ref, name: d.name, deployed_at: d.deployed_at,
26
+ adoption: adoption[d.deploy] || 0.0, new_issues: new_issues[d.id].to_i)
27
+ end
28
+ render inertia: { releases: releases, series: series,
29
+ summary: summary.merge(releases: adoption.count { |_deploy, share| share.positive? }) }
30
+ end
31
+
32
+ def show
33
+ release = params[:id]
34
+ deploy = environment.deploys.find_by(deploy: release)
35
+ previous = deploy&.previous_deploy
36
+ from, to = deploy ? deploy.window : window_range
37
+ data = telemetry do
38
+ { summary: Telemetry::ReleaseHealth.for_deploy(release, from, to),
39
+ previous_summary: previous && Telemetry::ReleaseHealth.for_deploy(previous.deploy, *previous.window),
40
+ series: Telemetry::ReleaseHealth.series(release, from, to),
41
+ sessions: Telemetry::Session.where(deploy: release).recent.limit(SESSIONS_SHOWN).map { |s| session_row(s) } }
42
+ end
43
+ render inertia: data.merge(
44
+ release: { deploy: release, ref: deploy&.short_ref || release.first(12), name: deploy&.name, deploy_id: deploy&.id,
45
+ deployed_at: deploy&.deployed_at, previous_ref: previous&.short_ref },
46
+ range: { from: from.iso8601, to: to.iso8601 },
47
+ new_issues: issue_rows(environment.issues.where(first_seen_at: from..to).or(environment.issues.where(SAMPLE_DEPLOY, release))),
48
+ resolved_issues: issue_rows(environment.issues.where(resolved_in_deploy: release)))
49
+ end
50
+
51
+ private
52
+
53
+ def session_row(s)
54
+ { id: s.id, session_id: s.session_id, source: s.source, status: s.status, user_ref: s.user_ref,
55
+ duration: s.duration_ms, requests: s.requests, visits: s.visits, errors: s.error_count, occurred_at: s.occurred_at,
56
+ started_at: s.started_at }
57
+ end
58
+
59
+ def issue_rows(scope)
60
+ scope.recent.limit(50).map { |i| { id: i.id, key: i.key, title: i.title, status: i.status } }
61
+ end
62
+
63
+ # Issues first seen while each release was live -- one query for the table,
64
+ # not one per row. A release is live until the next one deploys.
65
+ def new_issue_counts(deploys, to)
66
+ return {} if deploys.empty?
67
+
68
+ ordered = deploys.sort_by(&:deployed_at)
69
+ windows = ordered.each_cons(2).to_h { |release, following| [ release.id, release.deployed_at...following.deployed_at ] }
70
+ windows[ordered.last.id] = ordered.last.deployed_at...to
71
+ first_seen = environment.issues.where(first_seen_at: ordered.first.deployed_at..to).pluck(:first_seen_at)
72
+ windows.transform_values { |window| first_seen.count { |at| window.cover?(at) } }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class RequestsController < DashboardController
5
+ def index
6
+ routes = grouped("request", limit: 200, order: params[:sort], dir: params[:dir])
7
+ routes = apply_filters(routes, FilterQuery.parse(params[:q]).fetch(:fields))
8
+ render inertia: { routes: routes, series: series("request"), deploys: deploys_in_window,
9
+ sort: params[:sort] || "count", dir: params[:dir] || "desc", q: params[:q].to_s }
10
+ end
11
+
12
+ # All requests to one route in the window.
13
+ def route
14
+ group_hash = params[:group_hash]
15
+ rows = telemetry { Telemetry::Execution.requests.where(group_hash: group_hash).between(*window_range).recent.limit(200).to_a }
16
+ render inertia: "requests/route", props: {
17
+ route: rows.first&.name || telemetry { Telemetry::Rollup.for_type("request").where(group_hash: group_hash).pick(:name) },
18
+ group_hash: group_hash, summary: summary_with_delta("request", group_hash: group_hash),
19
+ series: series("request", group_hash: group_hash), deploys: deploys_in_window,
20
+ requests: rows.map { |r| execution_row(r) }
21
+ }
22
+ end
23
+
24
+ def show
25
+ exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
26
+ render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
27
+ end
28
+
29
+ private
30
+
31
+ # Rollup rows for "request" carry no raw status codes, only aggregated
32
+ # count/errors/client_errors, so status:5xx etc. is a family match against
33
+ # those buckets rather than an exact code lookup.
34
+ def apply_filters(routes, fields)
35
+ routes = routes.select { |r| r[:name].start_with?("#{fields['method'].upcase} ") } if fields["method"].present?
36
+ routes = routes.select { |r| r[:name].include?(fields["route"]) } if fields["route"].present?
37
+ if (range = FilterQuery.status_range(fields["status"]))
38
+ routes = routes.select do |r|
39
+ case range.begin
40
+ when 500..599 then r[:errors].positive?
41
+ when 400..499 then r[:client_errors].positive?
42
+ else (r[:count] - r[:errors] - r[:client_errors]).positive?
43
+ end
44
+ end
45
+ end
46
+ routes
47
+ end
48
+
49
+ def execution_row(r)
50
+ { execution_id: r.execution_id, name: r.name, status: r.status, duration: r.duration_ms.round(2), occurred_at: r.occurred_at,
51
+ user_ref: r.user_ref, tenant: r.app_tenant, exception_preview: r.exception_preview, inertia_component: r.inertia_component,
52
+ queries: r.counters["queries"], deploy: r.deploy }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class SavedViewsController < DashboardController
5
+ def create
6
+ view = environment.saved_views.new(create_params.merge(user: Viewer.user))
7
+ if view.save
8
+ redirect_back fallback_location: fallback, notice: "View saved"
9
+ else
10
+ redirect_back fallback_location: fallback, inertia: { errors: view.errors }
11
+ end
12
+ end
13
+
14
+ def update
15
+ view = environment.saved_views.find(params[:id])
16
+ return head :forbidden unless editable?(view)
17
+ if view.update(update_params)
18
+ redirect_back fallback_location: fallback, notice: "View updated"
19
+ else
20
+ redirect_back fallback_location: fallback, inertia: { errors: view.errors }
21
+ end
22
+ end
23
+
24
+ def destroy
25
+ view = environment.saved_views.find(params[:id])
26
+ return head :forbidden unless editable?(view)
27
+ view.destroy
28
+ redirect_back fallback_location: fallback, notice: "View deleted"
29
+ end
30
+
31
+ private
32
+
33
+ # Its owner can change a view, and only its owner. The platform also lets
34
+ # an account admin, so a shared view does not outlive the person who made
35
+ # it; embedded mode has no account and no admin, and `|| true` here let
36
+ # every dashboard user edit and delete everyone else's views. An app whose
37
+ # dashboard_user resolver returns one identity for everybody is unaffected
38
+ # either way: one owner, one editor.
39
+ def editable?(view)
40
+ view.viewer_id.to_s == Viewer.user.id.to_s
41
+ end
42
+
43
+ def fallback = application_environment_overview_path(application, environment)
44
+
45
+ def create_params
46
+ params.permit(:name, :page, :query, :window, :pinned, :shared, params: {})
47
+ end
48
+
49
+ def update_params
50
+ params.permit(:name, :pinned, :shared)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class ScheduledTasksController < DashboardController
5
+ include TelemetryIdentity
6
+
7
+ def index
8
+ from, to = window_range
9
+ tasks = telemetry do
10
+ rows = Telemetry::Execution.scheduled.between(from, to).group(:task_key)
11
+ .pluck(:task_key, Arel.sql("COUNT(*)"), Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)"), Arel.sql("AVG(duration)"), Arel.sql("MAX(occurred_at)"))
12
+ schedules = Telemetry::Execution.latest_schedules(rows.map(&:first))
13
+ rows.map { |k, c, f, d, last| { task_key: k, runs: c, failed: f, avg: (d.to_f / 1000.0).round(1), last_run_at: last, schedule: schedules[k] } }
14
+ end
15
+ tasks.each { |t| t[:next_run_at] = next_run(t[:schedule]) }
16
+ recent = telemetry do
17
+ rows = Telemetry::Execution.scheduled.between(from, to).recent.limit(100).to_a
18
+ people = origin_people(rows)
19
+ rows.map do |row|
20
+ { execution_id: row.execution_id, task_key: row.task_key, name: row.name, outcome: row.outcome,
21
+ duration: row.duration_ms.round(1), occurred_at: row.occurred_at,
22
+ exception_preview: row.exception_preview }.merge(origin_identity(row, people))
23
+ end
24
+ end
25
+ render inertia: { tasks: tasks, runs: recent, missed: environment.issues.open.where("group_hash LIKE 'missed:%'").map { |i| { key: i.key, title: i.title, id: i.id } } }
26
+ end
27
+
28
+ def show
29
+ exe = telemetry { Telemetry::Execution.find_by!(execution_id: params[:id]) }
30
+ render inertia: "executions/show", props: ExecutionPresenter.new(exe, environment).props
31
+ end
32
+
33
+ private
34
+
35
+ # Solid Queue schedules are cron or natural language ("every 5 minutes"),
36
+ # both of which fugit parses; anything it can't parse has no next run.
37
+ def next_run(schedule)
38
+ return nil if schedule.blank?
39
+ require "fugit"
40
+ Fugit.parse(schedule)&.next_time&.to_t
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ end
45
+ end