railwatch 0.9.3 → 1.0.0

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