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,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Evaluates every threshold for an environment against the last window of
5
- # rollups and opens performance issues. Runs on a schedule (recurring.yml).
6
- class DetectPerformanceIssuesJob < ApplicationJob
7
- include DetectionSnapshotting
8
-
9
- queue_as :default
10
-
11
- MAX_GROUPS_PER_RULE = 200
12
- MAX_ROLLUP_ROWS_PER_RULE = MAX_GROUPS_PER_RULE * 25
13
-
14
- TYPE_FOR = { "requests" => "request", "jobs" => "job_attempt", "commands" => "command", "queries" => "query",
15
- "scheduled_tasks" => "scheduled_task", "outgoing_requests" => "outgoing_request",
16
- "llm_calls" => "llm_call", "llm_tools" => "llm_tool" }.freeze
17
-
18
- def perform(environment)
19
- now = Time.current
20
- environment.thresholds.find_each do |threshold|
21
- from = now - threshold.window_minutes.minutes
22
- type = TYPE_FOR.fetch(threshold.target_kind)
23
- groups = environment.with_telemetry do
24
- scope = Telemetry::Rollup.for_type(type).between(from, now)
25
- scope = scope.where(name: threshold.target) unless threshold.target == "*"
26
- bounded_groups(scope, threshold)
27
- end
28
- groups.each do |group_hash, (name, summary)|
29
- value = value_for(threshold.metric, summary)
30
- next if value.nil? || value <= threshold.limit
31
- open_issue(environment, threshold, group_hash, name, value, from, now)
32
- end
33
- end
34
- end
35
-
36
- private
37
-
38
- def bounded_groups(scope, threshold)
39
- group_hashes = scope.reorder(nil).group(:group_hash).order(Arel.sql("SUM(count) DESC"), :group_hash)
40
- .limit(MAX_GROUPS_PER_RULE + 1).pluck(:group_hash)
41
- if group_hashes.length > MAX_GROUPS_PER_RULE
42
- Rails.logger.warn("threshold detector evaluated only the #{MAX_GROUPS_PER_RULE} busiest groups threshold_id=#{threshold.id}")
43
- group_hashes.pop
44
- end
45
- rows = scope.where(group_hash: group_hashes).order(:bucket, :group_hash).limit(MAX_ROLLUP_ROWS_PER_RULE + 1).to_a
46
- if rows.length > MAX_ROLLUP_ROWS_PER_RULE
47
- Rails.logger.error("threshold detector skipped threshold_id=#{threshold.id}: more than #{MAX_ROLLUP_ROWS_PER_RULE} rollups")
48
- return {}
49
- end
50
-
51
- rows.group_by(&:group_hash)
52
- .transform_values { |group_rows| [ group_rows.first.name, Telemetry::Rollup.summarize(group_rows) ] }
53
- end
54
-
55
- NANOS_PER_DOLLAR = 1_000_000_000.0
56
-
57
- def value_for(metric, s)
58
- extra = s[:extra] || {}
59
- case metric
60
- when "p95" then s[:p95] / 1000.0
61
- when "max" then s[:max] / 1000.0
62
- when "avg" then s[:avg] / 1000.0
63
- when "error_rate", "failure_rate" then s[:count].zero? ? nil : (s[:errors] * 100.0 / s[:count])
64
- # Money and tokens are sums over the window, not percentiles of it.
65
- when "spend" then extra["cost_nanos"].to_i / NANOS_PER_DOLLAR
66
- when "tokens" then extra["input_tokens"].to_i + extra["output_tokens"].to_i
67
- # A cut-off answer is a successful call, so it is invisible to
68
- # error_rate. This is the only way to be told about it.
69
- when "truncation_rate" then s[:count].zero? ? nil : (extra["truncated"].to_i * 100.0 / s[:count])
70
- end
71
- end
72
-
73
- def open_issue(environment, threshold, group_hash, name, value, from, now)
74
- issue, outcome = Issue.record_occurrence!(
75
- environment: environment, group_hash: "perf:#{threshold.id}:#{group_hash}", kind: "performance",
76
- title: "#{name} exceeded #{threshold.metric} #{threshold.format_value(threshold.limit)} (#{threshold.format_value(value.round(2))})",
77
- culprit: name, occurred_at: now, deploy: nil, user_ref: nil,
78
- sample: { threshold_id: threshold.id, value: value.round(2), metric: threshold.metric, limit: threshold.limit })
79
- carry_detection(issue, outcome) do
80
- IssueDetectionSnapshot.capture(
81
- environment: environment, kind: "performance", telemetry_type: TYPE_FOR.fetch(threshold.target_kind),
82
- telemetry_group_hash: group_hash, target: name, metric: threshold.metric, from: from, to: now,
83
- value: value.round(2), limit: threshold.limit,
84
- rule: { type: "threshold", id: threshold.id, target_kind: threshold.target_kind, target: threshold.target }
85
- )
86
- end
87
- return unless outcome == :new || outcome == :regressed
88
- payload = { issue_key: issue.key, title: issue.title, environment: environment.name }
89
- environment.application.alert_rules.where(event: "threshold").find_each do |rule|
90
- next unless rule.matches?(issue: issue, payload: payload)
91
- rule.fire!(event: "threshold", issue: issue, payload: payload)
92
- end
93
- end
94
- end
95
- end
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Turns newly ingested exception rows into Issues (create or bump), and
5
- # fires new / regressed alerts.
6
- class GroupExceptionsJob < ApplicationJob
7
- queue_as :default
8
-
9
- # A browser whose in-flight request died while kamal-proxy swapped
10
- # containers reports a network error a few seconds after the deploy
11
- # marker. That is the deploy working, not the app failing, and it opened
12
- # (then regressed) an issue on every deploy today. The exception rows are
13
- # still recorded; they just do not become an issue when they land inside
14
- # this window around a deploy. Same idea as CheckScheduledTasksJob's
15
- # DEPLOY_GRACE for missed runs.
16
- DEPLOY_GRACE = 3.minutes
17
- DEPLOY_NETWORK_ERRORS = %w[HttpNetworkError AxiosError InertiaException NetworkError TypeError:NetworkError].freeze
18
-
19
- # batch_id: the embedded ledger's id for the batch these rows came from.
20
- # With one, each group's occurrence count is committed together with a
21
- # FollowupReceipt for (batch, group), so running this again for the same
22
- # batch (a crash after the count but before the ledger was cleared, or
23
- # the inline drain racing the maintenance drain) counts nothing twice.
24
- # The hosted platform enqueues this once per HTTP batch and passes none.
25
- def perform(environment, exception_ids, batch_id: nil)
26
- rows = environment.with_telemetry { Telemetry::Exception.where(id: exception_ids).to_a }
27
- rows = rows.reject { |row| deploy_swap_noise?(environment, row) }
28
- rows.group_by(&:group_hash).each do |group_hash, group|
29
- issue = outcome = nil
30
- Issue.transaction do
31
- next if batch_id && !FollowupReceipt.claim!(batch_id: batch_id, group_hash: group_hash)
32
-
33
- latest = group.max_by(&:occurred_at)
34
- issue, outcome = Issue.record_occurrence!(
35
- environment: environment, group_hash: group_hash, kind: "exception",
36
- title: "#{latest.class_name}: #{latest.message.to_s.first(200)}",
37
- culprit: [ latest.file, latest.line ].compact.join(":").presence,
38
- occurred_at: latest.occurred_at, deploy: latest.deploy, user_ref: latest.user_ref, source: latest.source,
39
- sample: { exception_id: latest.id, handled: latest.handled, execution_id: latest.execution_id,
40
- execution_preview: latest.execution_preview, deploy: latest.deploy,
41
- fingerprint: latest.fingerprint, fingerprint_source: latest.fingerprint_source })
42
- issue.increment!(:occurrences, group.size - 1) if group.size > 1
43
- end
44
- next unless issue
45
-
46
- update_affected_users(environment, issue) if affected_users_due?(issue, outcome)
47
- alert(issue, outcome)
48
- end
49
- end
50
-
51
- # The affected-user count is a DISTINCT over every retained occurrence of
52
- # the group, so on a busy issue it grows with the retention window and
53
- # used to run once per batch per group. Once per window per issue, and
54
- # always for a new issue, keeps the number fresh at a bounded cost.
55
- AFFECTED_USERS_WINDOW = 5.minutes
56
- AFFECTED_USERS_AT = Concurrent::Map.new
57
-
58
- private
59
-
60
- def affected_users_due?(issue, outcome)
61
- now = Time.current
62
- return AFFECTED_USERS_AT[issue.id] = now if outcome == :new
63
-
64
- last = AFFECTED_USERS_AT[issue.id]
65
- return false if last && now - last < AFFECTED_USERS_WINDOW
66
-
67
- AFFECTED_USERS_AT[issue.id] = now
68
- end
69
-
70
- def deploy_swap_noise?(environment, row)
71
- return false unless row.source == "browser" && DEPLOY_NETWORK_ERRORS.include?(row.class_name)
72
-
73
- last_deploy_at = environment.deploys.maximum(:deployed_at) or return false
74
- row.occurred_at.between?(last_deploy_at - DEPLOY_GRACE, last_deploy_at + DEPLOY_GRACE)
75
- end
76
-
77
- def update_affected_users(environment, issue)
78
- count = environment.with_telemetry { Telemetry::Exception.where(group_hash: issue.group_hash).where.not(user_ref: nil).distinct.count(:user_ref) }
79
- issue.update_columns(affected_users: count)
80
- end
81
-
82
- def alert(issue, outcome)
83
- event = { new: "new_issue", regressed: "regressed_issue" }[outcome] or return
84
- payload = { issue_key: issue.key, title: issue.title, environment: issue.environment.name }
85
- issue.application.alert_rules.where(event: event).find_each do |rule|
86
- next unless rule.matches?(issue: issue, payload: payload)
87
- rule.fire!(event: event, issue: issue, payload: payload)
88
- end
89
- end
90
- end
91
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Refreshes SQLite's planner statistics for each environment's telemetry
5
- # database. Nothing else ever runs ANALYZE on these files, so the planner
6
- # has been choosing index shapes blind on tables that grow by hundreds of
7
- # thousands of rows a day. PRAGMA optimize only re-analyzes tables whose
8
- # stats look stale, so it is cheap to run daily; analysis_limit bounds the
9
- # rows it samples per index.
10
- class OptimizeTelemetryJob < ApplicationJob
11
- queue_as :maintenance
12
-
13
- def perform(environment = nil)
14
- return [ Environment.current ].each { |env| self.class.perform_later(env) } if environment.nil?
15
-
16
- environment.with_telemetry do
17
- connection = TelemetryRecord.connection
18
- connection.execute("PRAGMA analysis_limit = 1000")
19
- connection.execute("PRAGMA optimize")
20
- end
21
- end
22
- end
23
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- class PerformanceScanJob < ApplicationJob
5
- queue_as :default
6
-
7
- def perform
8
- [ Environment.current ].each { |env| DetectPerformanceIssuesJob.perform_later(env) }
9
- end
10
- end
11
- end
@@ -1,129 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Deletes raw rows past the account's retention window. Rollups are kept
5
- # for 13 months so long-range charts keep working after raw rows are gone.
6
- # Telemetry::Session is monitored-application telemetry; dashboard login
7
- # sessions live in the primary database and are deliberately not touched.
8
- class PruneTelemetryJob < ApplicationJob
9
- queue_as :maintenance
10
-
11
- AGGREGATE_RETENTION = 13.months
12
- # Sessions have never been pruned, so the first run of this job would walk
13
- # every hour an environment has ever recorded. Delete a bounded number of
14
- # hourly buckets per run and let the nightly schedule drain the backlog.
15
- SESSION_HOURS_PER_RUN = 48
16
- BATCH = 5_000
17
- # Per raw table per run. A backlog past this (a retention change, a
18
- # restore of an old file) drains over successive runs rather than in one
19
- # that holds SQLite's write lock for as long as it takes. In the embedded
20
- # writer that long hold would look like a wedge and end the process.
21
- MAX_BATCHES_PER_TABLE = 40
22
-
23
- # Reclaim, bounded the same way the deletes are. Deleting rows only moves
24
- # their pages to the freelist; PRAGMA incremental_vacuum is what hands
25
- # them back to the filesystem, and it is only possible at all on a
26
- # database in auto_vacuum=incremental (EnableIncrementalVacuum, or
27
- # `bin/rails railwatch:vacuum` for a database that predates it).
28
- # Measured at 80k-190k pages/s warm, so a full run is well under a second
29
- # there; the slicing is for the cold, large file, where each slice takes
30
- # and releases the write lock instead of holding it throughout.
31
- VACUUM_PAGES_PER_SLICE = 2_000
32
- # 50k pages, ~200MB at SQLite's 4KB default. A few thousand pages a night
33
- # would never keep up with a night's deletes on a busy app, and the file
34
- # would go on growing with the freelist; a backlog past this one still
35
- # drains over successive nightly runs.
36
- VACUUM_SLICES = 25
37
-
38
- RAW = [ Telemetry::Execution, Telemetry::Query, Telemetry::Exception, Telemetry::CacheEvent, Telemetry::Mail,
39
- Telemetry::Broadcast, Telemetry::Notification, Telemetry::OutgoingRequest, Telemetry::StorageOp,
40
- Telemetry::ViewRender, Telemetry::Log, Telemetry::EnqueuedJob, Telemetry::Transaction,
41
- Telemetry::NPlusOne, Telemetry::Deprecation, Telemetry::Visit, Telemetry::Span,
42
- Telemetry::LlmCall,
43
- Telemetry::Profile, Telemetry::Attachment ].freeze
44
-
45
- # checkpoint: the WAL checkpoint mode run at the end. TRUNCATE (the
46
- # default, for a dedicated worker) hands the space back to the filesystem
47
- # but blocks every reader and writer while it does; PASSIVE checkpoints
48
- # what it can without waiting on anyone, which is what a prune running
49
- # inside a Puma worker (Railwatch::Maintenance) must use.
50
- def perform(environment = nil, checkpoint: "TRUNCATE")
51
- return [ Environment.current ].each { |env| self.class.perform_later(env) } if environment.nil?
52
-
53
- cutoff = environment.retention_days.days.ago
54
- aggregate_cutoff = AGGREGATE_RETENTION.ago
55
- environment.with_telemetry do
56
- unindex_logs(cutoff) if Telemetry::Log.fts_available?
57
- RAW.each { |klass| prune(klass, cutoff) }
58
- # NOT EXISTS rather than NOT IN: queries.group_hash is nullable, and one
59
- # NULL in a NOT IN subquery makes it match nothing. A few hundred shapes.
60
- Telemetry::QueryShape.where("NOT EXISTS (SELECT 1 FROM queries WHERE queries.group_hash = query_shapes.group_hash)").delete_all
61
- prune_sessions(cutoff)
62
- Telemetry::Rollup.where(bucket: ...aggregate_cutoff).delete_all
63
- Telemetry::ReleaseHealth.where(bucket: ...aggregate_cutoff).in_batches(of: 5_000).delete_all
64
- Telemetry::Person.where(last_seen_at: ...cutoff).in_batches(of: 5_000).delete_all
65
- Telemetry::IngestBatch.where(received_at: ...cutoff).delete_all
66
- Telemetry::Process.where(booted_at: ...cutoff).delete_all
67
- Telemetry::HealthSample.where(sampled_at: ...cutoff).delete_all
68
- # Before the checkpoint, not after: incremental_vacuum truncates the
69
- # database file, and in WAL mode that truncation only reaches the file
70
- # on disk once it is checkpointed.
71
- TelemetryRecord.reclaim_freelist!(slice: VACUUM_PAGES_PER_SLICE, slices: VACUUM_SLICES)
72
- TelemetryRecord.connection.execute("PRAGMA wal_checkpoint(#{checkpoint})") if TelemetryRecord.sqlite?
73
- end
74
- end
75
-
76
- private
77
-
78
- # in_batches walks a table by primary key: its probe is "WHERE occurred_at
79
- # < ? ORDER BY id LIMIT n", which no index serves, so SQLite scanned the
80
- # whole table by rowid to find nothing to delete -- 42 seconds on the
81
- # platform's own 12M-row queries table, every night, for an empty result.
82
- # Ordered by occurred_at the same probe is one seek on any index led by
83
- # that column, and the first page of expired rows is exactly the oldest
84
- # ones. Tables without such an index still scan, but they are the small
85
- # ones.
86
- def prune(klass, cutoff)
87
- MAX_BATCHES_PER_TABLE.times do
88
- # Ordered by (occurred_at, id), not occurred_at alone: rows that tie
89
- # on the timestamp at the limit boundary would otherwise be a
90
- # different set here than in unindex_logs above, which leaves stale
91
- # FTS postings behind and withdraws postings for logs that are staying.
92
- ids = klass.where(occurred_at: ...cutoff).order(:occurred_at, :id).limit(BATCH).pluck(:id)
93
- break if ids.empty?
94
- klass.where(id: ids).delete_all
95
- break if ids.size < BATCH
96
- end
97
- end
98
-
99
- # Sessions are the raw input to the hourly release_health aggregate, so they
100
- # are deleted a whole hour at a time. Flooring the cutoff to the start of its
101
- # hour leaves the partial hour straddling the boundary in place: deleting
102
- # only its expired prefix would let the next rollup rebuild that hour from
103
- # the surviving tail and silently shrink an already correct aggregate.
104
- def prune_sessions(cutoff)
105
- expired = Telemetry::Session.where(occurred_at: ...cutoff.utc.beginning_of_hour)
106
- SESSION_HOURS_PER_RUN.times do
107
- oldest = expired.minimum(:occurred_at)
108
- break if oldest.nil?
109
-
110
- hour = oldest.utc.beginning_of_hour
111
- expired.where(occurred_at: hour...(hour + 1.hour)).in_batches(of: 5_000).delete_all
112
- end
113
- end
114
-
115
- # logs_fts is an external-content index with no triggers, so the postings
116
- # for a log line have to be withdrawn while its row (and message) is still
117
- # there. Deleting the rows first would leave the index permanently out of
118
- # sync -- searches would keep returning rowids that no longer exist.
119
- # Bounded the same way as the rows it precedes: the postings for at most
120
- # MAX_BATCHES_PER_TABLE * BATCH expired lines are withdrawn per run, which
121
- # is exactly the set prune(Telemetry::Log) will delete this run.
122
- def unindex_logs(cutoff)
123
- Telemetry::Log.connection.execute(Telemetry::Log.sanitize_sql_array([
124
- "INSERT INTO logs_fts(logs_fts, rowid, message) SELECT 'delete', id, message FROM logs " \
125
- "WHERE occurred_at < ? ORDER BY occurred_at, id LIMIT ?", cutoff, MAX_BATCHES_PER_TABLE * BATCH
126
- ]))
127
- end
128
- end
129
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Recomputes one hourly release_health bucket from raw `sessions` rows.
5
- # Idempotent, and debounced by the same Solid Queue concurrency control
6
- # RollupJob uses: while one recompute of a bucket is queued or running,
7
- # further enqueues for it are discarded rather than piling up.
8
- #
9
- # The gem re-sends a live session every flush interval, so the same
10
- # session_id lands in a bucket many times. Each id collapses to the worst
11
- # status it reached (crashed beats errored beats ok beats started) and its
12
- # longest reported duration, which is what makes "sessions" a session count
13
- # rather than a record count.
14
- class ReleaseHealthRollupJob < ApplicationJob
15
- queue_as :rollups
16
- limits_concurrency to: 1, key: ->(environment, bucket) { "release_health:#{environment.id}:#{bucket.to_i}" }, duration: 10.minutes, on_conflict: :discard
17
-
18
- RANKS = { "started" => 0, "ok" => 1, "errored" => 2, "crashed" => 3 }.freeze
19
- CRASHED = RANKS["crashed"]
20
- ERRORED = RANKS["errored"]
21
-
22
- def perform(environment, bucket)
23
- bucket = bucket.utc.beginning_of_hour
24
- # PruneTelemetryJob deletes raw sessions in whole hours below this floor.
25
- # A rollup enqueued before the prune can run after it, and rebuilding a
26
- # pruned hour would replace a complete aggregate with an empty one.
27
- return if bucket < environment.retention_days.days.ago.utc.beginning_of_hour
28
-
29
- environment.with_telemetry do
30
- rows = Telemetry::Session.where(occurred_at: bucket...(bucket + 1.hour))
31
- .where.not(deploy: nil).pluck(:deploy, :session_id, :status, :duration, :user_ref)
32
- aggregates = rows.group_by(&:first).map { |deploy, group| aggregate(deploy, bucket, collapse(group)) }
33
- Telemetry::ReleaseHealth.transaction do
34
- Telemetry::ReleaseHealth.where(bucket: bucket).delete_all
35
- Telemetry::ReleaseHealth.insert_all(aggregates) if aggregates.any?
36
- end
37
- end
38
- end
39
-
40
- private
41
-
42
- # session_id => the worst status, longest duration, and first user seen for
43
- # it in this bucket.
44
- def collapse(rows)
45
- rows.each_with_object({}) do |(_deploy, session_id, status, duration, user_ref), sessions|
46
- session = sessions[session_id] ||= { rank: 0, duration: nil, user: nil }
47
- session[:rank] = [ session[:rank], RANKS.fetch(status.to_s, 0) ].max
48
- session[:duration] = [ session[:duration] || 0, duration ].max if duration
49
- session[:user] ||= user_ref
50
- end
51
- end
52
-
53
- def aggregate(deploy, bucket, sessions)
54
- durations = sessions.values.filter_map { |s| s[:duration] }
55
- users = sessions.values.filter_map { |s| s[:user] }.uniq
56
- crashed_users = sessions.values.select { |s| s[:rank] == CRASHED }.filter_map { |s| s[:user] }.uniq
57
- { deploy: deploy, bucket: bucket, sessions: sessions.size,
58
- sessions_errored: sessions.values.count { |s| s[:rank] == ERRORED },
59
- sessions_crashed: sessions.values.count { |s| s[:rank] == CRASHED },
60
- users: users.size, users_crashed: crashed_users.size,
61
- duration_sum: durations.sum, duration_count: durations.size }
62
- end
63
- end
64
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Recomputes the current and previous hour so rollups are never more than a
5
- # schedule tick stale, whatever happened to the per-batch RollupJob enqueues
6
- # (debounced in the web process, and their concurrency semaphore can outlive
7
- # a worker restart). The platform iterates every active environment; an
8
- # embedded install has one.
9
- class RollupCatchupJob < ApplicationJob
10
- queue_as :rollups
11
-
12
- def perform
13
- env = Environment.current
14
- now = Time.current
15
- [ now.beginning_of_hour, (now - 1.hour).beginning_of_hour ].each do |bucket|
16
- RollupJob.perform_now(env, bucket)
17
- ReleaseHealthRollupJob.perform_now(env, bucket)
18
- end
19
- end
20
- end
21
- end
@@ -1,132 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Recomputes the hourly rollups for one bucket from raw rows. Idempotent:
5
- # runs again whenever new rows land in that hour. Debounced twice: Ingest::
6
- # Batch enqueues at most one run per bucket per minute, and Solid Queue's
7
- # concurrency control discards an enqueue while a run for that bucket is
8
- # already queued or running (the default :block would let a busy hour pile
9
- # up thousands of identical jobs). RollupCatchupJob sweeps the current and
10
- # previous hour on its recurring schedule for anything either debounce
11
- # dropped; an embedded install instead runs this once an hour for the
12
- # previous hour from Railwatch::Maintenance, since its batches already
13
- # fold themselves into the current hour as they land.
14
- class RollupJob < ApplicationJob
15
- queue_as :rollups
16
- limits_concurrency to: 1, key: ->(environment, bucket) { "#{environment.id}:#{bucket.to_i}" }, duration: 10.minutes, on_conflict: :discard
17
-
18
- SOURCES = {
19
- "request" => [ Telemetry::Execution, ->(r) { r.requests }, ->(row) { row.name } ],
20
- "job_attempt" => [ Telemetry::Execution, ->(r) { r.jobs }, ->(row) { row.name } ],
21
- "scheduled_task" => [ Telemetry::Execution, ->(r) { r.scheduled }, ->(row) { row.name } ],
22
- "command" => [ Telemetry::Execution, ->(r) { r.commands }, ->(row) { row.name } ],
23
- "channel_action" => [ Telemetry::Execution, ->(r) { r.channels }, ->(row) { row.name } ],
24
- "query" => [ Telemetry::Query, ->(r) { r }, ->(row) { row.sql.first(255) } ],
25
- "outgoing_request" => [ Telemetry::OutgoingRequest, ->(r) { r }, ->(row) { "#{row.method} #{row.host}" } ],
26
- "cache_event" => [ Telemetry::CacheEvent, ->(r) { r }, ->(row) { "#{row.store} #{row.key}" } ],
27
- "mail" => [ Telemetry::Mail, ->(r) { r }, ->(row) { row.mailer } ],
28
- "visit" => [ Telemetry::Visit, ->(r) { r }, ->(row) { row.component } ],
29
- "span" => [ Telemetry::Span, ->(r) { r }, ->(row) { row.name } ],
30
- "notification" => [ Telemetry::Notification, ->(r) { r }, ->(row) { row.notifier || row.delivery_method } ],
31
- "view_render" => [ Telemetry::ViewRender, ->(r) { r }, ->(row) { row.identifier } ],
32
- "transaction" => [ Telemetry::Transaction, ->(r) { r }, ->(row) { "#{row.connection} · #{row.outcome}" } ],
33
- "llm_call" => [ Telemetry::LlmCall, ->(r) { r.models }, ->(row) { "#{row.model} · #{row.operation}" } ],
34
- "llm_tool" => [ Telemetry::LlmCall, ->(r) { r.tools }, ->(row) { row.tool_name.to_s } ]
35
- }.freeze
36
-
37
- # SQLite binds at most 32,766 variables per statement; a rollup row is 14.
38
- INSERT_SLICE = 500
39
-
40
- def perform(environment, bucket)
41
- bucket = bucket.utc.beginning_of_hour
42
- range = bucket...(bucket + 1.hour)
43
- # Recomputing a busy hour is thousands of queries; recording each of
44
- # them into Railwatch's own telemetry was 70% of everything this platform
45
- # ingested about itself. The job record still ships with its duration.
46
- environment.with_telemetry do
47
- Railwatch.ignore { recompute(environment, bucket, range) }
48
- end
49
- end
50
-
51
- private
52
-
53
- # Every group of one type is written in one transaction: the hour's
54
- # rollups for that type replace each other atomically, and the write lock
55
- # is held only for the writes. Rails opens each SQLite transaction
56
- # IMMEDIATE, so reading the hour inside it would hold every other writer
57
- # off the tenant file for the whole read -- a busy hour is tens of
58
- # seconds, and ingest gets five before it gives up. On 2026-09-05 one
59
- # 37-second recompute failed every batch that arrived while it ran.
60
- def recompute(environment, bucket, range)
61
- SOURCES.each do |type, (klass, scope, namer)|
62
- Railwatch.span("rollup.#{type}", bucket: bucket.iso8601) do
63
- rows = scope.call(klass).where(occurred_at: range).select(:id, :group_hash, :duration, *(klass == Telemetry::Execution ? %i[name status outcome kind] : %i[]), *(klass == Telemetry::Query ? [ Telemetry::Query::SQL ] : []), *(klass == Telemetry::OutgoingRequest ? %i[method host status_code] : []), *(klass == Telemetry::CacheEvent ? %i[store key type] : []), *(klass == Telemetry::Mail ? %i[mailer failed] : []), *(klass == Telemetry::Visit ? %i[component status] : []), *(klass == Telemetry::Notification ? %i[notifier delivery_method failed] : []), *(klass == Telemetry::ViewRender ? %i[identifier kind] : []), *(klass == Telemetry::Transaction ? %i[outcome connection] : []), *(klass == Telemetry::Span ? %i[name status] : []), *(klass == Telemetry::LlmCall ? %i[operation model tool_name status input_tokens output_tokens cache_read_tokens cache_write_tokens cost_nanos finish_reason attachments] : []))
64
- groups = rows.group_by(&:group_hash).except(nil)
65
- next if groups.empty?
66
- rows = groups.map do |group_hash, group|
67
- Telemetry::Rollup.fresh_attributes(
68
- record_type: type, group_hash: group_hash, name: namer.call(group.first).to_s, bucket: bucket,
69
- durations: group.map(&:duration),
70
- errors: group.count { |r| error?(type, r) },
71
- client_errors: group.count { |r| type == "request" && r.status.to_i.between?(400, 499) },
72
- extra: extra_for(type, group))
73
- end
74
- # One DELETE and a few INSERTs per type instead of a SELECT, a DELETE
75
- # and an INSERT per group: a busy hour of queries is 2,000 groups,
76
- # and this job was 20,000 of the platform's own N+1 records a week.
77
- Telemetry::Rollup.transaction do
78
- # The hour was read outside this transaction (above), so a batch
79
- # that landed in between has already been folded into the row
80
- # about to be replaced (Ingest::RollupAbsorber). A stored count
81
- # higher than the recomputed one means exactly that: keep the
82
- # stored row rather than overwrite it with a snapshot that
83
- # predates the batch. The next run picks the group up.
84
- stored = Telemetry::Rollup.where(record_type: type, group_hash: groups.keys, bucket: bucket).pluck(:group_hash, :count).to_h
85
- rows = rows.reject { |row| stored[row[:group_hash]].to_i > row[:count] }
86
- next if rows.empty?
87
-
88
- Telemetry::Rollup.where(record_type: type, group_hash: rows.map { |row| row[:group_hash] }, bucket: bucket).delete_all
89
- rows.each_slice(INSERT_SLICE) { |slice| Telemetry::Rollup.insert_all(slice) }
90
- end
91
- end
92
- end
93
- end
94
-
95
- def error?(type, row)
96
- case type
97
- when "request" then row.status.to_i >= 500
98
- when "job_attempt", "scheduled_task", "channel_action" then row.outcome == "failed"
99
- when "command" then row.status.to_i != 0
100
- when "outgoing_request" then row.status_code.to_i >= 500 || row.status_code.to_i.zero?
101
- when "mail" then row.failed
102
- when "visit" then row.status == "error"
103
- when "notification" then row.failed
104
- when "transaction" then row.outcome == "rollback"
105
- when "span", "llm_call", "llm_tool" then row.status == "failed"
106
- else false
107
- end
108
- end
109
-
110
- def extra_for(type, group)
111
- case type
112
- when "cache_event"
113
- { hits: group.count { |r| r.type == "hit" }, misses: group.count { |r| r.type == "miss" || r.type == "generate" } }
114
- when "view_render"
115
- { kind: group.group_by(&:kind).max_by { |_kind, rows| rows.size }&.first }
116
- when "llm_call"
117
- { input_tokens: group.sum { |r| r.input_tokens.to_i }, output_tokens: group.sum { |r| r.output_tokens.to_i },
118
- cache_read_tokens: group.sum { |r| r.cache_read_tokens.to_i }, cache_write_tokens: group.sum { |r| r.cache_write_tokens.to_i },
119
- cost_nanos: group.sum { |r| r.cost_nanos.to_i },
120
- priced: group.count { |r| r.cost_nanos },
121
- # A call that failed before the provider answered used no tokens,
122
- # so there is nothing to price; only an answered call can be unpriced.
123
- unpriced: group.count { |r| r.cost_nanos.nil? && r.status != "failed" },
124
- # A cut-off answer is not an error and will never show in the error
125
- # rate, so it needs counting on its own or it stays invisible.
126
- truncated: group.count { |r| r.finish_reason == "max_tokens" },
127
- with_attachments: group.count { |r| r.attachments.to_i.positive? } }
128
- else {}
129
- end
130
- end
131
- end
132
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- class ScheduledTaskScanJob < ApplicationJob
5
- queue_as :default
6
-
7
- def perform
8
- [ Environment.current ].each { |env| CheckScheduledTasksJob.perform_later(env) }
9
- end
10
- end
11
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- # Issue.record_occurrence! replaces `sample` wholesale on every breach, but the
5
- # typed detector snapshot is durable context, not the latest reading. Capturing
6
- # it costs a telemetry query per detector run, so re-capture only when the
7
- # issue is newly open and otherwise carry the stored snapshot forward.
8
- module DetectionSnapshotting
9
- private
10
-
11
- def carry_detection(issue, outcome)
12
- detection = if outcome == :new || outcome == :regressed
13
- yield
14
- else
15
- issue.sample_previously_was&.dig("detection")
16
- end
17
- issue.update!(sample: issue.sample.merge("detection" => detection)) if detection
18
- end
19
- end
20
- end