railwatch 0.9.3 → 1.0.0

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