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,585 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- module Ingest
5
- # Maps a wire record (the gem's flat hash) to a plain row: [klass, attrs],
6
- # where attrs is a Hash of column name => value already serialized the way
7
- # SQLite wants it on the wire (JSON columns as JSON strings, times as UTC
8
- # "YYYY-MM-DD HH:MM:SS.ffffff" strings, booleans as 0/1, integers as
9
- # Integer, strings truncated to the column's limit). Building an AR
10
- # instance per record was measured at ~970ms of 1060ms for 300 records;
11
- # row_for skips that and lets Ingest::Writer bind straight into a prepared
12
- # statement.
13
- module Mapper
14
- PARENTS = %w[request job_attempt scheduled_task command channel_action].freeze
15
-
16
- # SQLite TEXT and JSON columns have no schema-level limit. These caps are
17
- # therefore part of the wire contract, not presentation limits: they stop
18
- # one valid 32 MiB request from becoming one equally large database cell.
19
- # JSON that exceeds its cap is replaced by a small valid marker object;
20
- # slicing encoded JSON could leave an invalid document in SQLite.
21
- TEXT_LIMITS = {
22
- "deprecations.message" => 65_536,
23
- "exceptions.cause" => 65_536,
24
- "exceptions.context" => 65_536,
25
- "exceptions.fingerprint" => 65_536,
26
- "exceptions.frames" => 262_144,
27
- "exceptions.locals" => 65_536,
28
- "exceptions.message" => 65_536,
29
- "executions.counters" => 65_536,
30
- "executions.detail" => 65_536,
31
- "executions.stages" => 65_536,
32
- "health_samples.detail" => 65_536,
33
- "llm_calls.completion" => 16_384,
34
- "llm_calls.params" => 16_384,
35
- "llm_calls.prompt" => 16_384,
36
- "logs.context" => 65_536,
37
- "logs.message" => 65_536,
38
- "logs.tags" => 65_536,
39
- "n_plus_ones.sql" => 16_384,
40
- "outgoing_requests.response_body" => 65_536,
41
- "processes.detail" => 65_536,
42
- "queries.explain" => 65_536,
43
- "queries.sql" => 16_384,
44
- "query_shapes.sql" => 16_384,
45
- "spans.payload" => 65_536,
46
- "visits.only" => 65_536
47
- }.freeze
48
-
49
- JSON_TRUNCATED = JSON.generate(truncated: true).freeze
50
- JSON_ARRAY_TRUNCATED = JSON.generate([ { truncated: true } ]).freeze
51
- INTEGER_MAX = (2**63) - 1
52
- INTEGER_MIN = -(2**63)
53
- # The gem sends UUIDs, but the wire contract only promises an opaque
54
- # identifier that fits the column, so any printable ASCII token is kept.
55
- IDENTIFIER = /\A[\x21-\x7e]{1,36}\z/
56
- GROUP_HASH = /\A[0-9a-f]{32}\z/i
57
- STRUCTURES = {
58
- "stages" => Hash, "counters" => Hash, "inertia" => Hash, "params" => Hash,
59
- "frames" => Array, "fingerprint" => Array, "tags" => Array,
60
- "only" => Array, "attributes" => Hash, "locals" => Hash
61
- }.freeze
62
-
63
- COLUMN_CACHE = {}
64
- SERIALIZATION_COLUMN_CACHE = {}
65
- COLUMN_CACHE_MUTEX = Mutex.new
66
- TIME_PREFIX_CACHE_KEY = :railwatch_ingest_time_prefixes
67
- TIME_PREFIX_CACHE_LIMIT = 64
68
- private_constant :COLUMN_CACHE, :SERIALIZATION_COLUMN_CACHE, :COLUMN_CACHE_MUTEX,
69
- :TIME_PREFIX_CACHE_KEY, :TIME_PREFIX_CACHE_LIMIT
70
-
71
- module_function
72
-
73
- # column name (String) => [type, limit, null, default], built once per
74
- # class and reused for every row of that class.
75
- def columns_for(klass)
76
- COLUMN_CACHE[klass] ||= COLUMN_CACHE_MUTEX.synchronize do
77
- COLUMN_CACHE[klass] ||= begin
78
- columns = {}
79
- serialization_columns = []
80
- klass.columns_hash.each_value do |column|
81
- metadata = [ column.type, column.limit, column.null, column.default ].freeze
82
- columns[column.name] = metadata
83
- serialization_columns << [ column.name.to_sym, *metadata ].freeze
84
- end
85
- SERIALIZATION_COLUMN_CACHE[klass] = serialization_columns.freeze
86
- columns.freeze
87
- end
88
- end
89
- end
90
-
91
- def serialization_columns_for(klass)
92
- columns_for(klass)
93
- SERIALIZATION_COLUMN_CACHE.fetch(klass)
94
- end
95
- private_class_method :serialization_columns_for
96
-
97
- # trusted: this record was produced by the gem in this process (an
98
- # embedded batch), not parsed from an HTTP body. Validation and the
99
- # control-character/encoding scrub are the trust boundary and are
100
- # skipped; every limit and cap still applies, since those are a storage
101
- # contract rather than a question of who sent the record.
102
- def row_for(rec, truncations: nil, validate_identifiers: true, trusted: false)
103
- validate_record!(rec, identifiers: validate_identifiers) unless trusted
104
- type = rec["t"].to_s
105
- pair = PARENTS.include?(type) ? execution(rec) : mapped(type, rec)
106
- return nil if pair.nil?
107
- klass, attrs = pair
108
- [ klass, serialize(klass, attrs, truncations: truncations, trusted: trusted) ]
109
- end
110
-
111
- def validate_record!(rec, identifiers: true)
112
- raise TypeError, "record must be an object" unless rec.is_a?(Hash)
113
- raise TypeError, "t must be a string" unless rec["t"].is_a?(String)
114
- validate_version!(rec)
115
-
116
- if identifiers
117
- validate_identifier!(rec, "_group", GROUP_HASH, "32 hexadecimal characters")
118
- validate_identifier!(rec, "execution_id", IDENTIFIER, "a printable token of at most 36 characters")
119
- validate_identifier!(rec, "trace_id", IDENTIFIER, "a printable token of at most 36 characters")
120
- end
121
- STRUCTURES.each do |field, expected|
122
- value = rec[field]
123
- next if value.nil? || value.is_a?(expected)
124
-
125
- raise TypeError, "#{field} must be a #{expected.name.downcase}"
126
- end
127
- validate_numeric_hash!(rec["stages"], "stages")
128
- validate_numeric_hash!(rec["counters"], "counters")
129
- validate_frames!(rec["frames"])
130
- validate_locals!(rec["locals"])
131
- end
132
-
133
- # A record shape is named by its type and version (Railwatch::Record::VERSIONS).
134
- # This mapper only knows the versions the gem it ships with emits; a
135
- # record from a newer or older gem is refused by name rather than read
136
- # through columns that may no longer mean the same thing.
137
- def validate_version!(rec)
138
- expected = Record::VERSIONS[rec["t"].to_sym]
139
- return if expected.nil? # unknown type: row_for answers nil and the batch rejects it as such
140
-
141
- raise TypeError, "#{rec["t"]} v#{rec["v"].inspect} is not v#{expected}" unless rec["v"].is_a?(Integer) && rec["v"] == expected
142
- end
143
-
144
- def validate_identifier!(rec, field, pattern, description)
145
- value = rec[field]
146
- return if value.nil?
147
- raise TypeError, "#{field} must be #{description}" unless value.is_a?(String) && value.match?(pattern)
148
- end
149
- private_class_method :validate_identifier!
150
-
151
- def validate_numeric_hash!(value, field)
152
- return unless value
153
- return if value.all? { |key, number| key.is_a?(String) && number.is_a?(Numeric) && number.to_f.finite? && number >= 0 }
154
-
155
- raise TypeError, "#{field} must contain non-negative numbers"
156
- end
157
- private_class_method :validate_numeric_hash!
158
-
159
- def validate_frames!(frames)
160
- return unless frames
161
- valid = frames.all? do |frame|
162
- frame.is_a?(Hash) && %w[file function].all? { |field| frame[field].nil? || frame[field].is_a?(String) } &&
163
- (frame["line"].nil? || (frame["line"].is_a?(Integer) && frame["line"] >= 0)) &&
164
- (frame["in_app"].nil? || frame["in_app"] == true || frame["in_app"] == false) &&
165
- (frame["code"].nil? || (frame["code"].is_a?(Hash) &&
166
- frame["code"].all? { |line, source| line.is_a?(String) && source.is_a?(String) }))
167
- end
168
- raise TypeError, "frames must contain frame objects" unless valid
169
- end
170
- private_class_method :validate_frames!
171
-
172
- def validate_locals!(locals)
173
- return unless locals
174
- return if locals.all? { |key, value| key.is_a?(String) && !value.is_a?(Hash) && !value.is_a?(Array) }
175
-
176
- raise TypeError, "locals must contain scalar values"
177
- end
178
- private_class_method :validate_locals!
179
-
180
- # AR instance for callers that want validations, callbacks, or a normal
181
- # object (specs, anything that isn't the hot ingest path). row_for's
182
- # JSON columns come back pre-encoded as strings; the AR json type does
183
- # not parse a String on assignment, so they're inflated here.
184
- def build(rec)
185
- pair = row_for(rec)
186
- return nil if pair.nil?
187
- klass, attrs = pair
188
- cols = columns_for(klass)
189
- ar_attrs = attrs.each_with_object({}) do |(name, value), out|
190
- type, = cols[name.to_s]
191
- out[name] = type == :json && value.is_a?(String) ? JSON.parse(value) : value
192
- end
193
- klass.new(ar_attrs)
194
- end
195
-
196
- # Person writes use a purpose-built bulk upsert instead of row_for, but
197
- # the same trust-boundary rules still apply to their wire strings.
198
- def person_record(rec, validate_identifiers: true)
199
- validate_record!(rec, identifiers: validate_identifiers)
200
- {
201
- "id" => cast_value(rec["id"], :string, 255, klass: Telemetry::Person, name: :ref),
202
- "name" => cast_value(rec["name"], :string, 255, klass: Telemetry::Person, name: :name),
203
- "email" => cast_value(rec["email"], :string, 255, klass: Telemetry::Person, name: :email),
204
- "tenant" => cast_value(rec["tenant"], :string, 255, klass: Telemetry::Person, name: :app_tenant),
205
- "timestamp" => rec["timestamp"]
206
- }
207
- end
208
-
209
- def mapped(type, rec)
210
- case type
211
- when "query" then child(Telemetry::Query, rec, %w[sql name duration connection adapter role async in_transaction row_count source allocations explain])
212
- when "span" then span(rec)
213
- when "health" then health(rec)
214
- when "exception" then exception(rec)
215
- when "cache_event" then child(Telemetry::CacheEvent, rec, %w[store key type duration ttl hits])
216
- when "mail" then child(Telemetry::Mail, rec, %w[mailer subject to cc bcc attachments delivery_method perform_deliveries duration failed message_id])
217
- when "broadcast" then child(Telemetry::Broadcast, rec, %w[kind stream channel action bytes duration failed])
218
- when "notification" then child(Telemetry::Notification, rec, %w[notifier delivery_method channel duration failed])
219
- when "outgoing_request" then child(Telemetry::OutgoingRequest, rec, %w[host method url duration status_code request_size response_size error source response_body in_transaction])
220
- when "llm_call" then child(Telemetry::LlmCall, rec, %w[operation provider model response_model tool_name duration status error
221
- streaming message_count tool_count input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens
222
- cost_nanos workflow_id workflow_name workflow_step_id workflow_step_name workflow_step_parent_id prompt completion
223
- finish_reason provider_request_id tools cost_reported attachments attachment_types attachment_names tool_call_id params])
224
- when "profile" then profile(rec)
225
- when "attachment" then attachment(rec)
226
- when "storage_op" then child(Telemetry::StorageOp, rec, %w[service op key duration])
227
- when "view_render" then child(Telemetry::ViewRender, rec, %w[identifier kind layout count cache_hits duration])
228
- when "log" then child(Telemetry::Log, rec, %w[level message tags context source])
229
- when "enqueued_job" then enqueued_job(rec)
230
- when "transaction" then child(Telemetry::Transaction, rec, %w[duration outcome connection statement_count])
231
- when "n_plus_one" then child(Telemetry::NPlusOne, rec, %w[sql count source])
232
- when "deprecation" then child(Telemetry::Deprecation, rec, %w[message gem_name horizon source])
233
- when "visit" then child(Telemetry::Visit, rec, %w[component url method duration status partial only props_bytes user_agent lcp cls inp ttfb])
234
- when "session" then session(rec)
235
- when "user" then [ Telemetry::Person, { ref: rec["id"].to_s } ] # replaced by touch_from_record; never saved as-is
236
- when "process" then process(rec)
237
- end
238
- end
239
-
240
- def envelope(rec)
241
- {
242
- occurred_at: timestamp_string(rec["timestamp"]),
243
- deploy: rec["deploy"],
244
- server: rec["server"],
245
- group_hash: rec["_group"],
246
- trace_id: rec["trace_id"],
247
- execution_source: rec["execution_source"],
248
- execution_id: rec["execution_id"],
249
- execution_preview: rec["execution_preview"],
250
- execution_stage: rec["execution_stage"],
251
- user_ref: rec["user"],
252
- app_tenant: rec["tenant"]
253
- }
254
- end
255
-
256
- def child(klass, rec, keys)
257
- attrs = envelope(rec)
258
- keys.each { |k| attrs[k.to_sym] = rec[k] }
259
- [ klass, attrs ]
260
- end
261
-
262
- def execution(rec)
263
- kind = rec["t"]
264
- name = case kind
265
- when "request" then "#{rec['method']} #{rec['route']}"
266
- when "command" then rec["command"] || rec["name"]
267
- when "scheduled_task" then rec["task_key"] || rec["name"]
268
- when "channel_action" then "#{rec['channel']}##{rec['action']}"
269
- else rec["name"]
270
- end
271
- detail_keys = case kind
272
- when "request" then %w[url path ip headers payload context user_agent view_runtime db_runtime redirect_to halted_callback unpermitted_parameters rate_limited format inertia request_size response_size gc_time]
273
- when "command" then %w[context gc_time interactive]
274
- when "channel_action" then %w[channel context gc_time]
275
- else %w[job_id provider_job_id attempt_id adapter priority db_runtime arguments_preview context gc_time schedule]
276
- end
277
- attrs = envelope(rec)
278
- attrs[:kind] = kind
279
- attrs[:name] = name.to_s
280
- attrs[:duration] = rec["duration"]
281
- attrs[:status] = rec["status_code"] || rec["exit_code"]
282
- attrs[:outcome] = rec["status"]
283
- attrs[:method] = rec["method"]
284
- attrs[:route] = rec["route"]
285
- attrs[:controller] = rec["controller"]
286
- attrs[:action] = rec["action"]
287
- attrs[:queue] = rec["queue"]
288
- attrs[:attempt] = rec["attempt"]
289
- attrs[:queue_latency] = rec["queue_latency"]
290
- attrs[:queue_time] = rec["queue_time"]
291
- attrs[:parent_id] = rec["parent_id"]
292
- attrs[:job_id] = rec["job_id"]
293
- attrs[:task_key] = rec["task_key"]
294
- attrs[:inertia_component] = rec.dig("inertia", "component")
295
- attrs[:inertia_partial] = rec.dig("inertia", "partial_only").present?
296
- attrs[:allocations] = rec["allocations"]
297
- attrs[:peak_memory] = rec["peak_memory"]
298
- attrs[:exception_preview] = rec["exception_preview"]
299
- attrs[:stages] = rec["stages"] || {}
300
- attrs[:counters] = rec["counters"] || {}
301
- attrs[:detail] = rec.slice(*detail_keys)
302
- [ Telemetry::Execution, attrs ]
303
- end
304
-
305
- def exception(rec)
306
- attrs = envelope(rec)
307
- attrs[:class_name] = rec["class"].to_s
308
- attrs[:message] = rec["message"].to_s
309
- attrs[:handled] = rec["handled"] ? true : false
310
- attrs[:severity] = rec["severity"]
311
- attrs[:source] = rec["source"]
312
- attrs[:file] = rec["file"]
313
- attrs[:line] = rec["line"]
314
- attrs[:frames] = rec["frames"] || []
315
- attrs[:cause] = rec["cause"]
316
- attrs[:locals] = rec["locals"].is_a?(Hash) ? rec["locals"] : nil
317
- attrs[:context] = rec["context"]
318
- attrs[:fingerprint] = rec["fingerprint"] || []
319
- attrs[:fingerprint_source] = rec["fingerprint_source"]
320
- attrs[:ruby_version] = rec["ruby_version"]
321
- attrs[:rails_version] = rec["rails_version"]
322
- [ Telemetry::Exception, attrs ]
323
- end
324
-
325
- def enqueued_job(rec)
326
- attrs = envelope(rec)
327
- attrs[:job_id] = rec["job_id"]
328
- attrs[:name] = rec["name"].to_s
329
- attrs[:queue] = rec["queue"]
330
- attrs[:adapter] = rec["adapter"]
331
- attrs[:priority] = rec["priority"]
332
- attrs[:scheduled_at] = rec["scheduled_at"] && timestamp_string(rec["scheduled_at"])
333
- attrs[:duration] = rec["duration"]
334
- attrs[:failed] = rec["failed"] ? true : false
335
- [ Telemetry::EnqueuedJob, attrs ]
336
- end
337
-
338
- # The gem ships collapsed stacks base64(gzip); stored as the gzip bytes.
339
- def profile(rec)
340
- attrs = envelope(rec)
341
- attrs[:profiler] = rec["profiler"]
342
- attrs[:mode] = rec["mode"]
343
- attrs[:interval] = rec["interval"]
344
- attrs[:duration] = rec["duration"]
345
- attrs[:samples] = rec["samples"]
346
- attrs[:stacks_bytes] = declared_bytes(rec["stacks_bytes"], "stacks_bytes",
347
- max_bytes: Telemetry::Profile::MAX_INGEST_BYTES)
348
- attrs[:stacks] = compressed_blob(rec["stacks"], "stacks")
349
- Telemetry::BoundedGzip.verify!(attrs[:stacks], max_bytes: Telemetry::Profile::MAX_INGEST_BYTES,
350
- expected_bytes: attrs[:stacks_bytes], utf8: true)
351
- [ Telemetry::Profile, attrs ]
352
- end
353
-
354
- def attachment(rec)
355
- attrs = envelope(rec)
356
- attrs[:name] = rec["name"]
357
- attrs[:content_type] = rec["content_type"]
358
- attrs[:bytes] = declared_bytes(rec["bytes"], "bytes", max_bytes: Telemetry::Attachment::MAX_BODY_BYTES)
359
- attrs[:data] = compressed_blob(rec["data"], "data")
360
- Telemetry::BoundedGzip.verify!(attrs[:data], max_bytes: Telemetry::Attachment::MAX_BODY_BYTES,
361
- expected_bytes: attrs[:bytes])
362
- attrs[:exception_group_hash] = rec["exception_group_hash"]
363
- attrs[:truncated] = rec["truncated"] ? true : false
364
- [ Telemetry::Attachment, attrs ]
365
- end
366
-
367
- def compressed_blob(value, field)
368
- Base64.strict_decode64(value.to_s)
369
- rescue ArgumentError
370
- raise ArgumentError, "#{field} is not valid base64"
371
- end
372
-
373
- def declared_bytes(value, field, max_bytes:)
374
- bytes = if value.is_a?(Integer)
375
- value
376
- elsif value.is_a?(String)
377
- # Check the O(1) byte length before scanning or converting. Without
378
- # this guard an otherwise-valid request can spend seconds turning a
379
- # multi-megabyte decimal string into a huge Integer merely to reject it.
380
- max_digits = max_bytes.to_s.bytesize
381
- value.to_i if value.bytesize <= max_digits && value.match?(/\A\d+\z/)
382
- end
383
- return bytes if bytes&.between?(0, max_bytes)
384
-
385
- raise ArgumentError, "#{field} must be an integer between 0 and #{max_bytes}"
386
- end
387
-
388
- # `id` is the gem's session key; `session_id` here, since the row has its
389
- # own primary key.
390
- def session(rec)
391
- attrs = envelope(rec)
392
- attrs[:session_id] = rec["id"].to_s
393
- attrs[:source] = rec["source"]
394
- attrs[:status] = rec["status"]
395
- attrs[:started_at] = rec["started_at"] && timestamp_string(rec["started_at"])
396
- attrs[:duration] = rec["duration"]
397
- attrs[:requests] = rec["requests"]
398
- attrs[:visits] = rec["visits"]
399
- attrs[:error_count] = rec["errors"]
400
- attrs[:ended] = rec["ended"] ? true : false
401
- [ Telemetry::Session, attrs ]
402
- end
403
-
404
- def span(rec)
405
- attrs = envelope(rec)
406
- attrs[:name] = rec["name"]
407
- attrs[:duration] = rec["duration"]
408
- attrs[:payload] = rec["attributes"]
409
- attrs[:status] = rec["status"]
410
- [ Telemetry::Span, attrs ]
411
- end
412
-
413
- def health(rec)
414
- [ Telemetry::HealthSample, {
415
- sampled_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"], deploy: rec["deploy"],
416
- threads_max: rec["threads_max"], threads_busy: rec["threads_busy"], backlog: rec["backlog"],
417
- pool_size: rec["pool_size"], pool_busy: rec["pool_busy"], pool_waiting: rec["pool_waiting"],
418
- queue_depth: rec["queue_depth"], queue_latency: rec["queue_latency"], memory: rec["memory"],
419
- # The gem packs queues, workers, requests_count, running,
420
- # max_threads_reached, and recurring_tasks into one JSON string
421
- # under `detail` (lib/railwatch/health.rb). Slicing those names off
422
- # the top of the record found nothing, so every health sample landed
423
- # with an empty detail and the queue-depth panel stayed blank.
424
- detail: rec["detail"] || {}
425
- } ]
426
- end
427
-
428
- def process(rec)
429
- [ Telemetry::Process, {
430
- booted_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"],
431
- deploy: rec["deploy"], ruby_version: rec["ruby_version"], rails_version: rec["rails_version"],
432
- railwatch_version: rec["railwatch_version"], boot_seconds: rec["boot_seconds"],
433
- detail: rec.slice("app", "environment", "database_adapter", "queue_adapter", "cache_store")
434
- } ]
435
- end
436
-
437
- # Applies the casts AR would apply on save, but that raw SQL bypasses:
438
- # JSON-encode hashes/arrays, format times as UTC strings, booleans as
439
- # 0/1, coerce numeric columns, truncate strings to their column limit,
440
- # and substitute the column's default when a NOT NULL column (frames,
441
- # tags, only, ...) was left out of the wire record - the raw insert
442
- # skips SQLite's own DEFAULT clause because it names every column
443
- # explicitly, so a bare nil would otherwise hit a NOT NULL violation
444
- # that AR's insert would have silently defaulted around.
445
- def serialize(klass, attrs, truncations: nil, trusted: false)
446
- out = {}
447
- serialization_columns_for(klass).each do |name, type, limit, null, default|
448
- next unless attrs.key?(name)
449
- value = attrs[name]
450
- value = default if value.nil? && !null && !default.nil?
451
- out[name] = cast_value(value, type, limit, klass: klass, name: name, truncations: truncations, trusted: trusted)
452
- end
453
- return out if out.size == attrs.size
454
-
455
- # Mapper attribute sets are fixed and take the allocation-free path
456
- # above. Preserve serialize's behavior for callers with an unexpected
457
- # key, including the useful KeyError identifying a missing column.
458
- columns = columns_for(klass)
459
- attrs.each do |name, value|
460
- type, limit, null, default = columns.fetch(name.to_s)
461
- value = default if value.nil? && !null && !default.nil?
462
- out[name] = cast_value(value, type, limit, klass: klass, name: name, truncations: truncations, trusted: trusted)
463
- end
464
- out
465
- end
466
-
467
- # Wire timestamps are rounded to integer microseconds, then only one
468
- # Time per distinct second supplies the UTC calendar prefix. This avoids
469
- # the Rational/divmod work MRI repeats for every calendar field on a Time
470
- # constructed from a Float while preserving SQLite's six-digit format.
471
- def timestamp_string(value)
472
- microseconds = (Float(value) * 1_000_000).round
473
- seconds = microseconds / 1_000_000
474
- usec = microseconds - (seconds * 1_000_000)
475
- cache = Thread.current[TIME_PREFIX_CACHE_KEY] ||= {}
476
- prefix = cache[seconds]
477
- unless prefix
478
- cache.clear if cache.size >= TIME_PREFIX_CACHE_LIMIT
479
- time = Time.at(seconds).utc
480
- prefix = format("%04d-%02d-%02d %02d:%02d:%02d.", time.year, time.month, time.day, time.hour, time.min, time.sec).freeze
481
- cache[seconds] = prefix
482
- end
483
- prefix + format("%06d", usec)
484
- end
485
-
486
- # Kept for callers that pass Time values directly to cast_value; wire
487
- # timestamps already arrive here as their final SQLite string.
488
- def format_time(value)
489
- t = value.utc
490
- format("%04d-%02d-%02d %02d:%02d:%02d.%06d", t.year, t.month, t.day, t.hour, t.min, t.sec, t.usec)
491
- end
492
-
493
- def cast_value(value, type, limit, klass: nil, name: nil, truncations: nil, trusted: false)
494
- return nil if value.nil?
495
- if type == :json
496
- # Health detail is already JSON-encoded on the wire. Decode it before
497
- # sanitising so the database stores an object, not a JSON string that
498
- # merely contains another document.
499
- value = JSON.parse(trusted ? value : sanitize_string(value)) if value.is_a?(String)
500
- sanitized = trusted ? value : sanitize_json(value)
501
- encoded = JSON.generate(sanitized)
502
- replacement = sanitized.is_a?(Array) ? JSON_ARRAY_TRUNCATED : JSON_TRUNCATED
503
- return cap_text(encoded, text_limit(klass, name), klass, name, truncations, replacement: replacement)
504
- end
505
- if type == :datetime
506
- return value if value.is_a?(String)
507
- return format_time(value) if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
508
- end
509
- if type == :boolean
510
- raise TypeError, "expected boolean" unless value == true || value == false
511
-
512
- return value ? 1 : 0
513
- end
514
-
515
- case value
516
- when Hash, Array
517
- raise TypeError, "unexpected #{value.class} for #{type.inspect} column"
518
- else
519
- case type
520
- when :string
521
- cap_text(trusted ? value.to_s : sanitize_string(value.to_s), limit, klass, name, truncations)
522
- when :text
523
- cap_text(trusted ? value.to_s : sanitize_string(value.to_s), text_limit(klass, name), klass, name, truncations)
524
- when :integer, :bigint
525
- # Signed: queue_latency and drift are clock differences between an
526
- # enqueuing host and a worker, and skew makes them negative.
527
- raise TypeError, "expected integer" unless value.is_a?(Integer)
528
- raise RangeError, "integer out of range" unless value.between?(INTEGER_MIN, INTEGER_MAX)
529
- value
530
- when :float
531
- raise TypeError, "expected number" unless value.is_a?(Numeric)
532
- number = value.to_f
533
- raise RangeError, "number out of range" unless number.finite?
534
- number
535
- else value
536
- end
537
- end
538
- end
539
-
540
- def text_limit(klass, name)
541
- return nil unless klass && name
542
- TEXT_LIMITS.fetch("#{klass.table_name}.#{name}")
543
- end
544
- private_class_method :text_limit
545
-
546
- def cap_text(value, limit, klass, name, truncations, replacement: nil)
547
- return value unless limit && value.bytesize > limit
548
-
549
- truncations << "#{klass.table_name}.#{name}" if truncations
550
- replacement || value.byteslice(0, limit).scrub("")
551
- end
552
- private_class_method :cap_text
553
-
554
- def sanitize_json(value)
555
- case value
556
- when Hash
557
- value.each_with_object({}) do |(key, child), out|
558
- out[sanitize_string(key.to_s)] = sanitize_json(child)
559
- end
560
- when Array
561
- value.map { |child| sanitize_json(child) }
562
- when String
563
- sanitize_string(value)
564
- else
565
- value
566
- end
567
- end
568
- private_class_method :sanitize_json
569
-
570
- CONTROL_RANGE = "\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F"
571
- CONTROL_CHARS = /[#{CONTROL_RANGE}]/
572
-
573
- # scrub + delete allocate two strings per value, and this runs on every
574
- # string column of every record -- half the mapper's time on a real
575
- # batch, in which not one string needed cleaning. Check first; only
576
- # copy when there is something to remove.
577
- def sanitize_string(value)
578
- return value if value.valid_encoding? && !value.match?(CONTROL_CHARS)
579
-
580
- value.scrub.delete(CONTROL_RANGE)
581
- end
582
- private_class_method :sanitize_string
583
- end
584
- end
585
- end
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Railwatch
4
- require "zlib"
5
- require "stringio"
6
-
7
- module Ingest
8
- # Decodes the gem's wire format: optionally gzipped NDJSON.
9
- module Payload
10
- class Malformed < StandardError; end
11
- MAX_BYTES = IngestRequestBodyLimit::MAX_BYTES
12
- MAX_RECORDS = 20_000
13
- READ_SIZE = 64.kilobytes
14
-
15
- module_function
16
-
17
- def parse(request)
18
- declared_bytes = request.content_length
19
- raise Malformed, "body too large" if declared_bytes && declared_bytes > MAX_BYTES
20
-
21
- raw = read_bounded(request.body)
22
- raise Malformed, "empty body" if raw.blank?
23
-
24
- records = []
25
- pending = String.new(encoding: Encoding::BINARY)
26
- decoded_bytes = 0
27
- each_decoded_chunk(raw, gzip: request.headers["Content-Encoding"].to_s.include?("gzip")) do |chunk|
28
- decoded_bytes += chunk.bytesize
29
- raise Malformed, "payload too large" if decoded_bytes > MAX_BYTES
30
-
31
- pending << chunk
32
- pending = consume_complete_lines(pending, records)
33
- end
34
- append_record(pending, records)
35
- records
36
- rescue Zlib::Error => e
37
- raise Malformed, "bad gzip: #{e.message}"
38
- rescue JSON::ParserError => e
39
- raise Malformed, "bad json: #{e.message}"
40
- end
41
-
42
- def read_bounded(io)
43
- body = String.new(encoding: Encoding::BINARY)
44
- loop do
45
- chunk = io.read([ READ_SIZE, (MAX_BYTES + 1) - body.bytesize ].min)
46
- break if chunk.nil? || chunk.empty?
47
-
48
- body << chunk
49
- raise Malformed, "body too large" if body.bytesize > MAX_BYTES
50
- end
51
- body
52
- end
53
-
54
- def each_decoded_chunk(raw, gzip:)
55
- return each_plain_chunk(raw) { |chunk| yield chunk } unless gzip
56
-
57
- reader = Zlib::GzipReader.new(StringIO.new(raw))
58
- remaining = MAX_BYTES + 1
59
- loop do
60
- chunk = reader.readpartial([ READ_SIZE, remaining ].min)
61
- remaining -= chunk.bytesize
62
- yield chunk
63
- end
64
- rescue EOFError
65
- nil
66
- ensure
67
- reader&.close
68
- end
69
-
70
- def each_plain_chunk(raw)
71
- offset = 0
72
- while offset < raw.bytesize
73
- yield raw.byteslice(offset, READ_SIZE)
74
- offset += READ_SIZE
75
- end
76
- end
77
-
78
- def consume_complete_lines(buffer, records)
79
- offset = 0
80
- while (newline = buffer.index("\n", offset))
81
- append_record(buffer.byteslice(offset, newline - offset), records)
82
- offset = newline + 1
83
- end
84
- offset.zero? ? buffer : buffer.byteslice(offset..)
85
- end
86
-
87
- def append_record(line, records)
88
- line = line.strip
89
- return if line.empty?
90
- raise Malformed, "too many records" if records.size >= MAX_RECORDS
91
-
92
- records << JSON.parse(line)
93
- end
94
- end
95
- end
96
- end