railwatch 0.9.4 → 1.0.0

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