railwatch 0.9.4 → 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 (452) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +37 -33
  3. data/CHANGELOG.md +50 -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 +35 -66
  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/configuration.rb +21 -151
  21. data/lib/railwatch/engine.rb +9 -118
  22. data/lib/railwatch/patches.rb +0 -11
  23. data/lib/railwatch/reporter.rb +3 -19
  24. data/lib/railwatch/subscribers/process_info.rb +1 -2
  25. data/lib/railwatch/version.rb +1 -1
  26. data/lib/railwatch.rb +4 -62
  27. data/lib/tasks/railwatch_tasks.rake +3 -325
  28. data/llms.txt +23 -25
  29. metadata +3 -453
  30. data/app/channels/railwatch/environment_channel.rb +0 -62
  31. data/app/controllers/concerns/railwatch/telemetry_identity.rb +0 -25
  32. data/app/controllers/railwatch/alerts_controller.rb +0 -48
  33. data/app/controllers/railwatch/anomaly_rules_controller.rb +0 -31
  34. data/app/controllers/railwatch/attachments_controller.rb +0 -22
  35. data/app/controllers/railwatch/broadcasts_controller.rb +0 -15
  36. data/app/controllers/railwatch/cache_events_controller.rb +0 -30
  37. data/app/controllers/railwatch/commands_controller.rb +0 -16
  38. data/app/controllers/railwatch/comments_controller.rb +0 -11
  39. data/app/controllers/railwatch/dashboard_controller.rb +0 -70
  40. data/app/controllers/railwatch/deploys_controller.rb +0 -34
  41. data/app/controllers/railwatch/deprecations_controller.rb +0 -54
  42. data/app/controllers/railwatch/environment_scoped.rb +0 -81
  43. data/app/controllers/railwatch/exceptions_controller.rb +0 -52
  44. data/app/controllers/railwatch/executions_controller.rb +0 -13
  45. data/app/controllers/railwatch/issues_controller.rb +0 -258
  46. data/app/controllers/railwatch/jobs_controller.rb +0 -63
  47. data/app/controllers/railwatch/llm_calls_controller.rb +0 -124
  48. data/app/controllers/railwatch/logs_controller.rb +0 -40
  49. data/app/controllers/railwatch/mails_controller.rb +0 -19
  50. data/app/controllers/railwatch/notifications_controller.rb +0 -11
  51. data/app/controllers/railwatch/outgoing_requests_controller.rb +0 -19
  52. data/app/controllers/railwatch/overview_controller.rb +0 -38
  53. data/app/controllers/railwatch/people_controller.rb +0 -25
  54. data/app/controllers/railwatch/processes_controller.rb +0 -33
  55. data/app/controllers/railwatch/profiles_controller.rb +0 -65
  56. data/app/controllers/railwatch/queries_controller.rb +0 -59
  57. data/app/controllers/railwatch/releases_controller.rb +0 -75
  58. data/app/controllers/railwatch/requests_controller.rb +0 -37
  59. data/app/controllers/railwatch/saved_views_controller.rb +0 -53
  60. data/app/controllers/railwatch/scheduled_tasks_controller.rb +0 -45
  61. data/app/controllers/railwatch/spans_controller.rb +0 -47
  62. data/app/controllers/railwatch/storage_ops_controller.rb +0 -15
  63. data/app/controllers/railwatch/tenants_controller.rb +0 -44
  64. data/app/controllers/railwatch/thresholds_controller.rb +0 -47
  65. data/app/controllers/railwatch/traces_controller.rb +0 -72
  66. data/app/controllers/railwatch/transactions_controller.rb +0 -21
  67. data/app/controllers/railwatch/view_renders_controller.rb +0 -28
  68. data/app/controllers/railwatch/visits_controller.rb +0 -53
  69. data/app/frontend/components/app-content.tsx +0 -26
  70. data/app/frontend/components/app-logo-icon.tsx +0 -23
  71. data/app/frontend/components/app-logo.tsx +0 -14
  72. data/app/frontend/components/app-shell.tsx +0 -38
  73. data/app/frontend/components/app-sidebar-header.tsx +0 -54
  74. data/app/frontend/components/app-sidebar.tsx +0 -94
  75. data/app/frontend/components/app-wordmark.tsx +0 -29
  76. data/app/frontend/components/breadcrumbs.tsx +0 -46
  77. data/app/frontend/components/icon.tsx +0 -16
  78. data/app/frontend/components/input-error.tsx +0 -18
  79. data/app/frontend/components/nav-footer.tsx +0 -46
  80. data/app/frontend/components/nav-main.tsx +0 -41
  81. data/app/frontend/components/nav-user.tsx +0 -50
  82. data/app/frontend/components/railwatch/chart-hover.tsx +0 -34
  83. data/app/frontend/components/railwatch/chart-panel.tsx +0 -170
  84. data/app/frontend/components/railwatch/code.tsx +0 -28
  85. data/app/frontend/components/railwatch/command-palette.tsx +0 -132
  86. data/app/frontend/components/railwatch/copy-block.tsx +0 -52
  87. data/app/frontend/components/railwatch/copy-id.tsx +0 -49
  88. data/app/frontend/components/railwatch/cursor-load-more.tsx +0 -39
  89. data/app/frontend/components/railwatch/data-table.tsx +0 -195
  90. data/app/frontend/components/railwatch/digit-roll.tsx +0 -312
  91. data/app/frontend/components/railwatch/empty-state.tsx +0 -159
  92. data/app/frontend/components/railwatch/env-nav.tsx +0 -130
  93. data/app/frontend/components/railwatch/env-switcher.tsx +0 -145
  94. data/app/frontend/components/railwatch/filter-bar.tsx +0 -153
  95. data/app/frontend/components/railwatch/flamegraph.tsx +0 -324
  96. data/app/frontend/components/railwatch/frames.tsx +0 -295
  97. data/app/frontend/components/railwatch/json-viewer.tsx +0 -159
  98. data/app/frontend/components/railwatch/live-dot.tsx +0 -78
  99. data/app/frontend/components/railwatch/live-toggle.tsx +0 -76
  100. data/app/frontend/components/railwatch/metric-chart.tsx +0 -112
  101. data/app/frontend/components/railwatch/mobile-page-switcher.tsx +0 -89
  102. data/app/frontend/components/railwatch/onboarding.tsx +0 -91
  103. data/app/frontend/components/railwatch/origin-identity.tsx +0 -84
  104. data/app/frontend/components/railwatch/page-header.tsx +0 -42
  105. data/app/frontend/components/railwatch/percentile-picker.tsx +0 -18
  106. data/app/frontend/components/railwatch/relative-time.tsx +0 -18
  107. data/app/frontend/components/railwatch/saved-views.tsx +0 -232
  108. data/app/frontend/components/railwatch/segmented.tsx +0 -64
  109. data/app/frontend/components/railwatch/series-chart.tsx +0 -400
  110. data/app/frontend/components/railwatch/shortcuts-dialog.tsx +0 -89
  111. data/app/frontend/components/railwatch/signal-toast.tsx +0 -121
  112. data/app/frontend/components/railwatch/sort-header.tsx +0 -37
  113. data/app/frontend/components/railwatch/source-link.tsx +0 -57
  114. data/app/frontend/components/railwatch/sparkline-cell.tsx +0 -14
  115. data/app/frontend/components/railwatch/sql-block.tsx +0 -116
  116. data/app/frontend/components/railwatch/stat.tsx +0 -185
  117. data/app/frontend/components/railwatch/status-badge.tsx +0 -79
  118. data/app/frontend/components/railwatch/step-picker.tsx +0 -57
  119. data/app/frontend/components/railwatch/timeline.tsx +0 -405
  120. data/app/frontend/components/railwatch/utilisation-bar.tsx +0 -44
  121. data/app/frontend/components/railwatch/window-picker.tsx +0 -147
  122. data/app/frontend/components/ui/avatar.tsx +0 -51
  123. data/app/frontend/components/ui/badge.tsx +0 -46
  124. data/app/frontend/components/ui/breadcrumb.tsx +0 -109
  125. data/app/frontend/components/ui/button.tsx +0 -60
  126. data/app/frontend/components/ui/card.tsx +0 -92
  127. data/app/frontend/components/ui/chart.tsx +0 -374
  128. data/app/frontend/components/ui/checkbox.tsx +0 -32
  129. data/app/frontend/components/ui/collapsible.tsx +0 -31
  130. data/app/frontend/components/ui/command.tsx +0 -182
  131. data/app/frontend/components/ui/dialog.tsx +0 -158
  132. data/app/frontend/components/ui/dropdown-menu.tsx +0 -255
  133. data/app/frontend/components/ui/hover-card.tsx +0 -42
  134. data/app/frontend/components/ui/input.tsx +0 -21
  135. data/app/frontend/components/ui/label.tsx +0 -24
  136. data/app/frontend/components/ui/popover.tsx +0 -87
  137. data/app/frontend/components/ui/select.tsx +0 -185
  138. data/app/frontend/components/ui/separator.tsx +0 -28
  139. data/app/frontend/components/ui/sheet.tsx +0 -137
  140. data/app/frontend/components/ui/sidebar.tsx +0 -726
  141. data/app/frontend/components/ui/skeleton.tsx +0 -13
  142. data/app/frontend/components/ui/sonner.tsx +0 -41
  143. data/app/frontend/components/ui/table.tsx +0 -114
  144. data/app/frontend/components/ui/tabs.tsx +0 -91
  145. data/app/frontend/components/ui/textarea.tsx +0 -18
  146. data/app/frontend/components/ui/tooltip.tsx +0 -61
  147. data/app/frontend/components/user-info.tsx +0 -32
  148. data/app/frontend/components/user-menu-content.tsx +0 -94
  149. data/app/frontend/entrypoints/application.css +0 -244
  150. data/app/frontend/entrypoints/inertia.tsx +0 -92
  151. data/app/frontend/fonts/CommitMono-Bold.woff2 +0 -0
  152. data/app/frontend/fonts/CommitMono-Regular.woff2 +0 -0
  153. data/app/frontend/fonts/Roboto-Black.woff2 +0 -0
  154. data/app/frontend/fonts/Roboto-Bold.woff2 +0 -0
  155. data/app/frontend/fonts/Roboto-Medium.woff2 +0 -0
  156. data/app/frontend/fonts/Roboto-Regular.woff2 +0 -0
  157. data/app/frontend/hooks/use-appearance.tsx +0 -67
  158. data/app/frontend/hooks/use-clipboard.ts +0 -32
  159. data/app/frontend/hooks/use-flash.tsx +0 -23
  160. data/app/frontend/hooks/use-hotkeys.ts +0 -63
  161. data/app/frontend/hooks/use-initials.tsx +0 -15
  162. data/app/frontend/hooks/use-live.ts +0 -205
  163. data/app/frontend/hooks/use-mobile-navigation.ts +0 -8
  164. data/app/frontend/hooks/use-mobile.ts +0 -21
  165. data/app/frontend/hooks/use-percentile.ts +0 -24
  166. data/app/frontend/hooks/use-reduced-motion.ts +0 -25
  167. data/app/frontend/hooks/use-row-nav.ts +0 -106
  168. data/app/frontend/hooks/use-step.ts +0 -27
  169. data/app/frontend/hooks/use-ticker.ts +0 -12
  170. data/app/frontend/hooks/use-window.ts +0 -58
  171. data/app/frontend/layouts/app/app-sidebar-layout.tsx +0 -107
  172. data/app/frontend/layouts/app-layout.tsx +0 -21
  173. data/app/frontend/layouts/env-layout.tsx +0 -53
  174. data/app/frontend/layouts/persistent-layout.tsx +0 -18
  175. data/app/frontend/lib/cable.ts +0 -10
  176. data/app/frontend/lib/env-pages.ts +0 -236
  177. data/app/frontend/lib/execution-path.ts +0 -55
  178. data/app/frontend/lib/explain.ts +0 -50
  179. data/app/frontend/lib/filter.ts +0 -25
  180. data/app/frontend/lib/flamegraph.ts +0 -155
  181. data/app/frontend/lib/format.ts +0 -71
  182. data/app/frontend/lib/keyboard.ts +0 -18
  183. data/app/frontend/lib/mount.ts +0 -23
  184. data/app/frontend/lib/source-link.ts +0 -217
  185. data/app/frontend/lib/utils.ts +0 -6
  186. data/app/frontend/pages/alerts/index.tsx +0 -171
  187. data/app/frontend/pages/broadcasts/index.tsx +0 -171
  188. data/app/frontend/pages/cache_events/index.tsx +0 -127
  189. data/app/frontend/pages/commands/index.tsx +0 -143
  190. data/app/frontend/pages/deploys/index.tsx +0 -94
  191. data/app/frontend/pages/deploys/show.tsx +0 -309
  192. data/app/frontend/pages/deprecations/index.tsx +0 -176
  193. data/app/frontend/pages/exceptions/index.tsx +0 -271
  194. data/app/frontend/pages/executions/show.tsx +0 -821
  195. data/app/frontend/pages/issues/index.tsx +0 -399
  196. data/app/frontend/pages/issues/show.tsx +0 -1367
  197. data/app/frontend/pages/jobs/index.tsx +0 -381
  198. data/app/frontend/pages/jobs/klass.tsx +0 -215
  199. data/app/frontend/pages/llm_calls/index.tsx +0 -664
  200. data/app/frontend/pages/logs/index.tsx +0 -303
  201. data/app/frontend/pages/mails/index.tsx +0 -218
  202. data/app/frontend/pages/notifications/index.tsx +0 -161
  203. data/app/frontend/pages/outgoing_requests/index.tsx +0 -309
  204. data/app/frontend/pages/overview/show.tsx +0 -333
  205. data/app/frontend/pages/people/index.tsx +0 -102
  206. data/app/frontend/pages/people/show.tsx +0 -149
  207. data/app/frontend/pages/processes/index.tsx +0 -335
  208. data/app/frontend/pages/profiles/index.tsx +0 -110
  209. data/app/frontend/pages/profiles/show.tsx +0 -105
  210. data/app/frontend/pages/queries/index.tsx +0 -492
  211. data/app/frontend/pages/queries/show.tsx +0 -276
  212. data/app/frontend/pages/releases/index.tsx +0 -190
  213. data/app/frontend/pages/releases/release-health.tsx +0 -68
  214. data/app/frontend/pages/releases/release-path.ts +0 -25
  215. data/app/frontend/pages/releases/show.tsx +0 -262
  216. data/app/frontend/pages/requests/index.tsx +0 -235
  217. data/app/frontend/pages/requests/route.tsx +0 -159
  218. data/app/frontend/pages/scheduled_tasks/index.tsx +0 -220
  219. data/app/frontend/pages/spans/index.tsx +0 -229
  220. data/app/frontend/pages/spans/show.tsx +0 -220
  221. data/app/frontend/pages/storage_ops/index.tsx +0 -150
  222. data/app/frontend/pages/tenants/index.tsx +0 -236
  223. data/app/frontend/pages/tenants/show.tsx +0 -477
  224. data/app/frontend/pages/tenants/tenant-path.ts +0 -26
  225. data/app/frontend/pages/thresholds/index.tsx +0 -414
  226. data/app/frontend/pages/traces/show.tsx +0 -208
  227. data/app/frontend/pages/transactions/index.tsx +0 -220
  228. data/app/frontend/pages/view_renders/index.tsx +0 -241
  229. data/app/frontend/pages/visits/index.tsx +0 -321
  230. data/app/frontend/public/icon.png +0 -0
  231. data/app/frontend/public/icon.svg +0 -5
  232. data/app/frontend/routes/index.d.ts +0 -2136
  233. data/app/frontend/routes/index.js +0 -2112
  234. data/app/frontend/types/globals.d.ts +0 -9
  235. data/app/frontend/types/index.ts +0 -288
  236. data/app/frontend/types/vite-env.d.ts +0 -9
  237. data/app/helpers/railwatch/assets_helper.rb +0 -52
  238. data/app/jobs/railwatch/anomaly_scan_job.rb +0 -11
  239. data/app/jobs/railwatch/application_job.rb +0 -7
  240. data/app/jobs/railwatch/auto_resolve_issues_job.rb +0 -19
  241. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +0 -113
  242. data/app/jobs/railwatch/detect_anomalies_job.rb +0 -171
  243. data/app/jobs/railwatch/detect_performance_issues_job.rb +0 -95
  244. data/app/jobs/railwatch/group_exceptions_job.rb +0 -91
  245. data/app/jobs/railwatch/optimize_telemetry_job.rb +0 -23
  246. data/app/jobs/railwatch/performance_scan_job.rb +0 -11
  247. data/app/jobs/railwatch/prune_telemetry_job.rb +0 -129
  248. data/app/jobs/railwatch/release_health_rollup_job.rb +0 -64
  249. data/app/jobs/railwatch/rollup_catchup_job.rb +0 -21
  250. data/app/jobs/railwatch/rollup_job.rb +0 -132
  251. data/app/jobs/railwatch/scheduled_task_scan_job.rb +0 -11
  252. data/app/models/concerns/railwatch/detection_snapshotting.rb +0 -20
  253. data/app/models/railwatch/alert.rb +0 -229
  254. data/app/models/railwatch/alert_rule.rb +0 -121
  255. data/app/models/railwatch/anomaly_rule.rb +0 -51
  256. data/app/models/railwatch/application.rb +0 -44
  257. data/app/models/railwatch/application_record.rb +0 -33
  258. data/app/models/railwatch/comment.rb +0 -36
  259. data/app/models/railwatch/deploy.rb +0 -67
  260. data/app/models/railwatch/environment.rb +0 -68
  261. data/app/models/railwatch/execution_presenter.rb +0 -185
  262. data/app/models/railwatch/filter_query.rb +0 -184
  263. data/app/models/railwatch/followup_receipt.rb +0 -27
  264. data/app/models/railwatch/ingest/batch.rb +0 -355
  265. data/app/models/railwatch/ingest/mapper.rb +0 -585
  266. data/app/models/railwatch/ingest/payload.rb +0 -96
  267. data/app/models/railwatch/ingest/rollup_absorber.rb +0 -170
  268. data/app/models/railwatch/ingest/writer.rb +0 -137
  269. data/app/models/railwatch/issue.rb +0 -277
  270. data/app/models/railwatch/issue_activity.rb +0 -23
  271. data/app/models/railwatch/issue_detection_presenter.rb +0 -309
  272. data/app/models/railwatch/issue_detection_snapshot.rb +0 -77
  273. data/app/models/railwatch/maintenance_task.rb +0 -53
  274. data/app/models/railwatch/saved_view.rb +0 -63
  275. data/app/models/railwatch/telemetry/aggregations.rb +0 -251
  276. data/app/models/railwatch/telemetry/attachment.rb +0 -31
  277. data/app/models/railwatch/telemetry/bounded_gzip.rb +0 -101
  278. data/app/models/railwatch/telemetry/broadcast.rb +0 -13
  279. data/app/models/railwatch/telemetry/cache_event.rb +0 -16
  280. data/app/models/railwatch/telemetry/child.rb +0 -53
  281. data/app/models/railwatch/telemetry/cursor_page.rb +0 -99
  282. data/app/models/railwatch/telemetry/deprecation.rb +0 -13
  283. data/app/models/railwatch/telemetry/enqueued_job.rb +0 -13
  284. data/app/models/railwatch/telemetry/exception.rb +0 -28
  285. data/app/models/railwatch/telemetry/execution.rb +0 -152
  286. data/app/models/railwatch/telemetry/export_delivery.rb +0 -32
  287. data/app/models/railwatch/telemetry/export_destination.rb +0 -66
  288. data/app/models/railwatch/telemetry/health_sample.rb +0 -72
  289. data/app/models/railwatch/telemetry/ingest_batch.rb +0 -31
  290. data/app/models/railwatch/telemetry/llm_call.rb +0 -37
  291. data/app/models/railwatch/telemetry/log.rb +0 -91
  292. data/app/models/railwatch/telemetry/mail.rb +0 -13
  293. data/app/models/railwatch/telemetry/n_plus_one.rb +0 -92
  294. data/app/models/railwatch/telemetry/notification.rb +0 -13
  295. data/app/models/railwatch/telemetry/outgoing_request.rb +0 -13
  296. data/app/models/railwatch/telemetry/person.rb +0 -50
  297. data/app/models/railwatch/telemetry/process.rb +0 -10
  298. data/app/models/railwatch/telemetry/profile.rb +0 -37
  299. data/app/models/railwatch/telemetry/query.rb +0 -57
  300. data/app/models/railwatch/telemetry/query_shape.rb +0 -45
  301. data/app/models/railwatch/telemetry/release_health.rb +0 -69
  302. data/app/models/railwatch/telemetry/rollup.rb +0 -157
  303. data/app/models/railwatch/telemetry/session.rb +0 -24
  304. data/app/models/railwatch/telemetry/span.rb +0 -19
  305. data/app/models/railwatch/telemetry/storage_op.rb +0 -13
  306. data/app/models/railwatch/telemetry/tenant.rb +0 -240
  307. data/app/models/railwatch/telemetry/transaction.rb +0 -13
  308. data/app/models/railwatch/telemetry/view_render.rb +0 -13
  309. data/app/models/railwatch/telemetry/visit.rb +0 -24
  310. data/app/models/railwatch/telemetry_record.rb +0 -107
  311. data/app/models/railwatch/threshold.rb +0 -66
  312. data/app/models/railwatch/user.rb +0 -47
  313. data/app/models/railwatch/viewer.rb +0 -13
  314. data/app/models/railwatch/window.rb +0 -66
  315. data/app/views/layouts/railwatch/dashboard.html.erb +0 -25
  316. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +0 -151
  317. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +0 -18
  318. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +0 -20
  319. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +0 -71
  320. data/db/railwatch_telemetry_migrate/20260903000000_enable_incremental_vacuum.rb +0 -62
  321. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +0 -481
  322. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +0 -14
  323. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +0 -9
  324. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +0 -10
  325. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +0 -9
  326. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +0 -82
  327. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +0 -10
  328. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +0 -60
  329. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +0 -9
  330. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +0 -51
  331. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +0 -11
  332. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +0 -10
  333. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +0 -37
  334. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +0 -11
  335. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +0 -15
  336. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +0 -9
  337. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +0 -10
  338. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +0 -9
  339. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +0 -18
  340. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +0 -25
  341. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +0 -55
  342. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +0 -21
  343. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +0 -16
  344. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +0 -18
  345. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +0 -16
  346. data/db/railwatch_telemetry_migrate/20260919000100_create_export_queue.rb +0 -146
  347. data/db/railwatch_telemetry_migrate/20260922000000_add_covering_indexes_for_dashboard_aggregates.rb +0 -34
  348. data/db/railwatch_telemetry_migrate/20260923000000_add_people_index.rb +0 -15
  349. data/db/railwatch_telemetry_migrate/20260925000000_add_tenant_summary_index.rb +0 -17
  350. data/db/railwatch_telemetry_migrate/20261008000000_add_in_transaction_to_outgoing_requests.rb +0 -10
  351. data/docs/embedded.md +0 -503
  352. data/lib/puma/plugin/railwatch.rb +0 -215
  353. data/lib/railwatch/dashboard_assets.rb +0 -45
  354. data/lib/railwatch/embedded.rb +0 -50
  355. data/lib/railwatch/export/client.rb +0 -124
  356. data/lib/railwatch/export/lease.rb +0 -56
  357. data/lib/railwatch/export/outbox.rb +0 -361
  358. data/lib/railwatch/export/policy.rb +0 -79
  359. data/lib/railwatch/export/sender.rb +0 -130
  360. data/lib/railwatch/ingest_request_body_limit.rb +0 -10
  361. data/lib/railwatch/maintenance.rb +0 -194
  362. data/lib/railwatch/patches/migration_busy_timeout.rb +0 -73
  363. data/lib/railwatch/transport/local.rb +0 -78
  364. data/lib/railwatch/transport/socket.rb +0 -183
  365. data/lib/railwatch/writer.rb +0 -370
  366. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  367. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  368. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  369. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  370. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  371. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  372. data/public/railwatch/assets/app-layout-by7G3sa1.js +0 -41
  373. data/public/railwatch/assets/application-Cc5v_Vk9.css +0 -1
  374. data/public/railwatch/assets/arrow-up-DCZ7emux.js +0 -1
  375. data/public/railwatch/assets/braces-CykT5_5u.js +0 -1
  376. data/public/railwatch/assets/card-C-PPSA0g.js +0 -1
  377. data/public/railwatch/assets/chart-Cov79YC5.js +0 -39
  378. data/public/railwatch/assets/chart-hover-DdK4E-2h.js +0 -1
  379. data/public/railwatch/assets/chart-panel-BPaYRXJB.js +0 -1
  380. data/public/railwatch/assets/code-BYjC4YZ_.js +0 -1
  381. data/public/railwatch/assets/copy-id-LPfoj9cC.js +0 -1
  382. data/public/railwatch/assets/cursor-load-more-DSdhgpGl.js +0 -1
  383. data/public/railwatch/assets/data-table-CQ_YRWQs.js +0 -1
  384. data/public/railwatch/assets/env-layout-CdMWbUkm.js +0 -1
  385. data/public/railwatch/assets/execution-path-7MYt-Qbn.js +0 -1
  386. data/public/railwatch/assets/filter-bar-CrzpQ_vD.js +0 -1
  387. data/public/railwatch/assets/flamegraph-BIFEdTAg.js +0 -2
  388. data/public/railwatch/assets/format-B9SDkrWj.js +0 -1
  389. data/public/railwatch/assets/frames-C5t-x4f0.js +0 -1
  390. data/public/railwatch/assets/index--NQ7us-L.js +0 -1
  391. data/public/railwatch/assets/index-2kgYe_99.js +0 -1
  392. data/public/railwatch/assets/index-4YEPHxtO.js +0 -1
  393. data/public/railwatch/assets/index-AOCC1GDJ.js +0 -1
  394. data/public/railwatch/assets/index-BEo-u0eO.js +0 -1
  395. data/public/railwatch/assets/index-BJ1V3w2N.js +0 -1
  396. data/public/railwatch/assets/index-BPoHsBKb.js +0 -1
  397. data/public/railwatch/assets/index-BZzyGUM3.js +0 -1
  398. data/public/railwatch/assets/index-BgVq7yKL.js +0 -1
  399. data/public/railwatch/assets/index-BqFGEZvW.js +0 -1
  400. data/public/railwatch/assets/index-ByuHJ1CH.js +0 -1
  401. data/public/railwatch/assets/index-C2j50AL2.js +0 -1
  402. data/public/railwatch/assets/index-CLEdTkvW.js +0 -1
  403. data/public/railwatch/assets/index-CWgq8qBu.js +0 -1
  404. data/public/railwatch/assets/index-CXQ7_oVj.js +0 -2
  405. data/public/railwatch/assets/index-CmyYrjS2.js +0 -1
  406. data/public/railwatch/assets/index-Cn9_HjDo.js +0 -1
  407. data/public/railwatch/assets/index-CsMAm9UK.js +0 -1
  408. data/public/railwatch/assets/index-D0Kk_oGY.js +0 -1
  409. data/public/railwatch/assets/index-DC9ZptJh.js +0 -1
  410. data/public/railwatch/assets/index-DNzg7jmV.js +0 -1
  411. data/public/railwatch/assets/index-DQ8uJO5f.js +0 -1
  412. data/public/railwatch/assets/index-Deq14BxB.js +0 -1
  413. data/public/railwatch/assets/index-IaV3Blzp.js +0 -1
  414. data/public/railwatch/assets/index-TYNWcJQs.js +0 -1
  415. data/public/railwatch/assets/index-WonH2fcZ.js +0 -1
  416. data/public/railwatch/assets/index-WxWUaH_d.js +0 -1
  417. data/public/railwatch/assets/index-mUNrl4ky.js +0 -1
  418. data/public/railwatch/assets/inertia-DfNRARuA.js +0 -97
  419. data/public/railwatch/assets/json-viewer-DMsF5glp.js +0 -1
  420. data/public/railwatch/assets/klass-CmHYk5rz.js +0 -1
  421. data/public/railwatch/assets/origin-identity-1R3bqa3R.js +0 -1
  422. data/public/railwatch/assets/percentile-picker-73z5PAps.js +0 -1
  423. data/public/railwatch/assets/relative-time-Dsc_cCdX.js +0 -1
  424. data/public/railwatch/assets/release-health-BV5OaOWd.js +0 -1
  425. data/public/railwatch/assets/route-CWh58C7K.js +0 -1
  426. data/public/railwatch/assets/segmented-DB4zh-Am.js +0 -1
  427. data/public/railwatch/assets/select-DBKjFlOX.js +0 -1
  428. data/public/railwatch/assets/series-chart-BaiDG795.js +0 -1
  429. data/public/railwatch/assets/show-B4ICCFGh.js +0 -1
  430. data/public/railwatch/assets/show-B5lOU9Gv.js +0 -1
  431. data/public/railwatch/assets/show-BYPB74t4.js +0 -2
  432. data/public/railwatch/assets/show-C7A0m2M8.js +0 -1
  433. data/public/railwatch/assets/show-DT5X1zqE.js +0 -1
  434. data/public/railwatch/assets/show-D_NBdXsn.js +0 -2
  435. data/public/railwatch/assets/show-DgPCnRg8.js +0 -1
  436. data/public/railwatch/assets/show-Yl69HF0x.js +0 -1
  437. data/public/railwatch/assets/show-_QxuUQCm.js +0 -3
  438. data/public/railwatch/assets/show-g__jnN36.js +0 -1
  439. data/public/railwatch/assets/show-l4ztfjl9.js +0 -1
  440. data/public/railwatch/assets/sort-header-BcG8ulEH.js +0 -1
  441. data/public/railwatch/assets/source-link-B2183i2-.js +0 -1
  442. data/public/railwatch/assets/sparkline-cell-B47ipscB.js +0 -1
  443. data/public/railwatch/assets/stat-DT2hhsqS.js +0 -1
  444. data/public/railwatch/assets/status-badge-CmbRKlY7.js +0 -1
  445. data/public/railwatch/assets/tenant-path-C4rNGauE.js +0 -1
  446. data/public/railwatch/assets/use-clipboard-oTNeP4ax.js +0 -1
  447. data/public/railwatch/assets/use-live-CLZZj245.js +0 -1
  448. data/public/railwatch/icon.png +0 -0
  449. data/public/railwatch/icon.svg +0 -5
  450. data/public/railwatch/manifest.json +0 -1245
  451. data/public/railwatch/rails-vite.json +0 -1
  452. /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