railwatch 0.1.3 → 0.2.0.pre1

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 (303) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +231 -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_telemetry_migrate/20260903000001_create_telemetry.rb +481 -0
  125. data/db/railwatch_telemetry_migrate/20260903000002_rename_tenant_to_app_tenant.rb +14 -0
  126. data/db/railwatch_telemetry_migrate/20260903000003_add_statement_count_to_transactions.rb +9 -0
  127. data/db/railwatch_telemetry_migrate/20260903000004_add_role_and_channel.rb +10 -0
  128. data/db/railwatch_telemetry_migrate/20260903000005_add_locals_to_exceptions.rb +9 -0
  129. data/db/railwatch_telemetry_migrate/20260903000006_add_spans_health_vitals_and_fts.rb +82 -0
  130. data/db/railwatch_telemetry_migrate/20260903000007_rename_span_attributes_to_payload.rb +10 -0
  131. data/db/railwatch_telemetry_migrate/20260903000008_add_profiles_and_attachments.rb +60 -0
  132. data/db/railwatch_telemetry_migrate/20260903000009_add_truncated_to_attachments.rb +9 -0
  133. data/db/railwatch_telemetry_migrate/20260903000010_create_sessions_and_release_health.rb +51 -0
  134. data/db/railwatch_telemetry_migrate/20260903000011_add_fingerprint_to_exceptions.rb +11 -0
  135. data/db/railwatch_telemetry_migrate/20260904000012_add_failed_to_broadcasts.rb +10 -0
  136. data/db/railwatch_telemetry_migrate/20260904010000_add_filter_cursor_indexes.rb +37 -0
  137. data/db/railwatch_telemetry_migrate/20260904020000_add_n_plus_ones_execution_id_index.rb +11 -0
  138. data/db/railwatch_telemetry_migrate/20260904120000_create_query_shapes.rb +15 -0
  139. data/db/railwatch_telemetry_migrate/20260906120000_add_backpressure_factor_to_ingest_batches.rb +9 -0
  140. data/db/railwatch_telemetry_migrate/20260907000000_rename_lantern_version_on_processes.rb +10 -0
  141. data/db/railwatch_telemetry_migrate/20260913000000_rename_nightrail_version_on_processes.rb +9 -0
  142. data/db/railwatch_telemetry_migrate/20260914000000_drop_orphan_durable_ingest_tables.rb +18 -0
  143. data/db/railwatch_telemetry_migrate/20260914010000_drop_orphan_durable_ingest_columns.rb +25 -0
  144. data/db/railwatch_telemetry_migrate/20260915000000_create_llm_calls.rb +55 -0
  145. data/db/railwatch_telemetry_migrate/20260915120000_add_detail_to_llm_calls.rb +21 -0
  146. data/db/railwatch_telemetry_migrate/20260915200000_add_explained_index_to_queries.rb +16 -0
  147. data/db/railwatch_telemetry_migrate/20260915210000_add_slowest_index_to_queries.rb +18 -0
  148. data/db/railwatch_telemetry_migrate/20260917010000_add_batch_ledger_to_ingest_batches.rb +16 -0
  149. data/docs/configuration.md +26 -0
  150. data/docs/embedded.md +342 -0
  151. data/docs/getting-started.md +5 -0
  152. data/docs/records.md +16 -1
  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 +51 -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/llm.rb +149 -4
  169. data/lib/railwatch/subscribers/process_info.rb +2 -1
  170. data/lib/railwatch/transport/local.rb +78 -0
  171. data/lib/railwatch/transport/socket.rb +183 -0
  172. data/lib/railwatch/version.rb +1 -1
  173. data/lib/railwatch/writer.rb +370 -0
  174. data/lib/railwatch.rb +38 -1
  175. data/lib/tasks/railwatch_tasks.rake +128 -0
  176. data/public/railwatch/assets/CommitMono-Bold-D6h61ieg.woff2 +0 -0
  177. data/public/railwatch/assets/CommitMono-Regular-zr8w7Obm.woff2 +0 -0
  178. data/public/railwatch/assets/Roboto-Black-auA4GeOK.woff2 +0 -0
  179. data/public/railwatch/assets/Roboto-Bold-CJLnO8j1.woff2 +0 -0
  180. data/public/railwatch/assets/Roboto-Medium-Cm2bwKpj.woff2 +0 -0
  181. data/public/railwatch/assets/Roboto-Regular-Chaq1-PV.woff2 +0 -0
  182. data/public/railwatch/assets/app-layout-yh-sPWgK.js +1 -0
  183. data/public/railwatch/assets/app-wordmark-nbjkzxwQ.js +1 -0
  184. data/public/railwatch/assets/appearance-CDuRvQTB.js +1 -0
  185. data/public/railwatch/assets/application-C_kpBdnf.css +1 -0
  186. data/public/railwatch/assets/arrow-up-DVtOGdVA.js +1 -0
  187. data/public/railwatch/assets/auth-layout-TCwPpS1K.js +1 -0
  188. data/public/railwatch/assets/badge-Daw4Hvr8.js +1 -0
  189. data/public/railwatch/assets/braces-DhFbHPsz.js +1 -0
  190. data/public/railwatch/assets/card-BX_3HXcJ.js +1 -0
  191. data/public/railwatch/assets/chart-B14-N9g7.js +39 -0
  192. data/public/railwatch/assets/chart-hover-Vy52H4uD.js +1 -0
  193. data/public/railwatch/assets/chart-panel-Cb4S_ej_.js +1 -0
  194. data/public/railwatch/assets/checkbox-D3aRSsBj.js +1 -0
  195. data/public/railwatch/assets/code-KvW8k7Jr.js +1 -0
  196. data/public/railwatch/assets/copy-DaQMJWoT.js +1 -0
  197. data/public/railwatch/assets/copy-block-BKFGd11J.js +1 -0
  198. data/public/railwatch/assets/copy-id-Djks1fXB.js +1 -0
  199. data/public/railwatch/assets/cursor-load-more-BXV0f1D_.js +1 -0
  200. data/public/railwatch/assets/data-table-iv1bdF6u.js +1 -0
  201. data/public/railwatch/assets/edit-BZc_Iawe.js +1 -0
  202. data/public/railwatch/assets/edit-DMKUF8Zi.js +8 -0
  203. data/public/railwatch/assets/edit-__9yJlO3.js +1 -0
  204. data/public/railwatch/assets/empty-state-6j_0AaQQ.js +1 -0
  205. data/public/railwatch/assets/env-layout-DrT8rO6P.js +1 -0
  206. data/public/railwatch/assets/execution-path-CzgBUi5e.js +1 -0
  207. data/public/railwatch/assets/filter-bar-9SU5NrzX.js +1 -0
  208. data/public/railwatch/assets/flamegraph-qcekju8V.js +2 -0
  209. data/public/railwatch/assets/format-B9SDkrWj.js +1 -0
  210. data/public/railwatch/assets/frames-Cyu7KMxZ.js +1 -0
  211. data/public/railwatch/assets/google-sign-in-button-DsTSfmzY.js +1 -0
  212. data/public/railwatch/assets/index-1ol1-QWI.js +1 -0
  213. data/public/railwatch/assets/index-5jI4aFzC.js +1 -0
  214. data/public/railwatch/assets/index-9KTrVnrc.js +1 -0
  215. data/public/railwatch/assets/index-B0-8lcTp.js +1 -0
  216. data/public/railwatch/assets/index-B7jjfNfO.js +1 -0
  217. data/public/railwatch/assets/index-BBchRy0M.js +1 -0
  218. data/public/railwatch/assets/index-BRiq3SNR.js +1 -0
  219. data/public/railwatch/assets/index-BgKj9xhr.js +1 -0
  220. data/public/railwatch/assets/index-BkTZqqOu.js +1 -0
  221. data/public/railwatch/assets/index-BprKx8QO.js +1 -0
  222. data/public/railwatch/assets/index-C3jzvPs3.js +1 -0
  223. data/public/railwatch/assets/index-Cbs6gGyQ.js +1 -0
  224. data/public/railwatch/assets/index-CdRZ6AWF.js +1 -0
  225. data/public/railwatch/assets/index-CeYKnapu.js +1 -0
  226. data/public/railwatch/assets/index-Cmlwy1-V.js +1 -0
  227. data/public/railwatch/assets/index-Cwx6058d.js +1 -0
  228. data/public/railwatch/assets/index-D4CSdbHv.js +1 -0
  229. data/public/railwatch/assets/index-DEFMSkdG.js +1 -0
  230. data/public/railwatch/assets/index-DFiHEBSh.js +1 -0
  231. data/public/railwatch/assets/index-DL4vWdWJ.js +1 -0
  232. data/public/railwatch/assets/index-DU9F5b5d.js +1 -0
  233. data/public/railwatch/assets/index-DaXgPcGL.js +1 -0
  234. data/public/railwatch/assets/index-DbtaU-EE.js +1 -0
  235. data/public/railwatch/assets/index-DeOe83F4.js +1 -0
  236. data/public/railwatch/assets/index-DiucHN4B.js +1 -0
  237. data/public/railwatch/assets/index-DlnR_l9o.js +1 -0
  238. data/public/railwatch/assets/index-DlumCsWY.js +2 -0
  239. data/public/railwatch/assets/index-DmRd7aIG.js +1 -0
  240. data/public/railwatch/assets/index-DxSh2UpM.js +1 -0
  241. data/public/railwatch/assets/index-MIMGuFNt.js +1 -0
  242. data/public/railwatch/assets/index-OqI59zPb.js +1 -0
  243. data/public/railwatch/assets/index-P4rC7IlX.js +1 -0
  244. data/public/railwatch/assets/index-gpPOcFWq.js +1 -0
  245. data/public/railwatch/assets/index-oVkururr.js +1 -0
  246. data/public/railwatch/assets/index-p9puqVge.js +1 -0
  247. data/public/railwatch/assets/inertia-TViv6kNv.js +97 -0
  248. data/public/railwatch/assets/input-error-LxImUkxv.js +1 -0
  249. data/public/railwatch/assets/json-viewer-Ar4cjPDW.js +1 -0
  250. data/public/railwatch/assets/klass-CJ-J4INB.js +1 -0
  251. data/public/railwatch/assets/label-COUKWqE_.js +1 -0
  252. data/public/railwatch/assets/layout-DNSLAkw_.js +1 -0
  253. data/public/railwatch/assets/live-dot-ChfUtY3p.js +41 -0
  254. data/public/railwatch/assets/nav-CNnDqPlm.js +1 -0
  255. data/public/railwatch/assets/new-84S8ZJq9.js +1 -0
  256. data/public/railwatch/assets/new-Be55nmt9.js +1 -0
  257. data/public/railwatch/assets/new-Bi_xQiIb.js +1 -0
  258. data/public/railwatch/assets/new-BvCT8TMg.js +1 -0
  259. data/public/railwatch/assets/new-D05SajFR.js +1 -0
  260. data/public/railwatch/assets/new-D4uewYC8.js +1 -0
  261. data/public/railwatch/assets/onboarding-CYZi5Cqc.js +1 -0
  262. data/public/railwatch/assets/origin-identity-6q1-CBts.js +1 -0
  263. data/public/railwatch/assets/percentile-picker-gFZCXtdb.js +1 -0
  264. data/public/railwatch/assets/relative-time-IOOgl5n2.js +1 -0
  265. data/public/railwatch/assets/release-health-DC8oc7uw.js +1 -0
  266. data/public/railwatch/assets/route-Dv6LAWvT.js +1 -0
  267. data/public/railwatch/assets/segmented-h1VdDTqE.js +1 -0
  268. data/public/railwatch/assets/select-_AJsUa7X.js +1 -0
  269. data/public/railwatch/assets/separator-BwwTYtCF.js +1 -0
  270. data/public/railwatch/assets/series-chart-DaFPefku.js +1 -0
  271. data/public/railwatch/assets/show-B7NCgkEo.js +1 -0
  272. data/public/railwatch/assets/show-BKqyKjBK.js +1 -0
  273. data/public/railwatch/assets/show-BM6X2Mpo.js +1 -0
  274. data/public/railwatch/assets/show-BNw4tN5q.js +1 -0
  275. data/public/railwatch/assets/show-BO3bnG5h.js +1 -0
  276. data/public/railwatch/assets/show-BhrAVAEA.js +1 -0
  277. data/public/railwatch/assets/show-C4Ltf5i9.js +2 -0
  278. data/public/railwatch/assets/show-C8sHalnw.js +1 -0
  279. data/public/railwatch/assets/show-CeTL4B37.js +2 -0
  280. data/public/railwatch/assets/show-CpfgV1jP.js +1 -0
  281. data/public/railwatch/assets/show-DACku6AD.js +3 -0
  282. data/public/railwatch/assets/show-DIOSGcXV.js +6 -0
  283. data/public/railwatch/assets/show-DQp_1n-B.js +1 -0
  284. data/public/railwatch/assets/show-DVNz46RI.js +1 -0
  285. data/public/railwatch/assets/show-DYteoYWW.js +1 -0
  286. data/public/railwatch/assets/show-DgSIoRvA.js +1 -0
  287. data/public/railwatch/assets/show-JxFtB4eK.js +2 -0
  288. data/public/railwatch/assets/sort-header-DpFzXblu.js +1 -0
  289. data/public/railwatch/assets/source-link-B2183i2-.js +1 -0
  290. data/public/railwatch/assets/sparkline-cell-C3-5vFkP.js +1 -0
  291. data/public/railwatch/assets/stat-s4RpOS9w.js +1 -0
  292. data/public/railwatch/assets/status-badge-8jVV-LA4.js +1 -0
  293. data/public/railwatch/assets/tenant-path-G-6u9A-o.js +1 -0
  294. data/public/railwatch/assets/text-link-DfsiaCcP.js +1 -0
  295. data/public/railwatch/assets/textarea-Dye72uP7.js +1 -0
  296. data/public/railwatch/assets/timeline-CD7WHnbo.js +1 -0
  297. data/public/railwatch/assets/transition-B_AW8rMK.js +5 -0
  298. data/public/railwatch/assets/use-clipboard-ColgLyQ2.js +1 -0
  299. data/public/railwatch/icon.png +0 -0
  300. data/public/railwatch/icon.svg +5 -0
  301. data/public/railwatch/manifest.json +2171 -0
  302. data/public/railwatch/rails-vite.json +1 -0
  303. metadata +313 -4
@@ -0,0 +1,575 @@
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
+
115
+ if identifiers
116
+ validate_identifier!(rec, "_group", GROUP_HASH, "32 hexadecimal characters")
117
+ validate_identifier!(rec, "execution_id", IDENTIFIER, "a printable token of at most 36 characters")
118
+ validate_identifier!(rec, "trace_id", IDENTIFIER, "a printable token of at most 36 characters")
119
+ end
120
+ STRUCTURES.each do |field, expected|
121
+ value = rec[field]
122
+ next if value.nil? || value.is_a?(expected)
123
+
124
+ raise TypeError, "#{field} must be a #{expected.name.downcase}"
125
+ end
126
+ validate_numeric_hash!(rec["stages"], "stages")
127
+ validate_numeric_hash!(rec["counters"], "counters")
128
+ validate_frames!(rec["frames"])
129
+ validate_locals!(rec["locals"])
130
+ end
131
+
132
+ def validate_identifier!(rec, field, pattern, description)
133
+ value = rec[field]
134
+ return if value.nil?
135
+ raise TypeError, "#{field} must be #{description}" unless value.is_a?(String) && value.match?(pattern)
136
+ end
137
+ private_class_method :validate_identifier!
138
+
139
+ def validate_numeric_hash!(value, field)
140
+ return unless value
141
+ return if value.all? { |key, number| key.is_a?(String) && number.is_a?(Numeric) && number.to_f.finite? && number >= 0 }
142
+
143
+ raise TypeError, "#{field} must contain non-negative numbers"
144
+ end
145
+ private_class_method :validate_numeric_hash!
146
+
147
+ def validate_frames!(frames)
148
+ return unless frames
149
+ valid = frames.all? do |frame|
150
+ frame.is_a?(Hash) && %w[file function].all? { |field| frame[field].nil? || frame[field].is_a?(String) } &&
151
+ (frame["line"].nil? || (frame["line"].is_a?(Integer) && frame["line"] >= 0)) &&
152
+ (frame["in_app"].nil? || frame["in_app"] == true || frame["in_app"] == false) &&
153
+ (frame["code"].nil? || (frame["code"].is_a?(Hash) &&
154
+ frame["code"].all? { |line, source| line.is_a?(String) && source.is_a?(String) }))
155
+ end
156
+ raise TypeError, "frames must contain frame objects" unless valid
157
+ end
158
+ private_class_method :validate_frames!
159
+
160
+ def validate_locals!(locals)
161
+ return unless locals
162
+ return if locals.all? { |key, value| key.is_a?(String) && !value.is_a?(Hash) && !value.is_a?(Array) }
163
+
164
+ raise TypeError, "locals must contain scalar values"
165
+ end
166
+ private_class_method :validate_locals!
167
+
168
+ # AR instance for callers that want validations, callbacks, or a normal
169
+ # object (specs, anything that isn't the hot ingest path). row_for's
170
+ # JSON columns come back pre-encoded as strings; the AR json type does
171
+ # not parse a String on assignment, so they're inflated here.
172
+ def build(rec)
173
+ pair = row_for(rec)
174
+ return nil if pair.nil?
175
+ klass, attrs = pair
176
+ cols = columns_for(klass)
177
+ ar_attrs = attrs.each_with_object({}) do |(name, value), out|
178
+ type, = cols[name.to_s]
179
+ out[name] = type == :json && value.is_a?(String) ? JSON.parse(value) : value
180
+ end
181
+ klass.new(ar_attrs)
182
+ end
183
+
184
+ # Person writes use a purpose-built bulk upsert instead of row_for, but
185
+ # the same trust-boundary rules still apply to their wire strings.
186
+ def person_record(rec, validate_identifiers: true)
187
+ validate_record!(rec, identifiers: validate_identifiers)
188
+ {
189
+ "id" => cast_value(rec["id"], :string, 255, klass: Telemetry::Person, name: :ref),
190
+ "name" => cast_value(rec["name"], :string, 255, klass: Telemetry::Person, name: :name),
191
+ "email" => cast_value(rec["email"], :string, 255, klass: Telemetry::Person, name: :email),
192
+ "tenant" => cast_value(rec["tenant"], :string, 255, klass: Telemetry::Person, name: :app_tenant),
193
+ "timestamp" => rec["timestamp"]
194
+ }
195
+ end
196
+
197
+ def mapped(type, rec)
198
+ case type
199
+ when "query" then child(Telemetry::Query, rec, %w[sql name duration connection adapter role async in_transaction row_count source allocations explain])
200
+ when "span" then span(rec)
201
+ when "health" then health(rec)
202
+ when "exception" then exception(rec)
203
+ when "cache_event" then child(Telemetry::CacheEvent, rec, %w[store key type duration ttl hits])
204
+ when "mail" then child(Telemetry::Mail, rec, %w[mailer subject to cc bcc attachments delivery_method perform_deliveries duration failed message_id])
205
+ when "broadcast" then child(Telemetry::Broadcast, rec, %w[kind stream channel action bytes duration failed])
206
+ when "notification" then child(Telemetry::Notification, rec, %w[notifier delivery_method channel duration failed])
207
+ when "outgoing_request" then child(Telemetry::OutgoingRequest, rec, %w[host method url duration status_code request_size response_size error source response_body])
208
+ when "llm_call" then child(Telemetry::LlmCall, rec, %w[operation provider model response_model tool_name duration status error
209
+ streaming message_count tool_count input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens
210
+ cost_nanos workflow_id workflow_name workflow_step_id workflow_step_name workflow_step_parent_id prompt completion
211
+ finish_reason provider_request_id tools cost_reported attachments attachment_types attachment_names tool_call_id params])
212
+ when "profile" then profile(rec)
213
+ when "attachment" then attachment(rec)
214
+ when "storage_op" then child(Telemetry::StorageOp, rec, %w[service op key duration])
215
+ when "view_render" then child(Telemetry::ViewRender, rec, %w[identifier kind layout count cache_hits duration])
216
+ when "log" then child(Telemetry::Log, rec, %w[level message tags context source])
217
+ when "enqueued_job" then enqueued_job(rec)
218
+ when "transaction" then child(Telemetry::Transaction, rec, %w[duration outcome connection statement_count])
219
+ when "n_plus_one" then child(Telemetry::NPlusOne, rec, %w[sql count source])
220
+ when "deprecation" then child(Telemetry::Deprecation, rec, %w[message gem_name horizon source])
221
+ when "visit" then child(Telemetry::Visit, rec, %w[component url method duration status partial only props_bytes user_agent lcp cls inp ttfb])
222
+ when "session" then session(rec)
223
+ when "user" then [ Telemetry::Person, { ref: rec["id"].to_s } ] # replaced by touch_from_record; never saved as-is
224
+ when "process" then process(rec)
225
+ end
226
+ end
227
+
228
+ def envelope(rec)
229
+ {
230
+ occurred_at: timestamp_string(rec["timestamp"]),
231
+ deploy: rec["deploy"],
232
+ server: rec["server"],
233
+ group_hash: rec["_group"],
234
+ trace_id: rec["trace_id"],
235
+ execution_source: rec["execution_source"],
236
+ execution_id: rec["execution_id"],
237
+ execution_preview: rec["execution_preview"],
238
+ execution_stage: rec["execution_stage"],
239
+ user_ref: rec["user"],
240
+ app_tenant: rec["tenant"]
241
+ }
242
+ end
243
+
244
+ def child(klass, rec, keys)
245
+ attrs = envelope(rec)
246
+ keys.each { |k| attrs[k.to_sym] = rec[k] }
247
+ [ klass, attrs ]
248
+ end
249
+
250
+ def execution(rec)
251
+ kind = rec["t"]
252
+ name = case kind
253
+ when "request" then "#{rec['method']} #{rec['route']}"
254
+ when "command" then rec["command"] || rec["name"]
255
+ when "scheduled_task" then rec["task_key"] || rec["name"]
256
+ when "channel_action" then "#{rec['channel']}##{rec['action']}"
257
+ else rec["name"]
258
+ end
259
+ detail_keys = case kind
260
+ 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]
261
+ when "command" then %w[context gc_time interactive]
262
+ when "channel_action" then %w[channel context gc_time]
263
+ else %w[job_id provider_job_id attempt_id adapter priority db_runtime arguments_preview context gc_time schedule]
264
+ end
265
+ attrs = envelope(rec)
266
+ attrs[:kind] = kind
267
+ attrs[:name] = name.to_s
268
+ attrs[:duration] = rec["duration"]
269
+ attrs[:status] = rec["status_code"] || rec["exit_code"]
270
+ attrs[:outcome] = rec["status"]
271
+ attrs[:method] = rec["method"]
272
+ attrs[:route] = rec["route"]
273
+ attrs[:controller] = rec["controller"]
274
+ attrs[:action] = rec["action"]
275
+ attrs[:queue] = rec["queue"]
276
+ attrs[:attempt] = rec["attempt"]
277
+ attrs[:queue_latency] = rec["queue_latency"]
278
+ attrs[:queue_time] = rec["queue_time"]
279
+ attrs[:parent_id] = rec["parent_id"]
280
+ attrs[:job_id] = rec["job_id"]
281
+ attrs[:task_key] = rec["task_key"]
282
+ attrs[:inertia_component] = rec.dig("inertia", "component")
283
+ attrs[:inertia_partial] = rec.dig("inertia", "partial_only").present?
284
+ attrs[:allocations] = rec["allocations"]
285
+ attrs[:peak_memory] = rec["peak_memory"]
286
+ attrs[:exception_preview] = rec["exception_preview"]
287
+ attrs[:stages] = rec["stages"] || {}
288
+ attrs[:counters] = rec["counters"] || {}
289
+ attrs[:detail] = rec.slice(*detail_keys)
290
+ [ Telemetry::Execution, attrs ]
291
+ end
292
+
293
+ def exception(rec)
294
+ attrs = envelope(rec)
295
+ attrs[:class_name] = rec["class"].to_s
296
+ attrs[:message] = rec["message"].to_s
297
+ attrs[:handled] = rec["handled"] ? true : false
298
+ attrs[:severity] = rec["severity"]
299
+ attrs[:source] = rec["source"]
300
+ attrs[:file] = rec["file"]
301
+ attrs[:line] = rec["line"]
302
+ attrs[:frames] = rec["frames"] || []
303
+ attrs[:cause] = rec["cause"]
304
+ attrs[:locals] = rec["locals"].is_a?(Hash) ? rec["locals"] : nil
305
+ attrs[:context] = rec["context"]
306
+ attrs[:fingerprint] = rec["fingerprint"] || []
307
+ attrs[:fingerprint_source] = rec["fingerprint_source"]
308
+ attrs[:ruby_version] = rec["ruby_version"]
309
+ attrs[:rails_version] = rec["rails_version"]
310
+ [ Telemetry::Exception, attrs ]
311
+ end
312
+
313
+ def enqueued_job(rec)
314
+ attrs = envelope(rec)
315
+ attrs[:job_id] = rec["job_id"]
316
+ attrs[:name] = rec["name"].to_s
317
+ attrs[:queue] = rec["queue"]
318
+ attrs[:adapter] = rec["adapter"]
319
+ attrs[:priority] = rec["priority"]
320
+ attrs[:scheduled_at] = rec["scheduled_at"] && timestamp_string(rec["scheduled_at"])
321
+ attrs[:duration] = rec["duration"]
322
+ attrs[:failed] = rec["failed"] ? true : false
323
+ [ Telemetry::EnqueuedJob, attrs ]
324
+ end
325
+
326
+ # The gem ships collapsed stacks base64(gzip); stored as the gzip bytes.
327
+ def profile(rec)
328
+ attrs = envelope(rec)
329
+ attrs[:profiler] = rec["profiler"]
330
+ attrs[:mode] = rec["mode"]
331
+ attrs[:interval] = rec["interval"]
332
+ attrs[:duration] = rec["duration"]
333
+ attrs[:samples] = rec["samples"]
334
+ attrs[:stacks_bytes] = declared_bytes(rec["stacks_bytes"], "stacks_bytes",
335
+ max_bytes: Telemetry::Profile::MAX_INGEST_BYTES)
336
+ attrs[:stacks] = compressed_blob(rec["stacks"], "stacks")
337
+ Telemetry::BoundedGzip.verify!(attrs[:stacks], max_bytes: Telemetry::Profile::MAX_INGEST_BYTES,
338
+ expected_bytes: attrs[:stacks_bytes], utf8: true)
339
+ [ Telemetry::Profile, attrs ]
340
+ end
341
+
342
+ def attachment(rec)
343
+ attrs = envelope(rec)
344
+ attrs[:name] = rec["name"]
345
+ attrs[:content_type] = rec["content_type"]
346
+ attrs[:bytes] = declared_bytes(rec["bytes"], "bytes", max_bytes: Telemetry::Attachment::MAX_BODY_BYTES)
347
+ attrs[:data] = compressed_blob(rec["data"], "data")
348
+ Telemetry::BoundedGzip.verify!(attrs[:data], max_bytes: Telemetry::Attachment::MAX_BODY_BYTES,
349
+ expected_bytes: attrs[:bytes])
350
+ attrs[:exception_group_hash] = rec["exception_group_hash"]
351
+ attrs[:truncated] = rec["truncated"] ? true : false
352
+ [ Telemetry::Attachment, attrs ]
353
+ end
354
+
355
+ def compressed_blob(value, field)
356
+ Base64.strict_decode64(value.to_s)
357
+ rescue ArgumentError
358
+ raise ArgumentError, "#{field} is not valid base64"
359
+ end
360
+
361
+ def declared_bytes(value, field, max_bytes:)
362
+ bytes = if value.is_a?(Integer)
363
+ value
364
+ elsif value.is_a?(String)
365
+ # Check the O(1) byte length before scanning or converting. Without
366
+ # this guard an otherwise-valid request can spend seconds turning a
367
+ # multi-megabyte decimal string into a huge Integer merely to reject it.
368
+ max_digits = max_bytes.to_s.bytesize
369
+ value.to_i if value.bytesize <= max_digits && value.match?(/\A\d+\z/)
370
+ end
371
+ return bytes if bytes&.between?(0, max_bytes)
372
+
373
+ raise ArgumentError, "#{field} must be an integer between 0 and #{max_bytes}"
374
+ end
375
+
376
+ # `id` is the gem's session key; `session_id` here, since the row has its
377
+ # own primary key.
378
+ def session(rec)
379
+ attrs = envelope(rec)
380
+ attrs[:session_id] = rec["id"].to_s
381
+ attrs[:source] = rec["source"]
382
+ attrs[:status] = rec["status"]
383
+ attrs[:started_at] = rec["started_at"] && timestamp_string(rec["started_at"])
384
+ attrs[:duration] = rec["duration"]
385
+ attrs[:requests] = rec["requests"]
386
+ attrs[:visits] = rec["visits"]
387
+ attrs[:error_count] = rec["errors"]
388
+ attrs[:ended] = rec["ended"] ? true : false
389
+ [ Telemetry::Session, attrs ]
390
+ end
391
+
392
+ def span(rec)
393
+ attrs = envelope(rec)
394
+ attrs[:name] = rec["name"]
395
+ attrs[:duration] = rec["duration"]
396
+ attrs[:payload] = rec["attributes"]
397
+ attrs[:status] = rec["status"]
398
+ [ Telemetry::Span, attrs ]
399
+ end
400
+
401
+ def health(rec)
402
+ [ Telemetry::HealthSample, {
403
+ sampled_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"], deploy: rec["deploy"],
404
+ threads_max: rec["threads_max"], threads_busy: rec["threads_busy"], backlog: rec["backlog"],
405
+ pool_size: rec["pool_size"], pool_busy: rec["pool_busy"], pool_waiting: rec["pool_waiting"],
406
+ queue_depth: rec["queue_depth"], queue_latency: rec["queue_latency"], memory: rec["memory"],
407
+ # The gem packs queues, workers, requests_count, running,
408
+ # max_threads_reached, and recurring_tasks into one JSON string
409
+ # under `detail` (lib/railwatch/health.rb). Slicing those names off
410
+ # the top of the record found nothing, so every health sample landed
411
+ # with an empty detail and the queue-depth panel stayed blank.
412
+ detail: rec["detail"] || {}
413
+ } ]
414
+ end
415
+
416
+ def process(rec)
417
+ [ Telemetry::Process, {
418
+ booted_at: timestamp_string(rec["timestamp"]), pid: rec["pid"], role: rec["role"], server: rec["server"],
419
+ deploy: rec["deploy"], ruby_version: rec["ruby_version"], rails_version: rec["rails_version"],
420
+ # Gems older than 0.1.3 send lantern_version; processes recorded
421
+ # by one are still readable.
422
+ railwatch_version: rec["railwatch_version"] || rec["lantern_version"], boot_seconds: rec["boot_seconds"],
423
+ detail: rec.slice("app", "environment", "database_adapter", "queue_adapter", "cache_store")
424
+ } ]
425
+ end
426
+
427
+ # Applies the casts AR would apply on save, but that raw SQL bypasses:
428
+ # JSON-encode hashes/arrays, format times as UTC strings, booleans as
429
+ # 0/1, coerce numeric columns, truncate strings to their column limit,
430
+ # and substitute the column's default when a NOT NULL column (frames,
431
+ # tags, only, ...) was left out of the wire record - the raw insert
432
+ # skips SQLite's own DEFAULT clause because it names every column
433
+ # explicitly, so a bare nil would otherwise hit a NOT NULL violation
434
+ # that AR's insert would have silently defaulted around.
435
+ def serialize(klass, attrs, truncations: nil, trusted: false)
436
+ out = {}
437
+ serialization_columns_for(klass).each do |name, type, limit, null, default|
438
+ next unless attrs.key?(name)
439
+ value = attrs[name]
440
+ value = default if value.nil? && !null && !default.nil?
441
+ out[name] = cast_value(value, type, limit, klass: klass, name: name, truncations: truncations, trusted: trusted)
442
+ end
443
+ return out if out.size == attrs.size
444
+
445
+ # Mapper attribute sets are fixed and take the allocation-free path
446
+ # above. Preserve serialize's behavior for callers with an unexpected
447
+ # key, including the useful KeyError identifying a missing column.
448
+ columns = columns_for(klass)
449
+ attrs.each do |name, value|
450
+ type, limit, null, default = columns.fetch(name.to_s)
451
+ value = default if value.nil? && !null && !default.nil?
452
+ out[name] = cast_value(value, type, limit, klass: klass, name: name, truncations: truncations, trusted: trusted)
453
+ end
454
+ out
455
+ end
456
+
457
+ # Wire timestamps are rounded to integer microseconds, then only one
458
+ # Time per distinct second supplies the UTC calendar prefix. This avoids
459
+ # the Rational/divmod work MRI repeats for every calendar field on a Time
460
+ # constructed from a Float while preserving SQLite's six-digit format.
461
+ def timestamp_string(value)
462
+ microseconds = (Float(value) * 1_000_000).round
463
+ seconds = microseconds / 1_000_000
464
+ usec = microseconds - (seconds * 1_000_000)
465
+ cache = Thread.current[TIME_PREFIX_CACHE_KEY] ||= {}
466
+ prefix = cache[seconds]
467
+ unless prefix
468
+ cache.clear if cache.size >= TIME_PREFIX_CACHE_LIMIT
469
+ time = Time.at(seconds).utc
470
+ prefix = format("%04d-%02d-%02d %02d:%02d:%02d.", time.year, time.month, time.day, time.hour, time.min, time.sec).freeze
471
+ cache[seconds] = prefix
472
+ end
473
+ prefix + format("%06d", usec)
474
+ end
475
+
476
+ # Kept for callers that pass Time values directly to cast_value; wire
477
+ # timestamps already arrive here as their final SQLite string.
478
+ def format_time(value)
479
+ t = value.utc
480
+ format("%04d-%02d-%02d %02d:%02d:%02d.%06d", t.year, t.month, t.day, t.hour, t.min, t.sec, t.usec)
481
+ end
482
+
483
+ def cast_value(value, type, limit, klass: nil, name: nil, truncations: nil, trusted: false)
484
+ return nil if value.nil?
485
+ if type == :json
486
+ # Health detail is already JSON-encoded on the wire. Decode it before
487
+ # sanitising so the database stores an object, not a JSON string that
488
+ # merely contains another document.
489
+ value = JSON.parse(trusted ? value : sanitize_string(value)) if value.is_a?(String)
490
+ sanitized = trusted ? value : sanitize_json(value)
491
+ encoded = JSON.generate(sanitized)
492
+ replacement = sanitized.is_a?(Array) ? JSON_ARRAY_TRUNCATED : JSON_TRUNCATED
493
+ return cap_text(encoded, text_limit(klass, name), klass, name, truncations, replacement: replacement)
494
+ end
495
+ if type == :datetime
496
+ return value if value.is_a?(String)
497
+ return format_time(value) if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
498
+ end
499
+ if type == :boolean
500
+ raise TypeError, "expected boolean" unless value == true || value == false
501
+
502
+ return value ? 1 : 0
503
+ end
504
+
505
+ case value
506
+ when Hash, Array
507
+ raise TypeError, "unexpected #{value.class} for #{type.inspect} column"
508
+ else
509
+ case type
510
+ when :string
511
+ cap_text(trusted ? value.to_s : sanitize_string(value.to_s), limit, klass, name, truncations)
512
+ when :text
513
+ cap_text(trusted ? value.to_s : sanitize_string(value.to_s), text_limit(klass, name), klass, name, truncations)
514
+ when :integer, :bigint
515
+ # Signed: queue_latency and drift are clock differences between an
516
+ # enqueuing host and a worker, and skew makes them negative.
517
+ raise TypeError, "expected integer" unless value.is_a?(Integer)
518
+ raise RangeError, "integer out of range" unless value.between?(INTEGER_MIN, INTEGER_MAX)
519
+ value
520
+ when :float
521
+ raise TypeError, "expected number" unless value.is_a?(Numeric)
522
+ number = value.to_f
523
+ raise RangeError, "number out of range" unless number.finite?
524
+ number
525
+ else value
526
+ end
527
+ end
528
+ end
529
+
530
+ def text_limit(klass, name)
531
+ return nil unless klass && name
532
+ TEXT_LIMITS.fetch("#{klass.table_name}.#{name}")
533
+ end
534
+ private_class_method :text_limit
535
+
536
+ def cap_text(value, limit, klass, name, truncations, replacement: nil)
537
+ return value unless limit && value.bytesize > limit
538
+
539
+ truncations << "#{klass.table_name}.#{name}" if truncations
540
+ replacement || value.byteslice(0, limit).scrub("")
541
+ end
542
+ private_class_method :cap_text
543
+
544
+ def sanitize_json(value)
545
+ case value
546
+ when Hash
547
+ value.each_with_object({}) do |(key, child), out|
548
+ out[sanitize_string(key.to_s)] = sanitize_json(child)
549
+ end
550
+ when Array
551
+ value.map { |child| sanitize_json(child) }
552
+ when String
553
+ sanitize_string(value)
554
+ else
555
+ value
556
+ end
557
+ end
558
+ private_class_method :sanitize_json
559
+
560
+ CONTROL_RANGE = "\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F"
561
+ CONTROL_CHARS = /[#{CONTROL_RANGE}]/
562
+
563
+ # scrub + delete allocate two strings per value, and this runs on every
564
+ # string column of every record -- half the mapper's time on a real
565
+ # batch, in which not one string needed cleaning. Check first; only
566
+ # copy when there is something to remove.
567
+ def sanitize_string(value)
568
+ return value if value.valid_encoding? && !value.match?(CONTROL_CHARS)
569
+
570
+ value.scrub.delete(CONTROL_RANGE)
571
+ end
572
+ private_class_method :sanitize_string
573
+ end
574
+ end
575
+ end
@@ -0,0 +1,96 @@
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