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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class NPlusOne < TelemetryRecord
6
+ include Child
7
+
8
+ # A bound value in the SQL the gem stores. Railwatch::SqlNormalizer has
9
+ # already rewritten literals to "?" and one-element IN lists to "IN (?)"
10
+ # before an n+1 record is written; the bare-number branch only covers SQL
11
+ # that reached us unnormalized.
12
+ BIND = /(?:=\s*(?:\?|-?\d+)|IN\s*\(\s*(?:\?|-?\d+)\s*\))/i
13
+
14
+ # Matched against `shape`, which strips identifier quoting so one set of
15
+ # patterns covers SQLite/Postgres ("comments") and MySQL (`comments`).
16
+ # POLYMORPHIC has to be tried before CHILD_LOAD: an imageable_id filter
17
+ # matches both, and only the first one reads it as a polymorphic owner.
18
+ POLYMORPHIC = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}\s+AND\s+(?:\w+\.)?\2_type\s*#{BIND}/i
19
+ COUNT_LOAD = /\ASELECT\s+COUNT\(\*\)\s+(?:AS\s+\w+\s+)?FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
20
+ EXISTS_LOAD = /\ASELECT\s+(?:1\s+AS\s+one|EXISTS\s*\(\s*SELECT\s+1)\b.*?\sFROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
21
+ CHILD_LOAD = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?(\w+)_id\s*#{BIND}/i
22
+ PARENT_LOAD = /\ASELECT\s+(?:\w+\.)?\*\s+FROM\s+(\w+)\s+WHERE\s+(?:\w+\.)?id\s*#{BIND}/i
23
+
24
+ def timeline_label
25
+ sql.to_s.first(120)
26
+ end
27
+
28
+ # A concrete fix for the repeated statement, read off its SQL shape:
29
+ # {kind:, parent:, association:, code:, explanation:}, or nil when the
30
+ # statement isn't a shape a preload or counter cache can fix.
31
+ def suggestion
32
+ s = shape
33
+ polymorphic_load(s) || count_load(s) || exists_load(s) || child_load(s) || parent_load(s)
34
+ end
35
+
36
+ private
37
+
38
+ def shape
39
+ sql.to_s.delete('"`').squeeze(" ").strip
40
+ end
41
+
42
+ def polymorphic_load(s)
43
+ m = POLYMORPHIC.match(s) or return nil
44
+ table, as = m[1], m[2]
45
+ { kind: "polymorphic", parent: nil, association: table,
46
+ code: ".includes(:#{table})",
47
+ explanation: "Each owner loads its #{table} in a separate query. #{table} is polymorphic (belongs_to :#{as}), " \
48
+ "so add .includes(:#{table}) on the collection you iterate#{at_source}." }
49
+ end
50
+
51
+ def count_load(s)
52
+ m = COUNT_LOAD.match(s) or return nil
53
+ table, parent = m[1], m[2].classify
54
+ { kind: "counter_cache", parent: parent, association: table,
55
+ code: "# app/models/#{m[1].singularize}.rb\nbelongs_to :#{m[2]}, counter_cache: true\n\n# then read\n#{m[2]}.#{table}_count",
56
+ explanation: "Each #{parent} counts its #{table} with its own COUNT query#{at_source}. " \
57
+ "A counter cache keeps the total on #{parent.tableize}.#{table}_count, so #{m[2]}.#{table}_count needs no query." }
58
+ end
59
+
60
+ def exists_load(s)
61
+ m = EXISTS_LOAD.match(s) or return nil
62
+ table, parent = m[1], m[2].classify
63
+ { kind: "exists", parent: parent, association: table,
64
+ code: "#{parent}.includes(:#{table})\n\n# then\n#{m[2]}.#{table}.any?",
65
+ explanation: "Each #{parent} checks whether it has #{table} with its own query#{at_source}. " \
66
+ "Preload with #{parent}.includes(:#{table}) and call .any? on the loaded collection, or add a counter cache." }
67
+ end
68
+
69
+ def child_load(s)
70
+ m = CHILD_LOAD.match(s) or return nil
71
+ table, parent = m[1], m[2].classify
72
+ { kind: "has_many", parent: parent, association: table,
73
+ code: "#{parent}.includes(:#{table})",
74
+ explanation: "Each #{parent} loads its #{table} in a separate query#{at_source}. " \
75
+ "Preload them with #{parent}.includes(:#{table}) where the #{parent.tableize} are loaded." }
76
+ end
77
+
78
+ def parent_load(s)
79
+ m = PARENT_LOAD.match(s) or return nil
80
+ association = m[1].singularize
81
+ { kind: "belongs_to", parent: nil, association: association,
82
+ code: ".includes(:#{association})",
83
+ explanation: "Each row loads its #{association} one at a time. " \
84
+ "Add .includes(:#{association}) on the collection you iterate#{at_source}." }
85
+ end
86
+
87
+ def at_source
88
+ source.present? ? " at #{source}" : ""
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Notification < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ notifier.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 OutgoingRequest < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ url.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # A monitored application's user, as seen in telemetry. Keyed by ref
6
+ # ("tenant:id" or "id") so the same person across tenants stays distinct.
7
+ class Person < TelemetryRecord
8
+ scope :recent, -> { order(last_seen_at: :desc) }
9
+
10
+ def self.touch_from_record(rec, at:)
11
+ touch_all([ [ rec, at ] ])
12
+ find_by(ref: rec["id"].to_s)
13
+ end
14
+
15
+ # One upsert per batch instead of a find-and-save per user record: the
16
+ # gem emits a user record once per user per hour per process, so a busy
17
+ # batch carries dozens. ON CONFLICT keeps first_seen_at and only
18
+ # overwrites name/email/tenant when the new value is present.
19
+ def self.touch_all(pairs)
20
+ rows = pairs.filter_map do |rec, at|
21
+ ref = rec["id"].to_s
22
+ next if ref.empty?
23
+ { ref: ref, name: rec["name"].presence, email: rec["email"].presence, app_tenant: rec["tenant"].presence,
24
+ first_seen_at: at, last_seen_at: at, requests_count: 0, exceptions_count: 0 }
25
+ end
26
+ return 0 if rows.empty?
27
+
28
+ # Latest timestamp wins when the same ref repeats within a batch.
29
+ rows = rows.group_by { |r| r[:ref] }.map { |_ref, dup| dup.max_by { |r| r[:last_seen_at] } }
30
+ upsert_all(rows, unique_by: :ref, record_timestamps: false,
31
+ on_duplicate: Arel.sql(<<~SQL.squish))
32
+ name = COALESCE(excluded.name, people.name),
33
+ email = COALESCE(excluded.email, people.email),
34
+ app_tenant = COALESCE(excluded.app_tenant, people.app_tenant),
35
+ first_seen_at = COALESCE(people.first_seen_at, excluded.first_seen_at),
36
+ last_seen_at = MAX(people.last_seen_at, excluded.last_seen_at)
37
+ SQL
38
+ rows.size
39
+ end
40
+
41
+ def executions
42
+ Execution.where(user_ref: ref)
43
+ end
44
+
45
+ def display_name
46
+ name.presence || email.presence || ref
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Process < TelemetryRecord
6
+ self.table_name = "processes"
7
+ scope :recent, -> { order(booted_at: :desc) }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # A sampled stack profile of one execution. `stacks` is gzip of collapsed
6
+ # stacks ("outer;inner;leaf count\n"), the format flamegraph tools read.
7
+ class Profile < TelemetryRecord
8
+ include Child
9
+
10
+ # Railwatch's profiler caps collapsed stacks at 4 MiB before compression.
11
+ # Enforce the same contract here even when a client lies about its size.
12
+ MAX_INGEST_BYTES = 4.megabytes
13
+
14
+ # A minute of wall profiling can decompress to tens of megabytes; the
15
+ # page only ever renders a flamegraph, so the payload is cut here rather
16
+ # than shipped whole to the browser.
17
+ MAX_COLLAPSED_BYTES = 2.megabytes
18
+
19
+ def collapsed
20
+ BoundedGzip.utf8!(BoundedGzip.decompress(stacks, max_bytes: MAX_INGEST_BYTES))
21
+ end
22
+
23
+ # [text, truncated?] - cut on a line boundary so the parser never sees
24
+ # half a stack line.
25
+ def collapsed_capped(limit = MAX_COLLAPSED_BYTES)
26
+ text, truncated = BoundedGzip.decompress_capped(stacks, limit: [ limit, MAX_INGEST_BYTES ].min)
27
+ text = text.sub(/[^\n]*\z/, "") if truncated
28
+
29
+ [ BoundedGzip.utf8!(text), truncated ]
30
+ end
31
+
32
+ def timeline_label
33
+ "#{profiler} profile (#{samples} samples)"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Query < TelemetryRecord
6
+ include Child
7
+
8
+ # Raised when a row's SQL is read on a relation that did not resolve it.
9
+ class SqlNotResolved < StandardError; end
10
+
11
+ # The statement, from the row or from its group's shape (QueryShape).
12
+ # A correlated lookup rather than a join: the queries page sorts a
13
+ # window of millions of rows before taking 50, and a LEFT JOIN there
14
+ # was paid per candidate row (47 s against 0.8 s on the 5M-row window).
15
+ SQL = Arel.sql("COALESCE(NULLIF(queries.sql, ''), " \
16
+ "(SELECT query_shapes.sql FROM query_shapes WHERE query_shapes.group_hash = queries.group_hash), '') AS resolved_sql").freeze
17
+
18
+ # Rows with their SQL resolved in the same statement, so a page loaded
19
+ # inside Environment#with_telemetry can be rendered after the block.
20
+ # Replaces the select list: count with count(:all), and pluck
21
+ # resolved_sql, not sql.
22
+ scope :with_sql, -> { select("queries.*", SQL) }
23
+
24
+ # Free-text search over the statement, wherever it is stored.
25
+ scope :matching, ->(text) {
26
+ pattern = "%#{sanitize_sql_like(text)}%"
27
+ where("queries.sql LIKE :pattern ESCAPE '\\' OR queries.name LIKE :pattern ESCAPE '\\' " \
28
+ "OR queries.group_hash IN (SELECT group_hash FROM query_shapes WHERE sql LIKE :pattern ESCAPE '\\')", pattern: pattern)
29
+ }
30
+
31
+ def self.timeline_scope(execution_id)
32
+ in_execution(execution_id).with_sql
33
+ end
34
+
35
+ # Always a String. A row whose text lives on its shape can only answer
36
+ # through with_sql; asking without it is a bug, not an empty statement.
37
+ def sql
38
+ return self[:resolved_sql].to_s if has_attribute?(:resolved_sql)
39
+
40
+ text = self[:sql].to_s
41
+ raise SqlNotResolved, "Telemetry::Query##{id} stores its SQL on its shape; load it through Query.with_sql" if text.empty? && group_hash
42
+ text
43
+ end
44
+
45
+ def timeline_label
46
+ sql.to_s.first(120)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # One query group's normalized statement, stored once. The group hash is
6
+ # the gem's digest of the connection name and that statement, so a row
7
+ # whose own text digests to its group carries the statement and stores
8
+ # "" instead (Ingest::Writer); any other text stays on the row. See
9
+ # docs/architecture.md.
10
+ class QueryShape < TelemetryRecord
11
+ self.primary_key = :group_hash
12
+
13
+ BACKFILL_BATCH = 20_000
14
+
15
+ # Backfill for rows written before this table existed. Files a shape
16
+ # for each group whose earliest or newest row normalizes back to the
17
+ # group hash (a tenant's earliest rows may predate the gem sending
18
+ # normalized text; its newest will not), then blanks every row whose
19
+ # text is its group's shape, in id-range batches so no statement holds
20
+ # the tenant's write lock for long. Idempotent, and never blanks a row
21
+ # whose text is not on file. Returns what it did.
22
+ def self.dedupe_rows!(batch: BACKFILL_BATCH)
23
+ before = count
24
+ file_shapes!
25
+ moved = Query.where.not(sql: "")
26
+ .where("queries.sql = (SELECT s.sql FROM query_shapes s WHERE s.group_hash = queries.group_hash)")
27
+ .in_batches(of: batch, use_ranges: true).update_all(sql: "")
28
+ { shapes: count - before, rows: moved }
29
+ end
30
+
31
+ def self.file_shapes!
32
+ ends = "SELECT MIN(id) AS id FROM queries WHERE group_hash IS NOT NULL GROUP BY group_hash " \
33
+ "UNION SELECT MAX(id) FROM queries WHERE group_hash IS NOT NULL GROUP BY group_hash"
34
+ candidates = Query.joins("JOIN (#{ends}) ends ON ends.id = queries.id").where.not(sql: "")
35
+ .pluck(:group_hash, :sql, :adapter, :connection)
36
+ shapes = candidates.filter_map do |group_hash, sql, adapter, connection|
37
+ digest, normalized = Railwatch::SqlNormalizer.group_and_normalized(sql, adapter: adapter, connection_name: connection)
38
+ { group_hash: group_hash, sql: normalized } if digest == group_hash
39
+ end
40
+ shapes.uniq { |shape| shape[:group_hash] }.each_slice(500) { |slice| insert_all(slice) }
41
+ end
42
+ private_class_method :file_shapes!
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # Hourly release health per deploy, recomputed from raw `sessions` rows by
6
+ # ReleaseHealthRollupJob. The deploy *is* the release -- the gem stamps it
7
+ # on every record it ships -- so counting sessions per deploy is all this
8
+ # needs. Sentry's crash-free session rate, crash-free user rate, and
9
+ # adoption, over the same data.
10
+ #
11
+ # A session that spans two hours is counted in both buckets, like every
12
+ # other rollup here: the bucket, not the session, is the unit these numbers
13
+ # sum over. Every method must run inside environment.with_telemetry { }.
14
+ class ReleaseHealth < TelemetryRecord
15
+ self.table_name = "release_health"
16
+
17
+ scope :between, ->(from, to) { where(bucket: from.beginning_of_hour..to) }
18
+ # A nil deploy means "every release in the window", which is what the
19
+ # index page's headline numbers are.
20
+ scope :for_release, ->(deploy) { deploy ? where(deploy: deploy) : all }
21
+
22
+ TOTALS = %i[sessions sessions_errored sessions_crashed users users_crashed duration_sum duration_count].freeze
23
+
24
+ # Window totals for one release. Rates come back nil, not zero, when there
25
+ # is nothing to divide by, so an environment with no sessions yet never
26
+ # renders as "0% crash-free".
27
+ def self.for_deploy(deploy, from, to)
28
+ sessions, errored, crashed, users, users_crashed, duration_sum, duration_count =
29
+ between(from, to).for_release(deploy).pick(*TOTALS.map { |column| Arel.sql("COALESCE(SUM(#{column}), 0)") })
30
+ {
31
+ sessions: sessions, users: users,
32
+ crash_free_sessions: rate(sessions - crashed, sessions),
33
+ crash_free_users: rate(users - users_crashed, users),
34
+ errored: rate(errored, sessions),
35
+ avg_duration_ms: duration_count.zero? ? nil : (duration_sum / duration_count / 1000.0).round(2)
36
+ }
37
+ end
38
+
39
+ # Hourly points for the stacked sessions-by-status chart.
40
+ def self.series(deploy, from, to)
41
+ between(from, to).for_release(deploy).group(:bucket).order(:bucket)
42
+ .pluck(:bucket, Arel.sql("SUM(sessions)"), Arel.sql("SUM(sessions_errored)"), Arel.sql("SUM(sessions_crashed)"))
43
+ .map { |bucket, sessions, errored, crashed|
44
+ { t: bucket, sessions: sessions, ok: sessions - errored - crashed, errored: errored, crashed: crashed }
45
+ }
46
+ end
47
+
48
+ # Each release's share of the window's sessions, as a percentage -- how
49
+ # far a new release has rolled out.
50
+ def self.adoption(from, to)
51
+ counts = between(from, to).group(:deploy).sum(:sessions)
52
+ total = counts.values.sum
53
+ return counts.transform_values { 0.0 } if total.zero?
54
+ counts.transform_values { |n| (n * 100.0 / total).round(1) }
55
+ end
56
+
57
+ def self.rate(numerator, denominator)
58
+ return nil if denominator.to_i.zero?
59
+ (numerator * 100.0 / denominator).round(2)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ require "tdigest"
5
+
6
+ module Telemetry
7
+ # Hourly aggregate per (record_type, group). Percentiles are exact within
8
+ # an hour and merged across hours with t-digest, so a 30-day p95 for a
9
+ # route is one query over 720 small rows.
10
+ class Rollup < TelemetryRecord
11
+ scope :for_type, ->(type) { where(record_type: type) }
12
+ scope :between, ->(from, to) { where(bucket: from.beginning_of_hour..to) }
13
+
14
+ def self.bucket_for(time)
15
+ time.utc.beginning_of_hour
16
+ end
17
+
18
+ # Merge a list of duration samples (microseconds) into the bucket.
19
+ def self.absorb!(record_type:, group_hash:, name:, bucket:, durations:, errors: 0, client_errors: 0, extra: {})
20
+ return if durations.empty?
21
+ row = find_or_initialize_by(record_type: record_type, group_hash: group_hash, bucket: bucket)
22
+ row.name = name
23
+ digest = row.digest ? TDigest::TDigest.from_bytes(row.digest) : TDigest::TDigest.new(0.01)
24
+ durations.each { |d| digest.push(d) }
25
+ digest.compress!
26
+ row.count += durations.size
27
+ row.error_count += errors
28
+ row.client_error_count += client_errors
29
+ row.duration_sum += durations.sum
30
+ row.duration_max = [ row.duration_max, durations.max ].max
31
+ row.p50 = digest.percentile(0.5).to_i
32
+ row.p95 = digest.percentile(0.95).to_i
33
+ row.p99 = digest.percentile(0.99).to_i
34
+ row.digest = digest.as_small_bytes
35
+ row.extra = row.extra.merge(extra) { |_k, a, b| a.is_a?(Numeric) && b.is_a?(Numeric) ? a + b : b }
36
+ row.save!
37
+ row
38
+ end
39
+
40
+ # The row absorb! would write over an empty bucket, as attributes for
41
+ # insert_all. RollupJob deletes an hour's rows and writes every group in
42
+ # one statement, so the read absorb! does per group is wasted there.
43
+ def self.fresh_attributes(record_type:, group_hash:, name:, bucket:, durations:, errors: 0, client_errors: 0, extra: {})
44
+ digest = TDigest::TDigest.new(0.01)
45
+ durations.each { |d| digest.push(d) }
46
+ digest.compress!
47
+ { record_type: record_type, group_hash: group_hash, name: name, bucket: bucket,
48
+ count: durations.size, error_count: errors, client_error_count: client_errors,
49
+ duration_sum: durations.sum, duration_max: durations.max,
50
+ p50: digest.percentile(0.5).to_i, p95: digest.percentile(0.95).to_i, p99: digest.percentile(0.99).to_i,
51
+ digest: digest.as_small_bytes, extra: extra }
52
+ end
53
+
54
+ # Aggregate a relation of rollups into one summary with merged percentiles.
55
+ def self.summarize(relation)
56
+ rows = relation.to_a
57
+ return { count: 0, errors: 0, client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0, max: 0 } if rows.empty?
58
+ # merge! pushes the row's centroids into one accumulating digest.
59
+ # `+` built a brand-new digest from both operands' centroids on every
60
+ # row, so merging N rows re-pushed every earlier centroid N times:
61
+ # 311 rows took 1.4s where merge! takes 0.5s, for the same percentiles.
62
+ merged = TDigest::TDigest.new(0.01)
63
+ rows.each { |r| merged.merge!(TDigest::TDigest.from_bytes(r.digest)) if r.digest }
64
+ count = rows.sum(&:count)
65
+ {
66
+ count: count,
67
+ errors: rows.sum(&:error_count),
68
+ client_errors: rows.sum(&:client_error_count),
69
+ avg: count.zero? ? 0 : (rows.sum(&:duration_sum) / count),
70
+ p50: merged.percentile(0.5).to_i,
71
+ p95: merged.percentile(0.95).to_i,
72
+ p99: merged.percentile(0.99).to_i,
73
+ max: rows.map(&:duration_max).max
74
+ }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # One session of the monitored application, for release health: a browser
6
+ # tab (reported by the gem's beacon client) or a server-side session (the
7
+ # gem's request middleware). The gem re-sends a live session on every flush
8
+ # interval, so the same session_id arrives many times and gets collapsed to
9
+ # one session per bucket by ReleaseHealthRollupJob.
10
+ class Session < TelemetryRecord
11
+ include Child
12
+
13
+ STATUSES = %w[started ok errored crashed].freeze
14
+
15
+ def crashed?
16
+ status == "crashed"
17
+ end
18
+
19
+ def timeline_label
20
+ session_id
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # A custom span recorded with Railwatch.span("name") { }, a child of the
6
+ # request/job/command it ran inside.
7
+ class Span < TelemetryRecord
8
+ include Child
9
+
10
+ scope :failed, -> { where(status: "failed") }
11
+
12
+ # The wire record calls the user payload "attributes"; the column is
13
+ # `payload` because `attributes` is Active Record's own method.
14
+ def timeline_label
15
+ name.to_s.first(120)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class StorageOp < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ key.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end