railwatch 0.1.4 → 0.2.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 (302) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +10 -0
  4. data/app/channels/railwatch/environment_channel.rb +28 -0
  5. data/app/controllers/concerns/railwatch/telemetry_identity.rb +25 -0
  6. data/app/controllers/railwatch/alerts_controller.rb +48 -0
  7. data/app/controllers/railwatch/anomaly_rules_controller.rb +31 -0
  8. data/app/controllers/railwatch/attachments_controller.rb +22 -0
  9. data/app/controllers/railwatch/beacon_controller.rb +67 -7
  10. data/app/controllers/railwatch/broadcasts_controller.rb +15 -0
  11. data/app/controllers/railwatch/cache_events_controller.rb +30 -0
  12. data/app/controllers/railwatch/commands_controller.rb +16 -0
  13. data/app/controllers/railwatch/comments_controller.rb +11 -0
  14. data/app/controllers/railwatch/dashboard_controller.rb +69 -0
  15. data/app/controllers/railwatch/deploys_controller.rb +34 -0
  16. data/app/controllers/railwatch/deprecations_controller.rb +54 -0
  17. data/app/controllers/railwatch/environment_scoped.rb +99 -0
  18. data/app/controllers/railwatch/exceptions_controller.rb +53 -0
  19. data/app/controllers/railwatch/executions_controller.rb +13 -0
  20. data/app/controllers/railwatch/issues_controller.rb +258 -0
  21. data/app/controllers/railwatch/jobs_controller.rb +63 -0
  22. data/app/controllers/railwatch/llm_calls_controller.rb +124 -0
  23. data/app/controllers/railwatch/logs_controller.rb +40 -0
  24. data/app/controllers/railwatch/mails_controller.rb +19 -0
  25. data/app/controllers/railwatch/notifications_controller.rb +11 -0
  26. data/app/controllers/railwatch/outgoing_requests_controller.rb +19 -0
  27. data/app/controllers/railwatch/overview_controller.rb +38 -0
  28. data/app/controllers/railwatch/people_controller.rb +25 -0
  29. data/app/controllers/railwatch/processes_controller.rb +33 -0
  30. data/app/controllers/railwatch/profiles_controller.rb +50 -0
  31. data/app/controllers/railwatch/queries_controller.rb +59 -0
  32. data/app/controllers/railwatch/releases_controller.rb +75 -0
  33. data/app/controllers/railwatch/requests_controller.rb +55 -0
  34. data/app/controllers/railwatch/saved_views_controller.rb +53 -0
  35. data/app/controllers/railwatch/scheduled_tasks_controller.rb +45 -0
  36. data/app/controllers/railwatch/spans_controller.rb +47 -0
  37. data/app/controllers/railwatch/storage_ops_controller.rb +15 -0
  38. data/app/controllers/railwatch/tenants_controller.rb +44 -0
  39. data/app/controllers/railwatch/thresholds_controller.rb +40 -0
  40. data/app/controllers/railwatch/traces_controller.rb +72 -0
  41. data/app/controllers/railwatch/transactions_controller.rb +21 -0
  42. data/app/controllers/railwatch/view_renders_controller.rb +28 -0
  43. data/app/controllers/railwatch/visits_controller.rb +53 -0
  44. data/app/helpers/railwatch/assets_helper.rb +52 -0
  45. data/app/jobs/railwatch/anomaly_scan_job.rb +11 -0
  46. data/app/jobs/railwatch/application_job.rb +7 -0
  47. data/app/jobs/railwatch/auto_resolve_issues_job.rb +19 -0
  48. data/app/jobs/railwatch/check_scheduled_tasks_job.rb +113 -0
  49. data/app/jobs/railwatch/detect_anomalies_job.rb +171 -0
  50. data/app/jobs/railwatch/detect_performance_issues_job.rb +85 -0
  51. data/app/jobs/railwatch/group_exceptions_job.rb +91 -0
  52. data/app/jobs/railwatch/optimize_telemetry_job.rb +23 -0
  53. data/app/jobs/railwatch/performance_scan_job.rb +11 -0
  54. data/app/jobs/railwatch/prune_telemetry_job.rb +110 -0
  55. data/app/jobs/railwatch/release_health_rollup_job.rb +64 -0
  56. data/app/jobs/railwatch/rollup_catchup_job.rb +21 -0
  57. data/app/jobs/railwatch/rollup_job.rb +130 -0
  58. data/app/jobs/railwatch/scheduled_task_scan_job.rb +11 -0
  59. data/app/models/concerns/railwatch/detection_snapshotting.rb +20 -0
  60. data/app/models/railwatch/alert.rb +229 -0
  61. data/app/models/railwatch/alert_rule.rb +121 -0
  62. data/app/models/railwatch/anomaly_rule.rb +33 -0
  63. data/app/models/railwatch/application.rb +44 -0
  64. data/app/models/railwatch/application_record.rb +33 -0
  65. data/app/models/railwatch/comment.rb +36 -0
  66. data/app/models/railwatch/deploy.rb +67 -0
  67. data/app/models/railwatch/environment.rb +54 -0
  68. data/app/models/railwatch/execution_presenter.rb +185 -0
  69. data/app/models/railwatch/filter_query.rb +143 -0
  70. data/app/models/railwatch/followup_receipt.rb +27 -0
  71. data/app/models/railwatch/ingest/batch.rb +305 -0
  72. data/app/models/railwatch/ingest/mapper.rb +575 -0
  73. data/app/models/railwatch/ingest/payload.rb +96 -0
  74. data/app/models/railwatch/ingest/rollup_absorber.rb +170 -0
  75. data/app/models/railwatch/ingest/writer.rb +137 -0
  76. data/app/models/railwatch/issue.rb +277 -0
  77. data/app/models/railwatch/issue_activity.rb +23 -0
  78. data/app/models/railwatch/issue_detection_presenter.rb +309 -0
  79. data/app/models/railwatch/issue_detection_snapshot.rb +77 -0
  80. data/app/models/railwatch/maintenance_task.rb +53 -0
  81. data/app/models/railwatch/saved_view.rb +63 -0
  82. data/app/models/railwatch/telemetry/aggregations.rb +116 -0
  83. data/app/models/railwatch/telemetry/attachment.rb +31 -0
  84. data/app/models/railwatch/telemetry/bounded_gzip.rb +101 -0
  85. data/app/models/railwatch/telemetry/broadcast.rb +13 -0
  86. data/app/models/railwatch/telemetry/cache_event.rb +16 -0
  87. data/app/models/railwatch/telemetry/child.rb +53 -0
  88. data/app/models/railwatch/telemetry/cursor_page.rb +99 -0
  89. data/app/models/railwatch/telemetry/deprecation.rb +13 -0
  90. data/app/models/railwatch/telemetry/enqueued_job.rb +13 -0
  91. data/app/models/railwatch/telemetry/exception.rb +21 -0
  92. data/app/models/railwatch/telemetry/execution.rb +71 -0
  93. data/app/models/railwatch/telemetry/health_sample.rb +72 -0
  94. data/app/models/railwatch/telemetry/ingest_batch.rb +31 -0
  95. data/app/models/railwatch/telemetry/llm_call.rb +37 -0
  96. data/app/models/railwatch/telemetry/log.rb +85 -0
  97. data/app/models/railwatch/telemetry/mail.rb +13 -0
  98. data/app/models/railwatch/telemetry/n_plus_one.rb +92 -0
  99. data/app/models/railwatch/telemetry/notification.rb +13 -0
  100. data/app/models/railwatch/telemetry/outgoing_request.rb +13 -0
  101. data/app/models/railwatch/telemetry/person.rb +50 -0
  102. data/app/models/railwatch/telemetry/process.rb +10 -0
  103. data/app/models/railwatch/telemetry/profile.rb +37 -0
  104. data/app/models/railwatch/telemetry/query.rb +50 -0
  105. data/app/models/railwatch/telemetry/query_shape.rb +45 -0
  106. data/app/models/railwatch/telemetry/release_health.rb +63 -0
  107. data/app/models/railwatch/telemetry/rollup.rb +78 -0
  108. data/app/models/railwatch/telemetry/session.rb +24 -0
  109. data/app/models/railwatch/telemetry/span.rb +19 -0
  110. data/app/models/railwatch/telemetry/storage_op.rb +13 -0
  111. data/app/models/railwatch/telemetry/tenant.rb +221 -0
  112. data/app/models/railwatch/telemetry/transaction.rb +13 -0
  113. data/app/models/railwatch/telemetry/view_render.rb +13 -0
  114. data/app/models/railwatch/telemetry/visit.rb +24 -0
  115. data/app/models/railwatch/telemetry_record.rb +57 -0
  116. data/app/models/railwatch/threshold.rb +29 -0
  117. data/app/models/railwatch/user.rb +47 -0
  118. data/app/models/railwatch/viewer.rb +13 -0
  119. data/app/views/layouts/railwatch/dashboard.html.erb +25 -0
  120. data/config/routes.rb +59 -0
  121. data/db/railwatch_migrate/20260916000000_create_railwatch_tables.rb +151 -0
  122. data/db/railwatch_migrate/20260917000000_create_railwatch_maintenance_tasks.rb +18 -0
  123. data/db/railwatch_migrate/20260917120000_create_railwatch_followup_receipts.rb +20 -0
  124. data/db/railwatch_migrate/20260918120000_widen_host_user_ids.rb +71 -0
  125. data/db/railwatch_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
  126. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
  127. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
  128. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
  129. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
  130. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
  131. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
  132. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
  133. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
  134. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
  135. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
  136. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
  137. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
  138. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
  139. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
  140. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
  141. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
  142. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
  143. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
  144. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
  145. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
  146. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
  147. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
  148. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
  149. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
  150. data/docs/configuration.md +26 -0
  151. data/docs/embedded.md +352 -0
  152. data/docs/getting-started.md +5 -0
  153. data/lib/generators/railwatch/install/install_generator.rb +222 -1
  154. data/lib/generators/railwatch/install/templates/{initializer.rb → initializer.rb.tt} +39 -0
  155. data/lib/generators/railwatch/install/templates/post-deploy +8 -0
  156. data/lib/puma/plugin/railwatch.rb +170 -0
  157. data/lib/railwatch/authentication.rb +83 -0
  158. data/lib/railwatch/configuration.rb +134 -3
  159. data/lib/railwatch/dashboard_assets.rb +45 -0
  160. data/lib/railwatch/embedded.rb +54 -0
  161. data/lib/railwatch/engine.rb +89 -1
  162. data/lib/railwatch/ingest_request_body_limit.rb +10 -0
  163. data/lib/railwatch/json_compat.rb +60 -0
  164. data/lib/railwatch/maintenance.rb +183 -0
  165. data/lib/railwatch/patches/runner_command.rb +21 -1
  166. data/lib/railwatch/record.rb +33 -8
  167. data/lib/railwatch/reporter.rb +16 -0
  168. data/lib/railwatch/subscribers/process_info.rb +2 -1
  169. data/lib/railwatch/transport/local.rb +78 -0
  170. data/lib/railwatch/transport/socket.rb +183 -0
  171. data/lib/railwatch/version.rb +1 -1
  172. data/lib/railwatch/writer.rb +370 -0
  173. data/lib/railwatch.rb +38 -1
  174. data/lib/tasks/railwatch_tasks.rake +128 -0
  175. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  176. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  177. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  180. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  181. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  182. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  183. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  184. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  185. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  186. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  187. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  188. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  189. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  190. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  191. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  192. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  193. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  194. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  195. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  196. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  197. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  198. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  199. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  200. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  201. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  202. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  203. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  204. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  205. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  206. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  207. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  208. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  209. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  210. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  211. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  212. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  213. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  214. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  215. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  216. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  217. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  218. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  219. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  220. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  221. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  222. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  223. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  224. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  225. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  226. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  227. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  228. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  229. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  230. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  231. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  232. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  233. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  234. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  235. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  236. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  237. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  238. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  239. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  240. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  241. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  242. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  243. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  244. data/public/railwatch/assets/index-oVkururr.js +1 -0
  245. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  246. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  247. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  248. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  249. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  250. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  251. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  252. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  253. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  254. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  255. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  256. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  257. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  258. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  259. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  260. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  261. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  262. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  263. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  264. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  265. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  266. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  267. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  268. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  269. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  270. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  271. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  272. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  273. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  274. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  275. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  276. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  277. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  278. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  279. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  280. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  281. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  282. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  283. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  284. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  285. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  286. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  287. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  288. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  289. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  290. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  291. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  292. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  293. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  294. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  295. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  296. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  297. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  298. data/public/railwatch/icon.png +0 -0
  299. data/public/railwatch/icon.svg +5 -0
  300. data/public/railwatch/manifest.json +2171 -0
  301. data/public/railwatch/rails-vite.json +1 -0
  302. metadata +314 -4
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Ingest
5
+ # Writes one batch into the environment's telemetry database: rows are
6
+ # grouped by table and bulk-inserted inside a single transaction, then the
7
+ # follow-up work (issue grouping, rollups) is enqueued. Unknown or
8
+ # malformed records are counted as rejected, never raised.
9
+ class Batch
10
+ Result = Struct.new(:accepted, :rejected, :rejections, keyword_init: true) do
11
+ def to_h = { accepted: accepted, rejected: rejected, rejections: rejections.first(10) }
12
+ end
13
+
14
+ # embedded: this batch is being written in the monitored application's own
15
+ # process rather than served over HTTP. Quota accounting is a platform
16
+ # concern, and count_events! writes the PRIMARY database on every batch --
17
+ # exactly the customer-database write an embedded install must not make.
18
+ # The live broadcast stays (it is sub-millisecond and throttled to once
19
+ # every 2s) but must not be able to break ingest in an app that has no
20
+ # Action Cable adapter configured.
21
+ def initialize(environment, records, dropped_by_client: 0, backpressure_factor: nil, bytes: 0, gem_version: nil,
22
+ received_at: nil, embedded: false, batch_id: nil)
23
+ @embedded = embedded
24
+ @batch_id = batch_id
25
+ @environment = environment
26
+ @records = records
27
+ @dropped_by_client = dropped_by_client
28
+ @backpressure_factor = normalize_backpressure_factor(backpressure_factor)
29
+ @bytes = bytes
30
+ @gem_version = gem_version
31
+ @received_at = received_at || Time.current
32
+ @rejections = []
33
+ @truncations = []
34
+ @counts = Hash.new(0)
35
+ @rows_by_class = Hash.new { |h, k| h[k] = [] }
36
+ @people = []
37
+ @rollup_buckets = Set.new
38
+ @session_buckets = Set.new
39
+ @bucket_cache = {}
40
+ @exception_ids = []
41
+ end
42
+
43
+ # The ledger row for a batch id that has already been written, or nil.
44
+ # The row is created inside the batch's transaction, so seeing it is
45
+ # seeing the commit. Callers run this inside environment.with_telemetry.
46
+ def self.committed(batch_id)
47
+ Telemetry::IngestBatch.committed(batch_id)
48
+ end
49
+
50
+ def write!
51
+ map_all
52
+ log_truncations
53
+ accepted = 0
54
+ Railwatch.span("ingest.write", records: @records.size) do
55
+ @environment.with_telemetry do
56
+ TelemetryRecord.transaction do
57
+ writer = Ingest::Writer.new(@rows_by_class)
58
+ @exception_ids = writer.write!
59
+ link_profiles!
60
+ absorb_rollups!(writer) if @embedded
61
+ accepted += @rows_by_class.values.sum(&:size)
62
+ Telemetry::Person.touch_all(@people.map { |rec| [ rec, Time.at(rec["timestamp"].to_f).utc ] })
63
+ accepted += @people.size
64
+ @ledger = Telemetry::IngestBatch.create!(received_at: @received_at, accepted: accepted, rejected: @rejections.size,
65
+ dropped_by_client: @dropped_by_client, backpressure_factor: @backpressure_factor,
66
+ bytes: @bytes, gem_version: @gem_version,
67
+ counts_by_type: @counts, rejections: @rejections.first(20),
68
+ batch_id: @batch_id, followups: followups)
69
+ end
70
+ end
71
+ end
72
+
73
+ if @embedded
74
+ touch_last_seen
75
+ else
76
+ @environment.count_events!(accepted)
77
+ end
78
+ enqueue_followups
79
+ broadcast_live
80
+ Result.new(accepted: accepted, rejected: @rejections.size, rejections: @rejections)
81
+ end
82
+
83
+ # What this batch still owes once its own transaction has committed, in
84
+ # embedded mode: the work that writes the railwatch database and so
85
+ # cannot share the telemetry transaction. Recorded on the ledger row in
86
+ # that same transaction and cleared when done, so a crash in between
87
+ # leaves a row Railwatch::Maintenance can finish rather than a batch
88
+ # whose exceptions never become issues. nil (nothing owed) otherwise.
89
+ def followups
90
+ return nil unless @embedded && @exception_ids.any?
91
+
92
+ { "group_exception_ids" => @exception_ids }
93
+ end
94
+
95
+ private
96
+
97
+ ROLLED_UP = %w[request job_attempt scheduled_task command channel_action query outgoing_request cache_event mail visit notification span llm_call].freeze
98
+ MAX_PAST_AGE = 30.days
99
+ MAX_FUTURE_AGE = 1.hour
100
+
101
+ def normalize_backpressure_factor(value)
102
+ factor = Float(value, exception: false)
103
+ return 1.0 unless factor&.finite?
104
+
105
+ [ factor, 1.0 ].max
106
+ end
107
+
108
+ # A plain Concurrent::Map keyed by environment id throttles across every
109
+ # thread writing batches in this process -- no need for Rails.cache
110
+ # (which is :null_store in test anyway, so it wouldn't throttle there at
111
+ # all) or a database column. It does not coordinate across separate
112
+ # Puma *worker processes*: a host running cluster mode broadcasts up to
113
+ # once per 2s per worker instead of once globally, which is fine since
114
+ # these broadcasts are only a "data changed, go refetch" ping, not the
115
+ # data itself.
116
+ LAST_BROADCAST_AT = Concurrent::Map.new
117
+ THROTTLE_WINDOW = 2.seconds
118
+
119
+ # RollupJob's limits_concurrency only discards an enqueue while a run
120
+ # for that bucket is queued or running; with a batch landing every
121
+ # second, the next enqueue arrives the moment the previous run finishes,
122
+ # and the rollups worker recomputed one busy hour 1,200 times an hour.
123
+ # One enqueue per bucket per window from here; RollupCatchupJob sweeps
124
+ # the current and previous hour every 5 minutes regardless.
125
+ LAST_ROLLUP_ENQUEUE_AT = Concurrent::Map.new
126
+ ROLLUP_ENQUEUE_WINDOW = 60.seconds
127
+
128
+ def map_all
129
+ @records.each do |rec|
130
+ if @embedded
131
+ # These two the mapper's own dispatch depends on; the rest of
132
+ # validate_record! is the untrusted-input check that an in-process
133
+ # batch does not need.
134
+ raise TypeError, "record must be an object" unless rec.is_a?(Hash)
135
+ raise TypeError, "t must be a string" unless rec["t"].is_a?(String)
136
+ else
137
+ Ingest::Mapper.validate_record!(rec)
138
+ end
139
+ type = rec["t"]
140
+ unless timestamp_in_range?(rec["timestamp"])
141
+ reject(rec, "timestamp out of range")
142
+ next
143
+ end
144
+ if type == "user"
145
+ @people << Ingest::Mapper.person_record(rec)
146
+ @counts[type] += 1
147
+ next
148
+ end
149
+ row = Ingest::Mapper.row_for(rec, truncations: @truncations, validate_identifiers: !@embedded, trusted: @embedded)
150
+ if row.nil?
151
+ reject(rec, "unknown type")
152
+ next
153
+ end
154
+ klass, attrs = row
155
+ @rows_by_class[klass] << attrs
156
+ @counts[type] += 1
157
+ if ROLLED_UP.include?(type)
158
+ @rollup_buckets << bucket_for(rec["timestamp"])
159
+ elsif type == "session"
160
+ # Sessions get their own hourly table rather than the t-digest
161
+ # rollups: what release health needs is each session's worst status,
162
+ # not duration percentiles.
163
+ @session_buckets << bucket_for(rec["timestamp"])
164
+ end
165
+ rescue ArgumentError, TypeError, KeyError, NoMethodError, RangeError, JSON::GeneratorError => e
166
+ reject(rec, "#{e.class}: #{e.message}")
167
+ rescue StandardError => e
168
+ Rails.error.report(e, handled: true, context: { ingest_record_type: record_type(rec) })
169
+ reject(rec, "invalid record")
170
+ end
171
+ end
172
+
173
+ def timestamp_in_range?(value)
174
+ raise TypeError, "timestamp must be a number" unless value.is_a?(Numeric)
175
+
176
+ timestamp = value.to_f
177
+ raise RangeError, "timestamp is not finite" unless timestamp.finite?
178
+
179
+ min, max = timestamp_bounds
180
+ timestamp.between?(min, max)
181
+ end
182
+
183
+ # Time - 30.days walks the calendar through ActiveSupport::Duration, and
184
+ # recomputing both ends per record was 13% of an embedded batch's write.
185
+ # The window is fixed for the batch; compute it once.
186
+ def timestamp_bounds
187
+ @timestamp_bounds ||= [ (@received_at - MAX_PAST_AGE).to_f, (@received_at + MAX_FUTURE_AGE).to_f ]
188
+ end
189
+
190
+ def bucket_for(value)
191
+ seconds = (Float(value) * 1_000_000).round / 1_000_000
192
+ hour = seconds - (seconds % 1.hour)
193
+ @bucket_cache[hour] ||= Telemetry::Rollup.bucket_for(Time.zone.at(hour))
194
+ end
195
+
196
+ # executions.profile_id is denormalised so a request list or detail page
197
+ # can tell "this one was profiled" without touching the profiles table.
198
+ # The writer does not hand profile ids back, so the freshly inserted
199
+ # rows are matched by execution_id in one correlated UPDATE. The profile
200
+ # and its execution usually arrive in the same batch, but not always --
201
+ # this only links the ones present here; a late profile links on its own
202
+ # batch, a late execution stays unlinked until the page falls back to
203
+ # looking the profile up by execution_id.
204
+ def link_profiles!
205
+ execution_ids = @rows_by_class.fetch(Telemetry::Profile, []).filter_map { |attrs| attrs[:execution_id] }.uniq
206
+ return if execution_ids.empty?
207
+ Telemetry::Execution.where(execution_id: execution_ids)
208
+ .update_all("profile_id = (SELECT MAX(profiles.id) FROM profiles WHERE profiles.execution_id = executions.execution_id)")
209
+ end
210
+
211
+ def reject(rec, reason)
212
+ @rejections << { type: record_type(rec), reason: reason.to_s.first(200) }
213
+ end
214
+
215
+ def record_type(rec)
216
+ rec.is_a?(Hash) && rec["t"].is_a?(String) ? rec["t"].first(64) : nil
217
+ end
218
+
219
+ def log_truncations
220
+ return if @truncations.empty?
221
+
222
+ counts = @truncations.tally.sort.to_h
223
+ Rails.logger.info("ingest truncated #{@truncations.size} field(s) for #{@environment.slug}: #{counts.to_json}")
224
+ end
225
+
226
+ # Embedded: nothing is enqueued, ever. The host may have no worker, and
227
+ # its queue adapter may be its primary database, which this gem never
228
+ # writes. Exceptions are grouped here, right after the batch commits;
229
+ # rollups were folded in by absorb_rollups!; release health and the
230
+ # detectors run from Railwatch::Maintenance on its own clock.
231
+ def enqueue_followups
232
+ return group_exceptions_now if @embedded
233
+
234
+ GroupExceptionsJob.perform_later(@environment, @exception_ids) if @exception_ids.any?
235
+ @rollup_buckets.each { |bucket| RollupJob.perform_later(@environment, bucket) if rollup_due?(bucket) }
236
+ @session_buckets.each { |bucket| ReleaseHealthRollupJob.perform_later(@environment, bucket) }
237
+ end
238
+
239
+ # Outside the telemetry transaction (it writes the railwatch database),
240
+ # and a failure here must not fail a batch that has already committed:
241
+ # the ledger row keeps the follow-ups, and the maintenance clock drains
242
+ # them on its next tick.
243
+ def group_exceptions_now
244
+ return if @exception_ids.empty?
245
+
246
+ @environment.with_telemetry { @ledger.drain_followups!(@environment) }
247
+ rescue StandardError => e
248
+ Rails.error.report(e, handled: true, context: { ingest_group_exceptions: @environment.slug })
249
+ end
250
+
251
+ # In-process there is no worker to recompute the hour and someone may
252
+ # be watching this one environment, so the batch folds its own rows
253
+ # into the hourly rollups as it lands (Ingest::RollupAbsorber). Inside
254
+ # the batch transaction: the raw rows and their rollup move together.
255
+ def absorb_rollups!(writer)
256
+ Railwatch.span("ingest.rollup_absorb") do
257
+ Ingest::RollupAbsorber.new(@rows_by_class, query_shapes: writer.query_shapes || {}).absorb!
258
+ end
259
+ end
260
+
261
+ # Embedded installs skip quota accounting, but last_seen_at still drives
262
+ # the "last seen" display and SilentHostCheckJob. One primary-database
263
+ # write per minute, not one per batch.
264
+ LAST_SEEN_TOUCH_AT = Concurrent::Map.new
265
+ LAST_SEEN_TOUCH_WINDOW = 60.seconds
266
+
267
+ def touch_last_seen
268
+ now = Time.current
269
+ last = LAST_SEEN_TOUCH_AT[@environment.id]
270
+ return if last && now - last < LAST_SEEN_TOUCH_WINDOW
271
+
272
+ LAST_SEEN_TOUCH_AT[@environment.id] = now
273
+ @environment.update_columns(last_seen_at: now)
274
+ end
275
+
276
+ def rollup_due?(bucket)
277
+ key = [ @environment.id, bucket.to_i ]
278
+ now = Time.current
279
+ last = LAST_ROLLUP_ENQUEUE_AT[key]
280
+ return false if last && now - last < ROLLUP_ENQUEUE_WINDOW
281
+
282
+ LAST_ROLLUP_ENQUEUE_AT[key] = now
283
+ true
284
+ end
285
+
286
+ def broadcast_live
287
+ return unless defined?(::ActionCable)
288
+
289
+ now = Time.current
290
+ last = LAST_BROADCAST_AT[@environment.id]
291
+ return if last && now - last < THROTTLE_WINDOW
292
+
293
+ LAST_BROADCAST_AT[@environment.id] = now
294
+ ActionCable.server.broadcast("environment_#{@environment.id}", { event: "ingested", at: now.iso8601, counts: @counts })
295
+ rescue StandardError, LoadError => e
296
+ # A live refresh ping is not worth failing a written batch over.
297
+ # LoadError too: a host whose production cable adapter is redis with
298
+ # no redis gem raises Gem::LoadError from inside the broadcast, and
299
+ # in the writer that took the whole process down with it (Puma
300
+ # restarted it, every batch retried, every retry did it again).
301
+ Rails.error.report(e, handled: true, context: { ingest_broadcast: @environment.slug })
302
+ end
303
+ end
304
+ end
305
+ end