railwatch 0.9.3 → 1.0.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 (454) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +37 -33
  3. data/CHANGELOG.md +66 -0
  4. data/README.md +51 -60
  5. data/config/routes.rb +0 -59
  6. data/docs/ai-and-mcp.md +9 -8
  7. data/docs/configuration.md +57 -71
  8. data/docs/faq.md +17 -25
  9. data/docs/getting-started.md +105 -117
  10. data/docs/replacing-nightwatch.md +8 -7
  11. data/docs/replacing-sentry.md +12 -16
  12. data/docs/security.md +14 -20
  13. data/docs/self-hosting.md +24 -22
  14. data/docs/testing.md +6 -14
  15. data/docs/troubleshooting.md +12 -46
  16. data/lib/generators/railwatch/install/install_generator.rb +12 -240
  17. data/lib/generators/railwatch/install/templates/initializer.rb.tt +10 -40
  18. data/lib/generators/railwatch/install/templates/post-deploy +0 -7
  19. data/lib/railwatch/authentication.rb +6 -5
  20. data/lib/railwatch/buffer.rb +9 -0
  21. data/lib/railwatch/configuration.rb +21 -151
  22. data/lib/railwatch/engine.rb +9 -118
  23. data/lib/railwatch/patches.rb +0 -11
  24. data/lib/railwatch/reporter.rb +92 -21
  25. data/lib/railwatch/sampler.rb +10 -2
  26. data/lib/railwatch/subscribers/process_info.rb +1 -2
  27. data/lib/railwatch/version.rb +1 -1
  28. data/lib/railwatch.rb +8 -64
  29. data/lib/tasks/railwatch_tasks.rake +3 -325
  30. data/llms.txt +23 -25
  31. metadata +3 -453
  32. data/app/channels/railwatch/environment_channel.rb +0 -62
  33. data/app/controllers/concerns/railwatch/telemetry_identity.rb +0 -25
  34. data/app/controllers/railwatch/alerts_controller.rb +0 -48
  35. data/app/controllers/railwatch/anomaly_rules_controller.rb +0 -31
  36. data/app/controllers/railwatch/attachments_controller.rb +0 -22
  37. data/app/controllers/railwatch/broadcasts_controller.rb +0 -15
  38. data/app/controllers/railwatch/cache_events_controller.rb +0 -30
  39. data/app/controllers/railwatch/commands_controller.rb +0 -16
  40. data/app/controllers/railwatch/comments_controller.rb +0 -11
  41. data/app/controllers/railwatch/dashboard_controller.rb +0 -70
  42. data/app/controllers/railwatch/deploys_controller.rb +0 -34
  43. data/app/controllers/railwatch/deprecations_controller.rb +0 -54
  44. data/app/controllers/railwatch/environment_scoped.rb +0 -81
  45. data/app/controllers/railwatch/exceptions_controller.rb +0 -52
  46. data/app/controllers/railwatch/executions_controller.rb +0 -13
  47. data/app/controllers/railwatch/issues_controller.rb +0 -258
  48. data/app/controllers/railwatch/jobs_controller.rb +0 -63
  49. data/app/controllers/railwatch/llm_calls_controller.rb +0 -124
  50. data/app/controllers/railwatch/logs_controller.rb +0 -40
  51. data/app/controllers/railwatch/mails_controller.rb +0 -19
  52. data/app/controllers/railwatch/notifications_controller.rb +0 -11
  53. data/app/controllers/railwatch/outgoing_requests_controller.rb +0 -19
  54. data/app/controllers/railwatch/overview_controller.rb +0 -38
  55. data/app/controllers/railwatch/people_controller.rb +0 -25
  56. data/app/controllers/railwatch/processes_controller.rb +0 -33
  57. data/app/controllers/railwatch/profiles_controller.rb +0 -65
  58. data/app/controllers/railwatch/queries_controller.rb +0 -59
  59. data/app/controllers/railwatch/releases_controller.rb +0 -75
  60. data/app/controllers/railwatch/requests_controller.rb +0 -37
  61. data/app/controllers/railwatch/saved_views_controller.rb +0 -53
  62. data/app/controllers/railwatch/scheduled_tasks_controller.rb +0 -45
  63. data/app/controllers/railwatch/spans_controller.rb +0 -47
  64. data/app/controllers/railwatch/storage_ops_controller.rb +0 -15
  65. data/app/controllers/railwatch/tenants_controller.rb +0 -44
  66. data/app/controllers/railwatch/thresholds_controller.rb +0 -47
  67. data/app/controllers/railwatch/traces_controller.rb +0 -72
  68. data/app/controllers/railwatch/transactions_controller.rb +0 -21
  69. data/app/controllers/railwatch/view_renders_controller.rb +0 -28
  70. data/app/controllers/railwatch/visits_controller.rb +0 -53
  71. data/app/frontend/components/app-content.tsx +0 -26
  72. data/app/frontend/components/app-logo-icon.tsx +0 -23
  73. data/app/frontend/components/app-logo.tsx +0 -14
  74. data/app/frontend/components/app-shell.tsx +0 -38
  75. data/app/frontend/components/app-sidebar-header.tsx +0 -54
  76. data/app/frontend/components/app-sidebar.tsx +0 -94
  77. data/app/frontend/components/app-wordmark.tsx +0 -29
  78. data/app/frontend/components/breadcrumbs.tsx +0 -46
  79. data/app/frontend/components/icon.tsx +0 -16
  80. data/app/frontend/components/input-error.tsx +0 -18
  81. data/app/frontend/components/nav-footer.tsx +0 -46
  82. data/app/frontend/components/nav-main.tsx +0 -41
  83. data/app/frontend/components/nav-user.tsx +0 -50
  84. data/app/frontend/components/railwatch/chart-hover.tsx +0 -34
  85. data/app/frontend/components/railwatch/chart-panel.tsx +0 -170
  86. data/app/frontend/components/railwatch/code.tsx +0 -28
  87. data/app/frontend/components/railwatch/command-palette.tsx +0 -132
  88. data/app/frontend/components/railwatch/copy-block.tsx +0 -52
  89. data/app/frontend/components/railwatch/copy-id.tsx +0 -49
  90. data/app/frontend/components/railwatch/cursor-load-more.tsx +0 -39
  91. data/app/frontend/components/railwatch/data-table.tsx +0 -195
  92. data/app/frontend/components/railwatch/digit-roll.tsx +0 -312
  93. data/app/frontend/components/railwatch/empty-state.tsx +0 -159
  94. data/app/frontend/components/railwatch/env-nav.tsx +0 -130
  95. data/app/frontend/components/railwatch/env-switcher.tsx +0 -145
  96. data/app/frontend/components/railwatch/filter-bar.tsx +0 -153
  97. data/app/frontend/components/railwatch/flamegraph.tsx +0 -324
  98. data/app/frontend/components/railwatch/frames.tsx +0 -295
  99. data/app/frontend/components/railwatch/json-viewer.tsx +0 -159
  100. data/app/frontend/components/railwatch/live-dot.tsx +0 -78
  101. data/app/frontend/components/railwatch/live-toggle.tsx +0 -76
  102. data/app/frontend/components/railwatch/metric-chart.tsx +0 -112
  103. data/app/frontend/components/railwatch/mobile-page-switcher.tsx +0 -89
  104. data/app/frontend/components/railwatch/onboarding.tsx +0 -91
  105. data/app/frontend/components/railwatch/origin-identity.tsx +0 -84
  106. data/app/frontend/components/railwatch/page-header.tsx +0 -42
  107. data/app/frontend/components/railwatch/percentile-picker.tsx +0 -18
  108. data/app/frontend/components/railwatch/relative-time.tsx +0 -18
  109. data/app/frontend/components/railwatch/saved-views.tsx +0 -232
  110. data/app/frontend/components/railwatch/segmented.tsx +0 -64
  111. data/app/frontend/components/railwatch/series-chart.tsx +0 -400
  112. data/app/frontend/components/railwatch/shortcuts-dialog.tsx +0 -89
  113. data/app/frontend/components/railwatch/signal-toast.tsx +0 -121
  114. data/app/frontend/components/railwatch/sort-header.tsx +0 -37
  115. data/app/frontend/components/railwatch/source-link.tsx +0 -57
  116. data/app/frontend/components/railwatch/sparkline-cell.tsx +0 -14
  117. data/app/frontend/components/railwatch/sql-block.tsx +0 -116
  118. data/app/frontend/components/railwatch/stat.tsx +0 -185
  119. data/app/frontend/components/railwatch/status-badge.tsx +0 -79
  120. data/app/frontend/components/railwatch/step-picker.tsx +0 -57
  121. data/app/frontend/components/railwatch/timeline.tsx +0 -405
  122. data/app/frontend/components/railwatch/utilisation-bar.tsx +0 -44
  123. data/app/frontend/components/railwatch/window-picker.tsx +0 -147
  124. data/app/frontend/components/ui/avatar.tsx +0 -51
  125. data/app/frontend/components/ui/badge.tsx +0 -46
  126. data/app/frontend/components/ui/breadcrumb.tsx +0 -109
  127. data/app/frontend/components/ui/button.tsx +0 -60
  128. data/app/frontend/components/ui/card.tsx +0 -92
  129. data/app/frontend/components/ui/chart.tsx +0 -374
  130. data/app/frontend/components/ui/checkbox.tsx +0 -32
  131. data/app/frontend/components/ui/collapsible.tsx +0 -31
  132. data/app/frontend/components/ui/command.tsx +0 -182
  133. data/app/frontend/components/ui/dialog.tsx +0 -158
  134. data/app/frontend/components/ui/dropdown-menu.tsx +0 -255
  135. data/app/frontend/components/ui/hover-card.tsx +0 -42
  136. data/app/frontend/components/ui/input.tsx +0 -21
  137. data/app/frontend/components/ui/label.tsx +0 -24
  138. data/app/frontend/components/ui/popover.tsx +0 -87
  139. data/app/frontend/components/ui/select.tsx +0 -185
  140. data/app/frontend/components/ui/separator.tsx +0 -28
  141. data/app/frontend/components/ui/sheet.tsx +0 -137
  142. data/app/frontend/components/ui/sidebar.tsx +0 -726
  143. data/app/frontend/components/ui/skeleton.tsx +0 -13
  144. data/app/frontend/components/ui/sonner.tsx +0 -41
  145. data/app/frontend/components/ui/table.tsx +0 -114
  146. data/app/frontend/components/ui/tabs.tsx +0 -91
  147. data/app/frontend/components/ui/textarea.tsx +0 -18
  148. data/app/frontend/components/ui/tooltip.tsx +0 -61
  149. data/app/frontend/components/user-info.tsx +0 -32
  150. data/app/frontend/components/user-menu-content.tsx +0 -94
  151. data/app/frontend/entrypoints/application.css +0 -244
  152. data/app/frontend/entrypoints/inertia.tsx +0 -92
  153. data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
  154. data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
  155. data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
  156. data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
  157. data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
  158. data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
  159. data/app/frontend/hooks/use-appearance.tsx +0 -67
  160. data/app/frontend/hooks/use-clipboard.ts +0 -32
  161. data/app/frontend/hooks/use-flash.tsx +0 -23
  162. data/app/frontend/hooks/use-hotkeys.ts +0 -63
  163. data/app/frontend/hooks/use-initials.tsx +0 -15
  164. data/app/frontend/hooks/use-live.ts +0 -205
  165. data/app/frontend/hooks/use-mobile-navigation.ts +0 -8
  166. data/app/frontend/hooks/use-mobile.ts +0 -21
  167. data/app/frontend/hooks/use-percentile.ts +0 -24
  168. data/app/frontend/hooks/use-reduced-motion.ts +0 -25
  169. data/app/frontend/hooks/use-row-nav.ts +0 -106
  170. data/app/frontend/hooks/use-step.ts +0 -27
  171. data/app/frontend/hooks/use-ticker.ts +0 -12
  172. data/app/frontend/hooks/use-window.ts +0 -58
  173. data/app/frontend/layouts/app/app-sidebar-layout.tsx +0 -107
  174. data/app/frontend/layouts/app-layout.tsx +0 -21
  175. data/app/frontend/layouts/env-layout.tsx +0 -53
  176. data/app/frontend/layouts/persistent-layout.tsx +0 -18
  177. data/app/frontend/lib/cable.ts +0 -10
  178. data/app/frontend/lib/env-pages.ts +0 -236
  179. data/app/frontend/lib/execution-path.ts +0 -55
  180. data/app/frontend/lib/explain.ts +0 -50
  181. data/app/frontend/lib/filter.ts +0 -25
  182. data/app/frontend/lib/flamegraph.ts +0 -155
  183. data/app/frontend/lib/format.ts +0 -71
  184. data/app/frontend/lib/keyboard.ts +0 -18
  185. data/app/frontend/lib/mount.ts +0 -23
  186. data/app/frontend/lib/source-link.ts +0 -217
  187. data/app/frontend/lib/utils.ts +0 -6
  188. data/app/frontend/pages/alerts/index.tsx +0 -171
  189. data/app/frontend/pages/broadcasts/index.tsx +0 -171
  190. data/app/frontend/pages/cache_events/index.tsx +0 -127
  191. data/app/frontend/pages/commands/index.tsx +0 -143
  192. data/app/frontend/pages/deploys/index.tsx +0 -94
  193. data/app/frontend/pages/deploys/show.tsx +0 -309
  194. data/app/frontend/pages/deprecations/index.tsx +0 -176
  195. data/app/frontend/pages/exceptions/index.tsx +0 -271
  196. data/app/frontend/pages/executions/show.tsx +0 -821
  197. data/app/frontend/pages/issues/index.tsx +0 -399
  198. data/app/frontend/pages/issues/show.tsx +0 -1367
  199. data/app/frontend/pages/jobs/index.tsx +0 -381
  200. data/app/frontend/pages/jobs/klass.tsx +0 -215
  201. data/app/frontend/pages/llm_calls/index.tsx +0 -664
  202. data/app/frontend/pages/logs/index.tsx +0 -303
  203. data/app/frontend/pages/mails/index.tsx +0 -218
  204. data/app/frontend/pages/notifications/index.tsx +0 -161
  205. data/app/frontend/pages/outgoing_requests/index.tsx +0 -309
  206. data/app/frontend/pages/overview/show.tsx +0 -333
  207. data/app/frontend/pages/people/index.tsx +0 -102
  208. data/app/frontend/pages/people/show.tsx +0 -149
  209. data/app/frontend/pages/processes/index.tsx +0 -335
  210. data/app/frontend/pages/profiles/index.tsx +0 -110
  211. data/app/frontend/pages/profiles/show.tsx +0 -105
  212. data/app/frontend/pages/queries/index.tsx +0 -492
  213. data/app/frontend/pages/queries/show.tsx +0 -276
  214. data/app/frontend/pages/releases/index.tsx +0 -190
  215. data/app/frontend/pages/releases/release-health.tsx +0 -68
  216. data/app/frontend/pages/releases/release-path.ts +0 -25
  217. data/app/frontend/pages/releases/show.tsx +0 -262
  218. data/app/frontend/pages/requests/index.tsx +0 -235
  219. data/app/frontend/pages/requests/route.tsx +0 -159
  220. data/app/frontend/pages/scheduled_tasks/index.tsx +0 -220
  221. data/app/frontend/pages/spans/index.tsx +0 -229
  222. data/app/frontend/pages/spans/show.tsx +0 -220
  223. data/app/frontend/pages/storage_ops/index.tsx +0 -150
  224. data/app/frontend/pages/tenants/index.tsx +0 -236
  225. data/app/frontend/pages/tenants/show.tsx +0 -477
  226. data/app/frontend/pages/tenants/tenant-path.ts +0 -26
  227. data/app/frontend/pages/thresholds/index.tsx +0 -414
  228. data/app/frontend/pages/traces/show.tsx +0 -208
  229. data/app/frontend/pages/transactions/index.tsx +0 -220
  230. data/app/frontend/pages/view_renders/index.tsx +0 -241
  231. data/app/frontend/pages/visits/index.tsx +0 -321
  232. data/app/frontend/public/icon.png +0 -0
  233. data/app/frontend/public/icon.svg +0 -5
  234. data/app/frontend/routes/index.d.ts +0 -2136
  235. data/app/frontend/routes/index.js +0 -2112
  236. data/app/frontend/types/globals.d.ts +0 -9
  237. data/app/frontend/types/index.ts +0 -288
  238. data/app/frontend/types/vite-env.d.ts +0 -9
  239. data/app/helpers/railwatch/assets_helper.rb +0 -52
  240. data/app/jobs/railwatch/anomaly_scan_job.rb +0 -11
  241. data/app/jobs/railwatch/application_job.rb +0 -7
  242. data/app/jobs/railwatch/auto_resolve_issues_job.rb +0 -19
  243. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +0 -113
  244. data/app/jobs/railwatch/detect_anomalies_job.rb +0 -171
  245. data/app/jobs/railwatch/detect_performance_issues_job.rb +0 -95
  246. data/app/jobs/railwatch/group_exceptions_job.rb +0 -91
  247. data/app/jobs/railwatch/optimize_telemetry_job.rb +0 -23
  248. data/app/jobs/railwatch/performance_scan_job.rb +0 -11
  249. data/app/jobs/railwatch/prune_telemetry_job.rb +0 -129
  250. data/app/jobs/railwatch/release_health_rollup_job.rb +0 -64
  251. data/app/jobs/railwatch/rollup_catchup_job.rb +0 -21
  252. data/app/jobs/railwatch/rollup_job.rb +0 -132
  253. data/app/jobs/railwatch/scheduled_task_scan_job.rb +0 -11
  254. data/app/models/concerns/railwatch/detection_snapshotting.rb +0 -20
  255. data/app/models/railwatch/alert.rb +0 -229
  256. data/app/models/railwatch/alert_rule.rb +0 -121
  257. data/app/models/railwatch/anomaly_rule.rb +0 -51
  258. data/app/models/railwatch/application.rb +0 -44
  259. data/app/models/railwatch/application_record.rb +0 -33
  260. data/app/models/railwatch/comment.rb +0 -36
  261. data/app/models/railwatch/deploy.rb +0 -67
  262. data/app/models/railwatch/environment.rb +0 -68
  263. data/app/models/railwatch/execution_presenter.rb +0 -185
  264. data/app/models/railwatch/filter_query.rb +0 -184
  265. data/app/models/railwatch/followup_receipt.rb +0 -27
  266. data/app/models/railwatch/ingest/batch.rb +0 -355
  267. data/app/models/railwatch/ingest/mapper.rb +0 -585
  268. data/app/models/railwatch/ingest/payload.rb +0 -96
  269. data/app/models/railwatch/ingest/rollup_absorber.rb +0 -170
  270. data/app/models/railwatch/ingest/writer.rb +0 -137
  271. data/app/models/railwatch/issue.rb +0 -277
  272. data/app/models/railwatch/issue_activity.rb +0 -23
  273. data/app/models/railwatch/issue_detection_presenter.rb +0 -309
  274. data/app/models/railwatch/issue_detection_snapshot.rb +0 -77
  275. data/app/models/railwatch/maintenance_task.rb +0 -53
  276. data/app/models/railwatch/saved_view.rb +0 -63
  277. data/app/models/railwatch/telemetry/aggregations.rb +0 -251
  278. data/app/models/railwatch/telemetry/attachment.rb +0 -31
  279. data/app/models/railwatch/telemetry/bounded_gzip.rb +0 -101
  280. data/app/models/railwatch/telemetry/broadcast.rb +0 -13
  281. data/app/models/railwatch/telemetry/cache_event.rb +0 -16
  282. data/app/models/railwatch/telemetry/child.rb +0 -53
  283. data/app/models/railwatch/telemetry/cursor_page.rb +0 -99
  284. data/app/models/railwatch/telemetry/deprecation.rb +0 -13
  285. data/app/models/railwatch/telemetry/enqueued_job.rb +0 -13
  286. data/app/models/railwatch/telemetry/exception.rb +0 -28
  287. data/app/models/railwatch/telemetry/execution.rb +0 -152
  288. data/app/models/railwatch/telemetry/export_delivery.rb +0 -32
  289. data/app/models/railwatch/telemetry/export_destination.rb +0 -66
  290. data/app/models/railwatch/telemetry/health_sample.rb +0 -72
  291. data/app/models/railwatch/telemetry/ingest_batch.rb +0 -31
  292. data/app/models/railwatch/telemetry/llm_call.rb +0 -37
  293. data/app/models/railwatch/telemetry/log.rb +0 -91
  294. data/app/models/railwatch/telemetry/mail.rb +0 -13
  295. data/app/models/railwatch/telemetry/n_plus_one.rb +0 -92
  296. data/app/models/railwatch/telemetry/notification.rb +0 -13
  297. data/app/models/railwatch/telemetry/outgoing_request.rb +0 -13
  298. data/app/models/railwatch/telemetry/person.rb +0 -50
  299. data/app/models/railwatch/telemetry/process.rb +0 -10
  300. data/app/models/railwatch/telemetry/profile.rb +0 -37
  301. data/app/models/railwatch/telemetry/query.rb +0 -57
  302. data/app/models/railwatch/telemetry/query_shape.rb +0 -45
  303. data/app/models/railwatch/telemetry/release_health.rb +0 -69
  304. data/app/models/railwatch/telemetry/rollup.rb +0 -157
  305. data/app/models/railwatch/telemetry/session.rb +0 -24
  306. data/app/models/railwatch/telemetry/span.rb +0 -19
  307. data/app/models/railwatch/telemetry/storage_op.rb +0 -13
  308. data/app/models/railwatch/telemetry/tenant.rb +0 -240
  309. data/app/models/railwatch/telemetry/transaction.rb +0 -13
  310. data/app/models/railwatch/telemetry/view_render.rb +0 -13
  311. data/app/models/railwatch/telemetry/visit.rb +0 -24
  312. data/app/models/railwatch/telemetry_record.rb +0 -107
  313. data/app/models/railwatch/threshold.rb +0 -66
  314. data/app/models/railwatch/user.rb +0 -47
  315. data/app/models/railwatch/viewer.rb +0 -13
  316. data/app/models/railwatch/window.rb +0 -66
  317. data/app/views/layouts/railwatch/dashboard.html.erb +0 -25
  318. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +0 -151
  319. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +0 -18
  320. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +0 -20
  321. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +0 -71
  322. data/db/railwatch_telemetry_migrate/20260903000000_enable_incremental_vacuum.rb +0 -62
  323. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +0 -481
  324. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +0 -14
  325. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +0 -9
  326. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +0 -10
  327. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +0 -9
  328. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +0 -82
  329. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +0 -10
  330. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +0 -60
  331. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +0 -9
  332. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +0 -51
  333. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +0 -11
  334. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +0 -10
  335. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +0 -37
  336. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +0 -11
  337. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +0 -15
  338. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +0 -9
  339. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +0 -10
  340. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +0 -9
  341. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +0 -18
  342. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +0 -25
  343. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +0 -55
  344. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +0 -21
  345. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +0 -16
  346. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +0 -18
  347. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +0 -16
  348. data/db/railwatch_telemetry_migrate/20260919000100_create_export_queue.rb +0 -146
  349. data/db/railwatch_telemetry_migrate/20260922000000_add_covering_indexes_for_dashboard_aggregates.rb +0 -34
  350. data/db/railwatch_telemetry_migrate/20260923000000_add_people_index.rb +0 -15
  351. data/db/railwatch_telemetry_migrate/20260925000000_add_tenant_summary_index.rb +0 -17
  352. data/db/railwatch_telemetry_migrate/20261008000000_add_in_transaction_to_outgoing_requests.rb +0 -10
  353. data/docs/embedded.md +0 -503
  354. data/lib/puma/plugin/railwatch.rb +0 -215
  355. data/lib/railwatch/dashboard_assets.rb +0 -45
  356. data/lib/railwatch/embedded.rb +0 -50
  357. data/lib/railwatch/export/client.rb +0 -124
  358. data/lib/railwatch/export/lease.rb +0 -56
  359. data/lib/railwatch/export/outbox.rb +0 -361
  360. data/lib/railwatch/export/policy.rb +0 -79
  361. data/lib/railwatch/export/sender.rb +0 -130
  362. data/lib/railwatch/ingest_request_body_limit.rb +0 -10
  363. data/lib/railwatch/maintenance.rb +0 -194
  364. data/lib/railwatch/patches/migration_busy_timeout.rb +0 -73
  365. data/lib/railwatch/transport/local.rb +0 -78
  366. data/lib/railwatch/transport/socket.rb +0 -183
  367. data/lib/railwatch/writer.rb +0 -370
  368. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  369. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  370. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  371. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  372. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  373. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  374. data/public/railwatch/assets/app-layout-by7G3sa1.js +0 -41
  375. data/public/railwatch/assets/application-Cc5v_Vk9.css +0 -1
  376. data/public/railwatch/assets/arrow-up-DCZ7emux.js +0 -1
  377. data/public/railwatch/assets/braces-CykT5_5u.js +0 -1
  378. data/public/railwatch/assets/card-C-PPSA0g.js +0 -1
  379. data/public/railwatch/assets/chart-Cov79YC5.js +0 -39
  380. data/public/railwatch/assets/chart-hover-DdK4E-2h.js +0 -1
  381. data/public/railwatch/assets/chart-panel-BPaYRXJB.js +0 -1
  382. data/public/railwatch/assets/code-BYjC4YZ_.js +0 -1
  383. data/public/railwatch/assets/copy-id-LPfoj9cC.js +0 -1
  384. data/public/railwatch/assets/cursor-load-more-DSdhgpGl.js +0 -1
  385. data/public/railwatch/assets/data-table-CQ_YRWQs.js +0 -1
  386. data/public/railwatch/assets/env-layout-CdMWbUkm.js +0 -1
  387. data/public/railwatch/assets/execution-path-7MYt-Qbn.js +0 -1
  388. data/public/railwatch/assets/filter-bar-CrzpQ_vD.js +0 -1
  389. data/public/railwatch/assets/flamegraph-BIFEdTAg.js +0 -2
  390. data/public/railwatch/assets/format-B9SDkrWj.js +0 -1
  391. data/public/railwatch/assets/frames-C5t-x4f0.js +0 -1
  392. data/public/railwatch/assets/index--NQ7us-L.js +0 -1
  393. data/public/railwatch/assets/index-2kgYe_99.js +0 -1
  394. data/public/railwatch/assets/index-4YEPHxtO.js +0 -1
  395. data/public/railwatch/assets/index-AOCC1GDJ.js +0 -1
  396. data/public/railwatch/assets/index-BEo-u0eO.js +0 -1
  397. data/public/railwatch/assets/index-BJ1V3w2N.js +0 -1
  398. data/public/railwatch/assets/index-BPoHsBKb.js +0 -1
  399. data/public/railwatch/assets/index-BZzyGUM3.js +0 -1
  400. data/public/railwatch/assets/index-BgVq7yKL.js +0 -1
  401. data/public/railwatch/assets/index-BqFGEZvW.js +0 -1
  402. data/public/railwatch/assets/index-ByuHJ1CH.js +0 -1
  403. data/public/railwatch/assets/index-C2j50AL2.js +0 -1
  404. data/public/railwatch/assets/index-CLEdTkvW.js +0 -1
  405. data/public/railwatch/assets/index-CWgq8qBu.js +0 -1
  406. data/public/railwatch/assets/index-CXQ7_oVj.js +0 -2
  407. data/public/railwatch/assets/index-CmyYrjS2.js +0 -1
  408. data/public/railwatch/assets/index-Cn9_HjDo.js +0 -1
  409. data/public/railwatch/assets/index-CsMAm9UK.js +0 -1
  410. data/public/railwatch/assets/index-D0Kk_oGY.js +0 -1
  411. data/public/railwatch/assets/index-DC9ZptJh.js +0 -1
  412. data/public/railwatch/assets/index-DNzg7jmV.js +0 -1
  413. data/public/railwatch/assets/index-DQ8uJO5f.js +0 -1
  414. data/public/railwatch/assets/index-Deq14BxB.js +0 -1
  415. data/public/railwatch/assets/index-IaV3Blzp.js +0 -1
  416. data/public/railwatch/assets/index-TYNWcJQs.js +0 -1
  417. data/public/railwatch/assets/index-WonH2fcZ.js +0 -1
  418. data/public/railwatch/assets/index-WxWUaH_d.js +0 -1
  419. data/public/railwatch/assets/index-mUNrl4ky.js +0 -1
  420. data/public/railwatch/assets/inertia-DfNRARuA.js +0 -97
  421. data/public/railwatch/assets/json-viewer-DMsF5glp.js +0 -1
  422. data/public/railwatch/assets/klass-CmHYk5rz.js +0 -1
  423. data/public/railwatch/assets/origin-identity-1R3bqa3R.js +0 -1
  424. data/public/railwatch/assets/percentile-picker-73z5PAps.js +0 -1
  425. data/public/railwatch/assets/relative-time-Dsc_cCdX.js +0 -1
  426. data/public/railwatch/assets/release-health-BV5OaOWd.js +0 -1
  427. data/public/railwatch/assets/route-CWh58C7K.js +0 -1
  428. data/public/railwatch/assets/segmented-DB4zh-Am.js +0 -1
  429. data/public/railwatch/assets/select-DBKjFlOX.js +0 -1
  430. data/public/railwatch/assets/series-chart-BaiDG795.js +0 -1
  431. data/public/railwatch/assets/show-B4ICCFGh.js +0 -1
  432. data/public/railwatch/assets/show-B5lOU9Gv.js +0 -1
  433. data/public/railwatch/assets/show-BYPB74t4.js +0 -2
  434. data/public/railwatch/assets/show-C7A0m2M8.js +0 -1
  435. data/public/railwatch/assets/show-DT5X1zqE.js +0 -1
  436. data/public/railwatch/assets/show-D_NBdXsn.js +0 -2
  437. data/public/railwatch/assets/show-DgPCnRg8.js +0 -1
  438. data/public/railwatch/assets/show-Yl69HF0x.js +0 -1
  439. data/public/railwatch/assets/show-_QxuUQCm.js +0 -3
  440. data/public/railwatch/assets/show-g__jnN36.js +0 -1
  441. data/public/railwatch/assets/show-l4ztfjl9.js +0 -1
  442. data/public/railwatch/assets/sort-header-BcG8ulEH.js +0 -1
  443. data/public/railwatch/assets/source-link-B2183i2-.js +0 -1
  444. data/public/railwatch/assets/sparkline-cell-B47ipscB.js +0 -1
  445. data/public/railwatch/assets/stat-DT2hhsqS.js +0 -1
  446. data/public/railwatch/assets/status-badge-CmbRKlY7.js +0 -1
  447. data/public/railwatch/assets/tenant-path-C4rNGauE.js +0 -1
  448. data/public/railwatch/assets/use-clipboard-oTNeP4ax.js +0 -1
  449. data/public/railwatch/assets/use-live-CLZZj245.js +0 -1
  450. data/public/railwatch/icon.png +0 -0
  451. data/public/railwatch/icon.svg +0 -5
  452. data/public/railwatch/manifest.json +0 -1245
  453. data/public/railwatch/rails-vite.json +0 -1
  454. /data/{app/frontend/lib → lib/generators/railwatch/install/templates}/railwatch.ts +0 -0
@@ -1,152 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # A request, job attempt, scheduled task, command, or channel action. The parent of
6
- # every other telemetry row via execution_id.
7
- class Execution < TelemetryRecord
8
- KINDS = %w[request job_attempt scheduled_task command channel_action].freeze
9
-
10
- scope :of_kind, ->(kind) { where(kind: kind) }
11
- scope :requests, -> { where(kind: "request") }
12
- scope :jobs, -> { where(kind: "job_attempt") }
13
- scope :scheduled, -> { where(kind: "scheduled_task") }
14
- scope :commands, -> { where(kind: "command") }
15
- scope :channels, -> { where(kind: "channel_action") }
16
- scope :recent, -> { order(occurred_at: :desc) }
17
- scope :between, ->(from, to) { where(occurred_at: from..to) }
18
- scope :failed, -> { where("status >= 500 OR outcome = 'failed'") }
19
-
20
- # Executions of `kind` in [from, to] whose name contains `text`, or
21
- # (with previews: true) whose exception preview does. A LIKE on the rows
22
- # themselves reads every execution in the window until it has a page:
23
- # 32 s for a week of the platform's own requests when the text is rare.
24
- # Names come from a few hundred groups, so the text is matched against
25
- # the window's rollups and the rows are fetched by group_hash. Rollups
26
- # trail ingest by up to a minute, so the last FRESH_NAMES are matched on
27
- # the rows themselves. Only failures carry a preview, and a partial
28
- # index holds just those rows.
29
- #
30
- # Each branch names its index and the outer lookup is by rowid, so the
31
- # plan does not depend on planner statistics (an embedded install's are
32
- # sampled, and sampled statistics make kind look selective). When the
33
- # text is common the old walk is the fast one -- a page of matches
34
- # turns up in the first few thousand rows -- so a text the rollups say
35
- # matches DENSE_MATCHES rows or more keeps it. On a copy of the
36
- # platform's own tenant, a week of job attempts: a text matching
37
- # nothing took 657 ms walking and 27 ms here; one matching 32,766 took
38
- # 11 ms walking and 215 ms here.
39
- FRESH_NAMES = 5.minutes
40
- DENSE_MATCHES = 1_000
41
-
42
- # `range` is used as given, so an exclusive or empty one (FilterQuery's
43
- # after:/before: can narrow a window to nothing) stays that way.
44
- def self.named_like(kind, text, range, previews: false)
45
- from, to = range.begin, range.end
46
- pattern = "%#{sanitize_sql_like(text)}%"
47
- window = where(kind: kind, occurred_at: range)
48
- groups = Rollup.for_type(kind).between(from, to).where("name LIKE ? ESCAPE '\\'", pattern)
49
- if !TelemetryRecord.sqlite? || groups.sum(:count) >= DENSE_MATCHES
50
- like = previews ? "name LIKE :pattern ESCAPE '\\' OR exception_preview LIKE :pattern ESCAPE '\\'" : "name LIKE :pattern ESCAPE '\\'"
51
- return window.where(like, pattern: pattern)
52
- end
53
-
54
- by_group = indexed_by("index_executions_on_group_hash_and_occurred_at")
55
- .where(group_hash: groups.distinct.select(:group_hash), occurred_at: range, kind: kind)
56
- fresh = where(kind: kind, occurred_at: [ from, to - FRESH_NAMES ].max..to).where("name LIKE ? ESCAPE '\\'", pattern)
57
- ids = [ by_group, fresh ]
58
- ids << indexed_by("idx_executions_with_preview").where(kind: kind, occurred_at: range)
59
- .where.not(exception_preview: nil).where("exception_preview LIKE ? ESCAPE '\\'", pattern) if previews
60
- from("#{quoted_table_name} NOT INDEXED").where(kind: kind, occurred_at: range)
61
- .where(ids.map { |branch| "#{quoted_table_name}.id IN (#{branch.select(:id).to_sql})" }.join(" OR "))
62
- end
63
-
64
- # Executions of `kind` in `range` that are in one of `groups`, or recent
65
- # enough (FRESH_NAMES) that their rollup may not exist yet. Each branch
66
- # names its index and the outer lookup is by rowid, as in named_like:
67
- # an OR over the two conditions made SQLite walk the whole window.
68
- def self.in_groups_or_fresh(kind, groups, range)
69
- by_group = indexed_by("index_executions_on_group_hash_and_occurred_at").where(group_hash: groups, occurred_at: range, kind: kind)
70
- fresh = where(kind: kind, occurred_at: [ range.begin, range.end - FRESH_NAMES ].max..range.end)
71
- return where(kind: kind, occurred_at: range).where(group_hash: groups).or(fresh) unless TelemetryRecord.sqlite?
72
-
73
- from("#{quoted_table_name} NOT INDEXED").where(kind: kind, occurred_at: range)
74
- .where([ by_group, fresh ].map { |branch| "#{quoted_table_name}.id IN (#{branch.select(:id).to_sql})" }.join(" OR "))
75
- end
76
-
77
- def self.indexed_by(index) = unscoped.from("#{quoted_table_name} INDEXED BY #{index}")
78
- private_class_method :indexed_by
79
-
80
- # Servers that reported an execution since `time`. Asked once per kind:
81
- # there is no index led by occurred_at alone, so the obvious
82
- # `where(occurred_at: time..).distinct.pluck(:server)` scans the whole
83
- # table -- 15 to 26 seconds every five minutes on the platform's own
84
- # 500k-row tenant, from SilentHostCheckJob -- while (kind, occurred_at)
85
- # seeks straight to the window for each of the five kinds.
86
- def self.servers_since(time)
87
- KINDS.flat_map { |kind| where(kind: kind, occurred_at: time..).distinct.pluck(:server) }.uniq
88
- end
89
-
90
- # The schedule string each task key last ran with: the newest
91
- # scheduled_task row per key carries it in detail. A task's runs share
92
- # its group (the digest of its key), so that row is the last entry of
93
- # its group_hash index -- one seek per key, a few dozen keys. A single
94
- # MAX(id) GROUP BY task_key read every scheduled run ever kept instead:
95
- # 10 s on the platform's own tenant.
96
- def self.latest_schedules(keys)
97
- keys.index_with do |key|
98
- scheduled.where(group_hash: Record.group_hash(key), task_key: key).order(occurred_at: :desc)
99
- .pick(Arel.sql("json_extract(detail, '$.schedule')"))
100
- end.compact
101
- end
102
-
103
- CHILDREN = {
104
- queries: Query, exceptions: Exception, cache_events: CacheEvent, mails: Mail,
105
- broadcasts: Broadcast, outgoing_requests: OutgoingRequest, storage_ops: StorageOp,
106
- view_renders: ViewRender, logs: Log, enqueued_jobs: EnqueuedJob, transactions: Transaction,
107
- notifications: Notification, spans: Span, profiles: Profile, attachments: Attachment,
108
- llm_calls: LlmCall
109
- }.freeze
110
-
111
- CHILDREN.each do |name, klass|
112
- define_method(name) { klass.where(execution_id: execution_id) }
113
- end
114
-
115
- # All children merged and ordered for the waterfall timeline.
116
- def timeline
117
- CHILDREN.flat_map { |name, klass| klass.timeline_scope(execution_id).map { |r| r.timeline_entry(name, occurred_at) } }
118
- .sort_by { |e| e[:offset] }
119
- end
120
-
121
- def failed?
122
- (status && status >= 500) || outcome == "failed"
123
- end
124
-
125
- def duration_ms
126
- duration / 1000.0
127
- end
128
-
129
- def queue_latency_ms
130
- queue_latency && (queue_latency / 1000.0).round(1)
131
- end
132
-
133
- # The row every list surface shows for an execution: what the kind has
134
- # in common, then what it adds.
135
- def as_row
136
- row = { execution_id: execution_id, kind: kind, name: name, status: status, outcome: outcome, duration: duration_ms.round(2),
137
- occurred_at: occurred_at, deploy: deploy, server: server, user_ref: user_ref, tenant: app_tenant,
138
- exception_preview: exception_preview }
139
- case kind
140
- when "request" then row.merge(method: self[:method], inertia_component: inertia_component, queries: counters["queries"])
141
- when "job_attempt" then row.merge(queue: queue, attempt: attempt, job_id: job_id, queue_latency: queue_latency_ms)
142
- when "scheduled_task" then row.merge(task_key: task_key)
143
- else row
144
- end
145
- end
146
-
147
- def children_count
148
- counters.values.sum
149
- end
150
- end
151
- end
152
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # One immutable delivery waiting to be sent, or the record of one that no
6
- # longer is. The body is the exact bytes that will go on the wire, kept
7
- # until the delivery reaches a terminal state and then freed: a retry has
8
- # to be the same delivery, and the receiver recognises it by those bytes.
9
- class ExportDelivery < TelemetryRecord
10
- DISPOSITIONS = %w[acked rejected expired discarded].freeze
11
-
12
- belongs_to :export_destination
13
-
14
- scope :live, -> { where.not(state: "done") }
15
- scope :due, ->(now = Time.current) { live.where(state: "pending").where(next_attempt_at: ..now) }
16
- scope :overdue, ->(now = Time.current) { live.where(expires_at: ...now) }
17
- scope :oldest_first, -> { order(:id) }
18
-
19
- def live? = state != "done"
20
-
21
- def sending? = state == "sending"
22
-
23
- # True when this claim is still the one allowed to finish the delivery.
24
- # A sender that stalled past its lease may wake and complete its request
25
- # anyway; the receiver's receipt makes that harmless, but it must not be
26
- # able to overwrite what the new owner has since recorded.
27
- def held_by?(token, generation)
28
- sending? && claim_token == token && claim_generation == generation
29
- end
30
- end
31
- end
32
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # Where this database mirrors its telemetry, and everything durable about
6
- # that relationship: who we are to the receiver, which token we were bound
7
- # with, whether it is currently taking deliveries, and who holds the lease
8
- # to send them.
9
- class ExportDestination < TelemetryRecord
10
- STATES = %w[ready deferred unauthorized inactive].freeze
11
- COUNTERS = %w[acked rejected expired discarded shed].freeze
12
-
13
- has_many :export_deliveries, dependent: :delete_all
14
-
15
- validates :state, inclusion: { in: STATES }
16
-
17
- def self.digest(value) = Digest::SHA256.hexdigest(value.to_s)
18
-
19
- # The binding for this url and token. A different token for the same url
20
- # is a different binding decision, not a silent rebind: queued bytes
21
- # were admitted under the old credential and must not follow the new one
22
- # to whatever tenant it belongs to.
23
- def self.bind!(url:, token:, now: Time.current, retried: false)
24
- row = find_or_initialize_by(url_sha256: digest(url))
25
- row.url = url
26
- row.producer_id ||= SecureRandom.uuid
27
- fingerprint = digest(token)
28
- if row.persisted? && row.credential_sha256 != fingerprint
29
- row.update!(credential_sha256: fingerprint, state: "unauthorized", reason: "credential_changed",
30
- retry_at: nil)
31
- else
32
- row.credential_sha256 = fingerprint
33
- row.created_at ||= now
34
- row.save!
35
- end
36
- row
37
- rescue ActiveRecord::RecordNotUnique
38
- # Two processes binding for the first time at once. One row wins; the
39
- # loser wants that row, not an error.
40
- raise if retried
41
-
42
- bind!(url: url, token: token, now: now, retried: true)
43
- end
44
-
45
- # A pause the receiver asked for ends when it said it would. Only a
46
- # credential problem needs a person: everything else is a delay, and a
47
- # delay that never ends is an outage we caused ourselves.
48
- # Needs a person before anything can move again: a credential that was
49
- # changed, or a destination taken out of service. Queueing into one
50
- # just fills it with work that rebind! will throw away.
51
- def blocked? = %w[unauthorized inactive].include?(state)
52
-
53
- def sendable?(now: Time.current)
54
- return false unless %w[ready deferred].include?(state)
55
-
56
- retry_at.nil? || retry_at <= now
57
- end
58
-
59
- def bump!(counter, by = 1)
60
- return unless COUNTERS.include?(counter.to_s)
61
-
62
- self.counters = counters.merge(counter.to_s => counters.fetch(counter.to_s, 0) + by)
63
- end
64
- end
65
- end
66
- end
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # Periodic Puma / Solid Queue health sample from one process: thread pool
6
- # utilisation, socket backlog, connection pool pressure, queue depth and
7
- # the age of the oldest ready job.
8
- class HealthSample < TelemetryRecord
9
- # The gem reports every 15s, so a process that has not sampled in ten
10
- # minutes is gone rather than idle.
11
- LIVE_WINDOW = 10.minutes
12
- BUCKET = 5.minutes
13
-
14
- scope :recent, -> { order(sampled_at: :desc) }
15
- scope :between, ->(from, to) { where(sampled_at: from..to) }
16
-
17
- # The newest sample from each (server, pid) pair seen since `time`: one
18
- # row per process still reporting.
19
- def self.live(time = LIVE_WINDOW.ago)
20
- where(id: where(sampled_at: time..).group(:server, :pid).select(Arel.sql("MAX(id)"))).order(:server, :pid)
21
- end
22
-
23
- # Health over the window, one point per BUCKET: thread utilisation is a
24
- # percentage averaged across the processes that sampled into the bucket,
25
- # backlog is summed across them, and the queue figures are the worst
26
- # seen (any process's reading covers the whole queue).
27
- def self.series(from, to, bucket: BUCKET)
28
- slice = bucket_expression(bucket)
29
- between(from, to).group(slice).order(slice)
30
- .pluck(slice,
31
- Arel.sql("AVG(CASE WHEN threads_max > 0 THEN threads_busy * 100.0 / threads_max END)"),
32
- Arel.sql("SUM(backlog)"), Arel.sql("MAX(queue_depth)"), Arel.sql("MAX(queue_latency)"))
33
- .map do |t, utilisation, backlog, depth, latency|
34
- { t: t, utilisation: utilisation.to_f.round(1), backlog: backlog.to_i,
35
- queue_depth: depth.to_i, queue_latency: (latency.to_i / 1000.0).round(1) }
36
- end
37
- end
38
-
39
- # Deepest depth reported for each queue across `samples`, most backed up
40
- # first. Every worker reports the same shared queue, so the readings are
41
- # merged with max, not summed.
42
- def self.queue_depths(samples)
43
- depths = samples.each_with_object({}) do |sample, out|
44
- # A process without a job backend reports queues: nil, not {}.
45
- (sample.detail["queues"] || {}).each { |queue, depth| out[queue] = [ out[queue].to_i, depth.to_i ].max }
46
- end
47
- depths.sort_by { |_queue, depth| -depth }.map { |queue, depth| { queue: queue, depth: depth } }
48
- end
49
-
50
- # The recurring task keys Solid Queue is running, from the newest live
51
- # sample that carries a manifest (the gem sends one on every health
52
- # sample). nil when no live process has reported one: an older gem, or
53
- # nothing alive to ask.
54
- def self.recurring_task_keys(since: LIVE_WINDOW.ago)
55
- manifest = where(sampled_at: since..).where("json_extract(detail, '$.recurring_tasks') IS NOT NULL")
56
- .recent.pick(Arel.sql("json_extract(detail, '$.recurring_tasks')"))
57
- manifest && JSON.parse(manifest).keys
58
- end
59
-
60
- def self.bucket_expression(bucket)
61
- seconds = bucket.to_i
62
- Arel.sql("strftime('%Y-%m-%dT%H:%M:00Z', (strftime('%s', sampled_at) / #{seconds}) * #{seconds}, 'unixepoch')")
63
- end
64
- private_class_method :bucket_expression
65
-
66
- def thread_utilisation
67
- return nil if threads_max.to_i.zero?
68
- threads_busy.to_f / threads_max
69
- end
70
- end
71
- end
72
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # One row per batch written into the telemetry database. In embedded mode
6
- # it is also the delivery ledger: `batch_id` is the reporter's stable id
7
- # for the batch (unique, so a replay is a no-op) and `followups` is the
8
- # work the batch still owes outside its own transaction, nil once done.
9
- class IngestBatch < TelemetryRecord
10
- scope :recent, -> { order(received_at: :desc) }
11
- scope :with_pending_followups, -> { where.not(followups: nil).order(:received_at) }
12
-
13
- def self.committed(batch_id)
14
- batch_id.present? ? find_by(batch_id: batch_id) : nil
15
- end
16
-
17
- # Runs the follow-ups a batch left behind and clears them. Safe to run
18
- # more than once: the grouping commits a FollowupReceipt per (batch,
19
- # group) in the railwatch database together with the occurrence count,
20
- # so a second run finds the receipts and counts nothing. Clearing the
21
- # outbox here is only what stops the maintenance drain from picking the
22
- # row up again; it is not what makes the replay safe.
23
- def drain_followups!(environment)
24
- work = followups || {}
25
- ids = Array(work["group_exception_ids"])
26
- GroupExceptionsJob.new.perform(environment, ids, batch_id: batch_id) if ids.any?
27
- update_columns(followups: nil)
28
- end
29
- end
30
- end
31
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class LlmCall < TelemetryRecord
6
- include Child
7
-
8
- # A tool invocation is a call in the same pipeline, so it shares this
9
- # table; it just has a name where a model call has a model.
10
- TOOL = "tool"
11
-
12
- scope :models, -> { where.not(operation: TOOL) }
13
- scope :tools, -> { where(operation: TOOL) }
14
-
15
- NANOS_PER_DOLLAR = 1_000_000_000.0
16
-
17
- # Dollars, or nil when the call is unpriced. Callers must render the
18
- # difference: an unpriced call is not a free one.
19
- def cost
20
- cost_nanos && cost_nanos / NANOS_PER_DOLLAR
21
- end
22
-
23
- # What the app paid for, which is the count a bill is drawn from.
24
- def total_tokens
25
- [ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens ].compact.sum
26
- end
27
-
28
- def timeline_label
29
- tool? ? tool_name.to_s : "#{provider}/#{model}"
30
- end
31
-
32
- def tool?
33
- operation == TOOL
34
- end
35
- end
36
- end
37
- end
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class Log < TelemetryRecord
6
- include Child
7
-
8
- # Wraps each hit inside a snippet. One character for both ends so the
9
- # frontend can split on it and mark the odd-numbered pieces; U+001F is
10
- # not something a log line can realistically contain.
11
- SNIPPET_MARK = "\u001F"
12
-
13
- # "quoted phrase" | -excluded | bare, in the order they were typed.
14
- TERM = /-?"[^"]*"|\S+/
15
-
16
- scope :fts, ->(text) {
17
- next none if text.to_s.strip.empty?
18
- next where("message LIKE ?", "%#{sanitize_sql_like(text)}%") unless fts_available?
19
- match = match_expression(text)
20
- match ? where("logs.id IN (SELECT rowid FROM logs_fts WHERE logs_fts MATCH ?)", match) : none
21
- }
22
-
23
- class << self
24
- # Free text -> an FTS5 MATCH expression. Quoted phrases stay whole,
25
- # every other word becomes a prefix token so "widg" finds "widgets",
26
- # and a leading "-" excludes -- but only when something positive is
27
- # left to exclude from, since FTS5's NOT needs a left operand. Every
28
- # term is emitted as a quoted string, which is what keeps FTS5 syntax
29
- # (NEAR, *, :, parens) inert instead of raising a "syntax error".
30
- # Returns nil when the text has nothing searchable in it.
31
- def match_expression(text)
32
- terms = text.to_s.scan(TERM).filter_map { |term| parse_term(term) }
33
- positive = terms.reject(&:first).map(&:last)
34
- negative = terms.select(&:first).map(&:last)
35
- if positive.empty?
36
- positive = negative
37
- negative = []
38
- end
39
- return nil if positive.empty?
40
- [ positive.join(" "), *negative.map { |term| "NOT #{term}" } ].join(" ")
41
- end
42
-
43
- # id => message with SNIPPET_MARK around each hit, for the rows the
44
- # page is about to render. Empty unless we actually ran an FTS query.
45
- def fts_snippets(ids, text)
46
- return {} if ids.empty? || !fts_available?
47
- match = match_expression(text)
48
- return {} if match.nil?
49
- sql = sanitize_sql_array([ "SELECT rowid, snippet(logs_fts, 0, ?, ?, ?, 24) FROM logs_fts WHERE logs_fts MATCH ? AND rowid IN (?)",
50
- SNIPPET_MARK, SNIPPET_MARK, "…", match, ids ])
51
- connection.select_rows(sql).to_h
52
- end
53
-
54
- # Postgres telemetry databases (SCOPE.md keeps them supported) have no
55
- # logs_fts, and neither does a database created before the index was
56
- # added. Every telemetry database is built from the same schema, so
57
- # this is a process-wide fact rather than a per-environment one.
58
- def fts_available?
59
- return @fts_available if defined?(@fts_available)
60
- @fts_available = connection.adapter_name.match?(/sqlite/i) &&
61
- connection.select_value("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'logs_fts'").present?
62
- end
63
-
64
- private
65
-
66
- # "term" -> [negated?, fts5 term], or nil if there is nothing left of
67
- # it once the quotes and the minus sign come off.
68
- def parse_term(term)
69
- negated = term.start_with?("-") && term.length > 1
70
- body = negated ? term[1..] : term
71
- phrase = body.start_with?('"') && body.end_with?('"') && body.length > 1
72
- body = body[1..-2].to_s if phrase
73
- return nil unless body.match?(/[[:alnum:]]/)
74
- [ negated, phrase ? quote(body) : "#{quote(body)}*" ]
75
- end
76
-
77
- def quote(term) = %("#{term.gsub('"', '""')}")
78
- end
79
-
80
- def as_row
81
- { id: id, level: level, message: message.first(2000), tags: tags, occurred_at: occurred_at, deploy: deploy,
82
- execution_id: execution_id, execution_source: execution_source, execution_preview: execution_preview,
83
- source: source, tenant: app_tenant, user_ref: user_ref, context: context }
84
- end
85
-
86
- def timeline_label
87
- message.to_s.first(120)
88
- end
89
- end
90
- end
91
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class Mail < TelemetryRecord
6
- include Child
7
-
8
- def timeline_label
9
- mailer.to_s.first(120)
10
- end
11
- end
12
- end
13
- end
@@ -1,92 +0,0 @@
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
@@ -1,13 +0,0 @@
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
@@ -1,13 +0,0 @@
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
@@ -1,50 +0,0 @@
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