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
@@ -67,7 +67,13 @@ module Railwatch
67
67
  streaming: p[:streaming] == true,
68
68
  message_count: p[:message_count],
69
69
  tool_count: Array(p[:tools]).size,
70
+ tools: tool_names(p),
70
71
  cost_nanos: cost_nanos(p),
72
+ cost_reported: cost_reported(p),
73
+ finish_reason: finish_reason(p),
74
+ provider_request_id: provider_request_id(p),
75
+ params: params(operation, p),
76
+ **attachments(p),
71
77
  **tokens(p),
72
78
  **workflow(p),
73
79
  **outcome(p),
@@ -103,6 +109,8 @@ module Railwatch
103
109
  provider: p[:provider].to_s,
104
110
  model: p[:model].to_s,
105
111
  tool_name: tool_name[0, 255],
112
+ tool_call_id: p[:tool_call_id]&.to_s&.slice(0, 128),
113
+ params: ({ result_class: p[:result_class].to_s[0, 128] } if p[:result_class]),
106
114
  duration: micros(event),
107
115
  **workflow(p),
108
116
  **outcome(p),
@@ -110,6 +118,145 @@ module Railwatch
110
118
  completion: content(p[:result_content]))
111
119
  end
112
120
 
121
+ # Why the model stopped. :max_tokens means the answer was cut off --
122
+ # a truncated extraction reads exactly like a complete one without
123
+ # this, which is the failure most worth being able to see.
124
+ def finish_reason(payload)
125
+ response = payload[:response]
126
+ return nil unless response.respond_to?(:finish_reason)
127
+
128
+ response.finish_reason&.to_s&.slice(0, 32)
129
+ end
130
+
131
+ # Message#raw is the Faraday response (protocol.rb hands it in), so the
132
+ # provider's own request id is in its headers. It is what a provider
133
+ # support ticket asks for, and the only key that joins our record to
134
+ # theirs.
135
+ REQUEST_ID_HEADERS = %w[request-id x-request-id x-amzn-requestid].freeze
136
+
137
+ def provider_request_id(payload)
138
+ raw = payload[:response]
139
+ raw = raw.raw if raw.respond_to?(:raw)
140
+ headers = raw.respond_to?(:headers) ? raw.headers : nil
141
+ return nil unless headers.respond_to?(:[])
142
+
143
+ REQUEST_ID_HEADERS.each do |name|
144
+ value = headers[name]
145
+ return value.to_s[0, 128] if value.present?
146
+ end
147
+ nil
148
+ end
149
+
150
+ # Which tools the model could reach on this call. tool_count alone says
151
+ # how many; retracing needs which.
152
+ def tool_names(payload)
153
+ names = Array(payload[:tools]).map(&:to_s)
154
+ names.empty? ? nil : names.first(50).join(",")[0, 1024]
155
+ end
156
+
157
+ # Whether the provider priced the call itself, or we estimated it from
158
+ # the registry. The difference matters when a total is queried against
159
+ # an invoice.
160
+ def cost_reported(payload)
161
+ tokens = payload[:tokens]
162
+ return nil unless tokens.respond_to?(:reported_cost)
163
+ # No cost means no provenance to report. false would claim the
164
+ # registry priced it, which is the same false certainty cost_nanos
165
+ # avoids by being nil rather than zero.
166
+ return nil if cost_nanos(payload).nil?
167
+
168
+ !tokens.reported_cost.nil?
169
+ end
170
+
171
+ # What the call carried besides text. Images and PDFs are most of the
172
+ # input tokens on a document-reading call, and without this an
173
+ # expensive scan is indistinguishable from an expensive prompt.
174
+ # Only the last user turn is measured: earlier turns were counted by
175
+ # the calls that sent them, and walking the whole history would both
176
+ # double-count and cost O(messages) on every call.
177
+ def attachments(payload)
178
+ message = last_user_message(payload)
179
+ list = message.respond_to?(:attachments) ? Array(message.attachments) : []
180
+ return {} if list.empty?
181
+
182
+ types = list.filter_map { |a| a.type.to_s if a.respond_to?(:type) }.tally
183
+ .sort_by { |_, n| -n }.map { |type, n| n > 1 ? "#{type}x#{n}" : type }.join(",")
184
+ { attachments: list.size, attachment_types: types[0, 128],
185
+ attachment_names: content(list.filter_map { |a| a.filename if a.respond_to?(:filename) }.join(", ")) }
186
+ end
187
+
188
+ # The knobs that change what a call costs and what it returns, so a
189
+ # surprising result can be reproduced with the settings that produced
190
+ # it. Provider options go through the app's own parameter filter: they
191
+ # are request configuration, but an app can put anything in them.
192
+ COMMON_PARAMS = %i[temperature max_output_tokens tool_choice tool_call_limit
193
+ thinking caching citations dimensions task_type size count
194
+ voice format language pages document_count top_n].freeze
195
+
196
+ def params(operation, payload)
197
+ out = {}
198
+ COMMON_PARAMS.each do |key|
199
+ value = payload[key]
200
+ next if value.nil?
201
+ # false is kept, not dropped: `caching` defaults to nil, so
202
+ # caching: false is a deliberate choice, and reproducing a call
203
+ # needs the settings it ran with. RubyLLM does not distinguish a
204
+ # boolean that was set from one that defaulted, so record both
205
+ # rather than guess which mattered.
206
+ out[key] = value.is_a?(Numeric) || [ true, false ].include?(value) ? value : value.to_s[0, 128]
207
+ end
208
+ out[:schema] = true if payload[:schema]
209
+ out[:server_tools] = Array(payload[:server_tools]).map(&:to_s).first(20) if payload[:server_tools].present?
210
+ if (usage = payload[:tokens]).respond_to?(:server_tool_use) && usage.server_tool_use.present?
211
+ out[:server_tool_use] = usage.server_tool_use
212
+ end
213
+ if (options = payload[:provider_options]).is_a?(Hash) && !options.empty?
214
+ out[:provider_options] = provider_options(options)
215
+ end
216
+ out[:operation] = operation unless out.empty?
217
+ out.empty? ? nil : out
218
+ end
219
+
220
+ # Two filters, because one is not enough here. The app's parameter
221
+ # filter defaults to password-shaped names only, and provider_options
222
+ # is the one place in this payload where a per-request credential
223
+ # plausibly lives -- an api_key passed per call sails straight through
224
+ # a password filter. The redactor's credential-name matcher (the same
225
+ # one that catches X-Api-Key on a header) closes that.
226
+ def provider_options(options)
227
+ redact_credentials(Railwatch.redactor.params(options.transform_keys(&:to_s)))
228
+ end
229
+
230
+ # Recursive, because provider options nest: extra_headers carrying an
231
+ # authorization value is a hash inside the hash, and a top-level scan
232
+ # walks straight past it.
233
+ def redact_credentials(value, depth = 0)
234
+ return value if depth > 4
235
+
236
+ case value
237
+ when Hash
238
+ value.each_with_object({}) do |(key, item), out|
239
+ # The matcher is written for header names, which are hyphenated;
240
+ # provider options are Ruby-ish and use underscores, so api_key
241
+ # would sail past a pattern expecting api-key.
242
+ out[key] = if Railwatch.redactor.redact_header?(key.to_s.tr("_", "-"))
243
+ Redactor::FILTERED
244
+ else
245
+ redact_credentials(item, depth + 1)
246
+ end
247
+ end
248
+ when Array then value.map { |item| redact_credentials(item, depth + 1) }
249
+ else value
250
+ end
251
+ end
252
+
253
+ def last_user_message(payload)
254
+ messages = payload[:input_messages]
255
+ return nil unless messages.respond_to?(:reverse_each)
256
+
257
+ messages.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
258
+ end
259
+
113
260
  # 2.0 sends a RubyLLM::Tokens; 1.16 sends bare counts on the event.
114
261
  def tokens(payload)
115
262
  counts = payload[:tokens]
@@ -161,11 +308,9 @@ module Railwatch
161
308
  # The last thing the app asked, which is the half of a conversation
162
309
  # worth seeing next to a cost. Earlier turns are the app's own records.
163
310
  def prompt_text(payload)
164
- messages = payload[:input_messages]
165
- return payload[:input] || payload[:prompt] || payload[:query] unless messages.respond_to?(:reverse_each)
311
+ return payload[:input] || payload[:prompt] || payload[:query] unless payload[:input_messages].respond_to?(:reverse_each)
166
312
 
167
- last = messages.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
168
- message_text(last)
313
+ message_text(last_user_message(payload))
169
314
  end
170
315
 
171
316
  def message_text(message)
@@ -89,7 +89,8 @@ module Railwatch
89
89
  # $PROGRAM_NAME after boot and would otherwise turn the supervisor,
90
90
  # dispatcher, and scheduler into "web" on every health sample.
91
91
  def role
92
- if defined?(::SolidQueue) && ($PROGRAM_NAME.include?("jobs") || $PROGRAM_NAME.start_with?("solid-queue-") || ARGV.first.to_s.start_with?("solid_queue:")) then "worker"
92
+ if defined?(Railwatch::Writer) && Railwatch::Writer.running? then "writer"
93
+ elsif defined?(::SolidQueue) && ($PROGRAM_NAME.include?("jobs") || $PROGRAM_NAME.start_with?("solid-queue-") || ARGV.first.to_s.start_with?("solid_queue:")) then "worker"
93
94
  elsif defined?(::Rails::Console) then "console"
94
95
  elsif $PROGRAM_NAME.end_with?("rake") then "command"
95
96
  elsif defined?(::Puma) then "web"
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Transport
5
+ # Writes each batch straight into the engine's telemetry database instead
6
+ # of POSTing it. Same Reporter, same buffer, same backpressure; the only
7
+ # difference from Transport::Http is that deliver ends in Ingest::Batch
8
+ # rather than Net::HTTP. Runs on the reporter thread, never on a request.
9
+ class Local
10
+ Result = Struct.new(:ok, :status, :accepted, :rejected, :rejections, :error, :retryable_error, keyword_init: true) do
11
+ def retryable? = retryable_error ? true : false
12
+ end
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ def deliver(records, dropped: 0, dropped_bytes: 0, backpressure_factor: 1.0, batch_id: nil)
19
+ wire = stringify(records)
20
+ # The reporter thread is not a request or a job: nothing has set up
21
+ # an ExecutionContext for it. Query log tags, Rails.error.report and
22
+ # connection checkin all assume one, so run the write inside the
23
+ # executor the same way a job would.
24
+ #
25
+ # The rescue is INSIDE the wrap. The executor reports any exception
26
+ # that escapes its block to Rails.error before re-raising it, and
27
+ # Railwatch subscribes to Rails.error: a write that failed (the
28
+ # telemetry database not migrated yet, a locked file) would otherwise
29
+ # be captured as one of the application's own exceptions and opened
30
+ # as an issue about Railwatch, by Railwatch.
31
+ Rails.application.executor.wrap do
32
+ write(wire, records.size, dropped: dropped, backpressure_factor: backpressure_factor, batch_id: batch_id)
33
+ rescue StandardError => e
34
+ Railwatch.debug { "local ingest failed: #{e.class}: #{e.message}" }
35
+ # With a batch id the reporter can safely retry: the ledger says
36
+ # whether the write committed, and the unique execution_id index
37
+ # makes a replay of a half-visible batch a no-op. Without one (an
38
+ # older caller) the batch is consumed, since a retry could
39
+ # double-insert. A malformed record never gets here: the mapper
40
+ # counts it as rejected and the rest of the batch is written.
41
+ Result.new(ok: false, error: "#{e.class}: #{e.message}", retryable_error: !batch_id.nil?)
42
+ end
43
+ end
44
+
45
+ def ping = true
46
+ def unauthorized? = false
47
+ def reset_after_fork! = self
48
+
49
+ private
50
+
51
+ def write(wire, count, dropped:, backpressure_factor:, batch_id:)
52
+ environment = Environment.current
53
+ # The same contract the HTTP path gets from X-Railwatch-Batch-Id: a
54
+ # batch the reporter retries after a failure is written once. The
55
+ # ledger row is created inside the batch transaction, so its presence
56
+ # is the commit.
57
+ if (ledger = environment.with_telemetry { Ingest::Batch.committed(batch_id) })
58
+ return Result.new(ok: true, status: 200, accepted: ledger.accepted, rejected: ledger.rejected, rejections: [])
59
+ end
60
+
61
+ result = Ingest::Batch.new(environment, wire, dropped_by_client: dropped, backpressure_factor: backpressure_factor,
62
+ gem_version: Railwatch::VERSION, embedded: true, batch_id: batch_id).write!
63
+ Result.new(ok: true, status: 200, accepted: result.accepted, rejected: result.rejected,
64
+ rejections: result.rejections.first(10))
65
+ end
66
+
67
+ # The gem builds symbol-keyed records, nested hashes included (stages,
68
+ # counters, headers); the mapper reads string keys throughout and
69
+ # rejects a symbol-keyed structure. A JSON round trip is the deep
70
+ # stringify. Measured against the alternatives on a 500-record batch:
71
+ # 1.9 ms and 11k allocations here versus 4.8 ms and 25k for
72
+ # deep_transform_keys, which also leaves Symbol values as Symbols.
73
+ def stringify(records)
74
+ JSON.parse(JSON.generate(records))
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "zlib"
5
+ require "json"
6
+
7
+ module Railwatch
8
+ module Transport
9
+ # Hands each batch to the embedded writer process (Railwatch::Writer) over
10
+ # a Unix socket instead of writing it into SQLite on this thread. The
11
+ # workers' Ruby threads then never map a record, hold SQLite's write lock,
12
+ # or run a t-digest; all of that happens in one process whose GVL no
13
+ # request shares. Same Reporter, same buffer, same batch id, same ledger:
14
+ # a batch the writer already committed replays as a no-op there.
15
+ #
16
+ # One request per connection, length-prefixed, gzip JSON both ways:
17
+ # > [u32 length][gzip(JSON {batch_id, records, dropped, dropped_bytes, backpressure_factor})]
18
+ # < [u32 length][JSON {ok, status, accepted, rejected, rejections, error, retryable_error}]
19
+ #
20
+ # Whether a writer is EXPECTED decides what a missing one means. Under
21
+ # the Puma plugin (which sets Writer.expected! in the master before it
22
+ # forks the workers) a socket that is absent or refusing is a writer
23
+ # that is starting or restarting: the batch is retained and retried on
24
+ # the reporter's backoff ladder, which is bounded -- after
25
+ # Reporter::MAX_RETRY_ATTEMPTS the batch is counted as dropped and
26
+ # reported through on_unrecoverable, rather than held for ever. Anywhere else
27
+ # (a plain `rails server`, a runner, a Solid Queue worker, the suite) no
28
+ # writer will ever appear, so the first miss switches this transport to
29
+ # an in-process Transport::Local for the rest of the process's life and
30
+ # embedded mode works exactly as it did before the writer existed. A
31
+ # refusing socket in that case is a stale inode from a dead writer and
32
+ # falls back the same way rather than dropping batches forever.
33
+ class Socket
34
+ Result = Local::Result
35
+ MAX_REPLY_BYTES = 1 << 20
36
+ # How long an in-process fallback lasts before the socket is tried
37
+ # again. The fallback is provisional on purpose: a process that missed
38
+ # the writer once (a bare `puma` whose plugin could not mark one
39
+ # expected, a worker forked before the writer bound) would otherwise
40
+ # write its own batches for the rest of its life.
41
+ FALLBACK_RECHECK = 30
42
+ # How long a process that EXPECTS a writer keeps retaining batches for
43
+ # one that never answers before it writes them itself. A writer that is
44
+ # restarting is back in seconds; one that cannot bind at all (an
45
+ # unwritable socket directory, a fork that keeps failing) would
46
+ # otherwise retain until the reporter's retry cap and then drop the
47
+ # batch. Writing it here instead keeps the records.
48
+ WRITER_GRACE = 60
49
+
50
+ def initialize(config, path: nil, expected: nil)
51
+ @config = config
52
+ @path = path || config.writer_socket_path
53
+ @expected = expected
54
+ # A path the kernel cannot bind (over 108 bytes on Linux) can never
55
+ # have a writer behind it; do not spend a batch finding out, and do
56
+ # not keep re-checking it either.
57
+ @unusable_path = !Writer.usable_path?(@path)
58
+ @fallback = @unusable_path ? Local.new(config) : nil
59
+ @fallback_at = Clock.monotonic if @fallback
60
+ Railwatch.debug { "writer socket path #{@path.inspect} is too long; writing batches in-process" } if @fallback
61
+ end
62
+
63
+ attr_reader :path
64
+
65
+ def fallback? = !@fallback.nil?
66
+
67
+ # Read when a batch misses, not when the transport is built: under
68
+ # `rails server` the app boots (and the reporter picks this transport)
69
+ # before Puma evaluates config/puma.rb, so the plugin's Writer.expected!
70
+ # lands after construction, and the workers inherit a copy of this
71
+ # object at fork. A flag captured here at construction was always false
72
+ # in every worker, and the first flush before the writer was listening
73
+ # fell back to in-process writes for the life of the worker.
74
+ def expected? = @expected.nil? ? Writer.expected? : @expected
75
+
76
+ def deliver(records, dropped: 0, dropped_bytes: 0, backpressure_factor: 1.0, batch_id: nil)
77
+ return @fallback.deliver(records, dropped: dropped, dropped_bytes: dropped_bytes,
78
+ backpressure_factor: backpressure_factor, batch_id: batch_id) if falling_back?
79
+
80
+ payload = Zlib.gzip(JSON.generate(batch_id: batch_id, records: records, dropped: dropped,
81
+ dropped_bytes: dropped_bytes, backpressure_factor: backpressure_factor))
82
+ reply = exchange(payload)
83
+ Result.new(**reply.slice("ok", "status", "accepted", "rejected", "rejections", "error", "retryable_error").transform_keys(&:to_sym))
84
+ rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::ENOTSOCK => e
85
+ @missing_since ||= Clock.monotonic
86
+ if expected? && Clock.monotonic - @missing_since < WRITER_GRACE
87
+ Railwatch.debug { "writer not answering at #{@path} (#{e.class}); retaining the batch" }
88
+ return Result.new(ok: false, error: "writer unavailable: #{e.message}", retryable_error: true)
89
+ end
90
+
91
+ Railwatch.debug do
92
+ if expected?
93
+ "no writer at #{@path} after #{WRITER_GRACE}s (#{e.class}); writing batches in-process, re-checking every #{FALLBACK_RECHECK}s"
94
+ else
95
+ "no writer at #{@path} (#{e.class}) and none expected; writing batches in-process, re-checking every #{FALLBACK_RECHECK}s"
96
+ end
97
+ end
98
+ @fallback = Local.new(@config)
99
+ @fallback_at = Clock.monotonic
100
+ deliver(records, dropped: dropped, dropped_bytes: dropped_bytes, backpressure_factor: backpressure_factor, batch_id: batch_id)
101
+ rescue SystemCallError, IOError, Zlib::Error, JSON::ParserError, Timeout::Error => e
102
+ Railwatch.debug { "writer delivery failed: #{e.class}: #{e.message}" }
103
+ Result.new(ok: false, error: "#{e.class}: #{e.message}", retryable_error: true)
104
+ end
105
+
106
+ # Whether this batch goes in-process. A fallback for an unbindable path
107
+ # is permanent; any other one is re-examined every FALLBACK_RECHECK
108
+ # seconds, so a writer that turns up later takes the work back.
109
+ def falling_back?
110
+ return false if @fallback.nil?
111
+ return true if @unusable_path || Clock.monotonic - @fallback_at < FALLBACK_RECHECK
112
+
113
+ if Writer.listening?(@path)
114
+ Railwatch.debug { "writer is answering at #{@path} again; handing batches back to it" }
115
+ @fallback = nil
116
+ @missing_since = nil
117
+ false
118
+ else
119
+ @fallback_at = Clock.monotonic
120
+ true
121
+ end
122
+ end
123
+
124
+ def ping
125
+ return true if @fallback
126
+
127
+ UNIXSocket.new(@path).close
128
+ true
129
+ rescue SystemCallError, ArgumentError
130
+ false
131
+ end
132
+
133
+ def unauthorized? = false
134
+ def reset_after_fork! = self
135
+
136
+ private
137
+
138
+ # Every read and write is under one deadline of config.timeout, the
139
+ # same budget the HTTP transport gives a POST; a writer that accepts but
140
+ # stops reading cannot hold the reporter thread past it.
141
+ def exchange(payload)
142
+ UNIXSocket.open(@path) do |sock|
143
+ deadline = Clock.monotonic + @config.timeout
144
+ write_exactly(sock, [ payload.bytesize ].pack("N") + payload, deadline)
145
+ header = read_exactly(sock, 4, deadline)
146
+ length = header.unpack1("N")
147
+ raise IOError, "writer reply too large (#{length} bytes)" if length > MAX_REPLY_BYTES
148
+
149
+ JSON.parse(read_exactly(sock, length, deadline))
150
+ end
151
+ end
152
+
153
+ def write_exactly(sock, data, deadline)
154
+ offset = 0
155
+ while offset < data.bytesize
156
+ remaining = deadline - Clock.monotonic
157
+ raise Timeout::Error, "writer did not accept the batch within #{@config.timeout}s" if remaining <= 0
158
+ raise IOError, "writer closed the connection" unless sock.wait_writable(remaining)
159
+
160
+ written = sock.write_nonblock(data.byteslice(offset..), exception: false)
161
+ offset += written if written.is_a?(Integer)
162
+ end
163
+ end
164
+
165
+ def read_exactly(sock, count, deadline)
166
+ buffer = +""
167
+ while buffer.bytesize < count
168
+ remaining = deadline - Clock.monotonic
169
+ raise Timeout::Error, "writer did not answer within #{@config.timeout}s" if remaining <= 0
170
+ raise IOError, "writer closed the connection" unless sock.wait_readable(remaining)
171
+
172
+ chunk = sock.read_nonblock(count - buffer.bytesize, exception: false)
173
+ case chunk
174
+ when :wait_readable then next
175
+ when nil then raise IOError, "writer closed the connection"
176
+ else buffer << chunk
177
+ end
178
+ end
179
+ buffer
180
+ end
181
+ end
182
+ end
183
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0.pre1"
5
5
  end