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,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