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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ require "stringio"
5
+ require "zlib"
6
+
7
+ module Telemetry
8
+ # Reads untrusted gzip streams without ever asking zlib for more than the
9
+ # configured output ceiling. Ingest uses verify! without retaining the
10
+ # expanded bytes; read paths either return a bounded body or a bounded
11
+ # prefix for displays that can be truncated.
12
+ module BoundedGzip
13
+ READ_SIZE = 64.kilobytes
14
+
15
+ class Error < ArgumentError; end
16
+ class Malformed < Error; end
17
+ class TooLarge < Error; end
18
+ class SizeMismatch < Error; end
19
+
20
+ module_function
21
+
22
+ def decompress(compressed, max_bytes:)
23
+ output = String.new(encoding: Encoding::BINARY)
24
+ each_chunk(compressed, max_bytes: max_bytes) { |chunk| output << chunk }
25
+ output
26
+ end
27
+
28
+ # Returns [prefix, truncated?]. Once one byte beyond the display limit is
29
+ # observed, decompression stops instead of expanding the rest of a legacy
30
+ # gzip bomb merely to prove that it was truncated.
31
+ def decompress_capped(compressed, limit:)
32
+ output = String.new(encoding: Encoding::BINARY)
33
+ each_chunk(compressed, max_bytes: limit) { |chunk| output << chunk }
34
+ [ output, false ]
35
+ rescue TooLarge
36
+ [ output, true ]
37
+ end
38
+
39
+ def verify!(compressed, max_bytes:, expected_bytes:, utf8: false)
40
+ unless expected_bytes.is_a?(Integer) && expected_bytes.between?(0, max_bytes)
41
+ raise SizeMismatch, "declared size #{expected_bytes} exceeds the #{max_bytes} byte ceiling"
42
+ end
43
+
44
+ validator = Encoding::Converter.new(Encoding::UTF_8, Encoding::UTF_16LE) if utf8
45
+ actual_bytes = each_chunk(compressed, max_bytes: max_bytes) do |chunk|
46
+ validator&.convert(chunk)
47
+ end
48
+ validator&.finish
49
+ return actual_bytes if actual_bytes == expected_bytes
50
+
51
+ raise SizeMismatch, "declared size #{expected_bytes} does not match decompressed size #{actual_bytes}"
52
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
53
+ raise Malformed, "decompressed content is not valid UTF-8"
54
+ end
55
+
56
+ def utf8!(text)
57
+ text.force_encoding(Encoding::UTF_8)
58
+ return text if text.valid_encoding?
59
+
60
+ raise Malformed, "decompressed content is not valid UTF-8"
61
+ end
62
+
63
+ def each_chunk(compressed, max_bytes:)
64
+ raise ArgumentError, "max_bytes must be non-negative" unless max_bytes.is_a?(Integer) && max_bytes >= 0
65
+
66
+ # StringIO and zlib operate on bytes regardless of the String's
67
+ # encoding. Avoid String#b here: it duplicates even an already-binary
68
+ # database BLOB, adding another full compressed-body allocation.
69
+ source = StringIO.new(compressed.to_s)
70
+ reader = Zlib::GzipReader.new(source)
71
+ total = 0
72
+ loop do
73
+ # The extra byte proves that a stream at the boundary has ended. Only
74
+ # the portion up to the ceiling is ever yielded or retained.
75
+ chunk = reader.readpartial([ READ_SIZE, max_bytes - total + 1 ].min)
76
+ if total + chunk.bytesize > max_bytes
77
+ remaining = max_bytes - total
78
+ yield chunk.byteslice(0, remaining) if remaining.positive?
79
+ raise TooLarge, "decompressed content exceeds #{max_bytes} bytes"
80
+ end
81
+
82
+ total += chunk.bytesize
83
+ yield chunk
84
+ end
85
+ rescue EOFError
86
+ # GzipReader reads ahead in fixed-size blocks. `unused` catches bytes
87
+ # already buffered past the gzip member; checking the underlying IO is
88
+ # also required when the member ends exactly on a read boundary and
89
+ # the trailing bytes have not been pulled into zlib's buffer yet.
90
+ raise Malformed, "gzip stream has trailing data" if reader.unused.present? || !source.eof?
91
+
92
+ total
93
+ rescue Zlib::Error => e
94
+ raise Malformed, "invalid gzip stream: #{e.message}"
95
+ ensure
96
+ reader&.close
97
+ end
98
+ private_class_method :each_chunk
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Broadcast < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ stream.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class CacheEvent < TelemetryRecord
6
+ include Child
7
+
8
+ # `type` is the cache operation (hit, miss, write...), not STI.
9
+ self.inheritance_column = nil
10
+
11
+ def timeline_label
12
+ "#{type} #{key}".first(120)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # Shared behaviour for every record that belongs to an execution.
6
+ module Child
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ scope :recent, -> { order(occurred_at: :desc) }
11
+ scope :between, ->(from, to) { where(occurred_at: from..to) }
12
+ scope :in_execution, ->(id) { where(execution_id: id) }
13
+ end
14
+
15
+ class_methods do
16
+ # One execution's rows for the waterfall; a child that resolves a
17
+ # column from elsewhere (Query) overrides this.
18
+ def timeline_scope(execution_id)
19
+ in_execution(execution_id)
20
+ end
21
+ end
22
+
23
+ def execution
24
+ Execution.find_by(execution_id: execution_id)
25
+ end
26
+
27
+ def duration_ms
28
+ respond_to?(:duration) && duration ? duration / 1000.0 : nil
29
+ end
30
+
31
+ # parent_start is passed in by Execution#timeline, which already holds
32
+ # the parent. Resolving it here instead cost one query per child row,
33
+ # so a waterfall of a few hundred logs and queries was a few hundred
34
+ # lookups of the same execution.
35
+ def timeline_entry(type, parent_start = nil)
36
+ parent_start ||= execution&.occurred_at
37
+ {
38
+ type: type.to_s.singularize,
39
+ id: id,
40
+ offset: parent_start ? ((occurred_at - parent_start) * 1000.0).round(3) : 0,
41
+ duration: duration_ms,
42
+ label: timeline_label,
43
+ stage: execution_stage,
44
+ source: (self[:source] if has_attribute?(:source))
45
+ }
46
+ end
47
+
48
+ def timeline_label
49
+ try(:name) || try(:key) || try(:message)&.first(120) || self.class.name.demodulize
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # Stable keyset pagination for high-volume telemetry tables. Cursors are
6
+ # signed and bind their sort shape, so clients cannot inject columns or use
7
+ # a cursor from one resource/order against another.
8
+ class CursorPage
9
+ class InvalidCursor < ArgumentError; end
10
+
11
+ DEFAULT_LIMIT = 50
12
+ MAX_LIMIT = 200
13
+ ORDERS = {
14
+ recent: { column: :occurred_at, direction: :desc, type: :time },
15
+ slowest: { column: :duration, direction: :desc, type: :integer }
16
+ }.freeze
17
+
18
+ def self.call(scope, cursor: nil, limit: nil, order: :recent, context: nil)
19
+ new(scope, cursor: cursor, limit: limit, order: order, context: context).call
20
+ end
21
+
22
+ def initialize(scope, cursor:, limit:, order:, context:)
23
+ @scope = scope
24
+ @cursor = cursor
25
+ parsed_limit = limit.to_i
26
+ parsed_limit = DEFAULT_LIMIT unless parsed_limit.positive?
27
+ @limit = [ parsed_limit, MAX_LIMIT ].min
28
+ @order_name = order.to_sym
29
+ @order = ORDERS.fetch(@order_name)
30
+ @context = context.to_s
31
+ end
32
+
33
+ def call
34
+ relation = apply_cursor(scope)
35
+ relation = relation.from("#{relation.klass.quoted_table_name} INDEXED BY idx_queries_slowest") if force_slowest_index?
36
+ rows = relation.reorder(order[:column] => order[:direction], id: order[:direction]).limit(limit + 1).to_a
37
+ has_more = rows.length > limit
38
+ rows = rows.first(limit)
39
+ next_cursor = encode(rows.last) if has_more
40
+ [ rows, { limit: limit, next_cursor: next_cursor, has_more: has_more } ]
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :scope, :cursor, :limit, :order, :order_name, :context
46
+
47
+ # SQLite's planner costs the duration-led covering index higher than
48
+ # scanning the whole occurred_at window and sorting it, even with fresh
49
+ # statistics, so it never picks it on its own. Forced only for the one
50
+ # (model, order) pair the index exists for.
51
+ # INDEXED BY is SQLite's own syntax, and telemetry is a SQLite database
52
+ # in both the platform and an embedded install. Ask anyway, so pointing
53
+ # this at anything else degrades to the planner's own choice rather
54
+ # than a syntax error on every page load.
55
+ def force_slowest_index?
56
+ order_name == :slowest && scope.klass == Telemetry::Query &&
57
+ scope.klass.connection.adapter_name.match?(/sqlite/i)
58
+ end
59
+
60
+ def apply_cursor(relation)
61
+ values = decode
62
+ return relation unless values
63
+
64
+ column = relation.klass.arel_table[order[:column]]
65
+ id = relation.klass.arel_table[:id]
66
+ comparator = order[:direction] == :desc ? :lt : :gt
67
+ relation.where(column.public_send(comparator, values[:value])
68
+ .or(column.eq(values[:value]).and(id.public_send(comparator, values[:id]))))
69
+ end
70
+
71
+ def encode(row)
72
+ return unless row
73
+ value = row.public_send(order[:column])
74
+ value = value.iso8601(6) if order[:type] == :time
75
+ # The page size is deliberately not bound: changing it mid-scroll must
76
+ # not hard-fail. The cursor is a stable position, not a security
77
+ # boundary -- the scope it is applied to is already tenant-scoped here.
78
+ verifier.generate({ v: 1, model: scope.klass.name, order: order_name,
79
+ context: context, value: value, id: row.id })
80
+ end
81
+
82
+ def decode
83
+ return if cursor.blank?
84
+ payload = verifier.verify(cursor.to_s).symbolize_keys
85
+ valid_shape = payload[:v] == 1 && payload[:model] == scope.klass.name &&
86
+ payload[:order].to_s == order_name.to_s && payload[:context] == context
87
+ raise InvalidCursor, "invalid telemetry cursor" unless valid_shape
88
+ value = order[:type] == :time ? Time.iso8601(payload[:value].to_s) : Integer(payload[:value].to_s, 10)
89
+ { value: value, id: Integer(payload[:id].to_s, 10) }
90
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError, TypeError
91
+ raise InvalidCursor, "invalid telemetry cursor"
92
+ end
93
+
94
+ def verifier
95
+ Rails.application.message_verifier("telemetry_cursor")
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Deprecation < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ message.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class EnqueuedJob < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ name.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Exception < TelemetryRecord
6
+ include Child
7
+
8
+ self.table_name = "exceptions"
9
+
10
+ scope :unhandled, -> { where(handled: false) }
11
+
12
+ def timeline_label
13
+ "#{class_name}: #{message.to_s.first(100)}"
14
+ end
15
+
16
+ def app_frames
17
+ frames.select { |f| f["in_app"] }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # A request, job attempt, scheduled task, command, or channel action. The parent of
6
+ # every other telemetry row via execution_id.
7
+ class Execution < TelemetryRecord
8
+ KINDS = %w[request job_attempt scheduled_task command channel_action].freeze
9
+
10
+ scope :of_kind, ->(kind) { where(kind: kind) }
11
+ scope :requests, -> { where(kind: "request") }
12
+ scope :jobs, -> { where(kind: "job_attempt") }
13
+ scope :scheduled, -> { where(kind: "scheduled_task") }
14
+ scope :commands, -> { where(kind: "command") }
15
+ scope :channels, -> { where(kind: "channel_action") }
16
+ scope :recent, -> { order(occurred_at: :desc) }
17
+ scope :between, ->(from, to) { where(occurred_at: from..to) }
18
+ scope :failed, -> { where("status >= 500 OR outcome = 'failed'") }
19
+
20
+ # Servers that reported an execution since `time`. Asked once per kind:
21
+ # there is no index led by occurred_at alone, so the obvious
22
+ # `where(occurred_at: time..).distinct.pluck(:server)` scans the whole
23
+ # table -- 15 to 26 seconds every five minutes on the platform's own
24
+ # 500k-row tenant, from SilentHostCheckJob -- while (kind, occurred_at)
25
+ # seeks straight to the window for each of the five kinds.
26
+ def self.servers_since(time)
27
+ KINDS.flat_map { |kind| where(kind: kind, occurred_at: time..).distinct.pluck(:server) }.uniq
28
+ end
29
+
30
+ # The schedule string each task key last ran with, in one query: the
31
+ # newest scheduled_task row per key carries it in detail. The scheduled
32
+ # tasks page and CheckScheduledTasksJob both used to look it up per key.
33
+ def self.latest_schedules(keys)
34
+ return {} if keys.empty?
35
+ latest = scheduled.where(task_key: keys).group(:task_key).select(:task_key, Arel.sql("MAX(id) AS id"))
36
+ scheduled.where(id: latest.map(&:id))
37
+ .pluck(:task_key, Arel.sql("json_extract(detail, '$.schedule')")).to_h
38
+ end
39
+
40
+ CHILDREN = {
41
+ queries: Query, exceptions: Exception, cache_events: CacheEvent, mails: Mail,
42
+ broadcasts: Broadcast, outgoing_requests: OutgoingRequest, storage_ops: StorageOp,
43
+ view_renders: ViewRender, logs: Log, enqueued_jobs: EnqueuedJob, transactions: Transaction,
44
+ notifications: Notification, spans: Span, profiles: Profile, attachments: Attachment,
45
+ llm_calls: LlmCall
46
+ }.freeze
47
+
48
+ CHILDREN.each do |name, klass|
49
+ define_method(name) { klass.where(execution_id: execution_id) }
50
+ end
51
+
52
+ # All children merged and ordered for the waterfall timeline.
53
+ def timeline
54
+ CHILDREN.flat_map { |name, klass| klass.timeline_scope(execution_id).map { |r| r.timeline_entry(name, occurred_at) } }
55
+ .sort_by { |e| e[:offset] }
56
+ end
57
+
58
+ def failed?
59
+ (status && status >= 500) || outcome == "failed"
60
+ end
61
+
62
+ def duration_ms
63
+ duration / 1000.0
64
+ end
65
+
66
+ def children_count
67
+ counters.values.sum
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # Periodic Puma / Solid Queue health sample from one process: thread pool
6
+ # utilisation, socket backlog, connection pool pressure, queue depth and
7
+ # the age of the oldest ready job.
8
+ class HealthSample < TelemetryRecord
9
+ # The gem reports every 15s, so a process that has not sampled in ten
10
+ # minutes is gone rather than idle.
11
+ LIVE_WINDOW = 10.minutes
12
+ BUCKET = 5.minutes
13
+
14
+ scope :recent, -> { order(sampled_at: :desc) }
15
+ scope :between, ->(from, to) { where(sampled_at: from..to) }
16
+
17
+ # The newest sample from each (server, pid) pair seen since `time`: one
18
+ # row per process still reporting.
19
+ def self.live(time = LIVE_WINDOW.ago)
20
+ where(id: where(sampled_at: time..).group(:server, :pid).select(Arel.sql("MAX(id)"))).order(:server, :pid)
21
+ end
22
+
23
+ # Health over the window, one point per BUCKET: thread utilisation is a
24
+ # percentage averaged across the processes that sampled into the bucket,
25
+ # backlog is summed across them, and the queue figures are the worst
26
+ # seen (any process's reading covers the whole queue).
27
+ def self.series(from, to, bucket: BUCKET)
28
+ slice = bucket_expression(bucket)
29
+ between(from, to).group(slice).order(slice)
30
+ .pluck(slice,
31
+ Arel.sql("AVG(CASE WHEN threads_max > 0 THEN threads_busy * 100.0 / threads_max END)"),
32
+ Arel.sql("SUM(backlog)"), Arel.sql("MAX(queue_depth)"), Arel.sql("MAX(queue_latency)"))
33
+ .map do |t, utilisation, backlog, depth, latency|
34
+ { t: t, utilisation: utilisation.to_f.round(1), backlog: backlog.to_i,
35
+ queue_depth: depth.to_i, queue_latency: (latency.to_i / 1000.0).round(1) }
36
+ end
37
+ end
38
+
39
+ # Deepest depth reported for each queue across `samples`, most backed up
40
+ # first. Every worker reports the same shared queue, so the readings are
41
+ # merged with max, not summed.
42
+ def self.queue_depths(samples)
43
+ depths = samples.each_with_object({}) do |sample, out|
44
+ # A process without a job backend reports queues: nil, not {}.
45
+ (sample.detail["queues"] || {}).each { |queue, depth| out[queue] = [ out[queue].to_i, depth.to_i ].max }
46
+ end
47
+ depths.sort_by { |_queue, depth| -depth }.map { |queue, depth| { queue: queue, depth: depth } }
48
+ end
49
+
50
+ # The recurring task keys Solid Queue is running, from the newest live
51
+ # sample that carries a manifest (the gem sends one on every health
52
+ # sample). nil when no live process has reported one: an older gem, or
53
+ # nothing alive to ask.
54
+ def self.recurring_task_keys(since: LIVE_WINDOW.ago)
55
+ manifest = where(sampled_at: since..).where("json_extract(detail, '$.recurring_tasks') IS NOT NULL")
56
+ .recent.pick(Arel.sql("json_extract(detail, '$.recurring_tasks')"))
57
+ manifest && JSON.parse(manifest).keys
58
+ end
59
+
60
+ def self.bucket_expression(bucket)
61
+ seconds = bucket.to_i
62
+ Arel.sql("strftime('%Y-%m-%dT%H:%M:00Z', (strftime('%s', sampled_at) / #{seconds}) * #{seconds}, 'unixepoch')")
63
+ end
64
+ private_class_method :bucket_expression
65
+
66
+ def thread_utilisation
67
+ return nil if threads_max.to_i.zero?
68
+ threads_busy.to_f / threads_max
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # One row per batch written into the telemetry database. In embedded mode
6
+ # it is also the delivery ledger: `batch_id` is the reporter's stable id
7
+ # for the batch (unique, so a replay is a no-op) and `followups` is the
8
+ # work the batch still owes outside its own transaction, nil once done.
9
+ class IngestBatch < TelemetryRecord
10
+ scope :recent, -> { order(received_at: :desc) }
11
+ scope :with_pending_followups, -> { where.not(followups: nil).order(:received_at) }
12
+
13
+ def self.committed(batch_id)
14
+ batch_id.present? ? find_by(batch_id: batch_id) : nil
15
+ end
16
+
17
+ # Runs the follow-ups a batch left behind and clears them. Safe to run
18
+ # more than once: the grouping commits a FollowupReceipt per (batch,
19
+ # group) in the railwatch database together with the occurrence count,
20
+ # so a second run finds the receipts and counts nothing. Clearing the
21
+ # outbox here is only what stops the maintenance drain from picking the
22
+ # row up again; it is not what makes the replay safe.
23
+ def drain_followups!(environment)
24
+ work = followups || {}
25
+ ids = Array(work["group_exception_ids"])
26
+ GroupExceptionsJob.new.perform(environment, ids, batch_id: batch_id) if ids.any?
27
+ update_columns(followups: nil)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class LlmCall < TelemetryRecord
6
+ include Child
7
+
8
+ # A tool invocation is a call in the same pipeline, so it shares this
9
+ # table; it just has a name where a model call has a model.
10
+ TOOL = "tool"
11
+
12
+ scope :models, -> { where.not(operation: TOOL) }
13
+ scope :tools, -> { where(operation: TOOL) }
14
+
15
+ NANOS_PER_DOLLAR = 1_000_000_000.0
16
+
17
+ # Dollars, or nil when the call is unpriced. Callers must render the
18
+ # difference: an unpriced call is not a free one.
19
+ def cost
20
+ cost_nanos && cost_nanos / NANOS_PER_DOLLAR
21
+ end
22
+
23
+ # What the app paid for, which is the count a bill is drawn from.
24
+ def total_tokens
25
+ [ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens ].compact.sum
26
+ end
27
+
28
+ def timeline_label
29
+ tool? ? tool_name.to_s : "#{provider}/#{model}"
30
+ end
31
+
32
+ def tool?
33
+ operation == TOOL
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Log < TelemetryRecord
6
+ include Child
7
+
8
+ # Wraps each hit inside a snippet. One character for both ends so the
9
+ # frontend can split on it and mark the odd-numbered pieces; U+001F is
10
+ # not something a log line can realistically contain.
11
+ SNIPPET_MARK = "\u001F"
12
+
13
+ # "quoted phrase" | -excluded | bare, in the order they were typed.
14
+ TERM = /-?"[^"]*"|\S+/
15
+
16
+ scope :fts, ->(text) {
17
+ next none if text.to_s.strip.empty?
18
+ next where("message LIKE ?", "%#{sanitize_sql_like(text)}%") unless fts_available?
19
+ match = match_expression(text)
20
+ match ? where("logs.id IN (SELECT rowid FROM logs_fts WHERE logs_fts MATCH ?)", match) : none
21
+ }
22
+
23
+ class << self
24
+ # Free text -> an FTS5 MATCH expression. Quoted phrases stay whole,
25
+ # every other word becomes a prefix token so "widg" finds "widgets",
26
+ # and a leading "-" excludes -- but only when something positive is
27
+ # left to exclude from, since FTS5's NOT needs a left operand. Every
28
+ # term is emitted as a quoted string, which is what keeps FTS5 syntax
29
+ # (NEAR, *, :, parens) inert instead of raising a "syntax error".
30
+ # Returns nil when the text has nothing searchable in it.
31
+ def match_expression(text)
32
+ terms = text.to_s.scan(TERM).filter_map { |term| parse_term(term) }
33
+ positive = terms.reject(&:first).map(&:last)
34
+ negative = terms.select(&:first).map(&:last)
35
+ if positive.empty?
36
+ positive = negative
37
+ negative = []
38
+ end
39
+ return nil if positive.empty?
40
+ [ positive.join(" "), *negative.map { |term| "NOT #{term}" } ].join(" ")
41
+ end
42
+
43
+ # id => message with SNIPPET_MARK around each hit, for the rows the
44
+ # page is about to render. Empty unless we actually ran an FTS query.
45
+ def fts_snippets(ids, text)
46
+ return {} if ids.empty? || !fts_available?
47
+ match = match_expression(text)
48
+ return {} if match.nil?
49
+ sql = sanitize_sql_array([ "SELECT rowid, snippet(logs_fts, 0, ?, ?, ?, 24) FROM logs_fts WHERE logs_fts MATCH ? AND rowid IN (?)",
50
+ SNIPPET_MARK, SNIPPET_MARK, "…", match, ids ])
51
+ connection.select_rows(sql).to_h
52
+ end
53
+
54
+ # Postgres telemetry databases (SCOPE.md keeps them supported) have no
55
+ # logs_fts, and neither does a database created before the index was
56
+ # added. Every telemetry database is built from the same schema, so
57
+ # this is a process-wide fact rather than a per-environment one.
58
+ def fts_available?
59
+ return @fts_available if defined?(@fts_available)
60
+ @fts_available = connection.adapter_name.match?(/sqlite/i) &&
61
+ connection.select_value("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'logs_fts'").present?
62
+ end
63
+
64
+ private
65
+
66
+ # "term" -> [negated?, fts5 term], or nil if there is nothing left of
67
+ # it once the quotes and the minus sign come off.
68
+ def parse_term(term)
69
+ negated = term.start_with?("-") && term.length > 1
70
+ body = negated ? term[1..] : term
71
+ phrase = body.start_with?('"') && body.end_with?('"') && body.length > 1
72
+ body = body[1..-2].to_s if phrase
73
+ return nil unless body.match?(/[[:alnum:]]/)
74
+ [ negated, phrase ? quote(body) : "#{quote(body)}*" ]
75
+ end
76
+
77
+ def quote(term) = %("#{term.gsub('"', '""')}")
78
+ end
79
+
80
+ def timeline_label
81
+ message.to_s.first(120)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ class Mail < TelemetryRecord
6
+ include Child
7
+
8
+ def timeline_label
9
+ mailer.to_s.first(120)
10
+ end
11
+ end
12
+ end
13
+ end