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,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class Process < TelemetryRecord
6
- self.table_name = "processes"
7
- scope :recent, -> { order(booted_at: :desc) }
8
- end
9
- end
10
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # A sampled stack profile of one execution. `stacks` is gzip of collapsed
6
- # stacks ("outer;inner;leaf count\n"), the format flamegraph tools read.
7
- class Profile < TelemetryRecord
8
- include Child
9
-
10
- # Railwatch's profiler caps collapsed stacks at 4 MiB before compression.
11
- # Enforce the same contract here even when a client lies about its size.
12
- MAX_INGEST_BYTES = 4.megabytes
13
-
14
- # A minute of wall profiling can decompress to tens of megabytes; the
15
- # page only ever renders a flamegraph, so the payload is cut here rather
16
- # than shipped whole to the browser.
17
- MAX_COLLAPSED_BYTES = 2.megabytes
18
-
19
- def collapsed
20
- BoundedGzip.utf8!(BoundedGzip.decompress(stacks, max_bytes: MAX_INGEST_BYTES))
21
- end
22
-
23
- # [text, truncated?] - cut on a line boundary so the parser never sees
24
- # half a stack line.
25
- def collapsed_capped(limit = MAX_COLLAPSED_BYTES)
26
- text, truncated = BoundedGzip.decompress_capped(stacks, limit: [ limit, MAX_INGEST_BYTES ].min)
27
- text = text.sub(/[^\n]*\z/, "") if truncated
28
-
29
- [ BoundedGzip.utf8!(text), truncated ]
30
- end
31
-
32
- def timeline_label
33
- "#{profiler} profile (#{samples} samples)"
34
- end
35
- end
36
- end
37
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class Query < TelemetryRecord
6
- include Child
7
-
8
- # Raised when a row's SQL is read on a relation that did not resolve it.
9
- class SqlNotResolved < StandardError; end
10
-
11
- # The statement, from the row or from its group's shape (QueryShape).
12
- # A correlated lookup rather than a join: the queries page sorts a
13
- # window of millions of rows before taking 50, and a LEFT JOIN there
14
- # was paid per candidate row (47 s against 0.8 s on the 5M-row window).
15
- SQL = Arel.sql("COALESCE(NULLIF(queries.sql, ''), " \
16
- "(SELECT query_shapes.sql FROM query_shapes WHERE query_shapes.group_hash = queries.group_hash), '') AS resolved_sql").freeze
17
-
18
- # Rows with their SQL resolved in the same statement, so a page loaded
19
- # inside Environment#with_telemetry can be rendered after the block.
20
- # Replaces the select list: count with count(:all), and pluck
21
- # resolved_sql, not sql.
22
- scope :with_sql, -> { select("queries.*", SQL) }
23
-
24
- # Free-text search over the statement, wherever it is stored.
25
- scope :matching, ->(text) {
26
- pattern = "%#{sanitize_sql_like(text)}%"
27
- where("queries.sql LIKE :pattern ESCAPE '\\' OR queries.name LIKE :pattern ESCAPE '\\' " \
28
- "OR queries.group_hash IN (SELECT group_hash FROM query_shapes WHERE sql LIKE :pattern ESCAPE '\\')", pattern: pattern)
29
- }
30
-
31
- def self.timeline_scope(execution_id)
32
- in_execution(execution_id).with_sql
33
- end
34
-
35
- # Always a String. A row whose text lives on its shape can only answer
36
- # through with_sql; asking without it is a bug, not an empty statement.
37
- def sql
38
- return self[:resolved_sql].to_s if has_attribute?(:resolved_sql)
39
-
40
- text = self[:sql].to_s
41
- raise SqlNotResolved, "Telemetry::Query##{id} stores its SQL on its shape; load it through Query.with_sql" if text.empty? && group_hash
42
- text
43
- end
44
-
45
- def as_row
46
- { id: id, group_hash: group_hash, sql: sql.first(2_000), name: name, duration: duration_ms.round(3), occurred_at: occurred_at,
47
- deploy: deploy, execution_id: execution_id, execution_preview: execution_preview, source: source,
48
- connection: connection, role: role, adapter: adapter, row_count: row_count, tenant: app_tenant, user_ref: user_ref,
49
- explain: explain.present? }
50
- end
51
-
52
- def timeline_label
53
- sql.to_s.first(120)
54
- end
55
- end
56
- end
57
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # One query group's normalized statement, stored once. The group hash is
6
- # the gem's digest of the connection name and that statement, so a row
7
- # whose own text digests to its group carries the statement and stores
8
- # "" instead (Ingest::Writer); any other text stays on the row. See
9
- # docs/architecture.md.
10
- class QueryShape < TelemetryRecord
11
- self.primary_key = :group_hash
12
-
13
- BACKFILL_BATCH = 20_000
14
-
15
- # Backfill for rows written before this table existed. Files a shape
16
- # for each group whose earliest or newest row normalizes back to the
17
- # group hash (a tenant's earliest rows may predate the gem sending
18
- # normalized text; its newest will not), then blanks every row whose
19
- # text is its group's shape, in id-range batches so no statement holds
20
- # the tenant's write lock for long. Idempotent, and never blanks a row
21
- # whose text is not on file. Returns what it did.
22
- def self.dedupe_rows!(batch: BACKFILL_BATCH)
23
- before = count
24
- file_shapes!
25
- moved = Query.where.not(sql: "")
26
- .where("queries.sql = (SELECT s.sql FROM query_shapes s WHERE s.group_hash = queries.group_hash)")
27
- .in_batches(of: batch, use_ranges: true).update_all(sql: "")
28
- { shapes: count - before, rows: moved }
29
- end
30
-
31
- def self.file_shapes!
32
- ends = "SELECT MIN(id) AS id FROM queries WHERE group_hash IS NOT NULL GROUP BY group_hash " \
33
- "UNION SELECT MAX(id) FROM queries WHERE group_hash IS NOT NULL GROUP BY group_hash"
34
- candidates = Query.joins("JOIN (#{ends}) ends ON ends.id = queries.id").where.not(sql: "")
35
- .pluck(:group_hash, :sql, :adapter, :connection)
36
- shapes = candidates.filter_map do |group_hash, sql, adapter, connection|
37
- digest, normalized = Railwatch::SqlNormalizer.group_and_normalized(sql, adapter: adapter, connection_name: connection)
38
- { group_hash: group_hash, sql: normalized } if digest == group_hash
39
- end
40
- shapes.uniq { |shape| shape[:group_hash] }.each_slice(500) { |slice| insert_all(slice) }
41
- end
42
- private_class_method :file_shapes!
43
- end
44
- end
45
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # Hourly release health per deploy, recomputed from raw `sessions` rows by
6
- # ReleaseHealthRollupJob. The deploy *is* the release -- the gem stamps it
7
- # on every record it ships -- so counting sessions per deploy is all this
8
- # needs. Sentry's crash-free session rate, crash-free user rate, and
9
- # adoption, over the same data.
10
- #
11
- # A session that spans two hours is counted in both buckets, like every
12
- # other rollup here: the bucket, not the session, is the unit these numbers
13
- # sum over. Every method must run inside environment.with_telemetry { }.
14
- class ReleaseHealth < TelemetryRecord
15
- self.table_name = "release_health"
16
-
17
- scope :between, ->(from, to) { where(bucket: from.beginning_of_hour..to) }
18
- # A nil deploy means "every release in the window", which is what the
19
- # index page's headline numbers are.
20
- scope :for_release, ->(deploy) { deploy ? where(deploy: deploy) : all }
21
-
22
- TOTALS = %i[sessions sessions_errored sessions_crashed users users_crashed duration_sum duration_count].freeze
23
-
24
- # Window totals for one release. Rates come back nil, not zero, when there
25
- # is nothing to divide by, so an environment with no sessions yet never
26
- # renders as "0% crash-free".
27
- def self.for_deploy(deploy, from, to)
28
- sessions, errored, crashed, users, users_crashed, duration_sum, duration_count =
29
- between(from, to).for_release(deploy).pick(*TOTALS.map { |column| Arel.sql("COALESCE(SUM(#{column}), 0)") })
30
- {
31
- sessions: sessions, users: users,
32
- crash_free_sessions: rate(sessions - crashed, sessions),
33
- crash_free_users: rate(users - users_crashed, users),
34
- errored: rate(errored, sessions),
35
- avg_duration_ms: duration_count.zero? ? nil : (duration_sum / duration_count / 1000.0).round(2)
36
- }
37
- end
38
-
39
- # Points for the stacked sessions-by-status chart, one per `step` across
40
- # the window. The table is hourly and a session is counted once per hour
41
- # it was seen, so a step under an hour is drawn at an hour: raw session
42
- # rows would count a live session on every flush.
43
- def self.series(deploy, from, to, step: nil)
44
- step = [ Telemetry::Aggregations::STEPS.fetch(step || Telemetry::Aggregations.default_step(from, to)), 1.hour ].max
45
- bucket = Telemetry::Aggregations.bucket_sql("bucket", step)
46
- points = between(from, to).for_release(deploy).group(bucket).order(bucket)
47
- .pluck(bucket, Arel.sql("SUM(sessions)"), Arel.sql("SUM(sessions_errored)"), Arel.sql("SUM(sessions_crashed)"))
48
- .map { |b, sessions, errored, crashed|
49
- { t: Time.at(b).utc, sessions: sessions, ok: sessions - errored - crashed, errored: errored, crashed: crashed }
50
- }
51
- Telemetry::Aggregations.fill(points, from, to, step) { |t| { t: t, sessions: 0, ok: 0, errored: 0, crashed: 0 } }
52
- end
53
-
54
- # Each release's share of the window's sessions, as a percentage -- how
55
- # far a new release has rolled out.
56
- def self.adoption(from, to)
57
- counts = between(from, to).group(:deploy).sum(:sessions)
58
- total = counts.values.sum
59
- return counts.transform_values { 0.0 } if total.zero?
60
- counts.transform_values { |n| (n * 100.0 / total).round(1) }
61
- end
62
-
63
- def self.rate(numerator, denominator)
64
- return nil if denominator.to_i.zero?
65
- (numerator * 100.0 / denominator).round(2)
66
- end
67
- end
68
- end
69
- end
@@ -1,157 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- require "tdigest"
5
-
6
- module Telemetry
7
- # Hourly aggregate per (record_type, group). Percentiles are exact within
8
- # an hour and merged across hours with t-digest, so a 30-day p95 for a
9
- # route is one query over 720 small rows.
10
- class Rollup < TelemetryRecord
11
- scope :for_type, ->(type) { where(record_type: type) }
12
- scope :between, ->(from, to) { where(bucket: from.beginning_of_hour..to) }
13
-
14
- def self.bucket_for(time)
15
- time.utc.beginning_of_hour
16
- end
17
-
18
- # Merge a list of duration samples (microseconds) into the bucket.
19
- def self.absorb!(record_type:, group_hash:, name:, bucket:, durations:, errors: 0, client_errors: 0, extra: {})
20
- return if durations.empty?
21
- row = find_or_initialize_by(record_type: record_type, group_hash: group_hash, bucket: bucket)
22
- row.name = name
23
- digest = row.digest ? TDigest::TDigest.from_bytes(row.digest) : TDigest::TDigest.new(0.01)
24
- durations.each { |d| digest.push(d) }
25
- digest.compress!
26
- row.count += durations.size
27
- row.error_count += errors
28
- row.client_error_count += client_errors
29
- row.duration_sum += durations.sum
30
- row.duration_max = [ row.duration_max, durations.max ].max
31
- row.p50 = digest.percentile(0.5).to_i
32
- row.p95 = digest.percentile(0.95).to_i
33
- row.p99 = digest.percentile(0.99).to_i
34
- row.digest = digest.as_small_bytes
35
- row.extra = row.extra.merge(extra) { |_k, a, b| a.is_a?(Numeric) && b.is_a?(Numeric) ? a + b : b }
36
- row.save!
37
- row
38
- end
39
-
40
- # The row absorb! would write over an empty bucket, as attributes for
41
- # insert_all. RollupJob deletes an hour's rows and writes every group in
42
- # one statement, so the read absorb! does per group is wasted there.
43
- def self.fresh_attributes(record_type:, group_hash:, name:, bucket:, durations:, errors: 0, client_errors: 0, extra: {})
44
- digest = TDigest::TDigest.new(0.01)
45
- durations.each { |d| digest.push(d) }
46
- digest.compress!
47
- { record_type: record_type, group_hash: group_hash, name: name, bucket: bucket,
48
- count: durations.size, error_count: errors, client_error_count: client_errors,
49
- duration_sum: durations.sum, duration_max: durations.max,
50
- p50: digest.percentile(0.5).to_i, p95: digest.percentile(0.95).to_i, p99: digest.percentile(0.99).to_i,
51
- digest: digest.as_small_bytes, extra: extra }
52
- end
53
-
54
- # Aggregate a relation of rollups into one summary with merged percentiles.
55
- def self.summarize(relation)
56
- rows = relation.to_a
57
- return { count: 0, errors: 0, client_errors: 0, avg: 0, p50: 0, p95: 0, p99: 0, max: 0, extra: {} } if rows.empty?
58
- p50, p95, p99 = merged_percentiles(rows, [ 0.5, 0.95, 0.99 ])
59
- count = rows.sum(&:count)
60
- {
61
- count: count,
62
- errors: rows.sum(&:error_count),
63
- client_errors: rows.sum(&:client_error_count),
64
- avg: count.zero? ? 0 : (rows.sum(&:duration_sum) / count),
65
- p50: p50,
66
- p95: p95,
67
- p99: p99,
68
- max: rows.map(&:duration_max).max,
69
- # Everything a type puts in `extra` -- llm_call's cost_nanos and
70
- # token counts, cache_event's hits and misses -- merged the way
71
- # absorb! merges it: numbers add up, anything else is last-wins.
72
- # Spend is a sum over a window rather than a percentile of
73
- # durations, so a rule about money has nowhere else to read from.
74
- extra: merge_extras(rows)
75
- }
76
- end
77
-
78
- # Percentiles of the union of every row's centroids, read straight out
79
- # of the stored bytes. Merging through TDigest pushed each centroid into
80
- # a red-black tree one at a time: 2.8 s for a week of the platform's
81
- # own query rollups (18,674 rows) and 7 s for thirty days, on every
82
- # cold page load. Sorting the centroids once and walking their
83
- # cumulative counts answers the same question without re-clustering:
84
- # 353 ms and 1 s for those two windows. The answer is the first
85
- # centroid whose running total reaches n * p, which is nearest rank --
86
- # the rule raw_series uses for sub-hour buckets, so both readings of a
87
- # window agree. (TDigest#percentile compares each centroid's midpoint
88
- # instead, which puts the median of 1,000 sevens and 300 nines at 9.)
89
- def self.merged_percentiles(rows, ps)
90
- means = []
91
- counts = []
92
- rows.each { |row| centroids(row.digest, means, counts) if row.digest }
93
- return ps.map { 0 } if means.empty?
94
-
95
- order = (0...means.size).sort_by { |i| means[i] }
96
- total = counts.sum
97
- targets = ps.map { |p| total * p }
98
- found = []
99
- seen = 0
100
- order.each do |i|
101
- seen += counts[i]
102
- found << means[i] while found.size < targets.size && seen >= targets[found.size]
103
- break if found.size == targets.size
104
- end
105
- found << means[order.last] while found.size < targets.size
106
- found.map(&:to_i)
107
- end
108
-
109
- # Appends one stored digest's centroid means and weights, in either of
110
- # the tdigest gem's encodings (TDigest.from_bytes reads the same two).
111
- def self.centroids(bytes, means, counts)
112
- format, _compression, size = bytes.unpack("LdL")
113
- case format
114
- when TDigest::TDigest::VERBOSE_ENCODING
115
- means.concat(bytes.unpack("@16d#{size}"))
116
- counts.concat(bytes.unpack("@#{16 + 8 * size}L#{size}"))
117
- when TDigest::TDigest::SMALL_ENCODING
118
- # Means are delta-encoded 4-byte floats; weights are 7-bit varints,
119
- # one byte each unless a centroid holds 128 or more samples.
120
- mean = 0.0
121
- bytes.unpack("@16f#{size}").each { |delta| means << (mean += delta) }
122
- weights = bytes.byteslice(16 + 4 * size, bytes.bytesize).unpack("C*")
123
- if weights.size == size
124
- counts.concat(weights)
125
- else
126
- at = 0
127
- size.times do
128
- byte = weights[at]
129
- at += 1
130
- weight = byte & 0x7f
131
- shift = 7
132
- while byte & 0x80 != 0
133
- byte = weights[at] || 0
134
- at += 1
135
- weight += (byte & 0x7f) << shift
136
- shift += 7
137
- end
138
- counts << weight
139
- end
140
- end
141
- else
142
- raise ArgumentError, "unknown t-digest encoding #{format}"
143
- end
144
- end
145
- private_class_method :centroids
146
-
147
- def self.merge_extras(rows)
148
- rows.each_with_object({}) do |row, out|
149
- row.extra.each do |key, value|
150
- existing = out[key]
151
- out[key] = existing.is_a?(Numeric) && value.is_a?(Numeric) ? existing + value : value
152
- end
153
- end
154
- end
155
- end
156
- end
157
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # One session of the monitored application, for release health: a browser
6
- # tab (reported by the gem's beacon client) or a server-side session (the
7
- # gem's request middleware). The gem re-sends a live session on every flush
8
- # interval, so the same session_id arrives many times and gets collapsed to
9
- # one session per bucket by ReleaseHealthRollupJob.
10
- class Session < TelemetryRecord
11
- include Child
12
-
13
- STATUSES = %w[started ok errored crashed].freeze
14
-
15
- def crashed?
16
- status == "crashed"
17
- end
18
-
19
- def timeline_label
20
- session_id
21
- end
22
- end
23
- end
24
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # A custom span recorded with Railwatch.span("name") { }, a child of the
6
- # request/job/command it ran inside.
7
- class Span < TelemetryRecord
8
- include Child
9
-
10
- scope :failed, -> { where(status: "failed") }
11
-
12
- # The wire record calls the user payload "attributes"; the column is
13
- # `payload` because `attributes` is Active Record's own method.
14
- def timeline_label
15
- name.to_s.first(120)
16
- end
17
- end
18
- end
19
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- class StorageOp < TelemetryRecord
6
- include Child
7
-
8
- def timeline_label
9
- key.to_s.first(120)
10
- end
11
- end
12
- end
13
- end
@@ -1,240 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Telemetry
5
- # A tenant of the monitored application, as seen in its telemetry. Not an
6
- # Active Record table: `app_tenant` is a column on every telemetry row (the
7
- # gem fills it from Railwatch::Context.current_tenant, which multi-tenant
8
- # Rails apps get from ActiveRecord::Tenanted / TenantRecord.current_tenant),
9
- # so a "tenant" only exists as a GROUP BY over the raw tables.
10
- #
11
- # Telemetry::Rollup -- what every other page's numbers come from -- is keyed
12
- # by record type and group_hash only and has no tenant dimension, so all of
13
- # this is computed from raw rows in the window. Every method must run inside
14
- # environment.with_telemetry { }.
15
- class Tenant
16
- MAX_TENANTS = 200
17
- # A p95 costs one ordered query per tenant (SQLite has no percentile
18
- # aggregate), so only the busiest tenants get one; the rest report 0.
19
- P95_TENANTS = 50
20
- SPARKLINE_BUCKETS = 12
21
- # Tenant names are bound one variable each, and SQLite allows 32,766
22
- # per statement; an app with more tenants than that is asked in slices.
23
- TENANT_SLICE = 1_000
24
- SORTS = { "requests" => :requests, "errors" => :errors, "p95" => :p95, "users" => :users }.freeze
25
-
26
- ERRORS_SQL = Arel.sql("SUM(CASE WHEN status >= 500 THEN 1 ELSE 0 END)")
27
- CLIENT_ERRORS_SQL = Arel.sql("SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END)")
28
- FAILED_SQL = Arel.sql("SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END)")
29
- COUNT_SQL = Arel.sql("COUNT(*)")
30
- USERS_SQL = Arel.sql("COUNT(DISTINCT user_ref)")
31
-
32
- # One aggregate row per tenant seen in the window, filtered by `q`
33
- # (a substring of the tenant name), sorted, and capped at MAX_TENANTS.
34
- def self.index(from, to, q: nil, sort: nil, dir: nil)
35
- rows = Hash.new { |h, tenant| h[tenant] = blank_row(tenant) }
36
- absorb_requests(rows, from, to, q)
37
- absorb_jobs(rows, from, to, q)
38
- absorb_counts(rows, filtered(Telemetry::Exception.between(from, to), q), :exceptions)
39
- absorb_counts(rows, filtered(Telemetry::Log.between(from, to), q), :logs)
40
- absorb_last_seen(rows, from, to, q)
41
- absorb_sparklines(rows, from, to)
42
- absorb_p95s(rows.values.max_by(P95_TENANTS) { |r| r[:requests] }, from, to)
43
- sorted(rows.values, sort, dir)
44
- end
45
-
46
- # Headline numbers for the index page, computed from the rows it shows
47
- # (so the top-tenant share is a share of the listed tenants' requests)
48
- # plus the share of requests in the window that carry no tenant at all.
49
- #
50
- # The untagged count is the window's requests less the tagged ones,
51
- # and the tagged ones are counted per tenant in app_tenant's index.
52
- # Counting either side with app_tenant IS [NOT] NULL read every request
53
- # in the window instead: 1 s on an untagged app, 24 s on a tagged one.
54
- def self.overview(rows, from, to)
55
- requests = Telemetry::Execution.requests.between(from, to)
56
- total = requests.count
57
- tagged_total = tagged_tenants(from, to).each_slice(TENANT_SLICE).sum { |slice| requests.where(app_tenant: slice).count }
58
- tagged = rows.sum { |r| r[:requests] }
59
- top = rows.max_by { |r| r[:requests] }
60
- {
61
- tenants: rows.size, top_tenant: top && top[:tenant],
62
- top_share: tagged.zero? ? 0.0 : (top[:requests] * 100.0 / tagged).round(1),
63
- with_errors: rows.count { |r| r[:errors].positive? },
64
- untagged_share: total.zero? ? 0.0 : ((total - tagged_total) * 100.0 / total).round(1)
65
- }
66
- end
67
-
68
- # Every tenant that sent anything in the window, from the tenant-led
69
- # index (a skip-scan over distinct values, not the window's rows).
70
- def self.tagged_tenants(from, to)
71
- Telemetry::Execution.between(from, to).where.not(app_tenant: nil).distinct.pluck(:app_tenant)
72
- end
73
-
74
- # Window totals for one tenant. Durations in milliseconds, like the rest
75
- # of the tenant props.
76
- def self.summary(tenant, from, to)
77
- requests = Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant)
78
- count, errors, users = requests.pick(COUNT_SQL, ERRORS_SQL, USERS_SQL)
79
- jobs, failed_jobs = Telemetry::Execution.jobs.between(from, to).where(app_tenant: tenant).pick(COUNT_SQL, FAILED_SQL)
80
- {
81
- requests: count, errors: errors.to_i, p95: p95_ms(requests, count), jobs: jobs, failed_jobs: failed_jobs.to_i,
82
- exceptions: Telemetry::Exception.between(from, to).where(app_tenant: tenant).count,
83
- users: users, logs: Telemetry::Log.between(from, to).where(app_tenant: tenant).count
84
- }
85
- end
86
-
87
- # Request volume and duration bucketed over the window at `step`,
88
- # SeriesPoint-shaped so the same charts render it. Rollups carry no tenant,
89
- # so every step reads raw rows (Telemetry::Aggregations.points does that
90
- # for a tenant), with exact percentiles per bucket.
91
- def self.series(tenant, from, to, step: nil)
92
- Telemetry::Aggregations.points("request", from: from, to: to, step: step, tenant: tenant)
93
- end
94
-
95
- def self.routes(tenant, from, to, limit: 20)
96
- grouped_executions(Telemetry::Execution.requests, tenant, from, to, ERRORS_SQL, :errors, limit)
97
- end
98
-
99
- def self.job_classes(tenant, from, to, limit: 10)
100
- grouped_executions(Telemetry::Execution.jobs, tenant, from, to, FAILED_SQL, :failed, limit)
101
- end
102
-
103
- def self.exceptions(tenant, from, to, limit: 20)
104
- Telemetry::Exception.between(from, to).where(app_tenant: tenant).recent.limit(limit).map do |e|
105
- { id: e.id, class_name: e.class_name, message: e.message.first(500), occurred_at: e.occurred_at,
106
- execution_id: e.execution_id, group_hash: e.group_hash }
107
- end
108
- end
109
-
110
- def self.people(tenant, limit: 20)
111
- Telemetry::Person.where(app_tenant: tenant).recent.limit(limit).map do |person|
112
- { ref: person.ref, name: person.display_name, email: person.email, last_seen_at: person.last_seen_at }
113
- end
114
- end
115
-
116
- # The tenant page's "Recent requests" table.
117
- def self.recent_requests(tenant, from, to, limit: 50)
118
- Telemetry::Execution.requests.between(from, to).where(app_tenant: tenant).recent.limit(limit).map(&:as_row)
119
- end
120
-
121
- # -- Aggregation steps -----------------------------------------------------
122
-
123
- # The per-tenant sums read through idx_executions_tenant_summary, which
124
- # holds every column they need for tagged rows only. SQLite prefers the
125
- # narrower (app_tenant, occurred_at) index on its own estimate and then
126
- # fetches every row from the table (8 s against 131 ms over 30 days).
127
- def self.summary_scope(kind)
128
- scope = Telemetry::Execution
129
- scope = scope.from("#{scope.quoted_table_name} INDEXED BY idx_executions_tenant_summary") if TelemetryRecord.sqlite?
130
- scope.where(kind: kind).where.not(app_tenant: nil)
131
- end
132
-
133
- def self.absorb_requests(rows, from, to, q)
134
- filtered(summary_scope("request").between(from, to), q).group(:app_tenant)
135
- .pluck(:app_tenant, COUNT_SQL, ERRORS_SQL, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"), USERS_SQL)
136
- .each do |tenant, count, errors, avg, max, users|
137
- rows[tenant].merge!(requests: count, errors: errors, avg: ms(avg), max: ms(max), users: users)
138
- end
139
- end
140
-
141
- def self.absorb_jobs(rows, from, to, q)
142
- filtered(summary_scope("job_attempt").between(from, to), q).group(:app_tenant)
143
- .pluck(:app_tenant, COUNT_SQL, FAILED_SQL)
144
- .each { |tenant, count, failed| rows[tenant].merge!(jobs: count, failed_jobs: failed) }
145
- end
146
-
147
- def self.absorb_counts(rows, scope, key)
148
- scope.group(:app_tenant).count.each { |tenant, count| rows[tenant][key] = count }
149
- end
150
-
151
- def self.absorb_last_seen(rows, from, to, q)
152
- filtered(summary_scope(Telemetry::Execution::KINDS).between(from, to), q).group(:app_tenant).maximum(:occurred_at)
153
- .each { |tenant, at| rows[tenant][:last_seen_at] = at }
154
- end
155
-
156
- # Only for the tenants already found, by name: grouped by tenant and a
157
- # time expression SQLite would not seek the app_tenant index and read
158
- # every request in the window instead -- 11 s over a week on an app
159
- # with no tenants at all, to draw nothing.
160
- def self.absorb_sparklines(rows, from, to)
161
- tenants = rows.keys.compact
162
- return if tenants.empty?
163
-
164
- width = bucket_width(from, to, SPARKLINE_BUCKETS)
165
- counts = tenants.each_slice(TENANT_SLICE).flat_map do |slice|
166
- Telemetry::Execution.requests.between(from, to).where(app_tenant: slice)
167
- .group(:app_tenant, bucket_sql(from, width)).count.to_a
168
- end
169
- counts.each do |(tenant, index), count|
170
- rows[tenant][:sparkline][[ index.to_i, SPARKLINE_BUCKETS - 1 ].min] += count
171
- end
172
- end
173
-
174
- def self.absorb_p95s(rows, from, to)
175
- rows.each do |row|
176
- scope = Telemetry::Execution.requests.between(from, to).where(app_tenant: row[:tenant])
177
- row[:p95] = p95_ms(scope, row[:requests])
178
- end
179
- end
180
-
181
- # -- Helpers ---------------------------------------------------------------
182
-
183
- def self.blank_row(tenant)
184
- { tenant: tenant, requests: 0, errors: 0, avg: 0.0, max: 0.0, p95: 0.0, jobs: 0, failed_jobs: 0,
185
- exceptions: 0, logs: 0, users: 0, last_seen_at: nil, sparkline: Array.new(SPARKLINE_BUCKETS, 0) }
186
- end
187
-
188
- # Tenant-tagged rows only: a NULL app_tenant never matches LIKE either.
189
- def self.filtered(scope, q)
190
- return scope.where.not(app_tenant: nil) if q.blank?
191
- scope.where("app_tenant LIKE ?", "%#{TelemetryRecord.sanitize_sql_like(q.to_s)}%")
192
- end
193
-
194
- def self.sorted(rows, sort, dir)
195
- key = SORTS.fetch(sort.to_s, :requests)
196
- sign = dir.to_s == "asc" ? 1 : -1
197
- rows.sort_by { |row| row[key] * sign }.first(MAX_TENANTS)
198
- end
199
-
200
- # The 95th percentile as the duration `count * 5%` rows from the slowest
201
- # end -- one indexed query, no percentile function needed.
202
- def self.p95_ms(scope, count)
203
- return 0.0 if count.to_i.zero?
204
- ms(scope.order(duration: :desc).offset((count * 0.05).floor).limit(1).pluck(:duration).first)
205
- end
206
-
207
- def self.grouped_executions(scope, tenant, from, to, error_sql, error_key, limit)
208
- scope.between(from, to).where(app_tenant: tenant).group(:group_hash, :name)
209
- .pluck(:group_hash, :name, COUNT_SQL, error_sql, Arel.sql("AVG(duration)"), Arel.sql("MAX(duration)"))
210
- .map { |group_hash, name, count, errors, avg, max|
211
- { group_hash: group_hash, name: name, count: count, error_key => errors, avg: ms(avg), max: ms(max) }
212
- }.sort_by { |row| -row[:count] }.first(limit)
213
- end
214
-
215
- # Microseconds on the wire, milliseconds in every prop.
216
- def self.ms(duration)
217
- duration ? (duration / 1000.0).round(2) : 0.0
218
- end
219
-
220
- def self.bucket_width(from, to, buckets)
221
- [ (to - from) / buckets, 1.0 ].max
222
- end
223
-
224
- # Bucket index per row: epoch seconds since `from` over the bucket width.
225
- # Windows are an hour or more, so second resolution is plenty.
226
- # Both interpolations are coerced to numbers first (`from` is a Time, `width`
227
- # a Float from bucket_width), so the fragment can only ever contain two
228
- # numeric literals -- never a caller-supplied string.
229
- def self.bucket_sql(from, width)
230
- Arel.sql("CAST((strftime('%s', occurred_at) - #{Integer(from.to_i)}) / #{Float(width)} AS INTEGER)")
231
- end
232
-
233
- # A row landing exactly on `to` indexes one bucket past the end; clamp it
234
- # into the last one.
235
- def self.bucket_at(from, width, index, buckets)
236
- from + ([ index.to_i, buckets - 1 ].min * width)
237
- end
238
- end
239
- end
240
- end