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,251 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # Pure per-environment rollup aggregation: time-bucketed series, grouped
6
- # tables, and window-over-window summaries. Takes explicit from/to rather
7
- # than reading a controller's window params, so it's the same math for the
8
- # web dashboard (EnvironmentScoped) and the public API (Api::V1).
9
- class Aggregations
10
- GROUPED_SORT_FIELDS = %i[count p50 p95 p99 errors avg max].freeze
11
-
12
- # Bucket widths a chart can be drawn at. Anything under an hour is read
13
- # from the raw tables (rollups are hourly); an hour and up groups rollups.
14
- STEPS = { "1m" => 1.minute, "5m" => 5.minutes, "15m" => 15.minutes, "1h" => 1.hour, "6h" => 6.hours, "1d" => 1.day }.freeze
15
- # A step is offered for a window when it draws at least two points and at
16
- # most this many: more bars than pixels is mush, and a sub-hour step over
17
- # a long window is also a long raw scan.
18
- MAX_POINTS = 400
19
-
20
- # The bucket a window is drawn at unless the page asks for another one.
21
- # Sub-hour steps scan raw rows, so they are the default only where that
22
- # scan is a few thousand rows; a day and beyond stays on rollups.
23
- def self.default_step(from, to)
24
- span = to - from
25
- if span <= 2.hours then "1m"
26
- elsif span <= 12.hours then "5m"
27
- elsif span <= 7.days then "1h"
28
- else "6h"
29
- end
30
- end
31
-
32
- # STEPS keys that make sense for the window, finest first.
33
- def self.steps_for(from, to)
34
- span = to - from
35
- STEPS.select { |_key, step| (span / step).between?(2, MAX_POINTS) }.keys
36
- end
37
-
38
- # Time-bucketed series for charts, one point per `step` from `from` to `to`
39
- # with empty buckets filled in: [{t, count, errors, client_errors, avg,
40
- # p50, p95, p99}]. Durations in milliseconds; an empty bucket carries nil
41
- # for them so a latency line breaks instead of dropping to zero.
42
- def self.series(environment, record_type, from:, to:, group_hash: nil, step: nil, tenant: nil)
43
- environment.with_telemetry { points(record_type, from: from, to: to, group_hash: group_hash, step: step, tenant: tenant) }
44
- end
45
-
46
- # `series` for a caller already inside environment.with_telemetry. A
47
- # tenant narrows to one app_tenant, which rollups do not carry, so a
48
- # tenant series reads raw rows at every step.
49
- def self.points(record_type, from:, to:, group_hash: nil, step: nil, tenant: nil)
50
- step = STEPS.fetch(step || default_step(from, to))
51
- raw = step < 1.hour || tenant
52
- points = raw ? raw_series(record_type, from, to, group_hash, step, tenant) : rollup_series(record_type, from, to, group_hash, step)
53
- fill(points, from, to, step)
54
- end
55
-
56
- # Sums hourly rollups into `step`-wide buckets. Percentiles are the
57
- # worst hour's, the same reading series always gave across groups.
58
- def self.rollup_series(record_type, from, to, group_hash, step)
59
- bucket = bucket_sql("bucket", step)
60
- scope = Telemetry::Rollup.for_type(record_type).between(from, to)
61
- scope = scope.where(group_hash: group_hash) if group_hash
62
- scope.group(bucket).order(bucket)
63
- .pluck(bucket, Arel.sql("SUM(count)"), Arel.sql("SUM(error_count)"), Arel.sql("SUM(client_error_count)"), Arel.sql("SUM(duration_sum)"), Arel.sql("MAX(p50)"), Arel.sql("MAX(p95)"), Arel.sql("MAX(p99)"))
64
- .map { |b, c, e, ce, ds, p50, p95, p99| point(b, c, e, ce, ds, p50, p95, p99) }
65
- end
66
-
67
- # What a raw row of each record type is, and when it counts as an error
68
- # (or a client error), mirroring RollupJob#error?. Both readings of the
69
- # same rows must agree, which spec/models/telemetry/aggregations_spec.rb
70
- # checks for every type.
71
- RAW = {
72
- "request" => [ -> { Telemetry::Execution.requests }, "status >= 500", "status BETWEEN 400 AND 499" ],
73
- "job_attempt" => [ -> { Telemetry::Execution.jobs }, "outcome = 'failed'" ],
74
- "scheduled_task" => [ -> { Telemetry::Execution.scheduled }, "outcome = 'failed'" ],
75
- "command" => [ -> { Telemetry::Execution.commands }, "status != 0" ],
76
- "channel_action" => [ -> { Telemetry::Execution.channels }, "outcome = 'failed'" ],
77
- "query" => [ -> { Telemetry::Query.all } ],
78
- "outgoing_request" => [ -> { Telemetry::OutgoingRequest.all }, "status_code >= 500 OR status_code IS NULL OR status_code = 0" ],
79
- "cache_event" => [ -> { Telemetry::CacheEvent.all } ],
80
- "mail" => [ -> { Telemetry::Mail.all }, "failed" ],
81
- "visit" => [ -> { Telemetry::Visit.all }, "status = 'error'" ],
82
- "span" => [ -> { Telemetry::Span.all }, "status = 'failed'" ],
83
- "notification" => [ -> { Telemetry::Notification.all }, "failed" ],
84
- "view_render" => [ -> { Telemetry::ViewRender.all } ],
85
- "transaction" => [ -> { Telemetry::Transaction.all }, "outcome = 'rollback'" ],
86
- "llm_call" => [ -> { Telemetry::LlmCall.models }, "status = 'failed'" ],
87
- "llm_tool" => [ -> { Telemetry::LlmCall.tools }, "status = 'failed'" ]
88
- }.freeze
89
-
90
- # Sub-hour buckets straight from the raw rows, in one statement:
91
- # counts and sums per bucket, and exact nearest-rank percentiles from a
92
- # ROW_NUMBER over each bucket's durations. Rollups are hourly, so this
93
- # is the only reading finer than an hour; it is also the freshest one,
94
- # since the current hour's rollup is up to a minute behind.
95
- def self.raw_series(record_type, from, to, group_hash, step, tenant = nil)
96
- scope, error_sql, client_error_sql = RAW.fetch(record_type)
97
- rows = scope.call.where(occurred_at: from..to)
98
- rows = rows.where(group_hash: group_hash) if group_hash
99
- rows = rows.where(app_tenant: tenant) if tenant
100
- model = rows.model
101
- inner = rows.select(bucket_sql("occurred_at", step).to_s + " AS b", "duration", flag_sql(error_sql) + " AS err", flag_sql(client_error_sql) + " AS cerr")
102
- ranked = model.unscoped.from(inner, "r").select("b", "duration", "err", "cerr",
103
- "ROW_NUMBER() OVER (PARTITION BY b ORDER BY duration) AS rn", "COUNT(*) OVER (PARTITION BY b) AS n")
104
- model.unscoped.from(ranked, "w").group("b").order("b")
105
- .pluck(Arel.sql("b"), Arel.sql("COUNT(*)"), Arel.sql("SUM(err)"), Arel.sql("SUM(cerr)"), Arel.sql("SUM(duration)"),
106
- rank_sql(50), rank_sql(95), rank_sql(99))
107
- .map { |b, c, e, ce, ds, p50, p95, p99| point(b, c, e, ce, ds, p50, p95, p99) }
108
- end
109
-
110
- # The timestamp columns a series buckets on: raw rows by occurred_at,
111
- # rollups and release health by their hourly bucket.
112
- BUCKET_COLUMNS = %w[occurred_at bucket].freeze
113
-
114
- # Epoch seconds of the start of the `step`-wide bucket holding `column`.
115
- # `column` must be one of BUCKET_COLUMNS and `step` is a Duration, so the
116
- # fragment can only ever hold a listed column name and an integer literal.
117
- def self.bucket_sql(column, step)
118
- raise ArgumentError, "unknown bucket column #{column.inspect}" unless BUCKET_COLUMNS.include?(column)
119
- seconds = Integer(step.to_i)
120
- Arel.sql("(strftime('%s', #{column}) / #{seconds}) * #{seconds}")
121
- end
122
-
123
- # One point per bucket from `from` to `to`, keeping the computed ones and
124
- # zero-filling the rest, so a quiet minute is a gap of the right width.
125
- # A series with its own point shape passes a block building its empty one.
126
- def self.fill(points, from, to, step)
127
- by_bucket = points.index_by { |p| p[:t] }
128
- first = Time.at((from.to_i / step.to_i) * step.to_i).utc
129
- last = Time.at((to.to_i / step.to_i) * step.to_i).utc
130
- (first.to_i..last.to_i).step(step.to_i).map do |t|
131
- at = Time.at(t).utc
132
- by_bucket[at] || (block_given? ? yield(at) : empty_point(at))
133
- end
134
- end
135
-
136
- def self.point(bucket, count, errors, client_errors, duration_sum, p50, p95, p99)
137
- { t: Time.at(bucket).utc, count: count, errors: errors, client_errors: client_errors,
138
- avg: count.zero? ? 0 : duration_sum / count / 1000.0, p50: p50 / 1000.0, p95: p95 / 1000.0, p99: p99 / 1000.0 }
139
- end
140
-
141
- def self.empty_point(t)
142
- { t: t, count: 0, errors: 0, client_errors: 0, avg: nil, p50: nil, p95: nil, p99: nil }
143
- end
144
-
145
- def self.flag_sql(predicate)
146
- predicate ? "CASE WHEN #{predicate} THEN 1 ELSE 0 END" : "0"
147
- end
148
-
149
- # The nearest-rank percentile: the duration whose rank is ceil(n * p/100).
150
- RANK_SQL = { 50 => Arel.sql("MAX(CASE WHEN rn = (n * 50 + 99) / 100 THEN duration END)"),
151
- 95 => Arel.sql("MAX(CASE WHEN rn = (n * 95 + 99) / 100 THEN duration END)"),
152
- 99 => Arel.sql("MAX(CASE WHEN rn = (n * 99 + 99) / 100 THEN duration END)") }.freeze
153
-
154
- def self.rank_sql(percent)
155
- RANK_SQL.fetch(percent)
156
- end
157
-
158
- private_class_method :rollup_series, :raw_series, :point, :empty_point, :flag_sql, :rank_sql
159
-
160
- # Per-group table rows for a record type in the window: count/error totals,
161
- # merged percentiles (via Telemetry::Rollup.summarize), and a sparkline.
162
- def self.grouped(environment, record_type, from:, to:, limit: 100, order: nil, dir: nil)
163
- order = GROUPED_SORT_FIELDS.include?(order.to_s.to_sym) ? order.to_s.to_sym : :count
164
- sign = dir.to_s == "asc" ? 1 : -1
165
- # Same minute cache as summary_with_delta, for the same reason: the
166
- # 200 busiest query groups on the rebulk environment span 1,500 rollup
167
- # rows and 55,000 centroids, and merging those digests is 480ms that
168
- # only changes when RollupJob writes.
169
- key = [ "aggregations", "grouped", environment.id, record_type, limit, order, sign, from.to_i / 60, to.to_i / 60 ]
170
- cached(key) { grouped_uncached(environment, record_type, from: from, to: to, limit: limit, order: order, sign: sign) }
171
- end
172
-
173
- # The minute cache exists because the platform's rollups only change
174
- # when RollupJob writes. Embedded, every batch updates them and one
175
- # person is looking, so the cache would only make the page a minute
176
- # stale for nothing.
177
- def self.cached(key, &block)
178
- return yield if Railwatch.config.local?
179
- Rails.cache.fetch(key, expires_in: 1.minute, &block)
180
- end
181
-
182
- def self.grouped_uncached(environment, record_type, from:, to:, limit:, order:, sign:)
183
- environment.with_telemetry do
184
- rows = Telemetry::Rollup.for_type(record_type).between(from, to).to_a
185
- groups = rows.group_by(&:group_hash)
186
- # Merging t-digests is the expensive part (the queries page has 2,000
187
- # groups over 8,500 rows: 500ms of digest merges for a table that
188
- # shows 200). Sort on what the columns already hold -- counts, sums
189
- # and maxima need no digest -- and only merge percentiles for the
190
- # groups that make the cut. A percentile sort falls back to the full
191
- # merge, since the ranking itself needs the digest.
192
- cheap = %i[count errors avg max].include?(order)
193
- ranked = groups.map { |group_hash, group_rows| [ group_hash, group_rows, cheap ? cheap_summary(group_rows) : Telemetry::Rollup.summarize(group_rows) ] }
194
- ranked.sort_by! { |_, _, summary| summary[order] * sign }
195
- ranked.first(limit).map do |group_hash, group_rows, summary|
196
- summary = Telemetry::Rollup.summarize(group_rows) if cheap
197
- {
198
- group_hash: group_hash, name: group_rows.max_by(&:bucket).name,
199
- count: summary[:count], errors: summary[:errors], client_errors: summary[:client_errors],
200
- avg: (summary[:avg] / 1000.0).round(2), p50: (summary[:p50] / 1000.0).round(2),
201
- p95: (summary[:p95] / 1000.0).round(2), p99: (summary[:p99] / 1000.0).round(2),
202
- max: (summary[:max] / 1000.0).round(2), sparkline: sparkline(group_rows, from, to)
203
- }
204
- end
205
- end
206
- end
207
-
208
- # The digest-free half of Rollup.summarize: everything the sort keys
209
- # that are not percentiles need.
210
- def self.cheap_summary(rows)
211
- count = rows.sum(&:count)
212
- { count: count, errors: rows.sum(&:error_count),
213
- avg: count.zero? ? 0 : (rows.sum(&:duration_sum) / count), max: rows.map(&:duration_max).max }
214
- end
215
-
216
- # { current: Summary, previous: Summary } so pages/endpoints can show deltas.
217
- # Cached for a minute per window. The two totals merge every rollup
218
- # digest in both windows (18,000 rows, 355ms on the rebulk environment's
219
- # queries page) to produce four numbers that only move when RollupJob
220
- # writes, which is at most once a minute per bucket; without this the
221
- # merge ran again on every page load.
222
- def self.summary_with_delta(environment, record_type, from:, to:, previous_from:, previous_to:, group_hash: nil)
223
- key = [ "aggregations", "summary_with_delta", environment.id, record_type, group_hash,
224
- from.to_i / 60, to.to_i / 60, previous_from.to_i / 60, previous_to.to_i / 60 ]
225
- cached(key) do
226
- environment.with_telemetry do
227
- current = Telemetry::Rollup.for_type(record_type).between(from, to)
228
- previous = Telemetry::Rollup.for_type(record_type).between(previous_from, previous_to)
229
- if group_hash
230
- current = current.where(group_hash: group_hash)
231
- previous = previous.where(group_hash: group_hash)
232
- end
233
- { current: Telemetry::Rollup.summarize(current), previous: Telemetry::Rollup.summarize(previous) }
234
- end
235
- end
236
- end
237
-
238
- # Hourly counts for a set of same-window rollup rows, zero-filled, coarsened
239
- # to at most 30 points for wide windows (7d/30d).
240
- def self.sparkline(rows, from, to)
241
- hours = [ ((to - from) / 1.hour).ceil, 1 ].max
242
- coarsen = (hours / 30.0).ceil
243
- base = from.beginning_of_hour
244
- buckets = Hash.new(0)
245
- rows.each { |r| buckets[((r.bucket - base) / 1.hour).to_i / coarsen] += r.count }
246
- bucket_count = (hours.to_f / coarsen).ceil
247
- (0...bucket_count).map { |i| buckets[i] }
248
- end
249
- end
250
- end
251
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # A file an app attached to an execution or exception with Railwatch.attach.
6
- # `data` is gzip of the original bytes.
7
- class Attachment < TelemetryRecord
8
- include Child
9
-
10
- # A record has to fit inside the API's decoded request ceiling. Keeping
11
- # the per-attachment ceiling equal to it preserves every valid existing
12
- # payload while bounding reads of legacy rows.
13
- MAX_BODY_BYTES = IngestRequestBodyLimit::MAX_BYTES
14
-
15
- def body
16
- BoundedGzip.decompress(data, max_bytes: MAX_BODY_BYTES)
17
- end
18
-
19
- # Only text-ish payloads are safe to render in the browser; everything
20
- # else downloads. Rendered as text/plain either way, so a stored
21
- # text/html attachment can never execute against our origin.
22
- def viewable?
23
- content_type.to_s.start_with?("text/") || content_type.to_s.split(";").first == "application/json"
24
- end
25
-
26
- def timeline_label
27
- name.to_s.first(120)
28
- end
29
- end
30
- end
31
- end
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- require "stringio"
5
- require "zlib"
6
-
7
- module Telemetry
8
- # Reads untrusted gzip streams without ever asking zlib for more than the
9
- # configured output ceiling. Ingest uses verify! without retaining the
10
- # expanded bytes; read paths either return a bounded body or a bounded
11
- # prefix for displays that can be truncated.
12
- module BoundedGzip
13
- READ_SIZE = 64.kilobytes
14
-
15
- class Error < ArgumentError; end
16
- class Malformed < Error; end
17
- class TooLarge < Error; end
18
- class SizeMismatch < Error; end
19
-
20
- module_function
21
-
22
- def decompress(compressed, max_bytes:)
23
- output = String.new(encoding: Encoding::BINARY)
24
- each_chunk(compressed, max_bytes: max_bytes) { |chunk| output << chunk }
25
- output
26
- end
27
-
28
- # Returns [prefix, truncated?]. Once one byte beyond the display limit is
29
- # observed, decompression stops instead of expanding the rest of a legacy
30
- # gzip bomb merely to prove that it was truncated.
31
- def decompress_capped(compressed, limit:)
32
- output = String.new(encoding: Encoding::BINARY)
33
- each_chunk(compressed, max_bytes: limit) { |chunk| output << chunk }
34
- [ output, false ]
35
- rescue TooLarge
36
- [ output, true ]
37
- end
38
-
39
- def verify!(compressed, max_bytes:, expected_bytes:, utf8: false)
40
- unless expected_bytes.is_a?(Integer) && expected_bytes.between?(0, max_bytes)
41
- raise SizeMismatch, "declared size #{expected_bytes} exceeds the #{max_bytes} byte ceiling"
42
- end
43
-
44
- validator = Encoding::Converter.new(Encoding::UTF_8, Encoding::UTF_16LE) if utf8
45
- actual_bytes = each_chunk(compressed, max_bytes: max_bytes) do |chunk|
46
- validator&.convert(chunk)
47
- end
48
- validator&.finish
49
- return actual_bytes if actual_bytes == expected_bytes
50
-
51
- raise SizeMismatch, "declared size #{expected_bytes} does not match decompressed size #{actual_bytes}"
52
- rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
53
- raise Malformed, "decompressed content is not valid UTF-8"
54
- end
55
-
56
- def utf8!(text)
57
- text.force_encoding(Encoding::UTF_8)
58
- return text if text.valid_encoding?
59
-
60
- raise Malformed, "decompressed content is not valid UTF-8"
61
- end
62
-
63
- def each_chunk(compressed, max_bytes:)
64
- raise ArgumentError, "max_bytes must be non-negative" unless max_bytes.is_a?(Integer) && max_bytes >= 0
65
-
66
- # StringIO and zlib operate on bytes regardless of the String's
67
- # encoding. Avoid String#b here: it duplicates even an already-binary
68
- # database BLOB, adding another full compressed-body allocation.
69
- source = StringIO.new(compressed.to_s)
70
- reader = Zlib::GzipReader.new(source)
71
- total = 0
72
- loop do
73
- # The extra byte proves that a stream at the boundary has ended. Only
74
- # the portion up to the ceiling is ever yielded or retained.
75
- chunk = reader.readpartial([ READ_SIZE, max_bytes - total + 1 ].min)
76
- if total + chunk.bytesize > max_bytes
77
- remaining = max_bytes - total
78
- yield chunk.byteslice(0, remaining) if remaining.positive?
79
- raise TooLarge, "decompressed content exceeds #{max_bytes} bytes"
80
- end
81
-
82
- total += chunk.bytesize
83
- yield chunk
84
- end
85
- rescue EOFError
86
- # GzipReader reads ahead in fixed-size blocks. `unused` catches bytes
87
- # already buffered past the gzip member; checking the underlying IO is
88
- # also required when the member ends exactly on a read boundary and
89
- # the trailing bytes have not been pulled into zlib's buffer yet.
90
- raise Malformed, "gzip stream has trailing data" if reader.unused.present? || !source.eof?
91
-
92
- total
93
- rescue Zlib::Error => e
94
- raise Malformed, "invalid gzip stream: #{e.message}"
95
- ensure
96
- reader&.close
97
- end
98
- private_class_method :each_chunk
99
- end
100
- end
101
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class Broadcast < TelemetryRecord
6
- include Child
7
-
8
- def timeline_label
9
- stream.to_s.first(120)
10
- end
11
- end
12
- end
13
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class CacheEvent < TelemetryRecord
6
- include Child
7
-
8
- # `type` is the cache operation (hit, miss, write...), not STI.
9
- self.inheritance_column = nil
10
-
11
- def timeline_label
12
- "#{type} #{key}".first(120)
13
- end
14
- end
15
- end
16
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # Shared behaviour for every record that belongs to an execution.
6
- module Child
7
- extend ActiveSupport::Concern
8
-
9
- included do
10
- scope :recent, -> { order(occurred_at: :desc) }
11
- scope :between, ->(from, to) { where(occurred_at: from..to) }
12
- scope :in_execution, ->(id) { where(execution_id: id) }
13
- end
14
-
15
- class_methods do
16
- # One execution's rows for the waterfall; a child that resolves a
17
- # column from elsewhere (Query) overrides this.
18
- def timeline_scope(execution_id)
19
- in_execution(execution_id)
20
- end
21
- end
22
-
23
- def execution
24
- Execution.find_by(execution_id: execution_id)
25
- end
26
-
27
- def duration_ms
28
- respond_to?(:duration) && duration ? duration / 1000.0 : nil
29
- end
30
-
31
- # parent_start is passed in by Execution#timeline, which already holds
32
- # the parent. Resolving it here instead cost one query per child row,
33
- # so a waterfall of a few hundred logs and queries was a few hundred
34
- # lookups of the same execution.
35
- def timeline_entry(type, parent_start = nil)
36
- parent_start ||= execution&.occurred_at
37
- {
38
- type: type.to_s.singularize,
39
- id: id,
40
- offset: parent_start ? ((occurred_at - parent_start) * 1000.0).round(3) : 0,
41
- duration: duration_ms,
42
- label: timeline_label,
43
- stage: execution_stage,
44
- source: (self[:source] if has_attribute?(:source))
45
- }
46
- end
47
-
48
- def timeline_label
49
- try(:name) || try(:key) || try(:message)&.first(120) || self.class.name.demodulize
50
- end
51
- end
52
- end
53
- end
@@ -1,99 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # Stable keyset pagination for high-volume telemetry tables. Cursors are
6
- # signed and bind their sort shape, so clients cannot inject columns or use
7
- # a cursor from one resource/order against another.
8
- class CursorPage
9
- class InvalidCursor < ArgumentError; end
10
-
11
- DEFAULT_LIMIT = 50
12
- MAX_LIMIT = 200
13
- ORDERS = {
14
- recent: { column: :occurred_at, direction: :desc, type: :time },
15
- slowest: { column: :duration, direction: :desc, type: :integer }
16
- }.freeze
17
-
18
- def self.call(scope, cursor: nil, limit: nil, order: :recent, context: nil)
19
- new(scope, cursor: cursor, limit: limit, order: order, context: context).call
20
- end
21
-
22
- def initialize(scope, cursor:, limit:, order:, context:)
23
- @scope = scope
24
- @cursor = cursor
25
- parsed_limit = limit.to_i
26
- parsed_limit = DEFAULT_LIMIT unless parsed_limit.positive?
27
- @limit = [ parsed_limit, MAX_LIMIT ].min
28
- @order_name = order.to_sym
29
- @order = ORDERS.fetch(@order_name)
30
- @context = context.to_s
31
- end
32
-
33
- def call
34
- relation = apply_cursor(scope)
35
- relation = relation.from("#{relation.klass.quoted_table_name} INDEXED BY idx_queries_slowest") if force_slowest_index?
36
- rows = relation.reorder(order[:column] => order[:direction], id: order[:direction]).limit(limit + 1).to_a
37
- has_more = rows.length > limit
38
- rows = rows.first(limit)
39
- next_cursor = encode(rows.last) if has_more
40
- [ rows, { limit: limit, next_cursor: next_cursor, has_more: has_more } ]
41
- end
42
-
43
- private
44
-
45
- attr_reader :scope, :cursor, :limit, :order, :order_name, :context
46
-
47
- # SQLite's planner costs the duration-led covering index higher than
48
- # scanning the whole occurred_at window and sorting it, even with fresh
49
- # statistics, so it never picks it on its own. Forced only for the one
50
- # (model, order) pair the index exists for.
51
- # INDEXED BY is SQLite's own syntax, and telemetry is a SQLite database
52
- # in both the platform and an embedded install. Ask anyway, so pointing
53
- # this at anything else degrades to the planner's own choice rather
54
- # than a syntax error on every page load.
55
- def force_slowest_index?
56
- order_name == :slowest && scope.klass == Telemetry::Query &&
57
- scope.klass.connection.adapter_name.match?(/sqlite/i)
58
- end
59
-
60
- def apply_cursor(relation)
61
- values = decode
62
- return relation unless values
63
-
64
- column = relation.klass.arel_table[order[:column]]
65
- id = relation.klass.arel_table[:id]
66
- comparator = order[:direction] == :desc ? :lt : :gt
67
- relation.where(column.public_send(comparator, values[:value])
68
- .or(column.eq(values[:value]).and(id.public_send(comparator, values[:id]))))
69
- end
70
-
71
- def encode(row)
72
- return unless row
73
- value = row.public_send(order[:column])
74
- value = value.iso8601(6) if order[:type] == :time
75
- # The page size is deliberately not bound: changing it mid-scroll must
76
- # not hard-fail. The cursor is a stable position, not a security
77
- # boundary -- the scope it is applied to is already tenant-scoped here.
78
- verifier.generate({ v: 1, model: scope.klass.name, order: order_name,
79
- context: context, value: value, id: row.id })
80
- end
81
-
82
- def decode
83
- return if cursor.blank?
84
- payload = verifier.verify(cursor.to_s).symbolize_keys
85
- valid_shape = payload[:v] == 1 && payload[:model] == scope.klass.name &&
86
- payload[:order].to_s == order_name.to_s && payload[:context] == context
87
- raise InvalidCursor, "invalid telemetry cursor" unless valid_shape
88
- value = order[:type] == :time ? Time.iso8601(payload[:value].to_s) : Integer(payload[:value].to_s, 10)
89
- { value: value, id: Integer(payload[:id].to_s, 10) }
90
- rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError, TypeError
91
- raise InvalidCursor, "invalid telemetry cursor"
92
- end
93
-
94
- def verifier
95
- Rails.application.message_verifier("telemetry_cursor")
96
- end
97
- end
98
- end
99
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class Deprecation < TelemetryRecord
6
- include Child
7
-
8
- def timeline_label
9
- message.to_s.first(120)
10
- end
11
- end
12
- end
13
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class EnqueuedJob < TelemetryRecord
6
- include Child
7
-
8
- def timeline_label
9
- name.to_s.first(120)
10
- end
11
- end
12
- end
13
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class Exception < TelemetryRecord
6
- include Child
7
-
8
- self.table_name = "exceptions"
9
-
10
- scope :unhandled, -> { where(handled: false) }
11
-
12
- def as_row
13
- { id: id, class_name: class_name, message: message.first(500), handled: handled, severity: severity, source: source,
14
- file: file, line: line, occurred_at: occurred_at, deploy: deploy, execution_id: execution_id,
15
- execution_source: execution_source, execution_preview: execution_preview, user_ref: user_ref, tenant: app_tenant,
16
- group_hash: group_hash }
17
- end
18
-
19
- def timeline_label
20
- "#{class_name}: #{message.to_s.first(100)}"
21
- end
22
-
23
- def app_frames
24
- frames.select { |f| f["in_app"] }
25
- end
26
- end
27
- end
28
- end